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 ( -
- {gutter} -
- -
-
- ); - } - 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"); }