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:
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
dist/
|
||||
dist-desketop/
|
||||
dist-desketop/
|
||||
public/fonts/*.ttf
|
||||
Binary file not shown.
@@ -1,38 +1,63 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const fontsDir = join(__dirname, "..", "public", "fonts");
|
||||
const outputWoff2 = join(fontsDir, "lxgwwenkaimono-medium.woff2");
|
||||
const sourceTtf = join(__dirname, "..", "assets", "fonts", "LXGWWenKaiMono-Medium.ttf");
|
||||
|
||||
const sourceCandidates = [
|
||||
join(fontsDir, "LXGWWenKaiMono-Medium.ttf"),
|
||||
join(__dirname, "..", "assets", "fonts", "LXGWWenKaiMono-Medium.ttf"),
|
||||
];
|
||||
|
||||
const sourceTtf = sourceCandidates.find((path) => existsSync(path));
|
||||
/** Full CJK font woff2 is typically 7–10 MB; reject obvious subset/corrupt output. */
|
||||
const MIN_WOFF2_BYTES = 5_000_000;
|
||||
|
||||
if (!sourceTtf) {
|
||||
console.warn("[prepare-font] LXGWWenKaiMono-Medium.ttf not found, skipping");
|
||||
if (!existsSync(sourceTtf)) {
|
||||
console.warn(`[prepare-font] source not found: ${sourceTtf}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
mkdirSync(fontsDir, { recursive: true });
|
||||
|
||||
function removePublicTtfCopies() {
|
||||
for (const name of readdirSync(fontsDir)) {
|
||||
if (name.toLowerCase().endsWith(".ttf")) {
|
||||
const path = join(fontsDir, name);
|
||||
unlinkSync(path);
|
||||
console.log(`[prepare-font] removed public TTF copy (woff2 only): ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function woff2LooksValid() {
|
||||
if (!existsSync(outputWoff2)) return false;
|
||||
return readFileSync(outputWoff2).length >= MIN_WOFF2_BYTES;
|
||||
}
|
||||
|
||||
function verifyGlyphCount() {
|
||||
const py = [
|
||||
"from fontTools.ttLib import TTFont",
|
||||
"import sys",
|
||||
"src, out = sys.argv[1], sys.argv[2]",
|
||||
"source_glyphs = len(TTFont(src).getGlyphOrder())",
|
||||
"output_glyphs = len(TTFont(out).getGlyphOrder())",
|
||||
"if source_glyphs != output_glyphs:",
|
||||
"\traise SystemExit(f'glyph count mismatch: source={source_glyphs}, output={output_glyphs}')",
|
||||
"print(output_glyphs)",
|
||||
].join("\n");
|
||||
const count = execFileSync("python", ["-c", py, sourceTtf, outputWoff2], {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
console.log(`[prepare-font] verified full glyph set (${count} glyphs)`);
|
||||
}
|
||||
|
||||
removePublicTtfCopies();
|
||||
|
||||
if (woff2LooksValid()) {
|
||||
const sourceMtime = statSync(sourceTtf).mtimeMs;
|
||||
const outputMtime = statSync(outputWoff2).mtimeMs;
|
||||
if (outputMtime >= sourceMtime) {
|
||||
const sizeMb = (readFileSync(outputWoff2).length / (1024 * 1024)).toFixed(2);
|
||||
console.log(`[prepare-font] up to date ${outputWoff2} (${sizeMb} MB)`);
|
||||
console.log(`[prepare-font] up to date ${outputWoff2} (${sizeMb} MB, woff2 only)`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
@@ -42,12 +67,14 @@ if (existsSync(outputWoff2) && !woff2LooksValid()) {
|
||||
unlinkSync(outputWoff2);
|
||||
}
|
||||
|
||||
console.log("[prepare-font] converting full font to woff2 (no glyph subsetting, may take a few minutes)...");
|
||||
console.log(
|
||||
"[prepare-font] converting full font to woff2 (no glyph subsetting, glyf/loca/hmtx transforms)...",
|
||||
);
|
||||
|
||||
const py = [
|
||||
"from fontTools.ttLib.woff2 import compress",
|
||||
"import sys",
|
||||
"compress(sys.argv[1], sys.argv[2])",
|
||||
"compress(sys.argv[1], sys.argv[2], transform_tables={'glyf', 'loca', 'hmtx'})",
|
||||
].join("\n");
|
||||
|
||||
execFileSync("python", ["-c", py, sourceTtf, outputWoff2], { stdio: "inherit" });
|
||||
@@ -59,5 +86,8 @@ if (sizeBytes < MIN_WOFF2_BYTES) {
|
||||
);
|
||||
}
|
||||
|
||||
verifyGlyphCount();
|
||||
removePublicTtfCopies();
|
||||
|
||||
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(2);
|
||||
console.log(`[prepare-font] wrote ${outputWoff2} (${sizeMb} MB, full glyph set)`);
|
||||
console.log(`[prepare-font] wrote ${outputWoff2} (${sizeMb} MB, full glyph set, woff2 only)`);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
35
frontend/src/components/chat/AssistantTurnBody.module.css
Normal file
35
frontend/src/components/chat/AssistantTurnBody.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
48
frontend/src/components/chat/AssistantTurnBody.tsx
Normal file
48
frontend/src/components/chat/AssistantTurnBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
61
frontend/src/utils/assistantBlocks.ts
Normal file
61
frontend/src/utils/assistantBlocks.ts
Normal 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 }] : [];
|
||||
}
|
||||
18
frontend/src/utils/avatars.ts
Normal file
18
frontend/src/utils/avatars.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user