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:
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist/
|
dist/
|
||||||
dist-desketop/
|
dist-desketop/
|
||||||
|
public/fonts/*.ttf
|
||||||
Binary file not shown.
@@ -1,38 +1,63 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const fontsDir = join(__dirname, "..", "public", "fonts");
|
const fontsDir = join(__dirname, "..", "public", "fonts");
|
||||||
const outputWoff2 = join(fontsDir, "lxgwwenkaimono-medium.woff2");
|
const outputWoff2 = join(fontsDir, "lxgwwenkaimono-medium.woff2");
|
||||||
|
const sourceTtf = join(__dirname, "..", "assets", "fonts", "LXGWWenKaiMono-Medium.ttf");
|
||||||
|
|
||||||
const sourceCandidates = [
|
/** Full CJK font woff2 is typically 7–10 MB; reject obvious subset/corrupt output. */
|
||||||
join(fontsDir, "LXGWWenKaiMono-Medium.ttf"),
|
|
||||||
join(__dirname, "..", "assets", "fonts", "LXGWWenKaiMono-Medium.ttf"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const sourceTtf = sourceCandidates.find((path) => existsSync(path));
|
|
||||||
const MIN_WOFF2_BYTES = 5_000_000;
|
const MIN_WOFF2_BYTES = 5_000_000;
|
||||||
|
|
||||||
if (!sourceTtf) {
|
if (!existsSync(sourceTtf)) {
|
||||||
console.warn("[prepare-font] LXGWWenKaiMono-Medium.ttf not found, skipping");
|
console.warn(`[prepare-font] source not found: ${sourceTtf}`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
mkdirSync(fontsDir, { recursive: true });
|
mkdirSync(fontsDir, { recursive: true });
|
||||||
|
|
||||||
|
function removePublicTtfCopies() {
|
||||||
|
for (const name of readdirSync(fontsDir)) {
|
||||||
|
if (name.toLowerCase().endsWith(".ttf")) {
|
||||||
|
const path = join(fontsDir, name);
|
||||||
|
unlinkSync(path);
|
||||||
|
console.log(`[prepare-font] removed public TTF copy (woff2 only): ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function woff2LooksValid() {
|
function woff2LooksValid() {
|
||||||
if (!existsSync(outputWoff2)) return false;
|
if (!existsSync(outputWoff2)) return false;
|
||||||
return readFileSync(outputWoff2).length >= MIN_WOFF2_BYTES;
|
return readFileSync(outputWoff2).length >= MIN_WOFF2_BYTES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verifyGlyphCount() {
|
||||||
|
const py = [
|
||||||
|
"from fontTools.ttLib import TTFont",
|
||||||
|
"import sys",
|
||||||
|
"src, out = sys.argv[1], sys.argv[2]",
|
||||||
|
"source_glyphs = len(TTFont(src).getGlyphOrder())",
|
||||||
|
"output_glyphs = len(TTFont(out).getGlyphOrder())",
|
||||||
|
"if source_glyphs != output_glyphs:",
|
||||||
|
"\traise SystemExit(f'glyph count mismatch: source={source_glyphs}, output={output_glyphs}')",
|
||||||
|
"print(output_glyphs)",
|
||||||
|
].join("\n");
|
||||||
|
const count = execFileSync("python", ["-c", py, sourceTtf, outputWoff2], {
|
||||||
|
encoding: "utf8",
|
||||||
|
}).trim();
|
||||||
|
console.log(`[prepare-font] verified full glyph set (${count} glyphs)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
removePublicTtfCopies();
|
||||||
|
|
||||||
if (woff2LooksValid()) {
|
if (woff2LooksValid()) {
|
||||||
const sourceMtime = statSync(sourceTtf).mtimeMs;
|
const sourceMtime = statSync(sourceTtf).mtimeMs;
|
||||||
const outputMtime = statSync(outputWoff2).mtimeMs;
|
const outputMtime = statSync(outputWoff2).mtimeMs;
|
||||||
if (outputMtime >= sourceMtime) {
|
if (outputMtime >= sourceMtime) {
|
||||||
const sizeMb = (readFileSync(outputWoff2).length / (1024 * 1024)).toFixed(2);
|
const sizeMb = (readFileSync(outputWoff2).length / (1024 * 1024)).toFixed(2);
|
||||||
console.log(`[prepare-font] up to date ${outputWoff2} (${sizeMb} MB)`);
|
console.log(`[prepare-font] up to date ${outputWoff2} (${sizeMb} MB, woff2 only)`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,12 +67,14 @@ if (existsSync(outputWoff2) && !woff2LooksValid()) {
|
|||||||
unlinkSync(outputWoff2);
|
unlinkSync(outputWoff2);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[prepare-font] converting full font to woff2 (no glyph subsetting, may take a few minutes)...");
|
console.log(
|
||||||
|
"[prepare-font] converting full font to woff2 (no glyph subsetting, glyf/loca/hmtx transforms)...",
|
||||||
|
);
|
||||||
|
|
||||||
const py = [
|
const py = [
|
||||||
"from fontTools.ttLib.woff2 import compress",
|
"from fontTools.ttLib.woff2 import compress",
|
||||||
"import sys",
|
"import sys",
|
||||||
"compress(sys.argv[1], sys.argv[2])",
|
"compress(sys.argv[1], sys.argv[2], transform_tables={'glyf', 'loca', 'hmtx'})",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
execFileSync("python", ["-c", py, sourceTtf, outputWoff2], { stdio: "inherit" });
|
execFileSync("python", ["-c", py, sourceTtf, outputWoff2], { stdio: "inherit" });
|
||||||
@@ -59,5 +86,8 @@ if (sizeBytes < MIN_WOFF2_BYTES) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verifyGlyphCount();
|
||||||
|
removePublicTtfCopies();
|
||||||
|
|
||||||
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(2);
|
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(2);
|
||||||
console.log(`[prepare-font] wrote ${outputWoff2} (${sizeMb} MB, full glyph set)`);
|
console.log(`[prepare-font] wrote ${outputWoff2} (${sizeMb} MB, full glyph set, woff2 only)`);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
|
|||||||
import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt";
|
import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt";
|
||||||
import { ChatLayout } from "./components/layout/ChatLayout";
|
import { ChatLayout } from "./components/layout/ChatLayout";
|
||||||
import { AvatarProvider } from "./context/AvatarContext";
|
import { AvatarProvider } from "./context/AvatarContext";
|
||||||
import { BootstrapProvider } from "./context/BootstrapContext";
|
|
||||||
import { ChatProvider } from "./context/ChatContext";
|
import { ChatProvider } from "./context/ChatContext";
|
||||||
import { ChatPage } from "./routes/ChatPage";
|
import { ChatPage } from "./routes/ChatPage";
|
||||||
|
|
||||||
@@ -12,7 +11,6 @@ const SettingsPage = lazy(() => import("./routes/SettingsPage"));
|
|||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<BootstrapProvider>
|
|
||||||
<AvatarProvider>
|
<AvatarProvider>
|
||||||
<ChatProvider>
|
<ChatProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -31,7 +29,6 @@ export function App() {
|
|||||||
<PwaUpdatePrompt />
|
<PwaUpdatePrompt />
|
||||||
</ChatProvider>
|
</ChatProvider>
|
||||||
</AvatarProvider>
|
</AvatarProvider>
|
||||||
</BootstrapProvider>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { useEffect, useRef, useState } from "react";
|
||||||
import { fetchSessionHistory } from "../../api/sessions";
|
import { fetchSessionHistory } from "../../api/sessions";
|
||||||
import { useChatContext } from "../../context/ChatContext";
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
import type { ChatMessage, ToolCallBlock } from "../../types/message";
|
import type { ChatMessage, SessionHistoryPayload } from "../../types/message";
|
||||||
import { extractContent, extractThinking, formatToolCall, getToolCalls } from "../../utils/toolCall";
|
import { buildHistoryMessages } from "../../utils/historyMessages";
|
||||||
import { nextMessageId } from "../../utils/format";
|
|
||||||
import {
|
import {
|
||||||
downloadTextFile,
|
downloadTextFile,
|
||||||
messagesToMarkdown,
|
messagesToMarkdown,
|
||||||
@@ -13,56 +12,9 @@ import {
|
|||||||
import styles from "./ExportMenu.module.css";
|
import styles from "./ExportMenu.module.css";
|
||||||
|
|
||||||
function historyToExportMessages(
|
function historyToExportMessages(
|
||||||
messages: NonNullable<Awaited<ReturnType<typeof fetchSessionHistory>>["messages"]>,
|
messages: NonNullable<SessionHistoryPayload["messages"]>,
|
||||||
): ChatMessage[] {
|
): ChatMessage[] {
|
||||||
const result: ChatMessage[] = [];
|
return buildHistoryMessages({ messages });
|
||||||
for (const m of messages) {
|
|
||||||
if (m.role === "user") {
|
|
||||||
result.push({ id: nextMessageId(), role: "user", content: extractContent(m.content) });
|
|
||||||
} else if (m.role === "assistant") {
|
|
||||||
if (Array.isArray(m.content)) {
|
|
||||||
for (const block of m.content) {
|
|
||||||
if (block.type === "thinking") {
|
|
||||||
const thinking = (block.thinking ?? "").trim();
|
|
||||||
if (thinking) {
|
|
||||||
result.push({ id: nextMessageId(), role: "thinking", content: thinking });
|
|
||||||
}
|
|
||||||
} else if (block.type === "text") {
|
|
||||||
const text = (block.text ?? "").trim();
|
|
||||||
if (text) {
|
|
||||||
result.push({ id: nextMessageId(), role: "assistant", content: text });
|
|
||||||
}
|
|
||||||
} else if (block.type === "toolCall") {
|
|
||||||
const tc = block as ToolCallBlock;
|
|
||||||
const tid = tc.toolCallId || tc.id;
|
|
||||||
result.push({
|
|
||||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
|
||||||
role: "tool_call",
|
|
||||||
content: formatToolCall(tc),
|
|
||||||
toolCallId: tid ? String(tid) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const thinking = extractThinking(m.content);
|
|
||||||
if (thinking) {
|
|
||||||
result.push({ id: nextMessageId(), role: "thinking", content: thinking });
|
|
||||||
}
|
|
||||||
const text = extractContent(m.content);
|
|
||||||
if (text) result.push({ id: nextMessageId(), role: "assistant", content: text });
|
|
||||||
for (const tc of getToolCalls(m.content)) {
|
|
||||||
const tid = tc.toolCallId || tc.id;
|
|
||||||
result.push({
|
|
||||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
|
||||||
role: "tool_call",
|
|
||||||
content: formatToolCall(tc),
|
|
||||||
toolCallId: tid ? String(tid) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportMenu({ compact = false }: { compact?: boolean }) {
|
export function ExportMenu({ compact = false }: { compact?: boolean }) {
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { ChatMessage } from "../../types/message";
|
import type { ChatMessage } from "../../types/message";
|
||||||
import { useAvatars } from "../../context/AvatarContext";
|
import { useAvatars } from "../../context/AvatarContext";
|
||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
import {
|
import {
|
||||||
downloadTextFile,
|
downloadTextFile,
|
||||||
messageMarkdownFilename,
|
messageMarkdownFilename,
|
||||||
singleAssistantMarkdown,
|
singleAssistantMarkdown,
|
||||||
} from "../../utils/exportConversation";
|
} from "../../utils/exportConversation";
|
||||||
import { renderMarkdown } from "../../utils/markdown";
|
import { resolveAssistantBlocks } from "../../utils/assistantBlocks";
|
||||||
|
import { DEFAULT_AGENT_AVATAR, DEFAULT_USER_AVATAR } from "../../utils/avatars";
|
||||||
import { imageToDataUrl } from "../../utils/images";
|
import { imageToDataUrl } from "../../utils/images";
|
||||||
|
import { AssistantTurnBody } from "./AssistantTurnBody";
|
||||||
import { CompactionSummaryBlock } from "./CompactionSummaryBlock";
|
import { CompactionSummaryBlock } from "./CompactionSummaryBlock";
|
||||||
import { ToolCallBlock } from "./ToolCallBlock";
|
import { ToolCallBlock } from "./ToolCallBlock";
|
||||||
import { ThinkingBlock } from "./ThinkingBlock";
|
|
||||||
import { BashOutputBlock } from "./BashOutputBlock";
|
import { BashOutputBlock } from "./BashOutputBlock";
|
||||||
import styles from "./MessageList.module.css";
|
import styles from "./MessageList.module.css";
|
||||||
|
|
||||||
@@ -20,19 +22,31 @@ interface MessageBubbleProps {
|
|||||||
toolsExpanded?: boolean;
|
toolsExpanded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatAvatar({ src, className }: { src: string; className: string }) {
|
function ChatAvatar({
|
||||||
const [failed, setFailed] = useState(false);
|
src,
|
||||||
if (!src || failed) return null;
|
fallback,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
fallback: string;
|
||||||
|
className: string;
|
||||||
|
}) {
|
||||||
|
const trimmed = src.trim();
|
||||||
|
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
||||||
|
const displaySrc = trimmed && failedSrc !== trimmed ? trimmed : fallback;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
className={className}
|
className={className}
|
||||||
src={src}
|
src={displaySrc}
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
alt=""
|
alt=""
|
||||||
decoding="async"
|
decoding="async"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
onError={() => setFailed(true)}
|
onError={() => {
|
||||||
|
if (trimmed) setFailedSrc(trimmed);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,7 +57,7 @@ function AgentSideGutter({ mode, avatarUrl }: { mode: "avatar" | "spacer"; avata
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={styles.assistantAvatarSlot}>
|
<div className={styles.assistantAvatarSlot}>
|
||||||
<ChatAvatar src={avatarUrl} className={styles.assistantAvatar} />
|
<ChatAvatar src={avatarUrl} fallback={DEFAULT_AGENT_AVATAR} className={styles.assistantAvatar} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -94,22 +108,12 @@ export function MessageBubble({
|
|||||||
toolsExpanded = false,
|
toolsExpanded = false,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
|
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
|
||||||
|
const { showThinkingAndTools } = useChatContext();
|
||||||
const gutter =
|
const gutter =
|
||||||
agentGutter === "avatar" || agentGutter === "spacer" ? (
|
agentGutter === "avatar" || agentGutter === "spacer" ? (
|
||||||
<AgentSideGutter mode={agentGutter} avatarUrl={agentAvatarUrl} />
|
<AgentSideGutter mode={agentGutter} avatarUrl={agentAvatarUrl} />
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (message.role === "thinking") {
|
|
||||||
return (
|
|
||||||
<div className={styles.assistantRow}>
|
|
||||||
{gutter}
|
|
||||||
<div className={`${styles.msg} ${styles.thinking}`}>
|
|
||||||
<ThinkingBlock content={message.content} streaming={message.streaming} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.role === "tool_call") {
|
if (message.role === "tool_call") {
|
||||||
return (
|
return (
|
||||||
<div className={styles.assistantRow}>
|
<div className={styles.assistantRow}>
|
||||||
@@ -154,28 +158,34 @@ export function MessageBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const classNames = [styles.msg];
|
if (message.role === "assistant" || message.role === "thinking") {
|
||||||
if (message.role === "user") classNames.push(styles.user);
|
const blocks = resolveAssistantBlocks(message);
|
||||||
if (message.role === "assistant") classNames.push(styles.assistant);
|
const hasVisibleText = blocks.some((b) => b.type === "text" && b.content.trim());
|
||||||
if (message.role === "system") classNames.push(styles.system);
|
const showActions = message.role === "assistant" && !message.streaming && hasVisibleText;
|
||||||
|
const classNames = [styles.msg, styles.assistant];
|
||||||
if (message.streaming) classNames.push(styles.streaming);
|
if (message.streaming) classNames.push(styles.streaming);
|
||||||
|
|
||||||
if (message.role === "assistant") {
|
|
||||||
const showActions = !message.streaming && message.content.trim().length > 0;
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.assistantRow}>
|
<div className={styles.assistantRow}>
|
||||||
{gutter}
|
{gutter}
|
||||||
<div className={classNames.join(" ")}>
|
<div className={classNames.join(" ")}>
|
||||||
{showActions ? <AssistantActions content={message.content} /> : null}
|
{showActions ? <AssistantActions content={message.content} /> : null}
|
||||||
<div
|
<div className={styles.assistantBody}>
|
||||||
className={styles.assistantBody}
|
<AssistantTurnBody
|
||||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
|
blocks={blocks}
|
||||||
|
streaming={message.streaming}
|
||||||
|
showThinking={showThinkingAndTools}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const classNames = [styles.msg];
|
||||||
|
if (message.role === "user") classNames.push(styles.user);
|
||||||
|
if (message.role === "system") classNames.push(styles.system);
|
||||||
|
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
return (
|
return (
|
||||||
<div className={styles.userRow}>
|
<div className={styles.userRow}>
|
||||||
@@ -195,7 +205,7 @@ export function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<ChatAvatar src={userAvatarUrl} className={styles.userAvatar} />
|
<ChatAvatar src={userAvatarUrl} fallback={DEFAULT_USER_AVATAR} className={styles.userAvatar} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,16 +206,11 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.46;
|
|
||||||
color: #666;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
background: transparent;
|
||||||
background: #fff;
|
border: none;
|
||||||
border: 1px solid #e8ecf0;
|
box-shadow: none;
|
||||||
border-radius: 14px 14px 14px 4px;
|
|
||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thinking {
|
.thinking {
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
.collapsible {
|
.block {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: #f8fafc;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending {
|
||||||
|
border-color: #dbeafe;
|
||||||
|
background: #f0f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fef2f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary {
|
.summary {
|
||||||
@@ -7,45 +26,52 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
justify-content: space-between;
|
||||||
padding: 6px 10px;
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
font-weight: 500;
|
|
||||||
color: #555;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary::-webkit-details-marker {
|
.summary::-webkit-details-marker {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary::before {
|
.toolName {
|
||||||
content: "";
|
font-weight: 600;
|
||||||
width: 0;
|
color: #374151;
|
||||||
height: 0;
|
font-size: 0.92em;
|
||||||
border-left: 5px solid #888;
|
}
|
||||||
border-top: 3.5px solid transparent;
|
|
||||||
border-bottom: 3.5px solid transparent;
|
.status {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transform: rotate(0deg);
|
font-size: 11px;
|
||||||
transition: transform 0.15s ease;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapsible[open] > .summary::before {
|
.pending .status {
|
||||||
transform: rotate(90deg);
|
color: #2563eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryText {
|
.success .status {
|
||||||
min-width: 0;
|
color: #15803d;
|
||||||
flex: 1;
|
}
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
.error .status {
|
||||||
white-space: nowrap;
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail {
|
.detail {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 6px 10px 8px;
|
padding: 8px 10px 10px;
|
||||||
border-top: 1px solid #eee;
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.args {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #6b7280;
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailPre {
|
.detailPre {
|
||||||
@@ -55,6 +81,7 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailPre :global(.diffAdd) {
|
.detailPre :global(.diffAdd) {
|
||||||
|
|||||||
@@ -6,17 +6,49 @@ interface ToolCallBlockProps {
|
|||||||
forceOpen?: boolean;
|
forceOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseToolCallContent(content: string): {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
status: "pending" | "success" | "error";
|
||||||
|
} {
|
||||||
|
const trimmed = content.trimStart();
|
||||||
|
let status: "pending" | "success" | "error" = "pending";
|
||||||
|
let rest = trimmed;
|
||||||
|
if (trimmed.startsWith("✅")) {
|
||||||
|
status = "success";
|
||||||
|
rest = trimmed.slice(1).trimStart();
|
||||||
|
} else if (trimmed.startsWith("❌")) {
|
||||||
|
status = "error";
|
||||||
|
rest = trimmed.slice(1).trimStart();
|
||||||
|
} else if (trimmed.startsWith("🔧")) {
|
||||||
|
status = "pending";
|
||||||
|
rest = trimmed.slice(2).trimStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = rest.split("\n");
|
||||||
|
const title = lines[0]?.trim() || "tool";
|
||||||
|
const body = lines.slice(1).join("\n").trim();
|
||||||
|
return { title, body, status };
|
||||||
|
}
|
||||||
|
|
||||||
export function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) {
|
export function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) {
|
||||||
const bodyStart = content.indexOf("\n");
|
const { title, body, status } = parseToolCallContent(content);
|
||||||
const body = bodyStart >= 0 ? content.slice(bodyStart + 1) : "";
|
const hasDiff =
|
||||||
const hasDiff = body.includes("\n+") || body.includes("\n-") || body.startsWith("+") || body.startsWith("-");
|
body.includes("\n+") || body.includes("\n-") || body.startsWith("+") || body.startsWith("-");
|
||||||
|
const statusLabel = status === "success" ? "完成" : status === "error" ? "失败" : "执行中";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details className={styles.collapsible} open={forceOpen}>
|
<details
|
||||||
|
className={`${styles.block} ${styles[status]}`}
|
||||||
|
open={forceOpen || status === "pending"}
|
||||||
|
>
|
||||||
<summary className={styles.summary}>
|
<summary className={styles.summary}>
|
||||||
<span className={styles.summaryText}>{deriveToolCallSummary(content)}</span>
|
<span className={styles.toolName}>{title.split("(")[0] || deriveToolCallSummary(content)}</span>
|
||||||
|
<span className={styles.status}>{statusLabel}</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
{(body || content !== title) && (
|
||||||
<div className={styles.detail}>
|
<div className={styles.detail}>
|
||||||
|
{title.includes("(") ? <div className={styles.args}>{title}</div> : null}
|
||||||
{hasDiff ? (
|
{hasDiff ? (
|
||||||
<pre
|
<pre
|
||||||
className={styles.detailPre}
|
className={styles.detailPre}
|
||||||
@@ -26,6 +58,7 @@ export function ToolCallBlock({ content, forceOpen }: ToolCallBlockProps) {
|
|||||||
<pre className={styles.detailPre}>{body || content}</pre>
|
<pre className={styles.detailPre}>{body || content}</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 chatApi from "../api/chat";
|
||||||
import * as sessionsApi from "../api/sessions";
|
import * as sessionsApi from "../api/sessions";
|
||||||
import { apiUrl } from "../api/base";
|
import { apiUrl } from "../api/base";
|
||||||
import { useBootstrap } from "./BootstrapContext";
|
|
||||||
import type { ExtensionDialogState, RetryState, SseEvent } from "../types/events";
|
import type { ExtensionDialogState, RetryState, SseEvent } from "../types/events";
|
||||||
import type { RunPhase, RunPhaseState } from "../types/runPhase";
|
import type { RunPhase, RunPhaseState } from "../types/runPhase";
|
||||||
import type { ChatMessage, ImageContent, ModelInfo, SessionState, ThinkingLevel, ToolCallBlock } from "../types/message";
|
import type { AssistantBlock, ChatMessage, ImageContent, ModelInfo, SessionState, ThinkingLevel, ToolCallBlock } from "../types/message";
|
||||||
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
|
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
|
||||||
import { nextMessageId, syncViewportHeight, stripAnsi } from "../utils/format";
|
import { nextMessageId, syncViewportHeight, stripAnsi } from "../utils/format";
|
||||||
import { formatBashOutput, parseBashInput } from "../utils/bash";
|
import { formatBashOutput, parseBashInput } from "../utils/bash";
|
||||||
import { buildHistoryMessages } from "../utils/historyMessages";
|
import { buildHistoryMessages } from "../utils/historyMessages";
|
||||||
|
import {
|
||||||
|
assistantPlainText,
|
||||||
|
extractAssistantBlocks,
|
||||||
|
hasTextBlock,
|
||||||
|
hasThinkingBlock,
|
||||||
|
} from "../utils/assistantBlocks";
|
||||||
import {
|
import {
|
||||||
extractContent,
|
extractContent,
|
||||||
extractImages,
|
extractImages,
|
||||||
extractThinking,
|
|
||||||
formatToolCall,
|
formatToolCall,
|
||||||
getToolCalls,
|
getToolCalls,
|
||||||
buildToolCallUpdateText,
|
buildToolCallUpdateText,
|
||||||
@@ -138,22 +142,8 @@ function historyToMessages(payload: SessionHistoryPayload): ChatMessage[] {
|
|||||||
return normalizeChatMessages(buildHistoryMessages(payload));
|
return normalizeChatMessages(buildHistoryMessages(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
function findStreamingThinkingId(messages: ChatMessage[]): string | null {
|
function withoutStreamingTurnMessages(prev: ChatMessage[], assistantId: string | null): ChatMessage[] {
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
return prev.filter((msg) => msg.id !== assistantId);
|
||||||
const msg = messages[i];
|
|
||||||
if (msg.role === "thinking" && msg.streaming) {
|
|
||||||
return msg.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function withoutStreamingTurnMessages(
|
|
||||||
prev: ChatMessage[],
|
|
||||||
thinkingId: string | null,
|
|
||||||
assistantId: string | null,
|
|
||||||
): ChatMessage[] {
|
|
||||||
return prev.filter((msg) => msg.id !== thinkingId && msg.id !== assistantId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Index where the current turn begins (just after the last user message). */
|
/** Index where the current turn begins (just after the last user message). */
|
||||||
@@ -164,68 +154,78 @@ function lastTurnStartIndex(messages: ChatMessage[]): number {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop every still-streaming assistant/thinking bubble in the current turn. */
|
/** Drop still-streaming assistant bubbles in the current turn. */
|
||||||
function stripStreamingTurnBubbles(messages: ChatMessage[]): ChatMessage[] {
|
function stripStreamingTurnBubbles(messages: ChatMessage[]): ChatMessage[] {
|
||||||
const turnStart = lastTurnStartIndex(messages);
|
const turnStart = lastTurnStartIndex(messages);
|
||||||
return messages.filter((msg, i) => {
|
return messages.filter((msg, i) => {
|
||||||
if (i < turnStart) return true;
|
if (i < turnStart) return true;
|
||||||
if (!msg.streaming) return true;
|
if (!msg.streaming) return true;
|
||||||
return msg.role !== "assistant" && msg.role !== "thinking";
|
return msg.role !== "assistant";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findStreamingAssistantId(messages: ChatMessage[]): string | null {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i];
|
||||||
|
if (msg.role === "assistant" && msg.streaming) {
|
||||||
|
return msg.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastTurnAssistantId(messages: ChatMessage[]): string | null {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i];
|
||||||
|
if (msg.role === "user") break;
|
||||||
|
if (msg.role === "assistant") return msg.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTurnAssistantId(messages: ChatMessage[], refId: string | null): string | null {
|
||||||
|
return refId ?? findStreamingAssistantId(messages) ?? findLastTurnAssistantId(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastTurnAssistantMessage(messages: ChatMessage[]): ChatMessage | null {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i];
|
||||||
|
if (msg.role === "user") break;
|
||||||
|
if (msg.role === "assistant") return msg;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function appendStreamingAssistantTurn(
|
function appendStreamingAssistantTurn(
|
||||||
prev: ChatMessage[],
|
prev: ChatMessage[],
|
||||||
thinking: string,
|
blocks: AssistantBlock[],
|
||||||
assistantText: string,
|
|
||||||
thinkingIdRef: { current: string | null },
|
|
||||||
assistantIdRef: { current: string | null },
|
assistantIdRef: { current: string | null },
|
||||||
): ChatMessage[] {
|
): ChatMessage[] {
|
||||||
// Reattach to any live streaming bubble whose id the ref may have lost (e.g.
|
if (!assistantIdRef.current) {
|
||||||
// after a reducer re-run, SSE reconnect, or out-of-order events) so we update
|
|
||||||
// it in place instead of spawning a duplicate orphan.
|
|
||||||
if (!thinkingIdRef.current && thinking.length > 0) {
|
|
||||||
thinkingIdRef.current = findStreamingThinkingId(prev);
|
|
||||||
}
|
|
||||||
if (!assistantIdRef.current && assistantText.length > 0) {
|
|
||||||
assistantIdRef.current = findStreamingAssistantId(prev);
|
assistantIdRef.current = findStreamingAssistantId(prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = stripStreamingTurnBubbles(prev);
|
const next = stripStreamingTurnBubbles(prev);
|
||||||
const turnMessages: ChatMessage[] = [];
|
|
||||||
|
|
||||||
if (thinking.length > 0 || thinkingIdRef.current) {
|
|
||||||
if (!thinkingIdRef.current) {
|
|
||||||
thinkingIdRef.current = nextMessageId();
|
|
||||||
}
|
|
||||||
turnMessages.push({
|
|
||||||
id: thinkingIdRef.current,
|
|
||||||
role: "thinking",
|
|
||||||
content: thinking,
|
|
||||||
streaming: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (assistantText.length > 0 || assistantIdRef.current) {
|
|
||||||
if (!assistantIdRef.current) {
|
if (!assistantIdRef.current) {
|
||||||
assistantIdRef.current = nextMessageId();
|
assistantIdRef.current = nextMessageId();
|
||||||
}
|
}
|
||||||
turnMessages.push({
|
|
||||||
id: assistantIdRef.current,
|
|
||||||
role: "assistant",
|
|
||||||
content: assistantText,
|
|
||||||
streaming: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...next, ...turnMessages];
|
const id = assistantIdRef.current;
|
||||||
|
return [
|
||||||
|
...next,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
role: "assistant" as const,
|
||||||
|
blocks,
|
||||||
|
content: assistantPlainText(blocks),
|
||||||
|
streaming: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalizeStreamingTurn(prev: ChatMessage[]): ChatMessage[] {
|
function finalizeStreamingTurn(prev: ChatMessage[]): ChatMessage[] {
|
||||||
const turnStart = lastTurnStartIndex(prev);
|
const turnStart = lastTurnStartIndex(prev);
|
||||||
|
|
||||||
// Remove orphaned streaming assistant bubbles whose content is just a prefix
|
|
||||||
// of a longer assistant bubble in the same turn (the real, final reply).
|
|
||||||
const survivors = prev.filter((msg, i) => {
|
const survivors = prev.filter((msg, i) => {
|
||||||
if (i < turnStart || msg.role !== "assistant" || !msg.streaming) return true;
|
if (i < turnStart || msg.role !== "assistant" || !msg.streaming) return true;
|
||||||
const shorter = msg.content.trim();
|
const shorter = msg.content.trim();
|
||||||
@@ -242,70 +242,21 @@ function finalizeStreamingTurn(prev: ChatMessage[]): ChatMessage[] {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear the streaming flag on every remaining bubble in the turn.
|
|
||||||
const survivorTurnStart = lastTurnStartIndex(survivors);
|
const survivorTurnStart = lastTurnStartIndex(survivors);
|
||||||
return survivors.map((msg, i) =>
|
return survivors.map((msg, i) =>
|
||||||
i >= survivorTurnStart && msg.streaming ? { ...msg, streaming: false } : msg,
|
i >= survivorTurnStart && msg.streaming ? { ...msg, streaming: false } : msg,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findStreamingAssistantId(messages: ChatMessage[]): string | null {
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
|
||||||
const msg = messages[i];
|
|
||||||
if (msg.role === "assistant" && msg.streaming) {
|
|
||||||
return msg.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Last thinking bubble in the current turn (since the preceding user message). */
|
|
||||||
function findLastTurnThinkingId(messages: ChatMessage[]): string | null {
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
|
||||||
const msg = messages[i];
|
|
||||||
if (msg.role === "user") break;
|
|
||||||
if (msg.role === "thinking") return msg.id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Last assistant bubble in the current turn (since the preceding user message). */
|
|
||||||
function findLastTurnAssistantId(messages: ChatMessage[]): string | null {
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
|
||||||
const msg = messages[i];
|
|
||||||
if (msg.role === "user") break;
|
|
||||||
if (msg.role === "assistant") return msg.id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTurnThinkingId(messages: ChatMessage[], refId: string | null): string | null {
|
|
||||||
return refId ?? findStreamingThinkingId(messages) ?? findLastTurnThinkingId(messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTurnAssistantId(messages: ChatMessage[], refId: string | null): string | null {
|
|
||||||
return refId ?? findStreamingAssistantId(messages) ?? findLastTurnAssistantId(messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLastTurnAssistantMessage(messages: ChatMessage[]): ChatMessage | null {
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
|
||||||
const msg = messages[i];
|
|
||||||
if (msg.role === "user") break;
|
|
||||||
if (msg.role === "assistant") return msg;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAssistantMessageUpdate(
|
function applyAssistantMessageUpdate(
|
||||||
prev: ChatMessage[],
|
prev: ChatMessage[],
|
||||||
thinking: string,
|
blocks: AssistantBlock[],
|
||||||
assistantText: string,
|
|
||||||
thinkingIdRef: { current: string | null },
|
|
||||||
assistantIdRef: { current: string | null },
|
assistantIdRef: { current: string | null },
|
||||||
): ChatMessage[] {
|
): ChatMessage[] {
|
||||||
|
const plainText = assistantPlainText(blocks);
|
||||||
const lastAssistant = findLastTurnAssistantMessage(prev);
|
const lastAssistant = findLastTurnAssistantMessage(prev);
|
||||||
if (lastAssistant && !lastAssistant.streaming) {
|
if (lastAssistant && !lastAssistant.streaming) {
|
||||||
const trimmed = assistantText.trim();
|
const trimmed = plainText.trim();
|
||||||
if (
|
if (
|
||||||
trimmed &&
|
trimmed &&
|
||||||
(lastAssistant.content === trimmed ||
|
(lastAssistant.content === trimmed ||
|
||||||
@@ -314,46 +265,36 @@ function applyAssistantMessageUpdate(
|
|||||||
) {
|
) {
|
||||||
if (trimmed.length >= lastAssistant.content.length) {
|
if (trimmed.length >= lastAssistant.content.length) {
|
||||||
return prev.map((msg) =>
|
return prev.map((msg) =>
|
||||||
msg.id === lastAssistant.id ? { ...msg, content: assistantText } : msg,
|
msg.id === lastAssistant.id
|
||||||
|
? { ...msg, blocks, content: plainText }
|
||||||
|
: msg,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return appendStreamingAssistantTurn(prev, thinking, assistantText, thinkingIdRef, assistantIdRef);
|
return appendStreamingAssistantTurn(prev, blocks, assistantIdRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalizeAssistantMessageEnd(
|
function finalizeAssistantMessageEnd(
|
||||||
prev: ChatMessage[],
|
prev: ChatMessage[],
|
||||||
thinking: string,
|
blocks: AssistantBlock[],
|
||||||
assistantText: string,
|
|
||||||
toolCalls: ToolCallBlock[],
|
toolCalls: ToolCallBlock[],
|
||||||
thinkingId: string | null,
|
|
||||||
assistantId: string | null,
|
assistantId: string | null,
|
||||||
): ChatMessage[] {
|
): ChatMessage[] {
|
||||||
let next = withoutStreamingTurnMessages(prev, thinkingId, assistantId);
|
let next = withoutStreamingTurnMessages(prev, assistantId);
|
||||||
|
|
||||||
if (thinking) {
|
const plainText = assistantPlainText(blocks).trim();
|
||||||
next = [
|
if (plainText || blocks.some((b) => b.type === "thinking")) {
|
||||||
...next,
|
|
||||||
{
|
|
||||||
id: thinkingId ?? nextMessageId(),
|
|
||||||
role: "thinking" as const,
|
|
||||||
content: thinking,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedAssistant = assistantText.trim();
|
|
||||||
if (trimmedAssistant) {
|
|
||||||
const lastAssistant = findLastTurnAssistantMessage(next);
|
const lastAssistant = findLastTurnAssistantMessage(next);
|
||||||
if (!(lastAssistant && !lastAssistant.streaming && lastAssistant.content === trimmedAssistant)) {
|
if (!(lastAssistant && !lastAssistant.streaming && lastAssistant.content === plainText)) {
|
||||||
next = [
|
next = [
|
||||||
...next,
|
...next,
|
||||||
{
|
{
|
||||||
id: assistantId ?? nextMessageId(),
|
id: assistantId ?? nextMessageId(),
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
content: assistantText,
|
blocks,
|
||||||
|
content: assistantPlainText(blocks),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -389,11 +330,15 @@ function messageDedupeSignature(msg: ChatMessage): string | null {
|
|||||||
if (msg.role === "tool_call" && msg.toolCallId) {
|
if (msg.role === "tool_call" && msg.toolCallId) {
|
||||||
return `tool_call:${msg.toolCallId}`;
|
return `tool_call:${msg.toolCallId}`;
|
||||||
}
|
}
|
||||||
if (msg.role !== "assistant" && msg.role !== "thinking" && msg.role !== "tool_call") {
|
if (msg.role === "thinking") {
|
||||||
|
const content = msg.content.trim().replace(/\s+/g, " ");
|
||||||
|
return content ? `thinking:${content}` : null;
|
||||||
|
}
|
||||||
|
if (msg.role !== "assistant" && msg.role !== "tool_call") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const content = msg.content.trim().replace(/\s+/g, " ");
|
const content = msg.content.trim().replace(/\s+/g, " ");
|
||||||
if (!content) return null;
|
if (!content && !msg.blocks?.length) return null;
|
||||||
return `${msg.role}:${msg.toolCallId ?? ""}:${content}`;
|
return `${msg.role}:${msg.toolCallId ?? ""}:${content}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,7 +439,6 @@ function isIgnorableExtensionError(error: string | undefined): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChatProvider({ children }: { children: ReactNode }) {
|
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||||
const { markRouteReady } = useBootstrap();
|
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [messages, setRawMessages] = useState<ChatMessage[]>([]);
|
const [messages, setRawMessages] = useState<ChatMessage[]>([]);
|
||||||
@@ -536,7 +480,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
const ensureSessionCreatePromise = useRef<Promise<boolean> | null>(null);
|
const ensureSessionCreatePromise = useRef<Promise<boolean> | null>(null);
|
||||||
const sessionBootRef = useRef<Promise<void> | null>(null);
|
const sessionBootRef = useRef<Promise<void> | null>(null);
|
||||||
const streamingAssistantId = useRef<string | null>(null);
|
const streamingAssistantId = useRef<string | null>(null);
|
||||||
const streamingThinkingId = useRef<string | null>(null);
|
|
||||||
const optimisticUserIdRef = useRef<string | null>(null);
|
const optimisticUserIdRef = useRef<string | null>(null);
|
||||||
const compactionReloadPendingRef = useRef(false);
|
const compactionReloadPendingRef = useRef(false);
|
||||||
const activeBashMsgIdRef = useRef<string | null>(null);
|
const activeBashMsgIdRef = useRef<string | null>(null);
|
||||||
@@ -580,7 +523,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const resetStreamingState = useCallback(() => {
|
const resetStreamingState = useCallback(() => {
|
||||||
streamingAssistantId.current = null;
|
streamingAssistantId.current = null;
|
||||||
streamingThinkingId.current = null;
|
|
||||||
optimisticUserIdRef.current = null;
|
optimisticUserIdRef.current = null;
|
||||||
clearRunPhase();
|
clearRunPhase();
|
||||||
}, [clearRunPhase]);
|
}, [clearRunPhase]);
|
||||||
@@ -1535,7 +1477,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
streamingAssistantId.current = null;
|
streamingAssistantId.current = null;
|
||||||
streamingThinkingId.current = null;
|
|
||||||
updateRunPhase("starting");
|
updateRunPhase("starting");
|
||||||
scheduleSessionDashboardRefresh();
|
scheduleSessionDashboardRefresh();
|
||||||
break;
|
break;
|
||||||
@@ -1545,7 +1486,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
setConnected(true);
|
setConnected(true);
|
||||||
setMessages((prev) => finalizeStreamingTurn(prev));
|
setMessages((prev) => finalizeStreamingTurn(prev));
|
||||||
streamingAssistantId.current = null;
|
streamingAssistantId.current = null;
|
||||||
streamingThinkingId.current = null;
|
|
||||||
if (backendSessionPathRef.current) {
|
if (backendSessionPathRef.current) {
|
||||||
sessionCache.current.delete(backendSessionPathRef.current);
|
sessionCache.current.delete(backendSessionPathRef.current);
|
||||||
}
|
}
|
||||||
@@ -1591,21 +1531,14 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
case "message_update": {
|
case "message_update": {
|
||||||
const m = ev.message;
|
const m = ev.message;
|
||||||
if (m.role === "assistant") {
|
if (m.role === "assistant") {
|
||||||
const thinking = extractThinking(m.content);
|
const blocks = extractAssistantBlocks(m.content);
|
||||||
const assistantText = extractContent(m.content);
|
if (hasThinkingBlock(blocks)) {
|
||||||
if (thinking.length > 0) {
|
|
||||||
updateRunPhase("thinking");
|
updateRunPhase("thinking");
|
||||||
} else if (assistantText.length > 0) {
|
} else if (hasTextBlock(blocks)) {
|
||||||
updateRunPhase("writing");
|
updateRunPhase("writing");
|
||||||
}
|
}
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
applyAssistantMessageUpdate(
|
applyAssistantMessageUpdate(prev, blocks, streamingAssistantId),
|
||||||
prev,
|
|
||||||
thinking,
|
|
||||||
assistantText,
|
|
||||||
streamingThinkingId,
|
|
||||||
streamingAssistantId,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1614,28 +1547,24 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
case "message_end": {
|
case "message_end": {
|
||||||
const m = ev.message;
|
const m = ev.message;
|
||||||
if (m.role === "assistant") {
|
if (m.role === "assistant") {
|
||||||
const thinking = extractThinking(m.content);
|
const blocks = extractAssistantBlocks(m.content);
|
||||||
const assistantText = extractContent(m.content);
|
|
||||||
const toolCalls = getToolCalls(m.content);
|
const toolCalls = getToolCalls(m.content);
|
||||||
if (toolCalls.length > 0) {
|
if (toolCalls.length > 0) {
|
||||||
updateRunPhase("processing");
|
updateRunPhase("processing");
|
||||||
} else if (thinking.length > 0 && assistantText.length === 0) {
|
} else if (hasThinkingBlock(blocks) && !hasTextBlock(blocks)) {
|
||||||
updateRunPhase("thinking");
|
updateRunPhase("thinking");
|
||||||
} else if (assistantText.length > 0) {
|
} else if (hasTextBlock(blocks)) {
|
||||||
updateRunPhase("writing");
|
updateRunPhase("writing");
|
||||||
}
|
}
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
finalizeAssistantMessageEnd(
|
finalizeAssistantMessageEnd(
|
||||||
prev,
|
prev,
|
||||||
thinking,
|
blocks,
|
||||||
assistantText,
|
|
||||||
toolCalls,
|
toolCalls,
|
||||||
resolveTurnThinkingId(prev, streamingThinkingId.current),
|
|
||||||
resolveTurnAssistantId(prev, streamingAssistantId.current),
|
resolveTurnAssistantId(prev, streamingAssistantId.current),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
streamingAssistantId.current = null;
|
streamingAssistantId.current = null;
|
||||||
streamingThinkingId.current = null;
|
|
||||||
scheduleSessionDashboardRefresh();
|
scheduleSessionDashboardRefresh();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1839,11 +1768,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const markRouteReadyRef = useRef(markRouteReady);
|
|
||||||
const handleSseEventRef = useRef(handleSseEvent);
|
const handleSseEventRef = useRef(handleSseEvent);
|
||||||
const addSystemMessageRef = useRef(addSystemMessage);
|
const addSystemMessageRef = useRef(addSystemMessage);
|
||||||
const restoreSessionOnBootRef = useRef(restoreSessionOnBoot);
|
const restoreSessionOnBootRef = useRef(restoreSessionOnBoot);
|
||||||
markRouteReadyRef.current = markRouteReady;
|
|
||||||
handleSseEventRef.current = handleSseEvent;
|
handleSseEventRef.current = handleSseEvent;
|
||||||
addSystemMessageRef.current = addSystemMessage;
|
addSystemMessageRef.current = addSystemMessage;
|
||||||
restoreSessionOnBootRef.current = restoreSessionOnBoot;
|
restoreSessionOnBootRef.current = restoreSessionOnBoot;
|
||||||
@@ -1860,29 +1787,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
window.addEventListener("orientationchange", syncViewportHeight);
|
window.addEventListener("orientationchange", syncViewportHeight);
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const bootstrap = {
|
|
||||||
sessions: false,
|
|
||||||
models: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const tryMarkBootstrapReady = () => {
|
void restoreSessionOnBootRef.current().catch(() => {});
|
||||||
if (cancelled) return;
|
|
||||||
if (bootstrap.sessions && bootstrap.models) {
|
|
||||||
markRouteReadyRef.current("chat");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await restoreSessionOnBootRef.current(() => {
|
|
||||||
bootstrap.sessions = true;
|
|
||||||
tryMarkBootstrapReady();
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
bootstrap.sessions = true;
|
|
||||||
tryMarkBootstrapReady();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
void chatApi
|
void chatApi
|
||||||
.fetchModels()
|
.fetchModels()
|
||||||
@@ -1895,10 +1801,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`,
|
`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
bootstrap.models = true;
|
|
||||||
tryMarkBootstrapReady();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let eventSource: EventSource | null = null;
|
let eventSource: EventSource | null = null;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import * as promptsApi from "../api/prompts";
|
|||||||
import type { PromptFile } from "../api/prompts";
|
import type { PromptFile } from "../api/prompts";
|
||||||
import * as settingsApi from "../api/settings";
|
import * as settingsApi from "../api/settings";
|
||||||
import { useAvatars } from "../context/AvatarContext";
|
import { useAvatars } from "../context/AvatarContext";
|
||||||
import { useBootstrap } from "../context/BootstrapContext";
|
|
||||||
import { useChatContext } from "../context/ChatContext";
|
import { useChatContext } from "../context/ChatContext";
|
||||||
import type { AgentToolInfo, EnvironmentInfo, EnvironmentToolsInfo, ExtensionInfo, McpServerInfo, RuntimeInfo, SettingsPaneId, SkillInfo } from "../types/events";
|
import type { AgentToolInfo, EnvironmentInfo, EnvironmentToolsInfo, ExtensionInfo, McpServerInfo, RuntimeInfo, SettingsPaneId, SkillInfo } from "../types/events";
|
||||||
import type { McpToolInfo } from "../types/events";
|
import type { McpToolInfo } from "../types/events";
|
||||||
@@ -308,7 +307,6 @@ function McpToolItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { markRouteReady } = useBootstrap();
|
|
||||||
const { updateAvatars } = useAvatars();
|
const { updateAvatars } = useAvatars();
|
||||||
const { showThinkingAndTools, toggleShowThinkingAndTools } = useChatContext();
|
const { showThinkingAndTools, toggleShowThinkingAndTools } = useChatContext();
|
||||||
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
|
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
|
||||||
@@ -408,9 +406,8 @@ export function SettingsPage() {
|
|||||||
setStatus(`加载设置失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
setStatus(`加载设置失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
markRouteReady("settings");
|
|
||||||
}
|
}
|
||||||
}, [markRouteReady, updateAvatars]);
|
}, [updateAvatars]);
|
||||||
|
|
||||||
const reloadSettings = async () => {
|
const reloadSettings = async () => {
|
||||||
setReloading(true);
|
setReloading(true);
|
||||||
|
|||||||
@@ -53,10 +53,18 @@ export interface ToolCallBlock {
|
|||||||
input?: unknown;
|
input?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ordered assistant content block (matches SproutClaw CLI assistant message layout). */
|
||||||
|
export interface AssistantBlock {
|
||||||
|
type: "thinking" | "text";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
role: MessageRole;
|
role: MessageRole;
|
||||||
content: string;
|
content: string;
|
||||||
|
/** Thinking + text blocks in RPC order (assistant role only). */
|
||||||
|
blocks?: AssistantBlock[];
|
||||||
images?: ImageContent[];
|
images?: ImageContent[];
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
bashCommand?: string;
|
bashCommand?: string;
|
||||||
|
|||||||
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 type { ChatMessage } from "../types/message";
|
||||||
|
import { resolveAssistantBlocks } from "./assistantBlocks";
|
||||||
import { escHtml } from "./format";
|
import { escHtml } from "./format";
|
||||||
|
|
||||||
function roleLabel(role: ChatMessage["role"]): string {
|
function roleLabel(role: ChatMessage["role"]): string {
|
||||||
@@ -35,9 +36,22 @@ export function messagesToMarkdown(title: string, messages: ChatMessage[]): stri
|
|||||||
case "user":
|
case "user":
|
||||||
lines.push("## 用户", "", m.content, "");
|
lines.push("## 用户", "", m.content, "");
|
||||||
break;
|
break;
|
||||||
case "assistant":
|
case "assistant": {
|
||||||
|
const blocks = resolveAssistantBlocks(m);
|
||||||
|
if (blocks.length) {
|
||||||
|
lines.push("## 助手", "");
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type === "thinking") {
|
||||||
|
lines.push("### 思考", "", block.content, "");
|
||||||
|
} else {
|
||||||
|
lines.push(block.content, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (m.content.trim()) {
|
||||||
lines.push("## 助手", "", m.content, "");
|
lines.push("## 助手", "", m.content, "");
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "thinking":
|
case "thinking":
|
||||||
lines.push("## 思考", "", m.content, "");
|
lines.push("## 思考", "", m.content, "");
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,53 +1,32 @@
|
|||||||
import type { ChatMessage, SessionHistoryPayload, ToolCallBlock } from "../types/message";
|
import type { ChatMessage, SessionHistoryPayload } from "../types/message";
|
||||||
import { nextMessageId } from "./format";
|
import { nextMessageId } from "./format";
|
||||||
|
import {
|
||||||
|
assistantPlainText,
|
||||||
|
extractAssistantBlocks,
|
||||||
|
} from "./assistantBlocks";
|
||||||
import {
|
import {
|
||||||
extractContent,
|
extractContent,
|
||||||
extractImages,
|
extractImages,
|
||||||
extractThinking,
|
|
||||||
formatToolCall,
|
formatToolCall,
|
||||||
formatToolResult,
|
formatToolResult,
|
||||||
getToolCalls,
|
getToolCalls,
|
||||||
upsertToolCallMessage,
|
upsertToolCallMessage,
|
||||||
} from "./toolCall";
|
} from "./toolCall";
|
||||||
|
|
||||||
function appendAssistantBlocks(m: { content?: string | unknown[] }, result: ChatMessage[]): void {
|
function appendAssistantTurn(m: { content?: string | unknown[] }, result: ChatMessage[]): void {
|
||||||
if (Array.isArray(m.content)) {
|
const blocks = extractAssistantBlocks(m.content as string | undefined);
|
||||||
for (const block of m.content) {
|
const toolCalls = getToolCalls(m.content as string | undefined);
|
||||||
const b = block as { type?: string; thinking?: string; text?: string };
|
|
||||||
if (b.type === "thinking") {
|
if (blocks.length > 0) {
|
||||||
const thinking = (b.thinking ?? "").trim();
|
result.push({
|
||||||
if (thinking) {
|
id: nextMessageId(),
|
||||||
result.push({ id: nextMessageId(), role: "thinking", content: thinking });
|
role: "assistant",
|
||||||
}
|
blocks,
|
||||||
} else if (b.type === "text") {
|
content: assistantPlainText(blocks),
|
||||||
const text = (b.text ?? "").trim();
|
|
||||||
if (text) {
|
|
||||||
const last = result[result.length - 1];
|
|
||||||
if (last?.role === "assistant") {
|
|
||||||
last.content += text;
|
|
||||||
} else {
|
|
||||||
result.push({ id: nextMessageId(), role: "assistant", content: text });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (b.type === "toolCall") {
|
|
||||||
const tc = block as ToolCallBlock;
|
|
||||||
const tid = tc.toolCallId || tc.id;
|
|
||||||
const tidStr = tid ? String(tid) : undefined;
|
|
||||||
upsertToolCallMessage(result, {
|
|
||||||
id: tidStr ? `tool-${tidStr}` : nextMessageId(),
|
|
||||||
role: "tool_call",
|
|
||||||
content: formatToolCall(tc),
|
|
||||||
toolCallId: tidStr,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return;
|
for (const tc of toolCalls) {
|
||||||
}
|
|
||||||
const text = extractContent(m.content as string | undefined);
|
|
||||||
if (text) {
|
|
||||||
result.push({ id: nextMessageId(), role: "assistant", content: text });
|
|
||||||
}
|
|
||||||
for (const tc of getToolCalls(m.content as string | undefined)) {
|
|
||||||
const tid = tc.toolCallId || tc.id;
|
const tid = tc.toolCallId || tc.id;
|
||||||
const tidStr = tid ? String(tid) : undefined;
|
const tidStr = tid ? String(tid) : undefined;
|
||||||
upsertToolCallMessage(result, {
|
upsertToolCallMessage(result, {
|
||||||
@@ -74,7 +53,7 @@ export function buildHistoryMessages(payload: SessionHistoryPayload): ChatMessag
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (m.role === "assistant") {
|
} else if (m.role === "assistant") {
|
||||||
appendAssistantBlocks(m, result);
|
appendAssistantTurn(m, result);
|
||||||
} else if (m.role === "toolResult" || m.role === "tool") {
|
} else if (m.role === "toolResult" || m.role === "tool") {
|
||||||
const text = formatToolResult({ content: m.content });
|
const text = formatToolResult({ content: m.content });
|
||||||
if (text.trim()) {
|
if (text.trim()) {
|
||||||
@@ -117,5 +96,8 @@ export function buildHistoryMessages(payload: SessionHistoryPayload): ChatMessag
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function extractThinkingFromMessage(m: { content?: string | unknown[] }): string {
|
export function extractThinkingFromMessage(m: { content?: string | unknown[] }): string {
|
||||||
return extractThinking(m.content as string | undefined);
|
return extractAssistantBlocks(m.content as string | undefined)
|
||||||
|
.filter((b) => b.type === "thinking")
|
||||||
|
.map((b) => b.content)
|
||||||
|
.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user