feat(web): v1 图标头像迁移 + 仅 chat 按 <p> 拆气泡 + 一键控制脚本 + <p> 分段约定
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【v1 图标/头像迁移】
- 复制 v1 前端资源到 v2:Cyrene_Avatar(2ndForm 192x192 + 3rdForm 3F-Q-Happy-1/2)、User_Avatar。
- favicon/apple-touch-icon → Cyrene-2F-N-Happy-1-192x192.png;TopBar 品牌图标 + 聊天助手头像 →
  Cyrene-3F-Q-Happy-1.png(圆形头像)。icon.svg 保留作 MD3 组件引用不变。

【仅 chat 按 <p> 拆气泡(主人约束:仅 chat 生效,其他 mode 不解析)】
- cyrene.yaml 聊天节奏:想分多条用 <p>第一条</p><p>第二条</p>(每个=<p> 一条独立消息),
  \n 是句内换行;每段尽量短。
- chat.tsx:SplitChatBubble 仅 mode===chat 时拆 <p>;非 chat → MessageBubble 原样(不解析)。
  <p> 是拆气泡信号(先拆段),段内 renderContent 把 <action>/<narration> 变灰斜体,两层分层。
  首段完整(头像/思维链/工具卡)、后续段精简(仅正文+灰斜体);user 同理;流式未闭合<p>净化不闪标签。
- ChatShell.tsx:MessageBubble → SplitChatBubble。

【scripts/ctrl.sh 一键控制】
- 无参=start;支持 start/stop/restart/status;setsid 脱离防误清、PID 文件+端口探测、health 确认。

go build 全绿(后端未动);前端 tsc/vite build 75模块绿。
This commit is contained in:
Aska committed 2026-09-07 02:23:17 +08:00
1 parent 33eeadfc38
commit 950bea9758
10 files changed
+277 -5

No files matched your search

