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

+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={