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

3
frontend/.gitignore vendored
View File

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

View File

@@ -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 710 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)`);

View File

@@ -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 (
<BrowserRouter>
<BootstrapProvider>
<AvatarProvider>
<ChatProvider>
<Routes>
<Route path="/" element={<ChatLayout />}>
<Route index element={<ChatPage />} />
</Route>
<Route
path="/settings"
element={
<Suspense fallback={null}>
<SettingsPage />
</Suspense>
}
/>
</Routes>
<PwaUpdatePrompt />
</ChatProvider>
</AvatarProvider>
</BootstrapProvider>
<AvatarProvider>
<ChatProvider>
<Routes>
<Route path="/" element={<ChatLayout />}>
<Route index element={<ChatPage />} />
</Route>
<Route
path="/settings"
element={
<Suspense fallback={null}>
<SettingsPage />
</Suspense>
}
/>
</Routes>
<PwaUpdatePrompt />
</ChatProvider>
</AvatarProvider>
</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 { 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<Awaited<ReturnType<typeof fetchSessionHistory>>["messages"]>,
messages: NonNullable<SessionHistoryPayload["messages"]>,
): 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 }) {

View File

@@ -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<string | null>(null);
const displaySrc = trimmed && failedSrc !== trimmed ? trimmed : fallback;
return (
<img
className={className}
src={src}
src={displaySrc}
width={32}
height={32}
alt=""
decoding="async"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
onError={() => {
if (trimmed) setFailedSrc(trimmed);
}}
/>
);
}
@@ -43,7 +57,7 @@ function AgentSideGutter({ mode, avatarUrl }: { mode: "avatar" | "spacer"; avata
}
return (
<div className={styles.assistantAvatarSlot}>
<ChatAvatar src={avatarUrl} className={styles.assistantAvatar} />
<ChatAvatar src={avatarUrl} fallback={DEFAULT_AGENT_AVATAR} className={styles.assistantAvatar} />
</div>
);
}
@@ -94,22 +108,12 @@ export function MessageBubble({
toolsExpanded = false,
}: MessageBubbleProps) {
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
const { showThinkingAndTools } = useChatContext();
const gutter =
agentGutter === "avatar" || agentGutter === "spacer" ? (
<AgentSideGutter mode={agentGutter} avatarUrl={agentAvatarUrl} />
) : 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") {
return (
<div className={styles.assistantRow}>
@@ -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 (
<div className={styles.assistantRow}>
{gutter}
<div className={classNames.join(" ")}>
{showActions ? <AssistantActions content={message.content} /> : null}
<div
className={styles.assistantBody}
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
<div className={styles.assistantBody}>
<AssistantTurnBody
blocks={blocks}
streaming={message.streaming}
showThinking={showThinkingAndTools}
/>
</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") {
return (
<div className={styles.userRow}>
@@ -195,7 +205,7 @@ export function MessageBubble({
</div>
) : null}
</div>
<ChatAvatar src={userAvatarUrl} className={styles.userAvatar} />
<ChatAvatar src={userAvatarUrl} fallback={DEFAULT_USER_AVATAR} className={styles.userAvatar} />
</div>
);
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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 (
<details className={styles.collapsible} open={forceOpen}>
<details
className={`${styles.block} ${styles[status]}`}
open={forceOpen || status === "pending"}
>
<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>
<div className={styles.detail}>
{hasDiff ? (
<pre
className={styles.detailPre}
dangerouslySetInnerHTML={{ __html: formatToolBodyHtml(body || content) }}
/>
) : (
<pre className={styles.detailPre}>{body || content}</pre>
)}
</div>
{(body || content !== title) && (
<div className={styles.detail}>
{title.includes("(") ? <div className={styles.args}>{title}</div> : null}
{hasDiff ? (
<pre
className={styles.detailPre}
dangerouslySetInnerHTML={{ __html: formatToolBodyHtml(body || content) }}
/>
) : (
<pre className={styles.detailPre}>{body || content}</pre>
)}
</div>
)}
</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 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<ChatMessage[]>([]);
@@ -536,7 +480,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const ensureSessionCreatePromise = useRef<Promise<boolean> | null>(null);
const sessionBootRef = useRef<Promise<void> | null>(null);
const streamingAssistantId = useRef<string | null>(null);
const streamingThinkingId = useRef<string | null>(null);
const optimisticUserIdRef = useRef<string | null>(null);
const compactionReloadPendingRef = useRef(false);
const activeBashMsgIdRef = useRef<string | null>(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;

View File

@@ -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<SettingsPaneId>("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);

View File

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

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 { 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;

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