feat: align chat rendering with CLI and trim frontend load weight

- Merge thinking and assistant text into one assistant bubble with ordered blocks

- Restyle tool execution blocks to match CLI layout and status

- Remove startup splash screen and ship woff2-only fonts in public

- Add default avatars when agent or user image is unset

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-21 20:42:48 +08:00
parent 2742e19a2e
commit 767a776a3e
21 changed files with 506 additions and 711 deletions

1
frontend/.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules node_modules
dist/ dist/
dist-desketop/ dist-desketop/
public/fonts/*.ttf

View File

@@ -1,38 +1,63 @@
import { execFileSync } from "node:child_process"; 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 { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const fontsDir = join(__dirname, "..", "public", "fonts"); const fontsDir = join(__dirname, "..", "public", "fonts");
const outputWoff2 = join(fontsDir, "lxgwwenkaimono-medium.woff2"); const outputWoff2 = join(fontsDir, "lxgwwenkaimono-medium.woff2");
const sourceTtf = join(__dirname, "..", "assets", "fonts", "LXGWWenKaiMono-Medium.ttf");
const sourceCandidates = [ /** Full CJK font woff2 is typically 710 MB; reject obvious subset/corrupt output. */
join(fontsDir, "LXGWWenKaiMono-Medium.ttf"),
join(__dirname, "..", "assets", "fonts", "LXGWWenKaiMono-Medium.ttf"),
];
const sourceTtf = sourceCandidates.find((path) => existsSync(path));
const MIN_WOFF2_BYTES = 5_000_000; const MIN_WOFF2_BYTES = 5_000_000;
if (!sourceTtf) { if (!existsSync(sourceTtf)) {
console.warn("[prepare-font] LXGWWenKaiMono-Medium.ttf not found, skipping"); console.warn(`[prepare-font] source not found: ${sourceTtf}`);
process.exit(0); process.exit(0);
} }
mkdirSync(fontsDir, { recursive: true }); 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() { function woff2LooksValid() {
if (!existsSync(outputWoff2)) return false; if (!existsSync(outputWoff2)) return false;
return readFileSync(outputWoff2).length >= MIN_WOFF2_BYTES; 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()) { if (woff2LooksValid()) {
const sourceMtime = statSync(sourceTtf).mtimeMs; const sourceMtime = statSync(sourceTtf).mtimeMs;
const outputMtime = statSync(outputWoff2).mtimeMs; const outputMtime = statSync(outputWoff2).mtimeMs;
if (outputMtime >= sourceMtime) { if (outputMtime >= sourceMtime) {
const sizeMb = (readFileSync(outputWoff2).length / (1024 * 1024)).toFixed(2); 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); process.exit(0);
} }
} }
@@ -42,12 +67,14 @@ if (existsSync(outputWoff2) && !woff2LooksValid()) {
unlinkSync(outputWoff2); 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 = [ const py = [
"from fontTools.ttLib.woff2 import compress", "from fontTools.ttLib.woff2 import compress",
"import sys", "import sys",
"compress(sys.argv[1], sys.argv[2])", "compress(sys.argv[1], sys.argv[2], transform_tables={'glyf', 'loca', 'hmtx'})",
].join("\n"); ].join("\n");
execFileSync("python", ["-c", py, sourceTtf, outputWoff2], { stdio: "inherit" }); 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); 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)`);

View File

@@ -3,7 +3,6 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt"; import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt";
import { ChatLayout } from "./components/layout/ChatLayout"; import { ChatLayout } from "./components/layout/ChatLayout";
import { AvatarProvider } from "./context/AvatarContext"; import { AvatarProvider } from "./context/AvatarContext";
import { BootstrapProvider } from "./context/BootstrapContext";
import { ChatProvider } from "./context/ChatContext"; import { ChatProvider } from "./context/ChatContext";
import { ChatPage } from "./routes/ChatPage"; import { ChatPage } from "./routes/ChatPage";
@@ -12,7 +11,6 @@ const SettingsPage = lazy(() => import("./routes/SettingsPage"));
export function App() { export function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<BootstrapProvider>
<AvatarProvider> <AvatarProvider>
<ChatProvider> <ChatProvider>
<Routes> <Routes>
@@ -31,7 +29,6 @@ export function App() {
<PwaUpdatePrompt /> <PwaUpdatePrompt />
</ChatProvider> </ChatProvider>
</AvatarProvider> </AvatarProvider>
</BootstrapProvider>
</BrowserRouter> </BrowserRouter>
); );
} }

View File

@@ -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;
}
}

View File

@@ -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 (
<div className={styles.turn}>
{blocks.map((block, index) => {
if (block.type === "thinking") {
if (!showThinking) {
return (
<p key={`thinking-${index}`} className={styles.thinkingHidden}>
Thinking
</p>
);
}
return (
<div
key={`thinking-${index}`}
className={`${styles.thinking} ${streaming ? styles.streaming : ""}`}
dangerouslySetInnerHTML={{ __html: renderMarkdown(block.content) }}
/>
);
}
return (
<div
key={`text-${index}`}
className={`${styles.text} ${streaming ? styles.streaming : ""}`}
dangerouslySetInnerHTML={{ __html: renderMarkdown(block.content) }}
/>
);
})}
</div>
);
}

View File

@@ -1,9 +1,8 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { fetchSessionHistory } from "../../api/sessions"; import { fetchSessionHistory } from "../../api/sessions";
import { useChatContext } from "../../context/ChatContext"; import { useChatContext } from "../../context/ChatContext";
import type { ChatMessage, ToolCallBlock } from "../../types/message"; import type { ChatMessage, SessionHistoryPayload } from "../../types/message";
import { extractContent, extractThinking, formatToolCall, getToolCalls } from "../../utils/toolCall"; import { buildHistoryMessages } from "../../utils/historyMessages";
import { nextMessageId } from "../../utils/format";
import { import {
downloadTextFile, downloadTextFile,
messagesToMarkdown, messagesToMarkdown,
@@ -13,56 +12,9 @@ import {
import styles from "./ExportMenu.module.css"; import styles from "./ExportMenu.module.css";
function historyToExportMessages( function historyToExportMessages(
messages: NonNullable<Awaited<ReturnType<typeof fetchSessionHistory>>["messages"]>, messages: NonNullable<SessionHistoryPayload["messages"]>,
): ChatMessage[] { ): ChatMessage[] {
const result: ChatMessage[] = []; return buildHistoryMessages({ messages });
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;
} }
export function ExportMenu({ compact = false }: { compact?: boolean }) { export function ExportMenu({ compact = false }: { compact?: boolean }) {

View File

@@ -1,16 +1,18 @@
import { useState } from "react"; import { useState } from "react";
import type { ChatMessage } from "../../types/message"; import type { ChatMessage } from "../../types/message";
import { useAvatars } from "../../context/AvatarContext"; import { useAvatars } from "../../context/AvatarContext";
import { useChatContext } from "../../context/ChatContext";
import { import {
downloadTextFile, downloadTextFile,
messageMarkdownFilename, messageMarkdownFilename,
singleAssistantMarkdown, singleAssistantMarkdown,
} from "../../utils/exportConversation"; } 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 { imageToDataUrl } from "../../utils/images";
import { AssistantTurnBody } from "./AssistantTurnBody";
import { CompactionSummaryBlock } from "./CompactionSummaryBlock"; import { CompactionSummaryBlock } from "./CompactionSummaryBlock";
import { ToolCallBlock } from "./ToolCallBlock"; import { ToolCallBlock } from "./ToolCallBlock";
import { ThinkingBlock } from "./ThinkingBlock";
import { BashOutputBlock } from "./BashOutputBlock"; import { BashOutputBlock } from "./BashOutputBlock";
import styles from "./MessageList.module.css"; import styles from "./MessageList.module.css";
@@ -20,19 +22,31 @@ interface MessageBubbleProps {
toolsExpanded?: boolean; toolsExpanded?: boolean;
} }
function ChatAvatar({ src, className }: { src: string; className: string }) { function ChatAvatar({
const [failed, setFailed] = useState(false); src,
if (!src || failed) return null; fallback,
className,
}: {
src: string;
fallback: string;
className: string;
}) {
const trimmed = src.trim();
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const displaySrc = trimmed && failedSrc !== trimmed ? trimmed : fallback;
return ( return (
<img <img
className={className} className={className}
src={src} src={displaySrc}
width={32} width={32}
height={32} height={32}
alt="" alt=""
decoding="async" decoding="async"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={() => setFailed(true)} onError={() => {
if (trimmed) setFailedSrc(trimmed);
}}
/> />
); );
} }
@@ -43,7 +57,7 @@ function AgentSideGutter({ mode, avatarUrl }: { mode: "avatar" | "spacer"; avata
} }
return ( return (
<div className={styles.assistantAvatarSlot}> <div className={styles.assistantAvatarSlot}>
<ChatAvatar src={avatarUrl} className={styles.assistantAvatar} /> <ChatAvatar src={avatarUrl} fallback={DEFAULT_AGENT_AVATAR} className={styles.assistantAvatar} />
</div> </div>
); );
} }
@@ -94,22 +108,12 @@ export function MessageBubble({
toolsExpanded = false, toolsExpanded = false,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const { userAvatarUrl, agentAvatarUrl } = useAvatars(); const { userAvatarUrl, agentAvatarUrl } = useAvatars();
const { showThinkingAndTools } = useChatContext();
const gutter = const gutter =
agentGutter === "avatar" || agentGutter === "spacer" ? ( agentGutter === "avatar" || agentGutter === "spacer" ? (
<AgentSideGutter mode={agentGutter} avatarUrl={agentAvatarUrl} /> <AgentSideGutter mode={agentGutter} avatarUrl={agentAvatarUrl} />
) : null; ) : null;
if (message.role === "thinking") {
return (
<div className={styles.assistantRow}>
{gutter}
<div className={`${styles.msg} ${styles.thinking}`}>
<ThinkingBlock content={message.content} streaming={message.streaming} />
</div>
</div>
);
}
if (message.role === "tool_call") { if (message.role === "tool_call") {
return ( return (
<div className={styles.assistantRow}> <div className={styles.assistantRow}>
@@ -154,28 +158,34 @@ export function MessageBubble({
); );
} }
const classNames = [styles.msg]; if (message.role === "assistant" || message.role === "thinking") {
if (message.role === "user") classNames.push(styles.user); const blocks = resolveAssistantBlocks(message);
if (message.role === "assistant") classNames.push(styles.assistant); const hasVisibleText = blocks.some((b) => b.type === "text" && b.content.trim());
if (message.role === "system") classNames.push(styles.system); const showActions = message.role === "assistant" && !message.streaming && hasVisibleText;
const classNames = [styles.msg, styles.assistant];
if (message.streaming) classNames.push(styles.streaming); if (message.streaming) classNames.push(styles.streaming);
if (message.role === "assistant") {
const showActions = !message.streaming && message.content.trim().length > 0;
return ( return (
<div className={styles.assistantRow}> <div className={styles.assistantRow}>
{gutter} {gutter}
<div className={classNames.join(" ")}> <div className={classNames.join(" ")}>
{showActions ? <AssistantActions content={message.content} /> : null} {showActions ? <AssistantActions content={message.content} /> : null}
<div <div className={styles.assistantBody}>
className={styles.assistantBody} <AssistantTurnBody
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }} blocks={blocks}
streaming={message.streaming}
showThinking={showThinkingAndTools}
/> />
</div> </div>
</div> </div>
</div>
); );
} }
const classNames = [styles.msg];
if (message.role === "user") classNames.push(styles.user);
if (message.role === "system") classNames.push(styles.system);
if (message.role === "user") { if (message.role === "user") {
return ( return (
<div className={styles.userRow}> <div className={styles.userRow}>
@@ -195,7 +205,7 @@ export function MessageBubble({
</div> </div>
) : null} ) : null}
</div> </div>
<ChatAvatar src={userAvatarUrl} className={styles.userAvatar} /> <ChatAvatar src={userAvatarUrl} fallback={DEFAULT_USER_AVATAR} className={styles.userAvatar} />
</div> </div>
); );
} }

View File

@@ -206,16 +206,11 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
font-size: 16px;
line-height: 1.46;
color: #666;
padding: 0; padding: 0;
margin: 0; margin: 0;
overflow: hidden; background: transparent;
background: #fff; border: none;
border: 1px solid #e8ecf0; box-shadow: none;
border-radius: 14px 14px 14px 4px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
} }
.thinking { .thinking {

View File

@@ -1,5 +1,24 @@
.collapsible { .block {
margin: 0; 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 { .summary {
@@ -7,45 +26,52 @@
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; justify-content: space-between;
padding: 6px 10px; gap: 8px;
padding: 8px 10px;
user-select: none; user-select: none;
font-weight: 500;
color: #555;
} }
.summary::-webkit-details-marker { .summary::-webkit-details-marker {
display: none; display: none;
} }
.summary::before { .toolName {
content: ""; font-weight: 600;
width: 0; color: #374151;
height: 0; font-size: 0.92em;
border-left: 5px solid #888; }
border-top: 3.5px solid transparent;
border-bottom: 3.5px solid transparent; .status {
flex-shrink: 0; flex-shrink: 0;
transform: rotate(0deg); font-size: 11px;
transition: transform 0.15s ease; color: #6b7280;
} }
.collapsible[open] > .summary::before { .pending .status {
transform: rotate(90deg); color: #2563eb;
} }
.summaryText { .success .status {
min-width: 0; color: #15803d;
flex: 1; }
overflow: hidden;
text-overflow: ellipsis; .error .status {
white-space: nowrap; color: #b91c1c;
} }
.detail { .detail {
margin: 0; margin: 0;
padding: 6px 10px 8px; padding: 8px 10px 10px;
border-top: 1px solid #eee; 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 { .detailPre {
@@ -55,6 +81,7 @@
word-break: break-word; word-break: break-word;
font-size: inherit; font-size: inherit;
line-height: inherit; line-height: inherit;
font-family: var(--font-mono, ui-monospace, monospace);
} }
.detailPre :global(.diffAdd) { .detailPre :global(.diffAdd) {

View File

@@ -6,17 +6,49 @@ interface ToolCallBlockProps {
forceOpen?: boolean; 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) { export function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) {
const bodyStart = content.indexOf("\n"); const { title, body, status } = parseToolCallContent(content);
const body = bodyStart >= 0 ? content.slice(bodyStart + 1) : ""; const hasDiff =
const hasDiff = body.includes("\n+") || body.includes("\n-") || body.startsWith("+") || body.startsWith("-"); body.includes("\n+") || body.includes("\n-") || body.startsWith("+") || body.startsWith("-");
const statusLabel = status === "success" ? "完成" : status === "error" ? "失败" : "执行中";
return ( return (
<details className={styles.collapsible} open={forceOpen}> <details
className={`${styles.block} ${styles[status]}`}
open={forceOpen || status === "pending"}
>
<summary className={styles.summary}> <summary className={styles.summary}>
<span className={styles.summaryText}>{deriveToolCallSummary(content)}</span> <span className={styles.toolName}>{title.split("(")[0] || deriveToolCallSummary(content)}</span>
<span className={styles.status}>{statusLabel}</span>
</summary> </summary>
{(body || content !== title) && (
<div className={styles.detail}> <div className={styles.detail}>
{title.includes("(") ? <div className={styles.args}>{title}</div> : null}
{hasDiff ? ( {hasDiff ? (
<pre <pre
className={styles.detailPre} className={styles.detailPre}
@@ -26,6 +58,7 @@ export function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) {
<pre className={styles.detailPre}>{body || content}</pre> <pre className={styles.detailPre}>{body || content}</pre>
)} )}
</div> </div>
)}
</details> </details>
); );
} }

View File

@@ -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;
}
}

View File

@@ -1,31 +0,0 @@
import styles from "./SplashScreen.module.css";
interface SplashScreenProps {
exiting?: boolean;
}
export function SplashScreen({ exiting = false }: SplashScreenProps) {
return (
<div className={`${styles.splash} ${exiting ? styles.splashExiting : ""}`} aria-hidden={exiting}>
<div className={styles.bgPulse} />
<div className={styles.bgGlow} />
<div className={styles.content}>
<div className={styles.logoWrap}>
<span className={styles.ring} />
<span className={`${styles.ring} ${styles.ring2}`} />
<span className={`${styles.ring} ${styles.ring3}`} />
<img className={styles.logo} src="/logo192.png" width={96} height={96} alt="" decoding="async" />
</div>
<div className={styles.titleBlock}>
<h1 className={styles.title}></h1>
<p className={styles.subtitle}></p>
</div>
<div className={styles.dots} aria-label="加载中">
<span className={styles.dot} />
<span className={`${styles.dot} ${styles.dot2}`} />
<span className={`${styles.dot} ${styles.dot3}`} />
</div>
</div>
</div>
);
}

View File

@@ -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<BootstrapContextValue | null>(null);
export function BootstrapProvider({ children }: { children: ReactNode }) {
const location = useLocation();
const route: BootstrapRoute = location.pathname.startsWith("/settings") ? "settings" : "chat";
const [readyRoutes, setReadyRoutes] = useState<Record<BootstrapRoute, boolean>>({
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 (
<BootstrapContext.Provider value={value}>
{children}
{splashVisible ? <SplashScreen exiting={splashExiting} /> : null}
</BootstrapContext.Provider>
);
}
export function useBootstrap(): BootstrapContextValue {
const ctx = useContext(BootstrapContext);
if (!ctx) throw new Error("useBootstrap must be used within BootstrapProvider");
return ctx;
}

View File

@@ -12,18 +12,22 @@ import {
import * as chatApi from "../api/chat"; import * as chatApi from "../api/chat";
import * as sessionsApi from "../api/sessions"; import * as sessionsApi from "../api/sessions";
import { apiUrl } from "../api/base"; import { apiUrl } from "../api/base";
import { useBootstrap } from "./BootstrapContext";
import type { ExtensionDialogState, RetryState, SseEvent } from "../types/events"; import type { ExtensionDialogState, RetryState, SseEvent } from "../types/events";
import type { RunPhase, RunPhaseState } from "../types/runPhase"; 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 type { SessionHistoryPayload, SessionSummary } from "../types/session";
import { nextMessageId, syncViewportHeight, stripAnsi } from "../utils/format"; import { nextMessageId, syncViewportHeight, stripAnsi } from "../utils/format";
import { formatBashOutput, parseBashInput } from "../utils/bash"; import { formatBashOutput, parseBashInput } from "../utils/bash";
import { buildHistoryMessages } from "../utils/historyMessages"; import { buildHistoryMessages } from "../utils/historyMessages";
import {
assistantPlainText,
extractAssistantBlocks,
hasTextBlock,
hasThinkingBlock,
} from "../utils/assistantBlocks";
import { import {
extractContent, extractContent,
extractImages, extractImages,
extractThinking,
formatToolCall, formatToolCall,
getToolCalls, getToolCalls,
buildToolCallUpdateText, buildToolCallUpdateText,
@@ -138,22 +142,8 @@ function historyToMessages(payload: SessionHistoryPayload): ChatMessage[] {
return normalizeChatMessages(buildHistoryMessages(payload)); return normalizeChatMessages(buildHistoryMessages(payload));
} }
function findStreamingThinkingId(messages: ChatMessage[]): string | null { function withoutStreamingTurnMessages(prev: ChatMessage[], assistantId: string | null): ChatMessage[] {
for (let i = messages.length - 1; i >= 0; i--) { return prev.filter((msg) => msg.id !== assistantId);
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);
} }
/** Index where the current turn begins (just after the last user message). */ /** Index where the current turn begins (just after the last user message). */
@@ -164,68 +154,78 @@ function lastTurnStartIndex(messages: ChatMessage[]): number {
return 0; 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[] { function stripStreamingTurnBubbles(messages: ChatMessage[]): ChatMessage[] {
const turnStart = lastTurnStartIndex(messages); const turnStart = lastTurnStartIndex(messages);
return messages.filter((msg, i) => { return messages.filter((msg, i) => {
if (i < turnStart) return true; if (i < turnStart) return true;
if (!msg.streaming) 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( function appendStreamingAssistantTurn(
prev: ChatMessage[], prev: ChatMessage[],
thinking: string, blocks: AssistantBlock[],
assistantText: string,
thinkingIdRef: { current: string | null },
assistantIdRef: { current: string | null }, assistantIdRef: { current: string | null },
): ChatMessage[] { ): ChatMessage[] {
// Reattach to any live streaming bubble whose id the ref may have lost (e.g. if (!assistantIdRef.current) {
// 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) {
assistantIdRef.current = findStreamingAssistantId(prev); assistantIdRef.current = findStreamingAssistantId(prev);
} }
const next = stripStreamingTurnBubbles(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 (assistantText.length > 0 || assistantIdRef.current) {
if (!assistantIdRef.current) { if (!assistantIdRef.current) {
assistantIdRef.current = nextMessageId(); assistantIdRef.current = nextMessageId();
} }
turnMessages.push({
id: assistantIdRef.current,
role: "assistant",
content: assistantText,
streaming: true,
});
}
return [...next, ...turnMessages]; const id = assistantIdRef.current;
return [
...next,
{
id,
role: "assistant" as const,
blocks,
content: assistantPlainText(blocks),
streaming: true,
},
];
} }
function finalizeStreamingTurn(prev: ChatMessage[]): ChatMessage[] { function finalizeStreamingTurn(prev: ChatMessage[]): ChatMessage[] {
const turnStart = lastTurnStartIndex(prev); 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) => { const survivors = prev.filter((msg, i) => {
if (i < turnStart || msg.role !== "assistant" || !msg.streaming) return true; if (i < turnStart || msg.role !== "assistant" || !msg.streaming) return true;
const shorter = msg.content.trim(); const shorter = msg.content.trim();
@@ -242,70 +242,21 @@ function finalizeStreamingTurn(prev: ChatMessage[]): ChatMessage[] {
return true; return true;
}); });
// Clear the streaming flag on every remaining bubble in the turn.
const survivorTurnStart = lastTurnStartIndex(survivors); const survivorTurnStart = lastTurnStartIndex(survivors);
return survivors.map((msg, i) => return survivors.map((msg, i) =>
i >= survivorTurnStart && msg.streaming ? { ...msg, streaming: false } : msg, 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( function applyAssistantMessageUpdate(
prev: ChatMessage[], prev: ChatMessage[],
thinking: string, blocks: AssistantBlock[],
assistantText: string,
thinkingIdRef: { current: string | null },
assistantIdRef: { current: string | null }, assistantIdRef: { current: string | null },
): ChatMessage[] { ): ChatMessage[] {
const plainText = assistantPlainText(blocks);
const lastAssistant = findLastTurnAssistantMessage(prev); const lastAssistant = findLastTurnAssistantMessage(prev);
if (lastAssistant && !lastAssistant.streaming) { if (lastAssistant && !lastAssistant.streaming) {
const trimmed = assistantText.trim(); const trimmed = plainText.trim();
if ( if (
trimmed && trimmed &&
(lastAssistant.content === trimmed || (lastAssistant.content === trimmed ||
@@ -314,46 +265,36 @@ function applyAssistantMessageUpdate(
) { ) {
if (trimmed.length >= lastAssistant.content.length) { if (trimmed.length >= lastAssistant.content.length) {
return prev.map((msg) => return prev.map((msg) =>
msg.id === lastAssistant.id ? { ...msg, content: assistantText } : msg, msg.id === lastAssistant.id
? { ...msg, blocks, content: plainText }
: msg,
); );
} }
return prev; return prev;
} }
} }
return appendStreamingAssistantTurn(prev, thinking, assistantText, thinkingIdRef, assistantIdRef); return appendStreamingAssistantTurn(prev, blocks, assistantIdRef);
} }
function finalizeAssistantMessageEnd( function finalizeAssistantMessageEnd(
prev: ChatMessage[], prev: ChatMessage[],
thinking: string, blocks: AssistantBlock[],
assistantText: string,
toolCalls: ToolCallBlock[], toolCalls: ToolCallBlock[],
thinkingId: string | null,
assistantId: string | null, assistantId: string | null,
): ChatMessage[] { ): ChatMessage[] {
let next = withoutStreamingTurnMessages(prev, thinkingId, assistantId); let next = withoutStreamingTurnMessages(prev, assistantId);
if (thinking) { const plainText = assistantPlainText(blocks).trim();
next = [ if (plainText || blocks.some((b) => b.type === "thinking")) {
...next,
{
id: thinkingId ?? nextMessageId(),
role: "thinking" as const,
content: thinking,
},
];
}
const trimmedAssistant = assistantText.trim();
if (trimmedAssistant) {
const lastAssistant = findLastTurnAssistantMessage(next); const lastAssistant = findLastTurnAssistantMessage(next);
if (!(lastAssistant && !lastAssistant.streaming && lastAssistant.content === trimmedAssistant)) { if (!(lastAssistant && !lastAssistant.streaming && lastAssistant.content === plainText)) {
next = [ next = [
...next, ...next,
{ {
id: assistantId ?? nextMessageId(), id: assistantId ?? nextMessageId(),
role: "assistant" as const, 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) { if (msg.role === "tool_call" && msg.toolCallId) {
return `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; return null;
} }
const content = msg.content.trim().replace(/\s+/g, " "); 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}`; return `${msg.role}:${msg.toolCallId ?? ""}:${content}`;
} }
@@ -494,7 +439,6 @@ function isIgnorableExtensionError(error: string | undefined): boolean {
} }
export function ChatProvider({ children }: { children: ReactNode }) { export function ChatProvider({ children }: { children: ReactNode }) {
const { markRouteReady } = useBootstrap();
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [messages, setRawMessages] = useState<ChatMessage[]>([]); const [messages, setRawMessages] = useState<ChatMessage[]>([]);
@@ -536,7 +480,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const ensureSessionCreatePromise = useRef<Promise<boolean> | null>(null); const ensureSessionCreatePromise = useRef<Promise<boolean> | null>(null);
const sessionBootRef = useRef<Promise<void> | null>(null); const sessionBootRef = useRef<Promise<void> | null>(null);
const streamingAssistantId = useRef<string | null>(null); const streamingAssistantId = useRef<string | null>(null);
const streamingThinkingId = useRef<string | null>(null);
const optimisticUserIdRef = useRef<string | null>(null); const optimisticUserIdRef = useRef<string | null>(null);
const compactionReloadPendingRef = useRef(false); const compactionReloadPendingRef = useRef(false);
const activeBashMsgIdRef = useRef<string | null>(null); const activeBashMsgIdRef = useRef<string | null>(null);
@@ -580,7 +523,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const resetStreamingState = useCallback(() => { const resetStreamingState = useCallback(() => {
streamingAssistantId.current = null; streamingAssistantId.current = null;
streamingThinkingId.current = null;
optimisticUserIdRef.current = null; optimisticUserIdRef.current = null;
clearRunPhase(); clearRunPhase();
}, [clearRunPhase]); }, [clearRunPhase]);
@@ -1535,7 +1477,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
setIsStreaming(true); setIsStreaming(true);
setConnected(true); setConnected(true);
streamingAssistantId.current = null; streamingAssistantId.current = null;
streamingThinkingId.current = null;
updateRunPhase("starting"); updateRunPhase("starting");
scheduleSessionDashboardRefresh(); scheduleSessionDashboardRefresh();
break; break;
@@ -1545,7 +1486,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
setConnected(true); setConnected(true);
setMessages((prev) => finalizeStreamingTurn(prev)); setMessages((prev) => finalizeStreamingTurn(prev));
streamingAssistantId.current = null; streamingAssistantId.current = null;
streamingThinkingId.current = null;
if (backendSessionPathRef.current) { if (backendSessionPathRef.current) {
sessionCache.current.delete(backendSessionPathRef.current); sessionCache.current.delete(backendSessionPathRef.current);
} }
@@ -1591,21 +1531,14 @@ export function ChatProvider({ children }: { children: ReactNode }) {
case "message_update": { case "message_update": {
const m = ev.message; const m = ev.message;
if (m.role === "assistant") { if (m.role === "assistant") {
const thinking = extractThinking(m.content); const blocks = extractAssistantBlocks(m.content);
const assistantText = extractContent(m.content); if (hasThinkingBlock(blocks)) {
if (thinking.length > 0) {
updateRunPhase("thinking"); updateRunPhase("thinking");
} else if (assistantText.length > 0) { } else if (hasTextBlock(blocks)) {
updateRunPhase("writing"); updateRunPhase("writing");
} }
setMessages((prev) => setMessages((prev) =>
applyAssistantMessageUpdate( applyAssistantMessageUpdate(prev, blocks, streamingAssistantId),
prev,
thinking,
assistantText,
streamingThinkingId,
streamingAssistantId,
),
); );
} }
break; break;
@@ -1614,28 +1547,24 @@ export function ChatProvider({ children }: { children: ReactNode }) {
case "message_end": { case "message_end": {
const m = ev.message; const m = ev.message;
if (m.role === "assistant") { if (m.role === "assistant") {
const thinking = extractThinking(m.content); const blocks = extractAssistantBlocks(m.content);
const assistantText = extractContent(m.content);
const toolCalls = getToolCalls(m.content); const toolCalls = getToolCalls(m.content);
if (toolCalls.length > 0) { if (toolCalls.length > 0) {
updateRunPhase("processing"); updateRunPhase("processing");
} else if (thinking.length > 0 && assistantText.length === 0) { } else if (hasThinkingBlock(blocks) && !hasTextBlock(blocks)) {
updateRunPhase("thinking"); updateRunPhase("thinking");
} else if (assistantText.length > 0) { } else if (hasTextBlock(blocks)) {
updateRunPhase("writing"); updateRunPhase("writing");
} }
setMessages((prev) => setMessages((prev) =>
finalizeAssistantMessageEnd( finalizeAssistantMessageEnd(
prev, prev,
thinking, blocks,
assistantText,
toolCalls, toolCalls,
resolveTurnThinkingId(prev, streamingThinkingId.current),
resolveTurnAssistantId(prev, streamingAssistantId.current), resolveTurnAssistantId(prev, streamingAssistantId.current),
), ),
); );
streamingAssistantId.current = null; streamingAssistantId.current = null;
streamingThinkingId.current = null;
scheduleSessionDashboardRefresh(); scheduleSessionDashboardRefresh();
} }
break; break;
@@ -1839,11 +1768,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
], ],
); );
const markRouteReadyRef = useRef(markRouteReady);
const handleSseEventRef = useRef(handleSseEvent); const handleSseEventRef = useRef(handleSseEvent);
const addSystemMessageRef = useRef(addSystemMessage); const addSystemMessageRef = useRef(addSystemMessage);
const restoreSessionOnBootRef = useRef(restoreSessionOnBoot); const restoreSessionOnBootRef = useRef(restoreSessionOnBoot);
markRouteReadyRef.current = markRouteReady;
handleSseEventRef.current = handleSseEvent; handleSseEventRef.current = handleSseEvent;
addSystemMessageRef.current = addSystemMessage; addSystemMessageRef.current = addSystemMessage;
restoreSessionOnBootRef.current = restoreSessionOnBoot; restoreSessionOnBootRef.current = restoreSessionOnBoot;
@@ -1860,29 +1787,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
window.addEventListener("orientationchange", syncViewportHeight); window.addEventListener("orientationchange", syncViewportHeight);
let cancelled = false; let cancelled = false;
const bootstrap = {
sessions: false,
models: false,
};
const tryMarkBootstrapReady = () => { void restoreSessionOnBootRef.current().catch(() => {});
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 chatApi void chatApi
.fetchModels() .fetchModels()
@@ -1895,10 +1801,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`, `模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`,
); );
} }
})
.finally(() => {
bootstrap.models = true;
tryMarkBootstrapReady();
}); });
let eventSource: EventSource | null = null; let eventSource: EventSource | null = null;

