diff --git a/frontend/.gitignore b/frontend/.gitignore
index 4c8abf3..a581c1e 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -1,3 +1,4 @@
node_modules
dist/
-dist-desketop/
\ No newline at end of file
+dist-desketop/
+public/fonts/*.ttf
\ No newline at end of file
diff --git a/frontend/public/fonts/LXGWWenKaiMono-Medium.ttf b/frontend/public/fonts/LXGWWenKaiMono-Medium.ttf
deleted file mode 100644
index 78f40c8..0000000
Binary files a/frontend/public/fonts/LXGWWenKaiMono-Medium.ttf and /dev/null differ
diff --git a/frontend/scripts/prepare-font.mjs b/frontend/scripts/prepare-font.mjs
index 1b43571..e4a7063 100644
--- a/frontend/scripts/prepare-font.mjs
+++ b/frontend/scripts/prepare-font.mjs
@@ -1,38 +1,63 @@
import { execFileSync } from "node:child_process";
-import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
+import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const fontsDir = join(__dirname, "..", "public", "fonts");
const outputWoff2 = join(fontsDir, "lxgwwenkaimono-medium.woff2");
+const sourceTtf = join(__dirname, "..", "assets", "fonts", "LXGWWenKaiMono-Medium.ttf");
-const sourceCandidates = [
- join(fontsDir, "LXGWWenKaiMono-Medium.ttf"),
- join(__dirname, "..", "assets", "fonts", "LXGWWenKaiMono-Medium.ttf"),
-];
-
-const sourceTtf = sourceCandidates.find((path) => existsSync(path));
+/** Full CJK font woff2 is typically 7–10 MB; reject obvious subset/corrupt output. */
const MIN_WOFF2_BYTES = 5_000_000;
-if (!sourceTtf) {
- console.warn("[prepare-font] LXGWWenKaiMono-Medium.ttf not found, skipping");
+if (!existsSync(sourceTtf)) {
+ console.warn(`[prepare-font] source not found: ${sourceTtf}`);
process.exit(0);
}
mkdirSync(fontsDir, { recursive: true });
+function removePublicTtfCopies() {
+ for (const name of readdirSync(fontsDir)) {
+ if (name.toLowerCase().endsWith(".ttf")) {
+ const path = join(fontsDir, name);
+ unlinkSync(path);
+ console.log(`[prepare-font] removed public TTF copy (woff2 only): ${path}`);
+ }
+ }
+}
+
function woff2LooksValid() {
if (!existsSync(outputWoff2)) return false;
return readFileSync(outputWoff2).length >= MIN_WOFF2_BYTES;
}
+function verifyGlyphCount() {
+ const py = [
+ "from fontTools.ttLib import TTFont",
+ "import sys",
+ "src, out = sys.argv[1], sys.argv[2]",
+ "source_glyphs = len(TTFont(src).getGlyphOrder())",
+ "output_glyphs = len(TTFont(out).getGlyphOrder())",
+ "if source_glyphs != output_glyphs:",
+ "\traise SystemExit(f'glyph count mismatch: source={source_glyphs}, output={output_glyphs}')",
+ "print(output_glyphs)",
+ ].join("\n");
+ const count = execFileSync("python", ["-c", py, sourceTtf, outputWoff2], {
+ encoding: "utf8",
+ }).trim();
+ console.log(`[prepare-font] verified full glyph set (${count} glyphs)`);
+}
+
+removePublicTtfCopies();
+
if (woff2LooksValid()) {
const sourceMtime = statSync(sourceTtf).mtimeMs;
const outputMtime = statSync(outputWoff2).mtimeMs;
if (outputMtime >= sourceMtime) {
const sizeMb = (readFileSync(outputWoff2).length / (1024 * 1024)).toFixed(2);
- console.log(`[prepare-font] up to date ${outputWoff2} (${sizeMb} MB)`);
+ console.log(`[prepare-font] up to date ${outputWoff2} (${sizeMb} MB, woff2 only)`);
process.exit(0);
}
}
@@ -42,12 +67,14 @@ if (existsSync(outputWoff2) && !woff2LooksValid()) {
unlinkSync(outputWoff2);
}
-console.log("[prepare-font] converting full font to woff2 (no glyph subsetting, may take a few minutes)...");
+console.log(
+ "[prepare-font] converting full font to woff2 (no glyph subsetting, glyf/loca/hmtx transforms)...",
+);
const py = [
"from fontTools.ttLib.woff2 import compress",
"import sys",
- "compress(sys.argv[1], sys.argv[2])",
+ "compress(sys.argv[1], sys.argv[2], transform_tables={'glyf', 'loca', 'hmtx'})",
].join("\n");
execFileSync("python", ["-c", py, sourceTtf, outputWoff2], { stdio: "inherit" });
@@ -59,5 +86,8 @@ if (sizeBytes < MIN_WOFF2_BYTES) {
);
}
+verifyGlyphCount();
+removePublicTtfCopies();
+
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(2);
-console.log(`[prepare-font] wrote ${outputWoff2} (${sizeMb} MB, full glyph set)`);
+console.log(`[prepare-font] wrote ${outputWoff2} (${sizeMb} MB, full glyph set, woff2 only)`);
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 55ea98e..e1edb40 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -3,7 +3,6 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt";
import { ChatLayout } from "./components/layout/ChatLayout";
import { AvatarProvider } from "./context/AvatarContext";
-import { BootstrapProvider } from "./context/BootstrapContext";
import { ChatProvider } from "./context/ChatContext";
import { ChatPage } from "./routes/ChatPage";
@@ -12,26 +11,24 @@ const SettingsPage = lazy(() => import("./routes/SettingsPage"));
export function App() {
return (
-
-
-
-
- }>
- } />
-
-
-
-
- }
- />
-
-
-
-
-
+
+
+
+ }>
+ } />
+
+
+
+
+ }
+ />
+
+
+
+
);
}
diff --git a/frontend/src/components/chat/AssistantTurnBody.module.css b/frontend/src/components/chat/AssistantTurnBody.module.css
new file mode 100644
index 0000000..4779a5e
--- /dev/null
+++ b/frontend/src/components/chat/AssistantTurnBody.module.css
@@ -0,0 +1,35 @@
+.turn {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.text {
+ line-height: 1.5;
+}
+
+.thinking {
+ line-height: 1.45;
+ font-style: italic;
+ color: #6b7280;
+}
+
+.thinkingHidden {
+ margin: 0;
+ font-style: italic;
+ color: #9ca3af;
+ font-size: 0.92em;
+}
+
+.streaming::after {
+ content: "▊";
+ animation: blink 0.8s step-end infinite;
+ color: #8b6fd4;
+ margin-left: 1px;
+}
+
+@keyframes blink {
+ 50% {
+ opacity: 0;
+ }
+}
diff --git a/frontend/src/components/chat/AssistantTurnBody.tsx b/frontend/src/components/chat/AssistantTurnBody.tsx
new file mode 100644
index 0000000..1de5745
--- /dev/null
+++ b/frontend/src/components/chat/AssistantTurnBody.tsx
@@ -0,0 +1,48 @@
+import type { AssistantBlock } from "../../types/message";
+import { renderMarkdown } from "../../utils/markdown";
+import styles from "./AssistantTurnBody.module.css";
+
+interface AssistantTurnBodyProps {
+ blocks: AssistantBlock[];
+ streaming?: boolean;
+ showThinking?: boolean;
+}
+
+export function AssistantTurnBody({
+ blocks,
+ streaming = false,
+ showThinking = true,
+}: AssistantTurnBodyProps) {
+ if (blocks.length === 0) return null;
+
+ return (
+
+ {blocks.map((block, index) => {
+ if (block.type === "thinking") {
+ if (!showThinking) {
+ return (
+
+ Thinking…
+
+ );
+ }
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/chat/ExportMenu.tsx b/frontend/src/components/chat/ExportMenu.tsx
index 2c6934f..1df3490 100644
--- a/frontend/src/components/chat/ExportMenu.tsx
+++ b/frontend/src/components/chat/ExportMenu.tsx
@@ -1,9 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { fetchSessionHistory } from "../../api/sessions";
import { useChatContext } from "../../context/ChatContext";
-import type { ChatMessage, ToolCallBlock } from "../../types/message";
-import { extractContent, extractThinking, formatToolCall, getToolCalls } from "../../utils/toolCall";
-import { nextMessageId } from "../../utils/format";
+import type { ChatMessage, SessionHistoryPayload } from "../../types/message";
+import { buildHistoryMessages } from "../../utils/historyMessages";
import {
downloadTextFile,
messagesToMarkdown,
@@ -13,56 +12,9 @@ import {
import styles from "./ExportMenu.module.css";
function historyToExportMessages(
- messages: NonNullable>["messages"]>,
+ messages: NonNullable,
): ChatMessage[] {
- const result: ChatMessage[] = [];
- for (const m of messages) {
- if (m.role === "user") {
- result.push({ id: nextMessageId(), role: "user", content: extractContent(m.content) });
- } else if (m.role === "assistant") {
- if (Array.isArray(m.content)) {
- for (const block of m.content) {
- if (block.type === "thinking") {
- const thinking = (block.thinking ?? "").trim();
- if (thinking) {
- result.push({ id: nextMessageId(), role: "thinking", content: thinking });
- }
- } else if (block.type === "text") {
- const text = (block.text ?? "").trim();
- if (text) {
- result.push({ id: nextMessageId(), role: "assistant", content: text });
- }
- } else if (block.type === "toolCall") {
- const tc = block as ToolCallBlock;
- const tid = tc.toolCallId || tc.id;
- result.push({
- id: tid ? `tool-${tid}` : nextMessageId(),
- role: "tool_call",
- content: formatToolCall(tc),
- toolCallId: tid ? String(tid) : undefined,
- });
- }
- }
- } else {
- const thinking = extractThinking(m.content);
- if (thinking) {
- result.push({ id: nextMessageId(), role: "thinking", content: thinking });
- }
- const text = extractContent(m.content);
- if (text) result.push({ id: nextMessageId(), role: "assistant", content: text });
- for (const tc of getToolCalls(m.content)) {
- const tid = tc.toolCallId || tc.id;
- result.push({
- id: tid ? `tool-${tid}` : nextMessageId(),
- role: "tool_call",
- content: formatToolCall(tc),
- toolCallId: tid ? String(tid) : undefined,
- });
- }
- }
- }
- }
- return result;
+ return buildHistoryMessages({ messages });
}
export function ExportMenu({ compact = false }: { compact?: boolean }) {
diff --git a/frontend/src/components/chat/MessageBubble.tsx b/frontend/src/components/chat/MessageBubble.tsx
index 90257d9..f8865b2 100644
--- a/frontend/src/components/chat/MessageBubble.tsx
+++ b/frontend/src/components/chat/MessageBubble.tsx
@@ -1,16 +1,18 @@
import { useState } from "react";
import type { ChatMessage } from "../../types/message";
import { useAvatars } from "../../context/AvatarContext";
+import { useChatContext } from "../../context/ChatContext";
import {
downloadTextFile,
messageMarkdownFilename,
singleAssistantMarkdown,
} from "../../utils/exportConversation";
-import { renderMarkdown } from "../../utils/markdown";
+import { resolveAssistantBlocks } from "../../utils/assistantBlocks";
+import { DEFAULT_AGENT_AVATAR, DEFAULT_USER_AVATAR } from "../../utils/avatars";
import { imageToDataUrl } from "../../utils/images";
+import { AssistantTurnBody } from "./AssistantTurnBody";
import { CompactionSummaryBlock } from "./CompactionSummaryBlock";
import { ToolCallBlock } from "./ToolCallBlock";
-import { ThinkingBlock } from "./ThinkingBlock";
import { BashOutputBlock } from "./BashOutputBlock";
import styles from "./MessageList.module.css";
@@ -20,19 +22,31 @@ interface MessageBubbleProps {
toolsExpanded?: boolean;
}
-function ChatAvatar({ src, className }: { src: string; className: string }) {
- const [failed, setFailed] = useState(false);
- if (!src || failed) return null;
+function ChatAvatar({
+ src,
+ fallback,
+ className,
+}: {
+ src: string;
+ fallback: string;
+ className: string;
+}) {
+ const trimmed = src.trim();
+ const [failedSrc, setFailedSrc] = useState(null);
+ const displaySrc = trimmed && failedSrc !== trimmed ? trimmed : fallback;
+
return (
setFailed(true)}
+ onError={() => {
+ if (trimmed) setFailedSrc(trimmed);
+ }}
/>
);
}
@@ -43,7 +57,7 @@ function AgentSideGutter({ mode, avatarUrl }: { mode: "avatar" | "spacer"; avata
}
return (
-
+
);
}
@@ -94,22 +108,12 @@ export function MessageBubble({
toolsExpanded = false,
}: MessageBubbleProps) {
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
+ const { showThinkingAndTools } = useChatContext();
const gutter =
agentGutter === "avatar" || agentGutter === "spacer" ? (
) : null;
- if (message.role === "thinking") {
- return (
-
- );
- }
-
if (message.role === "tool_call") {
return (
@@ -154,28 +158,34 @@ export function MessageBubble({
);
}
- const classNames = [styles.msg];
- if (message.role === "user") classNames.push(styles.user);
- if (message.role === "assistant") classNames.push(styles.assistant);
- if (message.role === "system") classNames.push(styles.system);
- if (message.streaming) classNames.push(styles.streaming);
+ if (message.role === "assistant" || message.role === "thinking") {
+ const blocks = resolveAssistantBlocks(message);
+ const hasVisibleText = blocks.some((b) => b.type === "text" && b.content.trim());
+ const showActions = message.role === "assistant" && !message.streaming && hasVisibleText;
+ const classNames = [styles.msg, styles.assistant];
+ if (message.streaming) classNames.push(styles.streaming);
- if (message.role === "assistant") {
- const showActions = !message.streaming && message.content.trim().length > 0;
return (
{gutter}
{showActions ?
: null}
-
+
);
}
+ const classNames = [styles.msg];
+ if (message.role === "user") classNames.push(styles.user);
+ if (message.role === "system") classNames.push(styles.system);
+
if (message.role === "user") {
return (
@@ -195,7 +205,7 @@ export function MessageBubble({
) : null}
-
+
);
}
diff --git a/frontend/src/components/chat/MessageList.module.css b/frontend/src/components/chat/MessageList.module.css
index 8d26ed6..7e42cf2 100644
--- a/frontend/src/components/chat/MessageList.module.css
+++ b/frontend/src/components/chat/MessageList.module.css
@@ -206,16 +206,11 @@
flex: 1;
min-width: 0;
max-width: 100%;
- font-size: 16px;
- line-height: 1.46;
- color: #666;
padding: 0;
margin: 0;
- overflow: hidden;
- background: #fff;
- border: 1px solid #e8ecf0;
- border-radius: 14px 14px 14px 4px;
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+ background: transparent;
+ border: none;
+ box-shadow: none;
}
.thinking {
diff --git a/frontend/src/components/chat/ToolCallBlock.module.css b/frontend/src/components/chat/ToolCallBlock.module.css
index 72a0754..068af1b 100644
--- a/frontend/src/components/chat/ToolCallBlock.module.css
+++ b/frontend/src/components/chat/ToolCallBlock.module.css
@@ -1,5 +1,24 @@
-.collapsible {
+.block {
margin: 0;
+ border-radius: 8px;
+ border: 1px solid #e5e7eb;
+ background: #f8fafc;
+ overflow: hidden;
+}
+
+.pending {
+ border-color: #dbeafe;
+ background: #f0f7ff;
+}
+
+.success {
+ border-color: #bbf7d0;
+ background: #f0fdf4;
+}
+
+.error {
+ border-color: #fecaca;
+ background: #fef2f2;
}
.summary {
@@ -7,45 +26,52 @@
cursor: pointer;
display: flex;
align-items: center;
- gap: 6px;
- padding: 6px 10px;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 8px 10px;
user-select: none;
- font-weight: 500;
- color: #555;
}
.summary::-webkit-details-marker {
display: none;
}
-.summary::before {
- content: "";
- width: 0;
- height: 0;
- border-left: 5px solid #888;
- border-top: 3.5px solid transparent;
- border-bottom: 3.5px solid transparent;
+.toolName {
+ font-weight: 600;
+ color: #374151;
+ font-size: 0.92em;
+}
+
+.status {
flex-shrink: 0;
- transform: rotate(0deg);
- transition: transform 0.15s ease;
+ font-size: 11px;
+ color: #6b7280;
}
-.collapsible[open] > .summary::before {
- transform: rotate(90deg);
+.pending .status {
+ color: #2563eb;
}
-.summaryText {
- min-width: 0;
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+.success .status {
+ color: #15803d;
+}
+
+.error .status {
+ color: #b91c1c;
}
.detail {
margin: 0;
- padding: 6px 10px 8px;
- border-top: 1px solid #eee;
+ padding: 8px 10px 10px;
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
+}
+
+.args {
+ margin: 0 0 6px;
+ font-size: 0.85em;
+ color: #6b7280;
+ font-family: var(--font-mono, ui-monospace, monospace);
+ word-break: break-all;
}
.detailPre {
@@ -55,6 +81,7 @@
word-break: break-word;
font-size: inherit;
line-height: inherit;
+ font-family: var(--font-mono, ui-monospace, monospace);
}
.detailPre :global(.diffAdd) {
diff --git a/frontend/src/components/chat/ToolCallBlock.tsx b/frontend/src/components/chat/ToolCallBlock.tsx
index a338547..87c49be 100644
--- a/frontend/src/components/chat/ToolCallBlock.tsx
+++ b/frontend/src/components/chat/ToolCallBlock.tsx
@@ -6,26 +6,59 @@ interface ToolCallBlockProps {
forceOpen?: boolean;
}
+function parseToolCallContent(content: string): {
+ title: string;
+ body: string;
+ status: "pending" | "success" | "error";
+} {
+ const trimmed = content.trimStart();
+ let status: "pending" | "success" | "error" = "pending";
+ let rest = trimmed;
+ if (trimmed.startsWith("✅")) {
+ status = "success";
+ rest = trimmed.slice(1).trimStart();
+ } else if (trimmed.startsWith("❌")) {
+ status = "error";
+ rest = trimmed.slice(1).trimStart();
+ } else if (trimmed.startsWith("🔧")) {
+ status = "pending";
+ rest = trimmed.slice(2).trimStart();
+ }
+
+ const lines = rest.split("\n");
+ const title = lines[0]?.trim() || "tool";
+ const body = lines.slice(1).join("\n").trim();
+ return { title, body, status };
+}
+
export function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) {
- const bodyStart = content.indexOf("\n");
- const body = bodyStart >= 0 ? content.slice(bodyStart + 1) : "";
- const hasDiff = body.includes("\n+") || body.includes("\n-") || body.startsWith("+") || body.startsWith("-");
+ const { title, body, status } = parseToolCallContent(content);
+ const hasDiff =
+ body.includes("\n+") || body.includes("\n-") || body.startsWith("+") || body.startsWith("-");
+ const statusLabel = status === "success" ? "完成" : status === "error" ? "失败" : "执行中";
return (
-
+
- {deriveToolCallSummary(content)}
+ {title.split("(")[0] || deriveToolCallSummary(content)}
+ {statusLabel}
-
- {hasDiff ? (
-
- ) : (
-
{body || content}
- )}
-
+ {(body || content !== title) && (
+
+ {title.includes("(") ?
{title}
: null}
+ {hasDiff ? (
+
+ ) : (
+
{body || content}
+ )}
+
+ )}
);
}
diff --git a/frontend/src/components/pwa/SplashScreen.module.css b/frontend/src/components/pwa/SplashScreen.module.css
deleted file mode 100644
index a3f795b..0000000
--- a/frontend/src/components/pwa/SplashScreen.module.css
+++ /dev/null
@@ -1,186 +0,0 @@
-.splash {
- position: fixed;
- inset: 0;
- z-index: 10000;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- background:
- radial-gradient(circle at 18% 12%, rgba(37, 99, 235, 0.12), transparent 34%),
- radial-gradient(circle at 82% 88%, rgba(34, 197, 94, 0.08), transparent 32%),
- linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%);
- opacity: 1;
- transition: opacity 0.42s ease;
-}
-
-.splashExiting {
- opacity: 0;
- pointer-events: none;
-}
-
-.bgPulse {
- position: absolute;
- inset: -20%;
- background:
- radial-gradient(circle at 50% 45%, rgba(37, 99, 235, 0.14), transparent 42%),
- radial-gradient(circle at 50% 55%, rgba(34, 197, 94, 0.1), transparent 48%);
- animation: bgPulse 3.6s ease-in-out infinite;
- pointer-events: none;
-}
-
-.bgGlow {
- position: absolute;
- width: min(72vw, 420px);
- height: min(72vw, 420px);
- border-radius: 50%;
- background: radial-gradient(circle, rgba(37, 99, 235, 0.12) 0%, transparent 68%);
- filter: blur(8px);
- animation: glowDrift 4.8s ease-in-out infinite;
- pointer-events: none;
-}
-
-.content {
- position: relative;
- z-index: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 18px;
- padding: 24px;
-}
-
-.logoWrap {
- position: relative;
- width: 132px;
- height: 132px;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.ring {
- position: absolute;
- inset: 0;
- border-radius: 50%;
- border: 2px solid rgba(37, 99, 235, 0.28);
- opacity: 0;
- animation: ringExpand 2.4s ease-out infinite;
-}
-
-.ring2 {
- animation-delay: 0.8s;
-}
-
-.ring3 {
- animation-delay: 1.6s;
-}
-
-.logo {
- width: 96px;
- height: 96px;
- object-fit: contain;
- border-radius: 22px;
- box-shadow:
- 0 10px 28px rgba(37, 99, 235, 0.18),
- 0 2px 8px rgba(15, 23, 42, 0.08);
- animation: logoFloat 2.8s ease-in-out infinite;
-}
-
-.titleBlock {
- text-align: center;
-}
-
-.title {
- font-size: clamp(28px, 6vw, 36px);
- font-weight: 700;
- letter-spacing: -0.4px;
- color: #111827;
- line-height: 1.15;
-}
-
-.subtitle {
- margin-top: 6px;
- font-size: 14px;
- color: #6b7280;
- letter-spacing: 0.2px;
-}
-
-.dots {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- margin-top: 4px;
-}
-
-.dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: #22c55e;
- animation: dotPulse 1.2s ease-in-out infinite;
-}
-
-.dot2 {
- animation-delay: 0.2s;
-}
-
-.dot3 {
- animation-delay: 0.4s;
-}
-
-@keyframes bgPulse {
- 0%, 100% { transform: scale(1); opacity: 0.72; }
- 50% { transform: scale(1.06); opacity: 1; }
-}
-
-@keyframes glowDrift {
- 0%, 100% { transform: translateY(0) scale(1); }
- 50% { transform: translateY(-10px) scale(1.04); }
-}
-
-@keyframes logoFloat {
- 0%, 100% { transform: translateY(0); }
- 50% { transform: translateY(-8px); }
-}
-
-@keyframes ringExpand {
- 0% {
- transform: scale(0.72);
- opacity: 0.65;
- }
- 70% {
- opacity: 0.12;
- }
- 100% {
- transform: scale(1.45);
- opacity: 0;
- }
-}
-
-@keyframes dotPulse {
- 0%, 80%, 100% {
- transform: scale(0.72);
- opacity: 0.45;
- }
- 40% {
- transform: scale(1);
- opacity: 1;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .bgPulse,
- .bgGlow,
- .logo,
- .ring,
- .dot {
- animation: none !important;
- }
-
- .ring {
- opacity: 0.2;
- }
-}
diff --git a/frontend/src/components/pwa/SplashScreen.tsx b/frontend/src/components/pwa/SplashScreen.tsx
deleted file mode 100644
index 7880148..0000000
--- a/frontend/src/components/pwa/SplashScreen.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import styles from "./SplashScreen.module.css";
-
-interface SplashScreenProps {
- exiting?: boolean;
-}
-
-export function SplashScreen({ exiting = false }: SplashScreenProps) {
- return (
-
-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/context/BootstrapContext.tsx b/frontend/src/context/BootstrapContext.tsx
deleted file mode 100644
index 331861f..0000000
--- a/frontend/src/context/BootstrapContext.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useRef,
- useState,
- type ReactNode,
-} from "react";
-import { useLocation } from "react-router-dom";
-import { SplashScreen } from "../components/pwa/SplashScreen";
-
-const MIN_SPLASH_MS = 400;
-const MAX_SPLASH_MS = 8000;
-const SKIP_SPLASH_KEY = "sproutclaw-warm-boot";
-
-type BootstrapRoute = "chat" | "settings";
-
-interface BootstrapContextValue {
- markRouteReady: (route: BootstrapRoute) => void;
-}
-
-const BootstrapContext = createContext(null);
-
-export function BootstrapProvider({ children }: { children: ReactNode }) {
- const location = useLocation();
- const route: BootstrapRoute = location.pathname.startsWith("/settings") ? "settings" : "chat";
- const [readyRoutes, setReadyRoutes] = useState>({
- chat: false,
- settings: false,
- });
- const [splashVisible, setSplashVisible] = useState(true);
- const [splashExiting, setSplashExiting] = useState(false);
- const mountTime = useRef(Date.now());
-
- const markRouteReady = useCallback((readyRoute: BootstrapRoute) => {
- setReadyRoutes((prev) => (prev[readyRoute] ? prev : { ...prev, [readyRoute]: true }));
- }, []);
-
- useEffect(() => {
- setReadyRoutes((prev) => ({ ...prev, [route]: false }));
- setSplashVisible(true);
- setSplashExiting(false);
- mountTime.current = Date.now();
- }, [route]);
-
- useEffect(() => {
- if (readyRoutes[route]) return;
- const timer = window.setTimeout(() => {
- setReadyRoutes((prev) => (prev[route] ? prev : { ...prev, [route]: true }));
- }, MAX_SPLASH_MS);
- return () => window.clearTimeout(timer);
- }, [route, readyRoutes]);
-
- useEffect(() => {
- if (!readyRoutes[route]) return;
-
- let warmBoot = false;
- try {
- warmBoot = sessionStorage.getItem(SKIP_SPLASH_KEY) === "1";
- sessionStorage.setItem(SKIP_SPLASH_KEY, "1");
- } catch {
- /* ignore */
- }
-
- if (warmBoot) {
- setSplashVisible(false);
- setSplashExiting(false);
- return;
- }
-
- const elapsed = Date.now() - mountTime.current;
- const remaining = Math.max(0, MIN_SPLASH_MS - elapsed);
-
- const timer = window.setTimeout(() => {
- setSplashExiting(true);
- window.setTimeout(() => setSplashVisible(false), 420);
- }, remaining);
-
- return () => window.clearTimeout(timer);
- }, [readyRoutes, route]);
-
- const value = useMemo(() => ({ markRouteReady }), [markRouteReady]);
-
- return (
-
- {children}
- {splashVisible ? : null}
-
- );
-}
-
-export function useBootstrap(): BootstrapContextValue {
- const ctx = useContext(BootstrapContext);
- if (!ctx) throw new Error("useBootstrap must be used within BootstrapProvider");
- return ctx;
-}
diff --git a/frontend/src/context/ChatContext.tsx b/frontend/src/context/ChatContext.tsx
index 16b1ad0..15c8164 100644
--- a/frontend/src/context/ChatContext.tsx
+++ b/frontend/src/context/ChatContext.tsx
@@ -12,18 +12,22 @@ import {
import * as chatApi from "../api/chat";
import * as sessionsApi from "../api/sessions";
import { apiUrl } from "../api/base";
-import { useBootstrap } from "./BootstrapContext";
import type { ExtensionDialogState, RetryState, SseEvent } from "../types/events";
import type { RunPhase, RunPhaseState } from "../types/runPhase";
-import type { ChatMessage, ImageContent, ModelInfo, SessionState, ThinkingLevel, ToolCallBlock } from "../types/message";
+import type { AssistantBlock, ChatMessage, ImageContent, ModelInfo, SessionState, ThinkingLevel, ToolCallBlock } from "../types/message";
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
import { nextMessageId, syncViewportHeight, stripAnsi } from "../utils/format";
import { formatBashOutput, parseBashInput } from "../utils/bash";
import { buildHistoryMessages } from "../utils/historyMessages";
+import {
+ assistantPlainText,
+ extractAssistantBlocks,
+ hasTextBlock,
+ hasThinkingBlock,
+} from "../utils/assistantBlocks";
import {
extractContent,
extractImages,
- extractThinking,
formatToolCall,
getToolCalls,
buildToolCallUpdateText,
@@ -138,22 +142,8 @@ function historyToMessages(payload: SessionHistoryPayload): ChatMessage[] {
return normalizeChatMessages(buildHistoryMessages(payload));
}
-function findStreamingThinkingId(messages: ChatMessage[]): string | null {
- for (let i = messages.length - 1; i >= 0; i--) {
- const msg = messages[i];
- if (msg.role === "thinking" && msg.streaming) {
- return msg.id;
- }
- }
- return null;
-}
-
-function withoutStreamingTurnMessages(
- prev: ChatMessage[],
- thinkingId: string | null,
- assistantId: string | null,
-): ChatMessage[] {
- return prev.filter((msg) => msg.id !== thinkingId && msg.id !== assistantId);
+function withoutStreamingTurnMessages(prev: ChatMessage[], assistantId: string | null): ChatMessage[] {
+ return prev.filter((msg) => msg.id !== assistantId);
}
/** Index where the current turn begins (just after the last user message). */
@@ -164,68 +154,78 @@ function lastTurnStartIndex(messages: ChatMessage[]): number {
return 0;
}
-/** Drop every still-streaming assistant/thinking bubble in the current turn. */
+/** Drop still-streaming assistant bubbles in the current turn. */
function stripStreamingTurnBubbles(messages: ChatMessage[]): ChatMessage[] {
const turnStart = lastTurnStartIndex(messages);
return messages.filter((msg, i) => {
if (i < turnStart) return true;
if (!msg.streaming) return true;
- return msg.role !== "assistant" && msg.role !== "thinking";
+ return msg.role !== "assistant";
});
}
+function findStreamingAssistantId(messages: ChatMessage[]): string | null {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i];
+ if (msg.role === "assistant" && msg.streaming) {
+ return msg.id;
+ }
+ }
+ return null;
+}
+
+function findLastTurnAssistantId(messages: ChatMessage[]): string | null {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i];
+ if (msg.role === "user") break;
+ if (msg.role === "assistant") return msg.id;
+ }
+ return null;
+}
+
+function resolveTurnAssistantId(messages: ChatMessage[], refId: string | null): string | null {
+ return refId ?? findStreamingAssistantId(messages) ?? findLastTurnAssistantId(messages);
+}
+
+function findLastTurnAssistantMessage(messages: ChatMessage[]): ChatMessage | null {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i];
+ if (msg.role === "user") break;
+ if (msg.role === "assistant") return msg;
+ }
+ return null;
+}
+
function appendStreamingAssistantTurn(
prev: ChatMessage[],
- thinking: string,
- assistantText: string,
- thinkingIdRef: { current: string | null },
+ blocks: AssistantBlock[],
assistantIdRef: { current: string | null },
): ChatMessage[] {
- // Reattach to any live streaming bubble whose id the ref may have lost (e.g.
- // after a reducer re-run, SSE reconnect, or out-of-order events) so we update
- // it in place instead of spawning a duplicate orphan.
- if (!thinkingIdRef.current && thinking.length > 0) {
- thinkingIdRef.current = findStreamingThinkingId(prev);
- }
- if (!assistantIdRef.current && assistantText.length > 0) {
+ if (!assistantIdRef.current) {
assistantIdRef.current = findStreamingAssistantId(prev);
}
const next = stripStreamingTurnBubbles(prev);
- const turnMessages: ChatMessage[] = [];
-
- if (thinking.length > 0 || thinkingIdRef.current) {
- if (!thinkingIdRef.current) {
- thinkingIdRef.current = nextMessageId();
- }
- turnMessages.push({
- id: thinkingIdRef.current,
- role: "thinking",
- content: thinking,
- streaming: true,
- });
+ if (!assistantIdRef.current) {
+ assistantIdRef.current = nextMessageId();
}
- if (assistantText.length > 0 || assistantIdRef.current) {
- if (!assistantIdRef.current) {
- assistantIdRef.current = nextMessageId();
- }
- turnMessages.push({
- id: assistantIdRef.current,
- role: "assistant",
- content: assistantText,
+ const id = assistantIdRef.current;
+ return [
+ ...next,
+ {
+ id,
+ role: "assistant" as const,
+ blocks,
+ content: assistantPlainText(blocks),
streaming: true,
- });
- }
-
- return [...next, ...turnMessages];
+ },
+ ];
}
function finalizeStreamingTurn(prev: ChatMessage[]): ChatMessage[] {
const turnStart = lastTurnStartIndex(prev);
- // Remove orphaned streaming assistant bubbles whose content is just a prefix
- // of a longer assistant bubble in the same turn (the real, final reply).
const survivors = prev.filter((msg, i) => {
if (i < turnStart || msg.role !== "assistant" || !msg.streaming) return true;
const shorter = msg.content.trim();
@@ -242,70 +242,21 @@ function finalizeStreamingTurn(prev: ChatMessage[]): ChatMessage[] {
return true;
});
- // Clear the streaming flag on every remaining bubble in the turn.
const survivorTurnStart = lastTurnStartIndex(survivors);
return survivors.map((msg, i) =>
i >= survivorTurnStart && msg.streaming ? { ...msg, streaming: false } : msg,
);
}
-function findStreamingAssistantId(messages: ChatMessage[]): string | null {
- for (let i = messages.length - 1; i >= 0; i--) {
- const msg = messages[i];
- if (msg.role === "assistant" && msg.streaming) {
- return msg.id;
- }
- }
- return null;
-}
-
-/** Last thinking bubble in the current turn (since the preceding user message). */
-function findLastTurnThinkingId(messages: ChatMessage[]): string | null {
- for (let i = messages.length - 1; i >= 0; i--) {
- const msg = messages[i];
- if (msg.role === "user") break;
- if (msg.role === "thinking") return msg.id;
- }
- return null;
-}
-
-/** Last assistant bubble in the current turn (since the preceding user message). */
-function findLastTurnAssistantId(messages: ChatMessage[]): string | null {
- for (let i = messages.length - 1; i >= 0; i--) {
- const msg = messages[i];
- if (msg.role === "user") break;
- if (msg.role === "assistant") return msg.id;
- }
- return null;
-}
-
-function resolveTurnThinkingId(messages: ChatMessage[], refId: string | null): string | null {
- return refId ?? findStreamingThinkingId(messages) ?? findLastTurnThinkingId(messages);
-}
-
-function resolveTurnAssistantId(messages: ChatMessage[], refId: string | null): string | null {
- return refId ?? findStreamingAssistantId(messages) ?? findLastTurnAssistantId(messages);
-}
-
-function findLastTurnAssistantMessage(messages: ChatMessage[]): ChatMessage | null {
- for (let i = messages.length - 1; i >= 0; i--) {
- const msg = messages[i];
- if (msg.role === "user") break;
- if (msg.role === "assistant") return msg;
- }
- return null;
-}
-
function applyAssistantMessageUpdate(
prev: ChatMessage[],
- thinking: string,
- assistantText: string,
- thinkingIdRef: { current: string | null },
+ blocks: AssistantBlock[],
assistantIdRef: { current: string | null },
): ChatMessage[] {
+ const plainText = assistantPlainText(blocks);
const lastAssistant = findLastTurnAssistantMessage(prev);
if (lastAssistant && !lastAssistant.streaming) {
- const trimmed = assistantText.trim();
+ const trimmed = plainText.trim();
if (
trimmed &&
(lastAssistant.content === trimmed ||
@@ -314,46 +265,36 @@ function applyAssistantMessageUpdate(
) {
if (trimmed.length >= lastAssistant.content.length) {
return prev.map((msg) =>
- msg.id === lastAssistant.id ? { ...msg, content: assistantText } : msg,
+ msg.id === lastAssistant.id
+ ? { ...msg, blocks, content: plainText }
+ : msg,
);
}
return prev;
}
}
- return appendStreamingAssistantTurn(prev, thinking, assistantText, thinkingIdRef, assistantIdRef);
+ return appendStreamingAssistantTurn(prev, blocks, assistantIdRef);
}
function finalizeAssistantMessageEnd(
prev: ChatMessage[],
- thinking: string,
- assistantText: string,
+ blocks: AssistantBlock[],
toolCalls: ToolCallBlock[],
- thinkingId: string | null,
assistantId: string | null,
): ChatMessage[] {
- let next = withoutStreamingTurnMessages(prev, thinkingId, assistantId);
+ let next = withoutStreamingTurnMessages(prev, assistantId);
- if (thinking) {
- next = [
- ...next,
- {
- id: thinkingId ?? nextMessageId(),
- role: "thinking" as const,
- content: thinking,
- },
- ];
- }
-
- const trimmedAssistant = assistantText.trim();
- if (trimmedAssistant) {
+ const plainText = assistantPlainText(blocks).trim();
+ if (plainText || blocks.some((b) => b.type === "thinking")) {
const lastAssistant = findLastTurnAssistantMessage(next);
- if (!(lastAssistant && !lastAssistant.streaming && lastAssistant.content === trimmedAssistant)) {
+ if (!(lastAssistant && !lastAssistant.streaming && lastAssistant.content === plainText)) {
next = [
...next,
{
id: assistantId ?? nextMessageId(),
role: "assistant" as const,
- content: assistantText,
+ blocks,
+ content: assistantPlainText(blocks),
},
];
}
@@ -389,11 +330,15 @@ function messageDedupeSignature(msg: ChatMessage): string | null {
if (msg.role === "tool_call" && msg.toolCallId) {
return `tool_call:${msg.toolCallId}`;
}
- if (msg.role !== "assistant" && msg.role !== "thinking" && msg.role !== "tool_call") {
+ if (msg.role === "thinking") {
+ const content = msg.content.trim().replace(/\s+/g, " ");
+ return content ? `thinking:${content}` : null;
+ }
+ if (msg.role !== "assistant" && msg.role !== "tool_call") {
return null;
}
const content = msg.content.trim().replace(/\s+/g, " ");
- if (!content) return null;
+ if (!content && !msg.blocks?.length) return null;
return `${msg.role}:${msg.toolCallId ?? ""}:${content}`;
}
@@ -494,7 +439,6 @@ function isIgnorableExtensionError(error: string | undefined): boolean {
}
export function ChatProvider({ children }: { children: ReactNode }) {
- const { markRouteReady } = useBootstrap();
const [connected, setConnected] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [messages, setRawMessages] = useState([]);
@@ -536,7 +480,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const ensureSessionCreatePromise = useRef | null>(null);
const sessionBootRef = useRef | null>(null);
const streamingAssistantId = useRef(null);
- const streamingThinkingId = useRef(null);
const optimisticUserIdRef = useRef(null);
const compactionReloadPendingRef = useRef(false);
const activeBashMsgIdRef = useRef(null);
@@ -580,7 +523,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const resetStreamingState = useCallback(() => {
streamingAssistantId.current = null;
- streamingThinkingId.current = null;
optimisticUserIdRef.current = null;
clearRunPhase();
}, [clearRunPhase]);
@@ -1535,7 +1477,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
setIsStreaming(true);
setConnected(true);
streamingAssistantId.current = null;
- streamingThinkingId.current = null;
updateRunPhase("starting");
scheduleSessionDashboardRefresh();
break;
@@ -1545,7 +1486,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
setConnected(true);
setMessages((prev) => finalizeStreamingTurn(prev));
streamingAssistantId.current = null;
- streamingThinkingId.current = null;
if (backendSessionPathRef.current) {
sessionCache.current.delete(backendSessionPathRef.current);
}
@@ -1591,21 +1531,14 @@ export function ChatProvider({ children }: { children: ReactNode }) {
case "message_update": {
const m = ev.message;
if (m.role === "assistant") {
- const thinking = extractThinking(m.content);
- const assistantText = extractContent(m.content);
- if (thinking.length > 0) {
+ const blocks = extractAssistantBlocks(m.content);
+ if (hasThinkingBlock(blocks)) {
updateRunPhase("thinking");
- } else if (assistantText.length > 0) {
+ } else if (hasTextBlock(blocks)) {
updateRunPhase("writing");
}
setMessages((prev) =>
- applyAssistantMessageUpdate(
- prev,
- thinking,
- assistantText,
- streamingThinkingId,
- streamingAssistantId,
- ),
+ applyAssistantMessageUpdate(prev, blocks, streamingAssistantId),
);
}
break;
@@ -1614,28 +1547,24 @@ export function ChatProvider({ children }: { children: ReactNode }) {
case "message_end": {
const m = ev.message;
if (m.role === "assistant") {
- const thinking = extractThinking(m.content);
- const assistantText = extractContent(m.content);
+ const blocks = extractAssistantBlocks(m.content);
const toolCalls = getToolCalls(m.content);
if (toolCalls.length > 0) {
updateRunPhase("processing");
- } else if (thinking.length > 0 && assistantText.length === 0) {
+ } else if (hasThinkingBlock(blocks) && !hasTextBlock(blocks)) {
updateRunPhase("thinking");
- } else if (assistantText.length > 0) {
+ } else if (hasTextBlock(blocks)) {
updateRunPhase("writing");
}
setMessages((prev) =>
finalizeAssistantMessageEnd(
prev,
- thinking,
- assistantText,
+ blocks,
toolCalls,
- resolveTurnThinkingId(prev, streamingThinkingId.current),
resolveTurnAssistantId(prev, streamingAssistantId.current),
),
);
streamingAssistantId.current = null;
- streamingThinkingId.current = null;
scheduleSessionDashboardRefresh();
}
break;
@@ -1839,11 +1768,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
],
);
- const markRouteReadyRef = useRef(markRouteReady);
const handleSseEventRef = useRef(handleSseEvent);
const addSystemMessageRef = useRef(addSystemMessage);
const restoreSessionOnBootRef = useRef(restoreSessionOnBoot);
- markRouteReadyRef.current = markRouteReady;
handleSseEventRef.current = handleSseEvent;
addSystemMessageRef.current = addSystemMessage;
restoreSessionOnBootRef.current = restoreSessionOnBoot;
@@ -1860,29 +1787,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
window.addEventListener("orientationchange", syncViewportHeight);
let cancelled = false;
- const bootstrap = {
- sessions: false,
- models: false,
- };
- const tryMarkBootstrapReady = () => {
- if (cancelled) return;
- if (bootstrap.sessions && bootstrap.models) {
- markRouteReadyRef.current("chat");
- }
- };
-
- void (async () => {
- try {
- await restoreSessionOnBootRef.current(() => {
- bootstrap.sessions = true;
- tryMarkBootstrapReady();
- });
- } catch {
- bootstrap.sessions = true;
- tryMarkBootstrapReady();
- }
- })();
+ void restoreSessionOnBootRef.current().catch(() => {});
void chatApi
.fetchModels()
@@ -1895,10 +1801,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`,
);
}
- })
- .finally(() => {
- bootstrap.models = true;
- tryMarkBootstrapReady();
});
let eventSource: EventSource | null = null;
diff --git a/frontend/src/routes/SettingsPage.tsx b/frontend/src/routes/SettingsPage.tsx
index dba670b..da1ebaa 100644
--- a/frontend/src/routes/SettingsPage.tsx
+++ b/frontend/src/routes/SettingsPage.tsx
@@ -4,7 +4,6 @@ import * as promptsApi from "../api/prompts";
import type { PromptFile } from "../api/prompts";
import * as settingsApi from "../api/settings";
import { useAvatars } from "../context/AvatarContext";
-import { useBootstrap } from "../context/BootstrapContext";
import { useChatContext } from "../context/ChatContext";
import type { AgentToolInfo, EnvironmentInfo, EnvironmentToolsInfo, ExtensionInfo, McpServerInfo, RuntimeInfo, SettingsPaneId, SkillInfo } from "../types/events";
import type { McpToolInfo } from "../types/events";
@@ -308,7 +307,6 @@ function McpToolItem({
}
export function SettingsPage() {
- const { markRouteReady } = useBootstrap();
const { updateAvatars } = useAvatars();
const { showThinkingAndTools, toggleShowThinkingAndTools } = useChatContext();
const [activePane, setActivePane] = useState("prompt");
@@ -408,9 +406,8 @@ export function SettingsPage() {
setStatus(`加载设置失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setLoading(false);
- markRouteReady("settings");
}
- }, [markRouteReady, updateAvatars]);
+ }, [updateAvatars]);
const reloadSettings = async () => {
setReloading(true);
diff --git a/frontend/src/types/message.ts b/frontend/src/types/message.ts
index fc4e939..ec257f5 100644
--- a/frontend/src/types/message.ts
+++ b/frontend/src/types/message.ts
@@ -53,10 +53,18 @@ export interface ToolCallBlock {
input?: unknown;
}
+/** Ordered assistant content block (matches SproutClaw CLI assistant message layout). */
+export interface AssistantBlock {
+ type: "thinking" | "text";
+ content: string;
+}
+
export interface ChatMessage {
id: string;
role: MessageRole;
content: string;
+ /** Thinking + text blocks in RPC order (assistant role only). */
+ blocks?: AssistantBlock[];
images?: ImageContent[];
toolCallId?: string;
bashCommand?: string;
diff --git a/frontend/src/utils/assistantBlocks.ts b/frontend/src/utils/assistantBlocks.ts
new file mode 100644
index 0000000..e23df99
--- /dev/null
+++ b/frontend/src/utils/assistantBlocks.ts
@@ -0,0 +1,61 @@
+import type { AssistantBlock, ContentBlock } from "../types/message";
+import { extractContent } from "./toolCall";
+
+/** Extract thinking/text blocks in RPC order (toolCall blocks excluded — CLI renders those separately). */
+export function extractAssistantBlocks(content: string | ContentBlock[] | undefined): AssistantBlock[] {
+ if (!content) return [];
+ if (typeof content === "string") {
+ const text = content.trim();
+ return text ? [{ type: "text", content: text }] : [];
+ }
+ if (!Array.isArray(content)) return [];
+
+ const blocks: AssistantBlock[] = [];
+ for (const block of content) {
+ if (block.type === "thinking") {
+ const thinking = (block.thinking ?? "").trim();
+ if (thinking) blocks.push({ type: "thinking", content: thinking });
+ } else if (block.type === "text") {
+ const text = (block.text ?? "").trim();
+ if (text) blocks.push({ type: "text", content: text });
+ }
+ }
+ return blocks;
+}
+
+export function assistantPlainText(blocks: AssistantBlock[]): string {
+ return blocks
+ .filter((b) => b.type === "text")
+ .map((b) => b.content)
+ .join("\n\n");
+}
+
+export function hasThinkingBlock(blocks: AssistantBlock[]): boolean {
+ return blocks.some((b) => b.type === "thinking" && b.content.trim());
+}
+
+export function hasTextBlock(blocks: AssistantBlock[]): boolean {
+ return blocks.some((b) => b.type === "text" && b.content.trim());
+}
+
+/** Resolve display blocks from a chat message (supports legacy thinking role). */
+export function resolveAssistantBlocks(message: {
+ role: string;
+ content: string;
+ blocks?: AssistantBlock[];
+}): AssistantBlock[] {
+ if (message.blocks?.length) return message.blocks;
+ if (message.role === "thinking" && message.content.trim()) {
+ return [{ type: "thinking", content: message.content.trim() }];
+ }
+ if (message.role === "assistant" && message.content.trim()) {
+ return [{ type: "text", content: message.content.trim() }];
+ }
+ return [];
+}
+
+export function legacyContentToBlocks(content: string | ContentBlock[] | undefined): AssistantBlock[] {
+ if (Array.isArray(content)) return extractAssistantBlocks(content);
+ const text = extractContent(content);
+ return text ? [{ type: "text", content: text }] : [];
+}
diff --git a/frontend/src/utils/avatars.ts b/frontend/src/utils/avatars.ts
new file mode 100644
index 0000000..36d4289
--- /dev/null
+++ b/frontend/src/utils/avatars.ts
@@ -0,0 +1,18 @@
+/** Bundled app logo — used when no custom agent avatar is configured. */
+export const DEFAULT_AGENT_AVATAR = "/logo192.png";
+
+/** Inline SVG placeholder for user messages without a custom avatar. */
+export const DEFAULT_USER_AVATAR =
+ "data:image/svg+xml," +
+ encodeURIComponent(
+ '",
+ );
+
+export function resolveAvatarUrl(url: string | undefined | null, fallback: string): string {
+ const trimmed = url?.trim();
+ return trimmed || fallback;
+}
diff --git a/frontend/src/utils/exportConversation.ts b/frontend/src/utils/exportConversation.ts
index f36dbf6..605caa7 100644
--- a/frontend/src/utils/exportConversation.ts
+++ b/frontend/src/utils/exportConversation.ts
@@ -1,4 +1,5 @@
import type { ChatMessage } from "../types/message";
+import { resolveAssistantBlocks } from "./assistantBlocks";
import { escHtml } from "./format";
function roleLabel(role: ChatMessage["role"]): string {
@@ -35,9 +36,22 @@ export function messagesToMarkdown(title: string, messages: ChatMessage[]): stri
case "user":
lines.push("## 用户", "", m.content, "");
break;
- case "assistant":
- lines.push("## 助手", "", m.content, "");
+ case "assistant": {
+ const blocks = resolveAssistantBlocks(m);
+ if (blocks.length) {
+ lines.push("## 助手", "");
+ for (const block of blocks) {
+ if (block.type === "thinking") {
+ lines.push("### 思考", "", block.content, "");
+ } else {
+ lines.push(block.content, "");
+ }
+ }
+ } else if (m.content.trim()) {
+ lines.push("## 助手", "", m.content, "");
+ }
break;
+ }
case "thinking":
lines.push("## 思考", "", m.content, "");
break;
diff --git a/frontend/src/utils/historyMessages.ts b/frontend/src/utils/historyMessages.ts
index fa69111..15c03aa 100644
--- a/frontend/src/utils/historyMessages.ts
+++ b/frontend/src/utils/historyMessages.ts
@@ -1,53 +1,32 @@
-import type { ChatMessage, SessionHistoryPayload, ToolCallBlock } from "../types/message";
+import type { ChatMessage, SessionHistoryPayload } from "../types/message";
import { nextMessageId } from "./format";
+import {
+ assistantPlainText,
+ extractAssistantBlocks,
+} from "./assistantBlocks";
import {
extractContent,
extractImages,
- extractThinking,
formatToolCall,
formatToolResult,
getToolCalls,
upsertToolCallMessage,
} from "./toolCall";
-function appendAssistantBlocks(m: { content?: string | unknown[] }, result: ChatMessage[]): void {
- if (Array.isArray(m.content)) {
- for (const block of m.content) {
- const b = block as { type?: string; thinking?: string; text?: string };
- if (b.type === "thinking") {
- const thinking = (b.thinking ?? "").trim();
- if (thinking) {
- result.push({ id: nextMessageId(), role: "thinking", content: thinking });
- }
- } else if (b.type === "text") {
- const text = (b.text ?? "").trim();
- if (text) {
- const last = result[result.length - 1];
- if (last?.role === "assistant") {
- last.content += text;
- } else {
- result.push({ id: nextMessageId(), role: "assistant", content: text });
- }
- }
- } else if (b.type === "toolCall") {
- const tc = block as ToolCallBlock;
- const tid = tc.toolCallId || tc.id;
- const tidStr = tid ? String(tid) : undefined;
- upsertToolCallMessage(result, {
- id: tidStr ? `tool-${tidStr}` : nextMessageId(),
- role: "tool_call",
- content: formatToolCall(tc),
- toolCallId: tidStr,
- });
- }
- }
- return;
+function appendAssistantTurn(m: { content?: string | unknown[] }, result: ChatMessage[]): void {
+ const blocks = extractAssistantBlocks(m.content as string | undefined);
+ const toolCalls = getToolCalls(m.content as string | undefined);
+
+ if (blocks.length > 0) {
+ result.push({
+ id: nextMessageId(),
+ role: "assistant",
+ blocks,
+ content: assistantPlainText(blocks),
+ });
}
- const text = extractContent(m.content as string | undefined);
- if (text) {
- result.push({ id: nextMessageId(), role: "assistant", content: text });
- }
- for (const tc of getToolCalls(m.content as string | undefined)) {
+
+ for (const tc of toolCalls) {
const tid = tc.toolCallId || tc.id;
const tidStr = tid ? String(tid) : undefined;
upsertToolCallMessage(result, {
@@ -74,7 +53,7 @@ export function buildHistoryMessages(payload: SessionHistoryPayload): ChatMessag
});
}
} else if (m.role === "assistant") {
- appendAssistantBlocks(m, result);
+ appendAssistantTurn(m, result);
} else if (m.role === "toolResult" || m.role === "tool") {
const text = formatToolResult({ content: m.content });
if (text.trim()) {
@@ -117,5 +96,8 @@ export function buildHistoryMessages(payload: SessionHistoryPayload): ChatMessag
}
export function extractThinkingFromMessage(m: { content?: string | unknown[] }): string {
- return extractThinking(m.content as string | undefined);
+ return extractAssistantBlocks(m.content as string | undefined)
+ .filter((b) => b.type === "thinking")
+ .map((b) => b.content)
+ .join("\n\n");
}