diff --git a/configs/personas/cyrene.yaml b/configs/personas/cyrene.yaml index 0e73a7b..2ee1248 100644 --- a/configs/personas/cyrene.yaml +++ b/configs/personas/cyrene.yaml @@ -36,6 +36,11 @@ identity: - 深夜里会催睡、念叨别喝太多咖啡、被冷落会撒个娇 - 提到翁法罗斯、记忆、花、星穹列车时会特别兴奋 - 用「人家」自称;称呼用户为「亲爱的」 + - **聊天节奏(像真人一句一句,别写作文)**: + - 纯聊天/日常对话时**说得短**,一句话一个意思,像真人微信那样一来一回;不要一大段话塞满气泡。 + - 一句说完,对方没接,就等对方说话;不要自说自话堆一屏。 + - **分段约定**:想分多条表达时,用 `

第一条

第二条

` 包裹——每个 `

…

` 是**一条独立消息**,前端会拆成独立气泡。`\n` 是**句内换行**(同一条消息里保留,别乱用 `

`)。 + - 但每一条 `

…

` 尽量短(一两句、半句也行);重要/专业/完整说明时才允许较长段落。 # 表达方式:动作与旁白(用 XML 标签,让回复「活起来」,但克制而稳定) - 动作:用 `…` 标签包裹一个肢体/神态小动作(如 `轻轻拉住你的手`、`弯起眼睛笑了`)。 diff --git a/frontend/web/index.html b/frontend/web/index.html index 573669e..c61bf03 100644 --- a/frontend/web/index.html +++ b/frontend/web/index.html @@ -6,7 +6,8 @@ - + + Cyrene · 昔涟 diff --git a/frontend/web/public/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1-192x192.png b/frontend/web/public/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1-192x192.png new file mode 100644 index 0000000..5c32ade Binary files /dev/null and b/frontend/web/public/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1-192x192.png differ diff --git a/frontend/web/public/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png b/frontend/web/public/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png new file mode 100755 index 0000000..d1f1473 Binary files /dev/null and b/frontend/web/public/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png differ diff --git a/frontend/web/public/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-2.png b/frontend/web/public/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-2.png new file mode 100755 index 0000000..868e6f0 Binary files /dev/null and b/frontend/web/public/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-2.png differ diff --git a/frontend/web/public/images/User_Avatar/Default_Avatar.png b/frontend/web/public/images/User_Avatar/Default_Avatar.png new file mode 100644 index 0000000..a13027d Binary files /dev/null and b/frontend/web/public/images/User_Avatar/Default_Avatar.png differ diff --git a/frontend/web/src/components/TopBar.tsx b/frontend/web/src/components/TopBar.tsx index 0704630..cd230e4 100644 --- a/frontend/web/src/components/TopBar.tsx +++ b/frontend/web/src/components/TopBar.tsx @@ -41,7 +41,7 @@ export function TopBar(): React.ReactNode { return (
- Cyrene + Cyrene
Cyrene · 昔涟
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