View File

@@ -4,7 +4,6 @@ import * as promptsApi from "../api/prompts";
import type { PromptFile } from "../api/prompts"; import type { PromptFile } from "../api/prompts";
import * as settingsApi from "../api/settings"; import * as settingsApi from "../api/settings";
import { useAvatars } from "../context/AvatarContext"; import { useAvatars } from "../context/AvatarContext";
import { useBootstrap } from "../context/BootstrapContext";
import { useChatContext } from "../context/ChatContext"; import { useChatContext } from "../context/ChatContext";
import type { AgentToolInfo, EnvironmentInfo, EnvironmentToolsInfo, ExtensionInfo, McpServerInfo, RuntimeInfo, SettingsPaneId, SkillInfo } from "../types/events"; import type { AgentToolInfo, EnvironmentInfo, EnvironmentToolsInfo, ExtensionInfo, McpServerInfo, RuntimeInfo, SettingsPaneId, SkillInfo } from "../types/events";
import type { McpToolInfo } from "../types/events"; import type { McpToolInfo } from "../types/events";
@@ -308,7 +307,6 @@ function McpToolItem({
} }
export function SettingsPage() { export function SettingsPage() {
const { markRouteReady } = useBootstrap();
const { updateAvatars } = useAvatars(); const { updateAvatars } = useAvatars();
const { showThinkingAndTools, toggleShowThinkingAndTools } = useChatContext(); const { showThinkingAndTools, toggleShowThinkingAndTools } = useChatContext();
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt"); const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
@@ -408,9 +406,8 @@ export function SettingsPage() {
setStatus(`加载设置失败: ${err instanceof Error ? err.message : String(err)}`, "error"); setStatus(`加载设置失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally { } finally {
setLoading(false); setLoading(false);
markRouteReady("settings");
} }
}, [markRouteReady, updateAvatars]); }, [updateAvatars]);
const reloadSettings = async () => { const reloadSettings = async () => {
setReloading(true); setReloading(true);

View File

@@ -53,10 +53,18 @@ export interface ToolCallBlock {
input?: unknown; input?: unknown;
} }
/** Ordered assistant content block (matches SproutClaw CLI assistant message layout). */
export interface AssistantBlock {
type: "thinking" | "text";
content: string;
}
export interface ChatMessage { export interface ChatMessage {
id: string; id: string;
role: MessageRole; role: MessageRole;
content: string; content: string;
/** Thinking + text blocks in RPC order (assistant role only). */
blocks?: AssistantBlock[];
images?: ImageContent[]; images?: ImageContent[];
toolCallId?: string; toolCallId?: string;
bashCommand?: string; bashCommand?: string;

View File

@@ -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 }] : [];
}

View File

@@ -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(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">' +
'<circle cx="16" cy="16" r="16" fill="#e8ecf0"/>' +
'<circle cx="16" cy="12" r="5" fill="#94a3b8"/>' +
'<path d="M6 28c0-5.5 4.5-10 10-10s10 4.5 10 10" fill="#94a3b8"/>' +
"</svg>",
);
export function resolveAvatarUrl(url: string | undefined | null, fallback: string): string {
const trimmed = url?.trim();
return trimmed || fallback;
}

View File

@@ -1,4 +1,5 @@
import type { ChatMessage } from "../types/message"; import type { ChatMessage } from "../types/message";
import { resolveAssistantBlocks } from "./assistantBlocks";
import { escHtml } from "./format"; import { escHtml } from "./format";
function roleLabel(role: ChatMessage["role"]): string { function roleLabel(role: ChatMessage["role"]): string {
@@ -35,9 +36,22 @@ export function messagesToMarkdown(title: string, messages: ChatMessage[]): stri
case "user": case "user":
lines.push("## 用户", "", m.content, ""); lines.push("## 用户", "", m.content, "");
break; break;
case "assistant": 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, ""); lines.push("## 助手", "", m.content, "");
}
break; break;
}
case "thinking": case "thinking":
lines.push("## 思考", "", m.content, ""); lines.push("## 思考", "", m.content, "");
break; break;

View File

@@ -1,53 +1,32 @@
import type { ChatMessage, SessionHistoryPayload, ToolCallBlock } from "../types/message"; import type { ChatMessage, SessionHistoryPayload } from "../types/message";
import { nextMessageId } from "./format"; import { nextMessageId } from "./format";
import {
assistantPlainText,
extractAssistantBlocks,
} from "./assistantBlocks";
import { import {
extractContent, extractContent,
extractImages, extractImages,
extractThinking,
formatToolCall, formatToolCall,
formatToolResult, formatToolResult,
getToolCalls, getToolCalls,
upsertToolCallMessage, upsertToolCallMessage,
} from "./toolCall"; } from "./toolCall";
function appendAssistantBlocks(m: { content?: string | unknown[] }, result: ChatMessage[]): void { function appendAssistantTurn(m: { content?: string | unknown[] }, result: ChatMessage[]): void {
if (Array.isArray(m.content)) { const blocks = extractAssistantBlocks(m.content as string | undefined);
for (const block of m.content) { const toolCalls = getToolCalls(m.content as string | undefined);
const b = block as { type?: string; thinking?: string; text?: string };
if (b.type === "thinking") { if (blocks.length > 0) {
const thinking = (b.thinking ?? "").trim(); result.push({
if (thinking) { id: nextMessageId(),
result.push({ id: nextMessageId(), role: "thinking", content: thinking }); role: "assistant",
} blocks,
} else if (b.type === "text") { content: assistantPlainText(blocks),
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; for (const tc of toolCalls) {
}
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)) {
const tid = tc.toolCallId || tc.id; const tid = tc.toolCallId || tc.id;
const tidStr = tid ? String(tid) : undefined; const tidStr = tid ? String(tid) : undefined;
upsertToolCallMessage(result, { upsertToolCallMessage(result, {
@@ -74,7 +53,7 @@ export function buildHistoryMessages(payload: SessionHistoryPayload): ChatMessag
}); });
} }
} else if (m.role === "assistant") { } else if (m.role === "assistant") {
appendAssistantBlocks(m, result); appendAssistantTurn(m, result);
} else if (m.role === "toolResult" || m.role === "tool") { } else if (m.role === "toolResult" || m.role === "tool") {
const text = formatToolResult({ content: m.content }); const text = formatToolResult({ content: m.content });
if (text.trim()) { if (text.trim()) {
@@ -117,5 +96,8 @@ export function buildHistoryMessages(payload: SessionHistoryPayload): ChatMessag
} }
export function extractThinkingFromMessage(m: { content?: string | unknown[] }): string { 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");
} }