diff --git a/frontend/web/src/components/chat.tsx b/frontend/web/src/components/chat.tsx
index d9d5ede..24490d2 100644
--- a/frontend/web/src/components/chat.tsx
+++ b/frontend/web/src/components/chat.tsx
@@ -92,6 +92,137 @@ export function MessageBubble({
return
;
}
+/** Split a chat reply into its `
`-delimited paragraph bodies. Each complete
+ * `
…
` block becomes one bubble segment; a reply that carries no `
`
+ * wrapper yields an empty list so the caller renders one bubble as before.
+ * Non-greedy + multi-line so a tag may span newlines; a still-streaming,
+ * unclosed `
` simply does not match and stays out until it closes. */
+function splitParagraphs(content: string): string[] {
+ const re = /
]*>([\s\S]*?)<\/p>/g;
+ return [...content.matchAll(re)].map((m) => m[1]);
+}
+
+/** Chat-mode body: a Miuix surface shell around a whitespace-preserving span,
+ * with ``/`` rendered as muted italic via renderContent. */
+function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
+ return (
+
+ {text ? (
+ {renderContent(text)}
+ ) : streaming ? (
+ 生成中…
+ ) : null}
+
+ );
+}
+
+/** One assistant paragraph bubble. `full` renders the complete bubble (avatar /
+ * mood / 思维链 / 工具卡 / wake path); compact segments render just the aligned
+ * body, so the chrome is not repeated paragraph after paragraph. */
+function AssistantParaBubble({
+ message,
+ text,
+ full,
+ active = false,
+}: {
+ message: ChatMessage;
+ text: string;
+ full: boolean;
+ active?: boolean;
+}): React.ReactNode {
+ return (
+
+ {full ? (
+

+ ) : (
+
+ )}
+
+ {full ?
: null}
+ {full && message.mood ? (
+
心情 · {message.mood}
+ ) : null}
+ {full ?
: null}
+ {full ?
: null}
+ {full ? message.tools?.map((t, i) =>
) : null}
+
+
+
+ );
+}
+
+/** One user paragraph bubble. Only the first segment carries the optional image. */
+function UserParaBubble({
+ message,
+ text,
+ full,
+}: {
+ message: ChatMessage;
+ text: string;
+ full: boolean;
+}): React.ReactNode {
+ return (
+
+
+ {full && message.image ? (
+

+ ) : null}
+
{renderContent(text)}
+
+
+ );
+}
+
+/** Chat-only entry: a `chat`-mode reply's `…
` paragraphs are split into
+ * stacked bubbles (first = full bubble, remainder = compact). Any other mode —
+ * code / home / work / docs / vision / voice / agents / settings — is passed
+ * straight through to MessageBubble so its body renders untouched: no ``
+ * splitting, and its content (which may itself contain `
` / `` /
+ * `` as literal text or code) is never re-parsed. A `chat` message
+ * with no `` wrapper renders as one bubble exactly as before. */
+export function SplitChatBubble({
+ message,
+ active = false,
+}: {
+ message: ChatMessage;
+ active?: boolean;
+}): React.ReactNode {
+ const mode = message.mode ?? "chat";
+ if (mode !== "chat") return ;
+
+ const segments = splitParagraphs(message.content ?? "");
+ if (segments.length === 0) {
+ // No complete `
…
` pair: render the single bubble as-is (but strip a
+ // lone wrapper tag so a still-streaming reply never flashes raw ``).
+ const raw = message.content ?? "";
+ const clean = raw.replace(/
]*>/g, "").replace(/<\/p>/g, "");
+ const bubble = clean === raw ? message : { ...message, content: clean };
+ return ;
+ }
+
+ return (
+ <>
+ {segments.map((seg, i) =>
+ message.role === "user" ? (
+
+ ) : (
+
+ ),
+ )}
+ >
+ );
+}
+
/** User-side bubble. The outer accent pill is shared; the inner content may be
* mode-shaped (e.g. code request renders in a mono pre). */
function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): React.ReactNode {
@@ -130,7 +261,7 @@ function AssistantBubble({
}): React.ReactNode {
return (
-

+
{message.mood ? (
diff --git a/frontend/web/src/modes/ChatShell.tsx b/frontend/web/src/modes/ChatShell.tsx
index 35e0b13..50b410a 100644
--- a/frontend/web/src/modes/ChatShell.tsx
+++ b/frontend/web/src/modes/ChatShell.tsx
@@ -3,7 +3,7 @@ import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { useSendChat } from "../lib/useChat";
import { sendUp } from "../lib/ws";
-import { MessageBubble } from "../components/chat";
+import { SplitChatBubble } from "../components/chat";
import { ChatInput } from "../components/ChatInput";
import { EmptyState } from "../components/primitives";
import { Icon } from "../components/Icon";
@@ -47,7 +47,7 @@ export function ChatShell(): React.ReactNode {
/>
) : (
messages.map((m) => (
- /dev/null | head -1 | cut -d= -f2)"
+GW_PORT="${GW_PORT:-10080}"
+HEALTH_URL="http://127.0.0.1:$GW_PORT/api/v1/health"
+
+# 读取进程 PID:先看 PID 文件,没有则从监听端口探测(能识别外部已起的 cyrene)
+read_pid() {
+ # 1) PID 文件优先
+ if [ -f "$PID_FILE" ]; then
+ local pid
+ pid="$(cat "$PID_FILE" 2>/dev/null)"
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
+ echo "$pid"
+ return 0
+ fi
+ fi
+ # 2) 端口探测兜底(ss 找监听 GW_PORT 的 cyrene 进程)
+ local p
+ p="$(ss -tlnp 2>/dev/null | grep ":$GW_PORT" | grep -oE 'pid=[0-9]+' | head -1 | sed -E 's/[^0-9]//g')"
+ if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then
+ echo "$p"
+ return 0
+ fi
+ return 1
+}
+
+is_running() {
+ read_pid >/dev/null 2>&1
+}
+
+# 健康检查:进程在 + HTTP 200
+healthy() {
+ read_pid >/dev/null 2>&1 && curl -s --max-time 2 "$HEALTH_URL" >/dev/null 2>&1
+}
+
+start() {
+ if is_running; then
+ echo "cyrene 已在运行 (PID $(read_pid)),跳过。用 restart 重启。"
+ return 0
+ fi
+ [ -x "$APP_BIN" ] || { echo "未找到二进制 $APP_BIN,请先 go build"; return 1; }
+ echo "启动 cyrene → $APP_BIN (端口 $GW_PORT)"
+ ( cd "$REPO" && setsid nohup "$APP_BIN" > "$LOG_FILE" 2>&1 < /dev/null & )
+ # 等待健康
+ local i
+ for i in $(seq 1 20); do
+ sleep 0.5
+ if healthy; then
+ echo "已启动: http://localhost:$GW_PORT (日志 $LOG_FILE)"
+ return 0
+ fi
+ done
+ echo "启动后 ${GW_PORT} 未就绪,请查看日志 $LOG_FILE"
+ return 1
+}
+
+stop() {
+ local pid
+ if read_pid >/dev/null 2>&1; then
+ pid="$(read_pid)"
+ kill "$pid" 2>/dev/null || true
+ echo "已发送终止信号 → PID $pid"
+ local i
+ for i in $(seq 1 20); do
+ sleep 0.4
+ kill -0 "$pid" 2>/dev/null || { echo "已退出"; rm -f "$PID_FILE"; return 0; }
+ done
+ echo "进程未在 8s 内退出,强制 kill -9"
+ kill -9 "$pid" 2>/dev/null || true
+ rm -f "$PID_FILE"
+ else
+ echo "cyrene 未在运行(无 PID 文件或进程已退出)"
+ rm -f "$PID_FILE"
+ fi
+}
+
+restart() {
+ echo "重启 cyrene…"
+ stop
+ sleep 1
+ start
+}
+
+status() {
+ if healthy; then
+ echo "cyrene: 运行中 (PID $(read_pid)) · 端口 $GW_PORT · health ok"
+ elif is_running; then
+ echo "cyrene: 进程在 (PID $(read_pid)) 但 health 未就绪 ($HEALTH_URL)"
+ else
+ echo "cyrene: 未运行"
+ fi
+}
+
+usage() {
+ echo "用法: bash scripts/ctrl.sh [start|stop|restart|status] (无参 = start)"
+}
+
+# start 成功后把探测到的 PID 写入文件,供后续 stop 精确管理
+record_pid() {
+ if healthy; then
+ local p
+ p="$(ss -tlnp 2>/dev/null | grep ":$GW_PORT" | grep -oE 'pid=[0-9]+' | head -1 | sed -E 's/[^0-9]//g')"
+ [ -n "$p" ] && echo "$p" > "$PID_FILE"
+ fi
+}
+
+case "${1:-start}" in
+ start) start; record_pid ;;
+ restart) restart; record_pid ;;
+ stop) stop ;;
+ status) status ;;
+ *) usage; exit 1 ;;
+esac