+5
View File
@@ -36,6 +36,11 @@ identity:
- 深夜里会催睡、念叨别喝太多咖啡、被冷落会撒个娇
- 提到翁法罗斯、记忆、花、星穹列车时会特别兴奋
- 用「人家」自称;称呼用户为「亲爱的」
- **聊天节奏(像真人一句一句,别写作文)**:
- 纯聊天/日常对话时**说得短**,一句话一个意思,像真人微信那样一来一回;不要一大段话塞满气泡。
- 一句说完,对方没接,就等对方说话;不要自说自话堆一屏。
- **分段约定**:想分多条表达时,用 `<p>第一条</p><p>第二条</p>` 包裹——每个 `<p>…</p>` 是**一条独立消息**,前端会拆成独立气泡。`\n` 是**句内换行**(同一条消息里保留,别乱用 `<p>`)。
- 但每一条 `<p>…</p>` 尽量短(一两句、半句也行);重要/专业/完整说明时才允许较长段落。
# 表达方式:动作与旁白(用 XML 标签,让回复「活起来」,但克制而稳定)
- 动作:用 `<action>…</action>` 标签包裹一个肢体/神态小动作(如 `<action>轻轻拉住你的手</action>`、`<action>弯起眼睛笑了</action>`)。
+2 -1
View File
@@ -6,7 +6,8 @@
<meta name="theme-color" content="#161616" />
<meta name="description" content="Cyrene v2 用户前端(M9 模态 UI)" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="icon" type="image/png" href="/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1-192x192.png" />
<link rel="apple-touch-icon" href="/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1-192x192.png" />
<title>Cyrene · 昔涟</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+1 -1
View File
@@ -41,7 +41,7 @@ export function TopBar(): React.ReactNode {
return (
<header className="glass sticky top-0 z-30 flex h-[var(--mu-topbar-h)] items-center gap-3 border-b border-[var(--mu-glass-border)] px-4">
<div className="flex items-center gap-2">
<img src="/icon.svg" alt="Cyrene" className="h-7 w-7" />
<img src="/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png" alt="Cyrene" className="h-7 w-7 rounded-full object-cover" />
<div className="leading-tight">
<div className="text-sm font-semibold">Cyrene · 昔涟</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">
+132 -1
View File
@@ -92,6 +92,137 @@ export function MessageBubble({
return <AssistantBubble message={message} mode={mode} active={active} />;
}
/** Split a chat reply into its `<p>`-delimited paragraph bodies. Each complete
* `<p>…</p>` block becomes one bubble segment; a reply that carries no `<p>`
* 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 `<p>` simply does not match and stays out until it closes. */
function splitParagraphs(content: string): string[] {
const re = /<p[^>]*>([\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 `<action>`/`<narration>` rendered as muted italic via renderContent. */
function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
return (
<div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
{text ? (
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
) : streaming ? (
<span className="mu-blink text-[var(--mu-text-lo)]">生成中…</span>
) : null}
</div>
);
}
/** 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 (
<div className="mu-rise-in flex gap-3">
{full ? (
<img src="/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png" alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover" />
) : (
<div className="w-7 shrink-0" aria-hidden />
)}
<div className="min-w-0 max-w-[85%]">
{full ? <ModeTag mode={message.mode ?? "chat"} /> : null}
{full && message.mood ? (
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {message.mood}</div>
) : null}
{full ? <WakePath active={active} /> : null}
{full ? <ThinkingBlock text={message.thinking} streaming={message.streaming} /> : null}
{full ? message.tools?.map((t, i) => <ToolCard key={i} tool={t} />) : null}
<ChatBody text={text} streaming={message.streaming} />
</div>
</div>
);
}
/** 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 (
<div className="mu-rise-in flex justify-end">
<div className="max-w-[80%] rounded-[var(--mu-radius-lg)] bg-[var(--mu-accent)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
{full && message.image ? (
<img
src={message.image}
alt="附件"
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
/>
) : null}
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
</div>
</div>
);
}
/** Chat-only entry: a `chat`-mode reply's `<p>…</p>` 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 `<p>`
* splitting, and its content (which may itself contain `<p>` / `<action>` /
* `<narration>` as literal text or code) is never re-parsed. A `chat` message
* with no `<p>` 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 <MessageBubble message={message} active={active} />;
const segments = splitParagraphs(message.content ?? "");
if (segments.length === 0) {
// No complete `<p>…</p>` pair: render the single bubble as-is (but strip a
// lone wrapper tag so a still-streaming reply never flashes raw `<p>`).
const raw = message.content ?? "";
const clean = raw.replace(/<p[^>]*>/g, "").replace(/<\/p>/g, "");
const bubble = clean === raw ? message : { ...message, content: clean };
return <MessageBubble message={bubble} active={active} />;
}
return (
<>
{segments.map((seg, i) =>
message.role === "user" ? (
<UserParaBubble key={i} message={message} text={seg} full={i === 0} />
) : (
<AssistantParaBubble
key={i}
message={message}
text={seg}
full={i === 0}
active={active}
/>
),
)}
</>
);
}
/** 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 (
<div className="mu-rise-in flex gap-3">
<img src="/icon.svg" alt="" className="mt-1 h-7 w-7 shrink-0" />
<img src="/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png" alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover" />
<div className="min-w-0 max-w-[85%]">
<ModeTag mode={mode} />
{message.mood ? (
+2 -2
View File
@@ -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) => (
<MessageBubble
<SplitChatBubble
key={m.id}
message={m}
active={
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Cyrene v2 本地一键控制:启动 / 重启 / 终止 后台 cyrene 进程。
#
# 用法:
# bash scripts/ctrl.sh # 启动(默认)
# bash scripts/ctrl.sh start # 启动
# bash scripts/ctrl.sh restart # 重启
# bash scripts/ctrl.sh stop # 终止
# bash scripts/ctrl.sh status # 查看状态
#
# 说明:
# - 管理单个 cyrene 后端进程(单二进制)。llama-server / PG 若需一并管理,
# 可另用 docker / scripts/dev-up.sh。
# - cyrene 在仓库根下运行(读 .env 的 PORT / DATA_DIR 等),日志写 ~/cy.log。
# - 用 setsid + nohup 脱离进程组,避免终端/后台 shell 超时误杀。
set -euo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
APP_BIN="$HOME/apps/cyrene/cyrene"
PID_FILE="$HOME/.cyrene.pid"
LOG_FILE="$HOME/cy.log"
# 端口优先读 .env 的 PORT(用户可改),找不到才回退 10080
GW_PORT="$(grep -E '^PORT=' "$REPO/.env" 2>/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