first commit
This commit is contained in:
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@@ -1,3 +1,3 @@
|
||||
node_modules
|
||||
dist/
|
||||
dist-desketop/
|
||||
dist-desketop/
|
||||
BIN
frontend/assets/fonts/LXGWWenKaiMono-Medium.ttf
Normal file
BIN
frontend/assets/fonts/LXGWWenKaiMono-Medium.ttf
Normal file
Binary file not shown.
48
frontend/package-lock.json
generated
48
frontend/package-lock.json
generated
@@ -8,6 +8,10 @@
|
||||
"name": "sproutclaw-webui-frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"highlight.js": "11.11.1",
|
||||
"marked": "15.0.12",
|
||||
"react": "19.2.6",
|
||||
@@ -15,7 +19,6 @@
|
||||
"react-router-dom": "7.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mogeko/maple-mono-cn": "7.9.0",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
@@ -2630,13 +2633,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mogeko/maple-mono-cn": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@mogeko/maple-mono-cn/-/maple-mono-cn-7.9.0.tgz",
|
||||
"integrity": "sha512-NZls63+8Q4+17saZqhN5sFB9UqIfyI73q+NNyXNQFmdzivzSYAKLCuo2yvheyEm95kuH8VEhwTl4YvbMoqIbFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -3237,6 +3233,30 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -4284,6 +4304,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/github-markdown-css": {
|
||||
"version": "5.8.1",
|
||||
"resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.8.1.tgz",
|
||||
"integrity": "sha512-8G+PFvqigBQSWLQjyzgpa2ThD9bo7+kDsriUIidGcRhXgmcaAWUIpCZf8DavJgc+xifjbCG+GvMyWr0XMXmc7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
|
||||
|
||||
@@ -5,13 +5,18 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"generate-icons": "node scripts/generate-icons.mjs",
|
||||
"prepare-font": "node scripts/prepare-font.mjs",
|
||||
"dev": "vite",
|
||||
"build": "npm run generate-icons && tsc -b && vite build && vite build --mode desktop",
|
||||
"build:web": "npm run generate-icons && tsc -b && vite build",
|
||||
"build:desktop": "npm run generate-icons && tsc -b && vite build --mode desktop",
|
||||
"build": "npm run generate-icons && npm run prepare-font && tsc -b && vite build && vite build --mode desktop",
|
||||
"build:web": "npm run generate-icons && npm run prepare-font && tsc -b && vite build",
|
||||
"build:desktop": "npm run generate-icons && npm run prepare-font && tsc -b && vite build --mode desktop",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"highlight.js": "11.11.1",
|
||||
"marked": "15.0.12",
|
||||
"react": "19.2.6",
|
||||
@@ -19,7 +24,6 @@
|
||||
"react-router-dom": "7.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mogeko/maple-mono-cn": "7.9.0",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
|
||||
BIN
frontend/public/fonts/LXGWWenKaiMono-Medium.ttf
Normal file
BIN
frontend/public/fonts/LXGWWenKaiMono-Medium.ttf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/lxgwwenkaimono-medium.woff2
Normal file
BIN
frontend/public/fonts/lxgwwenkaimono-medium.woff2
Normal file
Binary file not shown.
63
frontend/scripts/prepare-font.mjs
Normal file
63
frontend/scripts/prepare-font.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, 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 sourceCandidates = [
|
||||
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;
|
||||
|
||||
if (!sourceTtf) {
|
||||
console.warn("[prepare-font] LXGWWenKaiMono-Medium.ttf not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
mkdirSync(fontsDir, { recursive: true });
|
||||
|
||||
function woff2LooksValid() {
|
||||
if (!existsSync(outputWoff2)) return false;
|
||||
return readFileSync(outputWoff2).length >= MIN_WOFF2_BYTES;
|
||||
}
|
||||
|
||||
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)`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(outputWoff2) && !woff2LooksValid()) {
|
||||
console.warn("[prepare-font] removing invalid woff2 (< 5 MB, likely subset or corrupt)");
|
||||
unlinkSync(outputWoff2);
|
||||
}
|
||||
|
||||
console.log("[prepare-font] converting full font to woff2 (no glyph subsetting, may take a few minutes)...");
|
||||
|
||||
const py = [
|
||||
"from fontTools.ttLib.woff2 import compress",
|
||||
"import sys",
|
||||
"compress(sys.argv[1], sys.argv[2])",
|
||||
].join("\n");
|
||||
|
||||
execFileSync("python", ["-c", py, sourceTtf, outputWoff2], { stdio: "inherit" });
|
||||
|
||||
const sizeBytes = readFileSync(outputWoff2).length;
|
||||
if (sizeBytes < MIN_WOFF2_BYTES) {
|
||||
throw new Error(
|
||||
`[prepare-font] woff2 output too small (${sizeBytes} bytes); conversion likely failed`,
|
||||
);
|
||||
}
|
||||
|
||||
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(2);
|
||||
console.log(`[prepare-font] wrote ${outputWoff2} (${sizeMb} MB, full glyph set)`);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt";
|
||||
import { ChatLayout } from "./components/layout/ChatLayout";
|
||||
@@ -5,7 +6,8 @@ import { AvatarProvider } from "./context/AvatarContext";
|
||||
import { BootstrapProvider } from "./context/BootstrapContext";
|
||||
import { ChatProvider } from "./context/ChatContext";
|
||||
import { ChatPage } from "./routes/ChatPage";
|
||||
import { SettingsPage } from "./routes/SettingsPage";
|
||||
|
||||
const SettingsPage = lazy(() => import("./routes/SettingsPage"));
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -17,7 +19,14 @@ export function App() {
|
||||
<Route path="/" element={<ChatLayout />}>
|
||||
<Route index element={<ChatPage />} />
|
||||
</Route>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
<SettingsPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<PwaUpdatePrompt />
|
||||
</ChatProvider>
|
||||
|
||||
@@ -11,3 +11,16 @@ export function apiUrl(path: string): string {
|
||||
export function getApiBase(): string {
|
||||
return API_BASE;
|
||||
}
|
||||
|
||||
/** Resolve a WebSocket URL for an API path. */
|
||||
export function wsUrl(path: string): string {
|
||||
const httpPath = apiUrl(path);
|
||||
if (httpPath.startsWith("https://")) {
|
||||
return `wss://${httpPath.slice("https://".length)}`;
|
||||
}
|
||||
if (httpPath.startsWith("http://")) {
|
||||
return `ws://${httpPath.slice("http://".length)}`;
|
||||
}
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}${httpPath}`;
|
||||
}
|
||||
|
||||
@@ -54,3 +54,7 @@ export function saveAvatars(avatars: AvatarSettings): Promise<AvatarSettings & {
|
||||
export function fetchEnvironment(): Promise<EnvironmentInfo> {
|
||||
return apiGet("/api/environment");
|
||||
}
|
||||
|
||||
export function fetchTerminalInfo(): Promise<{ repoRoot?: string; platform?: string }> {
|
||||
return apiGet("/api/terminal");
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyInline {
|
||||
padding: 10px 10px 4px;
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -48,6 +55,16 @@
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.ephemeral {
|
||||
margin-bottom: 6px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.ephemeral .main {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
|
||||
@@ -5,8 +5,16 @@ import { formatSessionTitle } from "../../utils/sessionTitle";
|
||||
import styles from "./SessionList.module.css";
|
||||
|
||||
export function SessionList() {
|
||||
const { sessions, activeSessionPath, loadSession, deleteSession, renameSession, toggleSessionPin } =
|
||||
useChatContext();
|
||||
const {
|
||||
sessions,
|
||||
activeSessionPath,
|
||||
isEphemeralSession,
|
||||
loadSession,
|
||||
newTemporarySession,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
toggleSessionPin,
|
||||
} = useChatContext();
|
||||
const [editingPath, setEditingPath] = useState<string | null>(null);
|
||||
const [editingValue, setEditingValue] = useState("");
|
||||
|
||||
@@ -29,102 +37,109 @@ export function SessionList() {
|
||||
setEditingValue("");
|
||||
};
|
||||
|
||||
if (!sessions.length) {
|
||||
return <div className={styles.empty}>暂无历史会话</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{sessions.map((s) => {
|
||||
const isActive = activeSessionPath === s.path;
|
||||
const isPinned = Boolean(s.pinned);
|
||||
const title = formatSessionTitle(s.name);
|
||||
const isEditing = editingPath === s.path;
|
||||
return (
|
||||
<div
|
||||
key={s.path}
|
||||
className={`${styles.item} ${isActive ? styles.active : ""} ${isPinned ? styles.pinned : ""}`}
|
||||
>
|
||||
<button type="button" className={styles.main} onClick={() => void loadSession(s.path)}>
|
||||
<div className={styles.titleRow}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
className={styles.renameInput}
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
confirmRename(s.path);
|
||||
} else if (e.key === "Escape") {
|
||||
cancelEditing();
|
||||
}
|
||||
}}
|
||||
onBlur={() => confirmRename(s.path)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.name}>{title}</div>
|
||||
)}
|
||||
<div className={`${styles.item} ${isEphemeralSession ? styles.active : ""} ${styles.ephemeral}`}>
|
||||
<button type="button" className={styles.main} onClick={newTemporarySession}>
|
||||
<div className={styles.name}>临时对话</div>
|
||||
<div className={styles.meta}>不保存 · 刷新后消失</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<div className={styles.emptyInline}>暂无历史会话</div>
|
||||
) : (
|
||||
sessions.map((s) => {
|
||||
const isActive = !isEphemeralSession && activeSessionPath === s.path;
|
||||
const isPinned = Boolean(s.pinned);
|
||||
const title = formatSessionTitle(s.name);
|
||||
const isEditing = editingPath === s.path;
|
||||
return (
|
||||
<div
|
||||
key={s.path}
|
||||
className={`${styles.item} ${isActive ? styles.active : ""} ${isPinned ? styles.pinned : ""}`}
|
||||
>
|
||||
<button type="button" className={styles.main} onClick={() => void loadSession(s.path)}>
|
||||
<div className={styles.titleRow}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
className={styles.renameInput}
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
confirmRename(s.path);
|
||||
} else if (e.key === "Escape") {
|
||||
cancelEditing();
|
||||
}
|
||||
}}
|
||||
onBlur={() => confirmRename(s.path)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.name}>{title}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.meta}>
|
||||
{s.messageCount ?? 0} 条 · {formatTimeAgo(s.modified || s.created)}
|
||||
</div>
|
||||
</button>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.pinBtn} ${isPinned ? styles.pinBtnActive : ""}`}
|
||||
title={isPinned ? "取消置顶" : "置顶会话"}
|
||||
aria-label={isPinned ? `取消置顶 ${title}` : `置顶 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleSessionPin(s.path, !isPinned);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 3h6l1 7h4l-5 6v4H9v-4L4 10h4l1-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.renameBtn}
|
||||
title="重命名会话"
|
||||
aria-label={`重命名 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isEditing) {
|
||||
cancelEditing();
|
||||
} else {
|
||||
startEditing(s.path, s.name || title);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.deleteBtn}
|
||||
title="删除会话"
|
||||
aria-label={`删除 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void deleteSession(s.path);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.meta}>
|
||||
{s.messageCount ?? 0} 条 · {formatTimeAgo(s.modified || s.created)}
|
||||
</div>
|
||||
</button>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.pinBtn} ${isPinned ? styles.pinBtnActive : ""}`}
|
||||
title={isPinned ? "取消置顶" : "置顶会话"}
|
||||
aria-label={isPinned ? `取消置顶 ${title}` : `置顶 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleSessionPin(s.path, !isPinned);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 3h6l1 7h4l-5 6v4H9v-4L4 10h4l1-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.renameBtn}
|
||||
title="重命名会话"
|
||||
aria-label={`重命名 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isEditing) {
|
||||
cancelEditing();
|
||||
} else {
|
||||
startEditing(s.path, s.name || title);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.deleteBtn}
|
||||
title="删除会话"
|
||||
aria-label={`删除 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void deleteSession(s.path);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
134
frontend/src/components/terminal/WebTerminal.module.css
Normal file
134
frontend/src/components/terminal/WebTerminal.module.css
Normal file
@@ -0,0 +1,134 @@
|
||||
.shell {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0d1117;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
background: #161b22;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.toolbarTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.statusLine {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statusText {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #6e7681;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusDot.connecting {
|
||||
background: #d29922;
|
||||
}
|
||||
|
||||
.statusDot.connected {
|
||||
background: #3fb950;
|
||||
}
|
||||
|
||||
.statusDot.error {
|
||||
background: #f85149;
|
||||
}
|
||||
|
||||
.reconnectBtn {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
background: #21262d;
|
||||
color: #c9d1d9;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reconnectBtn:hover:not(:disabled) {
|
||||
background: #30363d;
|
||||
}
|
||||
|
||||
.reconnectBtn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.terminalHost {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 260px;
|
||||
overflow: hidden;
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
.terminalHost :global(.xterm) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10px 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.terminalHost :global(.xterm-viewport) {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.terminalHost :global(.xterm-screen) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.terminalHost :global(.xterm-rows) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.terminalHost :global(.xterm-helper-textarea) {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toolbar {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.terminalHost {
|
||||
min-height: min(360px, calc(var(--app-height) - 220px));
|
||||
}
|
||||
|
||||
.terminalHost :global(.xterm) {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
201
frontend/src/components/terminal/WebTerminal.tsx
Normal file
201
frontend/src/components/terminal/WebTerminal.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { wsUrl } from "../../api/base";
|
||||
import styles from "./WebTerminal.module.css";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
type ConnectionState = "idle" | "connecting" | "connected" | "error";
|
||||
|
||||
function normalizeTerminalInput(data: string, platform?: string): string {
|
||||
if (platform !== "windows") return data;
|
||||
return data.replace(/\r(?!\n)/g, "\r\n");
|
||||
}
|
||||
|
||||
function terminalFontSize(width: number): number {
|
||||
if (width < 360) return 11;
|
||||
if (width < 480) return 12;
|
||||
return 13;
|
||||
}
|
||||
|
||||
export function WebTerminal({
|
||||
active,
|
||||
platform,
|
||||
}: {
|
||||
active: boolean;
|
||||
platform?: string;
|
||||
}) {
|
||||
const shellRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>("idle");
|
||||
const [sessionKey, setSessionKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setConnectionState("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const shell = shellRef.current;
|
||||
if (!container || !shell) return;
|
||||
|
||||
let disposed = false;
|
||||
let resizeTimer: number | undefined;
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: terminalFontSize(container.clientWidth),
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: 0,
|
||||
fontFamily: '"LXGW WenKai Mono", "PingFang SC", "Microsoft YaHei UI", ui-monospace, monospace',
|
||||
scrollback: 5000,
|
||||
allowProposedApi: true,
|
||||
theme: {
|
||||
background: "#0d1117",
|
||||
foreground: "#c9d1d9",
|
||||
cursor: "#58a6ff",
|
||||
selectionBackground: "#264f78",
|
||||
},
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(webLinksAddon);
|
||||
term.open(container);
|
||||
|
||||
setConnectionState("connecting");
|
||||
|
||||
const ws = new WebSocket(wsUrl("/api/terminal/ws"));
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
const sendResize = () => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
};
|
||||
|
||||
const fit = () => {
|
||||
if (disposed) return;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
if (width < 24 || height < 24) return;
|
||||
|
||||
const nextFontSize = terminalFontSize(width);
|
||||
if (term.options.fontSize !== nextFontSize) {
|
||||
term.options.fontSize = nextFontSize;
|
||||
}
|
||||
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
sendResize();
|
||||
};
|
||||
|
||||
const scheduleFit = () => {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(() => {
|
||||
requestAnimationFrame(fit);
|
||||
}, 32);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(scheduleFit);
|
||||
resizeObserver.observe(shell);
|
||||
resizeObserver.observe(container);
|
||||
window.addEventListener("resize", scheduleFit);
|
||||
|
||||
ws.onopen = () => {
|
||||
if (disposed) return;
|
||||
setConnectionState("connected");
|
||||
scheduleFit();
|
||||
window.setTimeout(scheduleFit, 120);
|
||||
term.focus();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (disposed) return;
|
||||
if (typeof event.data === "string") {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as { type?: string; error?: string };
|
||||
if (payload.type === "error" && payload.error) {
|
||||
setConnectionState("error");
|
||||
term.writeln(`\r\n\x1b[31m${payload.error}\x1b[0m`);
|
||||
}
|
||||
} catch {
|
||||
term.write(event.data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
term.write(new Uint8Array(event.data));
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (disposed) return;
|
||||
setConnectionState("error");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (disposed) return;
|
||||
setConnectionState("idle");
|
||||
term.writeln("\r\n\x1b[90m[连接已断开]\x1b[0m");
|
||||
};
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "input", data: normalizeTerminalInput(data, platform) }));
|
||||
}
|
||||
});
|
||||
|
||||
const resizeDisposable = term.onResize(() => {
|
||||
sendResize();
|
||||
});
|
||||
|
||||
scheduleFit();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(resizeTimer);
|
||||
window.removeEventListener("resize", scheduleFit);
|
||||
dataDisposable.dispose();
|
||||
resizeDisposable.dispose();
|
||||
resizeObserver.disconnect();
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
}
|
||||
term.dispose();
|
||||
};
|
||||
}, [active, platform, sessionKey]);
|
||||
|
||||
const statusLabel =
|
||||
connectionState === "connecting"
|
||||
? "连接中…"
|
||||
: connectionState === "connected"
|
||||
? "已连接"
|
||||
: connectionState === "error"
|
||||
? "连接失败"
|
||||
: "未连接";
|
||||
|
||||
return (
|
||||
<div ref={shellRef} className={styles.shell}>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarTop}>
|
||||
<div className={styles.statusLine}>
|
||||
<span className={`${styles.statusDot} ${styles[connectionState]}`} aria-hidden />
|
||||
<span className={styles.statusText}>{statusLabel}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.reconnectBtn}
|
||||
disabled={!active || connectionState === "connecting"}
|
||||
onClick={() => setSessionKey((key) => key + 1)}
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerRef} className={styles.terminalHost} tabIndex={0} aria-label="Web 终端" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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";
|
||||
@@ -44,6 +45,14 @@ export function BootstrapProvider({ children }: { children: ReactNode }) {
|
||||
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;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
formatToolCall,
|
||||
getToolCalls,
|
||||
buildToolCallUpdateText,
|
||||
preferToolCallMessage,
|
||||
} from "../utils/toolCall";
|
||||
import {
|
||||
formatSessionTitle,
|
||||
@@ -65,6 +66,8 @@ interface ChatContextValue {
|
||||
renameSession: (path: string, currentTitle: string) => Promise<void>;
|
||||
toggleSessionPin: (path: string, pinned: boolean) => Promise<void>;
|
||||
newSession: () => void;
|
||||
newTemporarySession: () => void;
|
||||
isEphemeralSession: boolean;
|
||||
sendMessage: (
|
||||
text: string,
|
||||
images?: ImageContent[],
|
||||
@@ -85,6 +88,20 @@ const ChatContext = createContext<ChatContextValue | null>(null);
|
||||
|
||||
const THINKING_LEVELS_LIST: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
||||
const LAST_ACTIVE_SESSION_KEY = "webui:lastActiveSessionPath";
|
||||
const EPHEMERAL_SESSION_ID = "__ephemeral__";
|
||||
const EPHEMERAL_CLEANUP_KEY = "webui:ephemeralSessionPath";
|
||||
|
||||
function isActiveSessionBound(
|
||||
isEphemeral: boolean,
|
||||
activePath: string | null,
|
||||
backendPath: string | null,
|
||||
): boolean {
|
||||
if (isEphemeral) {
|
||||
return Boolean(backendPath);
|
||||
}
|
||||
if (!activePath) return false;
|
||||
return backendPath === activePath;
|
||||
}
|
||||
|
||||
/** Survives React StrictMode remount so boot only runs once per page load. */
|
||||
let sharedSessionBootPromise: Promise<void> | null = null;
|
||||
@@ -364,6 +381,9 @@ function finalizeAssistantMessageEnd(
|
||||
}
|
||||
|
||||
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") {
|
||||
return null;
|
||||
}
|
||||
@@ -437,12 +457,24 @@ function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] {
|
||||
|
||||
const existingIndex = turnSignatures.get(signature);
|
||||
if (existingIndex === undefined) {
|
||||
if (msg.role === "tool_call") {
|
||||
const idIdx = result.findIndex((m) => m.id === msg.id);
|
||||
if (idIdx >= 0) {
|
||||
result[idIdx] = preferToolCallMessage(result[idIdx], msg);
|
||||
turnSignatures.set(signature, idIdx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
turnSignatures.set(signature, result.length);
|
||||
result.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = result[existingIndex];
|
||||
if (msg.role === "tool_call") {
|
||||
result[existingIndex] = preferToolCallMessage(existing, msg);
|
||||
continue;
|
||||
}
|
||||
if (existing.streaming && !msg.streaming) {
|
||||
result[existingIndex] = msg;
|
||||
}
|
||||
@@ -451,6 +483,11 @@ function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] {
|
||||
return collapseStreamingPrefixOrphans(result);
|
||||
}
|
||||
|
||||
function isIgnorableExtensionError(error: string | undefined): boolean {
|
||||
if (!error) return false;
|
||||
return error.includes("extension ctx is stale after session replacement or reload");
|
||||
}
|
||||
|
||||
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
const { markRouteReady } = useBootstrap();
|
||||
const [connected, setConnected] = useState(false);
|
||||
@@ -475,8 +512,12 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
const [extensionStatuses, setExtensionStatuses] = useState<Record<string, string>>({});
|
||||
const [extensionDialog, setExtensionDialog] = useState<ExtensionDialogState | null>(null);
|
||||
const [toolsExpanded, setToolsExpanded] = useState(false);
|
||||
const [isEphemeralSession, setIsEphemeralSession] = useState(false);
|
||||
|
||||
const sessionCache = useRef(new Map<string, SessionHistoryPayload>());
|
||||
const isEphemeralRef = useRef(false);
|
||||
const ephemeralSessionPathRef = useRef<string | null>(null);
|
||||
const suppressStreamMessagesRef = useRef(false);
|
||||
const sessionActivationPromise = useRef<Promise<SessionState | null> | null>(null);
|
||||
const newSessionPromise = useRef<Promise<void> | null>(null);
|
||||
const ensureSessionCreatePromise = useRef<Promise<boolean> | null>(null);
|
||||
@@ -550,11 +591,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
|
||||
if (data.isStreaming !== undefined) setIsStreaming(data.isStreaming);
|
||||
|
||||
if (!activeSessionPathRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (backendSessionPathRef.current !== activeSessionPathRef.current) {
|
||||
if (!isActiveSessionBound(isEphemeralRef.current, activeSessionPathRef.current, backendSessionPathRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -574,20 +611,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
if (data.isStreaming !== undefined) setIsStreaming(data.isStreaming);
|
||||
if (data.isCompacting !== undefined) setIsCompacting(data.isCompacting);
|
||||
|
||||
if (!activeSessionPathRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (backendSessionPathRef.current !== activeSessionPathRef.current) {
|
||||
if (!isActiveSessionBound(isEphemeralRef.current, activeSessionPathRef.current, backendSessionPathRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sid = data.sessionId != null ? String(data.sessionId) : "";
|
||||
const pathKey = activeSessionPathRef.current;
|
||||
const pathKey = isEphemeralRef.current ? backendSessionPathRef.current : activeSessionPathRef.current;
|
||||
setSessions((currentSessions) => {
|
||||
const row = pathKey ? currentSessions.find((s) => s.path === pathKey) : undefined;
|
||||
if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) {
|
||||
setHeaderTitle(formatSessionTitle(data.sessionName));
|
||||
} else if (isEphemeralRef.current) {
|
||||
setHeaderTitle("临时对话");
|
||||
} else if (row) {
|
||||
if (row.name && !isMachineSessionLabel(row.name, sid)) {
|
||||
setHeaderTitle(formatSessionTitle(row.name));
|
||||
@@ -627,26 +662,89 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const data = await sessionsApi.fetchSessions();
|
||||
setSessions(data.sessions || []);
|
||||
const ephemeralPath = ephemeralSessionPathRef.current;
|
||||
const list = (data.sessions || []).filter((s) => s.path !== ephemeralPath);
|
||||
setSessions(list);
|
||||
} catch (err) {
|
||||
addSystemMessage(`会话列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [addSystemMessage]);
|
||||
|
||||
const createAndBindNewSession = useCallback(async (): Promise<string> => {
|
||||
const data = await chatApi.createNewSession();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
const cleanupEphemeralSessionOnBoot = useCallback(async () => {
|
||||
try {
|
||||
const path = sessionStorage.getItem(EPHEMERAL_CLEANUP_KEY)?.trim();
|
||||
if (!path) return;
|
||||
sessionStorage.removeItem(EPHEMERAL_CLEANUP_KEY);
|
||||
await sessionsApi.deleteSession(path);
|
||||
} catch {
|
||||
/* ignore cleanup errors */
|
||||
}
|
||||
const path = data.sessionFile?.trim() || (await syncBackendSessionPathFromState());
|
||||
if (!path) {
|
||||
throw new Error("无法获取会话路径");
|
||||
}, []);
|
||||
|
||||
const discardEphemeralSession = useCallback(async () => {
|
||||
if (!isEphemeralRef.current && !ephemeralSessionPathRef.current) return;
|
||||
|
||||
const path = ephemeralSessionPathRef.current;
|
||||
isEphemeralRef.current = false;
|
||||
ephemeralSessionPathRef.current = null;
|
||||
setIsEphemeralSession(false);
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(EPHEMERAL_CLEANUP_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setBackendSessionPath(path);
|
||||
void persistLastActiveSession(path);
|
||||
void loadSessions();
|
||||
return path;
|
||||
}, [syncBackendSessionPathFromState, persistLastActiveSession, loadSessions]);
|
||||
|
||||
if (!path) return;
|
||||
|
||||
try {
|
||||
await sessionsApi.deleteSession(path);
|
||||
} catch {
|
||||
/* ignore delete errors */
|
||||
}
|
||||
sessionCache.current.delete(path);
|
||||
setSessions((prev) => prev.filter((s) => s.path !== path));
|
||||
}, []);
|
||||
|
||||
const createAndBindNewSession = useCallback(
|
||||
async (options?: { persist?: boolean; refreshList?: boolean }): Promise<string> => {
|
||||
const { persist = true, refreshList = true } = options ?? {};
|
||||
const data = await chatApi.createNewSession();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
const path = data.sessionFile?.trim() || (await syncBackendSessionPathFromState());
|
||||
if (!path) {
|
||||
throw new Error("无法获取会话路径");
|
||||
}
|
||||
setBackendSessionPath(path);
|
||||
if (persist) {
|
||||
void persistLastActiveSession(path);
|
||||
}
|
||||
if (refreshList) {
|
||||
void loadSessions();
|
||||
}
|
||||
return path;
|
||||
},
|
||||
[syncBackendSessionPathFromState, persistLastActiveSession, loadSessions],
|
||||
);
|
||||
|
||||
const syncMessagesForPath = useCallback(async (path: string) => {
|
||||
try {
|
||||
const payload = await sessionsApi.fetchSessionHistory(path);
|
||||
if (payload.error) {
|
||||
invalidateTurnEvents();
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
sessionCache.current.set(path, payload);
|
||||
invalidateTurnEvents();
|
||||
setMessages(historyToMessages(payload));
|
||||
} catch {
|
||||
invalidateTurnEvents();
|
||||
setMessages([]);
|
||||
}
|
||||
}, [invalidateTurnEvents]);
|
||||
|
||||
const clearChat = useCallback(
|
||||
(title = "聊天") => {
|
||||
@@ -661,6 +759,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const loadSession = useCallback(
|
||||
async (path: string, options?: { skipDashboardRefresh?: boolean }) => {
|
||||
await discardEphemeralSession();
|
||||
|
||||
if (!compactionReloadPendingRef.current) {
|
||||
invalidateTurnEvents();
|
||||
}
|
||||
@@ -731,15 +831,19 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
addSystemMessage(`加载会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
},
|
||||
[addSystemMessage, persistLastActiveSession, scheduleSessionDashboardRefresh, invalidateTurnEvents, activateSessionBackend, fetchSessionStateFull],
|
||||
[addSystemMessage, persistLastActiveSession, scheduleSessionDashboardRefresh, invalidateTurnEvents, activateSessionBackend, fetchSessionStateFull, discardEphemeralSession],
|
||||
);
|
||||
|
||||
const restoreSessionOnBoot = useCallback(() => {
|
||||
const restoreSessionOnBoot = useCallback((onSessionsReady?: () => void) => {
|
||||
const notifySessionsReady = () => onSessionsReady?.();
|
||||
if (!sharedSessionBootPromise) {
|
||||
sharedSessionBootPromise = (async () => {
|
||||
await cleanupEphemeralSessionOnBoot();
|
||||
|
||||
const data = await sessionsApi.fetchSessions();
|
||||
const list = data.sessions || [];
|
||||
setSessions(list);
|
||||
notifySessionsReady();
|
||||
|
||||
let targetPath: string | null = null;
|
||||
try {
|
||||
@@ -756,24 +860,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
if (targetPath) {
|
||||
let activatedState: SessionState | null = null;
|
||||
await Promise.all([
|
||||
loadSession(targetPath, { skipDashboardRefresh: true }),
|
||||
(async () => {
|
||||
try {
|
||||
activatedState = await activateSessionBackend(targetPath);
|
||||
} catch (err) {
|
||||
addSystemMessage(
|
||||
`恢复上次会话失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
})(),
|
||||
]);
|
||||
if (activatedState) {
|
||||
applySessionState(activatedState);
|
||||
} else {
|
||||
await fetchSessionStateFull();
|
||||
}
|
||||
await loadSession(targetPath, { skipDashboardRefresh: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -789,17 +876,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
addSystemMessage(`初始化会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
});
|
||||
} else if (onSessionsReady) {
|
||||
void sharedSessionBootPromise.then(notifySessionsReady).catch(notifySessionsReady);
|
||||
}
|
||||
sessionBootRef.current = sharedSessionBootPromise;
|
||||
return sharedSessionBootPromise;
|
||||
}, [
|
||||
loadSession,
|
||||
activateSessionBackend,
|
||||
applySessionState,
|
||||
fetchSessionStateFull,
|
||||
addSystemMessage,
|
||||
createAndBindNewSession,
|
||||
invalidateTurnEvents,
|
||||
cleanupEphemeralSessionOnBoot,
|
||||
]);
|
||||
|
||||
const deleteSessionHandler = useCallback(
|
||||
@@ -816,7 +904,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
await sessionsApi.deleteSession(path);
|
||||
sessionCache.current.delete(path);
|
||||
setSessions((prev) => prev.filter((s) => s.path !== path));
|
||||
if (activeSessionPathRef.current === path) {
|
||||
if (activeSessionPathRef.current === path || (isEphemeralRef.current && ephemeralSessionPathRef.current === path)) {
|
||||
clearChat();
|
||||
setBackendSessionPath(null);
|
||||
sessionActivationPromise.current = null;
|
||||
@@ -881,15 +969,19 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
if (newSessionPromise.current) return;
|
||||
|
||||
setBackendSessionPath(null);
|
||||
setActiveSessionPath(null);
|
||||
sessionActivationPromise.current = null;
|
||||
setHeaderMeta("");
|
||||
clearChat("新会话");
|
||||
void persistLastActiveSession(null);
|
||||
|
||||
const promise = (async () => {
|
||||
await createAndBindNewSession();
|
||||
await discardEphemeralSession();
|
||||
|
||||
suppressStreamMessagesRef.current = true;
|
||||
setBackendSessionPath(null);
|
||||
setActiveSessionPath(null);
|
||||
sessionActivationPromise.current = null;
|
||||
setHeaderMeta("");
|
||||
clearChat("新会话");
|
||||
void persistLastActiveSession(null);
|
||||
|
||||
const path = await createAndBindNewSession();
|
||||
await syncMessagesForPath(path);
|
||||
await fetchSessionStateFull();
|
||||
})();
|
||||
|
||||
@@ -900,9 +992,68 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
suppressStreamMessagesRef.current = false;
|
||||
newSessionPromise.current = null;
|
||||
});
|
||||
}, [addSystemMessage, clearChat, createAndBindNewSession, fetchSessionStateFull, persistLastActiveSession]);
|
||||
}, [addSystemMessage, clearChat, createAndBindNewSession, discardEphemeralSession, fetchSessionStateFull, persistLastActiveSession, syncMessagesForPath]);
|
||||
|
||||
const newTemporarySession = useCallback(() => {
|
||||
if (isStreamingRef.current) {
|
||||
addSystemMessage("正在回复中,暂不能创建临时对话");
|
||||
return;
|
||||
}
|
||||
if (newSessionPromise.current) return;
|
||||
|
||||
const promise = (async () => {
|
||||
await discardEphemeralSession();
|
||||
|
||||
suppressStreamMessagesRef.current = true;
|
||||
setSidebarOpen(false);
|
||||
setBackendSessionPath(null);
|
||||
sessionActivationPromise.current = null;
|
||||
setHeaderMeta("");
|
||||
clearChat("临时对话");
|
||||
void persistLastActiveSession(null);
|
||||
|
||||
const path = await createAndBindNewSession({ persist: false, refreshList: false });
|
||||
|
||||
isEphemeralRef.current = true;
|
||||
ephemeralSessionPathRef.current = path;
|
||||
setIsEphemeralSession(true);
|
||||
setActiveSessionPath(EPHEMERAL_SESSION_ID);
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(EPHEMERAL_CLEANUP_KEY, path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
await syncMessagesForPath(path);
|
||||
await fetchSessionStateFull();
|
||||
})();
|
||||
|
||||
newSessionPromise.current = promise;
|
||||
void promise
|
||||
.catch((err) => {
|
||||
isEphemeralRef.current = false;
|
||||
ephemeralSessionPathRef.current = null;
|
||||
setIsEphemeralSession(false);
|
||||
setHeaderMeta("");
|
||||
addSystemMessage(`创建临时对话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
suppressStreamMessagesRef.current = false;
|
||||
newSessionPromise.current = null;
|
||||
});
|
||||
}, [
|
||||
addSystemMessage,
|
||||
clearChat,
|
||||
createAndBindNewSession,
|
||||
discardEphemeralSession,
|
||||
fetchSessionStateFull,
|
||||
persistLastActiveSession,
|
||||
syncMessagesForPath,
|
||||
]);
|
||||
|
||||
const ensureSessionReady = useCallback(async (): Promise<boolean> => {
|
||||
if (newSessionPromise.current) {
|
||||
@@ -939,7 +1090,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
if (!created) return false;
|
||||
}
|
||||
|
||||
if (activeSessionPathRef.current && backendSessionPathRef.current !== activeSessionPathRef.current) {
|
||||
if (
|
||||
!isEphemeralRef.current &&
|
||||
activeSessionPathRef.current &&
|
||||
backendSessionPathRef.current !== activeSessionPathRef.current
|
||||
) {
|
||||
setHeaderMeta("正在切换会话...");
|
||||
if (sessionActivationPromise.current) {
|
||||
try {
|
||||
@@ -986,6 +1141,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
if (path) {
|
||||
await loadSession(path);
|
||||
} else {
|
||||
await discardEphemeralSession();
|
||||
clearChat();
|
||||
await fetchSessionStateFull();
|
||||
}
|
||||
@@ -1005,6 +1161,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
[
|
||||
addSystemMessage,
|
||||
clearChat,
|
||||
discardEphemeralSession,
|
||||
loadSessions,
|
||||
loadSession,
|
||||
fetchSessionStateFull,
|
||||
@@ -1062,6 +1219,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
const persistPathAfterSend = () => {
|
||||
if (isEphemeralRef.current) return;
|
||||
const persistPath = resolvePersistSessionPath(
|
||||
backendSessionPathRef.current,
|
||||
activeSessionPathRef.current,
|
||||
@@ -1318,6 +1476,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const processStreamEvent = useCallback(
|
||||
(ev: SseEvent) => {
|
||||
if (
|
||||
suppressStreamMessagesRef.current &&
|
||||
(ev.type === "message_start" ||
|
||||
ev.type === "message_update" ||
|
||||
ev.type === "message_end" ||
|
||||
ev.type === "tool_execution_start" ||
|
||||
ev.type === "tool_execution_update" ||
|
||||
ev.type === "tool_execution_end")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (ev.type) {
|
||||
case "agent_start":
|
||||
setIsStreaming(true);
|
||||
@@ -1470,7 +1640,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
if (ev.errorMessage) addSystemMessage(ev.errorMessage);
|
||||
break;
|
||||
}
|
||||
const activePath = activeSessionPathRef.current;
|
||||
const activePath = isEphemeralRef.current
|
||||
? backendSessionPathRef.current
|
||||
: activeSessionPathRef.current;
|
||||
const summaryText = ev.result?.summary?.trim();
|
||||
if (summaryText) {
|
||||
setMessages((prev) => [
|
||||
@@ -1522,9 +1694,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
break;
|
||||
|
||||
case "extension_error":
|
||||
addSystemMessage(
|
||||
`扩展错误${ev.extensionPath ? ` (${ev.extensionPath})` : ""}: ${ev.error || "unknown"}`,
|
||||
);
|
||||
if (!isIgnorableExtensionError(ev.error)) {
|
||||
addSystemMessage(
|
||||
`扩展错误${ev.extensionPath ? ` (${ev.extensionPath})` : ""}: ${ev.error || "unknown"}`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "bash_update": {
|
||||
@@ -1557,6 +1731,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
setIsStreaming(ev.isStreaming);
|
||||
}
|
||||
for (const replayEv of ev.replay ?? []) {
|
||||
if (suppressStreamMessagesRef.current) {
|
||||
if (
|
||||
replayEv.type === "message_start" ||
|
||||
replayEv.type === "message_update" ||
|
||||
replayEv.type === "message_end" ||
|
||||
replayEv.type === "tool_execution_start" ||
|
||||
replayEv.type === "tool_execution_update" ||
|
||||
replayEv.type === "tool_execution_end"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (replayEv.type === "extension_ui_request") {
|
||||
handleExtensionUiRequest(replayEv as Record<string, unknown>);
|
||||
} else {
|
||||
@@ -1594,6 +1780,15 @@ 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;
|
||||
|
||||
useEffect(() => {
|
||||
syncViewportHeight();
|
||||
const onResize = () => {
|
||||
@@ -1605,24 +1800,42 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
window.visualViewport?.addEventListener("scroll", syncViewportHeight);
|
||||
window.addEventListener("orientationchange", syncViewportHeight);
|
||||
|
||||
let cancelled = false;
|
||||
const bootstrap = {
|
||||
sessions: false,
|
||||
models: false,
|
||||
sessionState: false,
|
||||
sse: false,
|
||||
};
|
||||
|
||||
const tryMarkBootstrapReady = () => {
|
||||
if (bootstrap.sessions && bootstrap.models && bootstrap.sessionState && bootstrap.sse) {
|
||||
markRouteReady("chat");
|
||||
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
|
||||
.fetchModels()
|
||||
.then((data) => setAvailableModels(data.models || []))
|
||||
.then((data) => {
|
||||
if (!cancelled) setAvailableModels(data.models || []);
|
||||
})
|
||||
.catch((err) => {
|
||||
addSystemMessage(`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
if (!cancelled) {
|
||||
addSystemMessageRef.current(
|
||||
`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
bootstrap.models = true;
|
||||
@@ -1631,31 +1844,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let sseBootstrapTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const finishSseBootstrap = () => {
|
||||
if (bootstrap.sse) return;
|
||||
bootstrap.sse = true;
|
||||
tryMarkBootstrapReady();
|
||||
};
|
||||
|
||||
sseBootstrapTimer = setTimeout(() => {
|
||||
if (!bootstrap.sse) {
|
||||
addSystemMessage("无法连接实时事件流,请确认 WebUI 已启动且穿透域名可访问");
|
||||
finishSseBootstrap();
|
||||
}
|
||||
}, 10_000);
|
||||
let sseNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let sseNoticeShown = false;
|
||||
|
||||
const connect = () => {
|
||||
if (eventSource) eventSource.close();
|
||||
eventSource = new EventSource(apiUrl("/api/events"));
|
||||
eventSource.onopen = () => {
|
||||
setConnected(true);
|
||||
finishSseBootstrap();
|
||||
};
|
||||
eventSource.onmessage = (e) => {
|
||||
try {
|
||||
handleSseEvent(JSON.parse(e.data) as SseEvent);
|
||||
handleSseEventRef.current(JSON.parse(e.data) as SseEvent);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -1669,25 +1869,23 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
connect();
|
||||
|
||||
void restoreSessionOnBoot()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
bootstrap.sessions = true;
|
||||
bootstrap.sessionState = true;
|
||||
tryMarkBootstrapReady();
|
||||
});
|
||||
sseNoticeTimer = setTimeout(() => {
|
||||
if (cancelled || sseNoticeShown) return;
|
||||
sseNoticeShown = true;
|
||||
addSystemMessageRef.current("无法连接实时事件流,请确认 WebUI 已启动且穿透域名可访问");
|
||||
}, 12_000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("resize", onResize);
|
||||
window.visualViewport?.removeEventListener("resize", syncViewportHeight);
|
||||
window.visualViewport?.removeEventListener("scroll", syncViewportHeight);
|
||||
window.removeEventListener("orientationchange", syncViewportHeight);
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (sseBootstrapTimer) clearTimeout(sseBootstrapTimer);
|
||||
if (sseNoticeTimer) clearTimeout(sseNoticeTimer);
|
||||
eventSource?.close();
|
||||
sharedSessionBootPromise = null;
|
||||
};
|
||||
}, [restoreSessionOnBoot, fetchSessionStateFull, handleSseEvent, addSystemMessage, markRouteReady]);
|
||||
}, []);
|
||||
|
||||
const value: ChatContextValue = {
|
||||
connected,
|
||||
@@ -1720,6 +1918,8 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
renameSession: renameSessionHandler,
|
||||
toggleSessionPin: toggleSessionPinHandler,
|
||||
newSession,
|
||||
newTemporarySession,
|
||||
isEphemeralSession,
|
||||
sendMessage,
|
||||
runBashCommand,
|
||||
abortStream,
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@mogeko/maple-mono-cn/dist/font/result.css";
|
||||
import { App } from "./App";
|
||||
import "./styles/global.css";
|
||||
|
||||
function scheduleFontLoad() {
|
||||
const load = () => {
|
||||
void import("./styles/fonts.css");
|
||||
};
|
||||
if (typeof window.requestIdleCallback === "function") {
|
||||
window.requestIdleCallback(load);
|
||||
} else {
|
||||
setTimeout(load, 200);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleFontLoad();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -181,6 +181,55 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.panelHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.headerBtnActive {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.markdownPreview {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.markdownBody {
|
||||
max-width: none;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.markdownBody :global(pre.assistant-pre) {
|
||||
margin: 0.5em 0 0.6em;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.markdownBody :global(pre.assistant-pre code.hljs) {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
background: #f6f8fa;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
word-break: normal;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn,
|
||||
.linkBtn {
|
||||
@@ -256,6 +305,20 @@
|
||||
box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.textareaInvalid:focus {
|
||||
box-shadow: inset 0 0 0 2px rgba(220, 38, 38, 0.35);
|
||||
}
|
||||
|
||||
.jsonError {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 14px;
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
border-top: 1px solid #fecaca;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.formBody {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -453,17 +516,23 @@
|
||||
.envValue {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: #111827;
|
||||
word-break: break-all;
|
||||
background: none;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.extensionGroupList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.terminalPanel {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.terminalPanel .panelHeader {
|
||||
border-bottom-color: #30363d;
|
||||
}
|
||||
|
||||
.terminalPanel .panelHeader p {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.extensionHeader {
|
||||
@@ -807,51 +876,170 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pageHeader {
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
padding-top: max(12px, env(safe-area-inset-top));
|
||||
padding-bottom: 12px;
|
||||
padding-left: max(12px, env(safe-area-inset-left));
|
||||
padding-right: max(12px, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.pageTitle h1 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 10px max(12px, env(safe-area-inset-left)) 10px max(12px, env(safe-area-inset-right));
|
||||
gap: 6px;
|
||||
padding: 8px max(10px, env(safe-area-inset-left)) 8px max(10px, env(safe-area-inset-right));
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar {
|
||||
height: 0;
|
||||
width: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
min-width: auto;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 12px 8px;
|
||||
white-space: nowrap;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.panes {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px max(10px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right));
|
||||
padding: 10px max(10px, env(safe-area-inset-left)) max(10px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.panelHeaderActions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.panelHeader h2 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.panelHeader p {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.markdownBody {
|
||||
padding: 16px 14px;
|
||||
}
|
||||
|
||||
.envRow {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.envLabel {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.mcpServerHead {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.mcpServerBadges {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.skillActions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.extensionTitleRow {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.terminalPanel {
|
||||
min-height: min(420px, calc(var(--app-height) - 180px));
|
||||
}
|
||||
|
||||
.textarea {
|
||||
font-size: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.formBody {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.extensionGroupList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn,
|
||||
.linkBtn {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panelHeaderActions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panelHeaderActions .primaryBtn,
|
||||
.panelHeaderActions .secondaryBtn {
|
||||
flex: 1 1 auto;
|
||||
min-width: 88px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as settingsApi from "../api/settings";
|
||||
import { useAvatars } from "../context/AvatarContext";
|
||||
import { useBootstrap } from "../context/BootstrapContext";
|
||||
import type { EnvironmentInfo, ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "../types/events";
|
||||
import type { McpToolInfo } from "../types/events";
|
||||
import { renderMarkdown } from "../utils/markdown";
|
||||
import styles from "./SettingsPage.module.css";
|
||||
|
||||
const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "other", "env"];
|
||||
const WebTerminal = lazy(() =>
|
||||
import("../components/terminal/WebTerminal").then((module) => ({ default: module.WebTerminal })),
|
||||
);
|
||||
|
||||
const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "other", "terminal", "env"];
|
||||
|
||||
function isNpmSkill(skill: SkillInfo): boolean {
|
||||
if (skill.toggleable === false) return true;
|
||||
@@ -28,6 +33,41 @@ function formatSkillMeta(skill: SkillInfo): string {
|
||||
}
|
||||
const SETTINGS_PANE_KEY = "sproutclaw-settings-pane";
|
||||
|
||||
function validateModelsConfigJson(text: string): string | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return "JSON 不能为空";
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch (err) {
|
||||
const message = err instanceof SyntaxError ? err.message : String(err);
|
||||
return `JSON 语法错误:${message}`;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return "模型配置必须是 JSON 对象";
|
||||
}
|
||||
|
||||
const root = parsed as Record<string, unknown>;
|
||||
if (!("providers" in root)) {
|
||||
return '缺少必填字段 "providers"';
|
||||
}
|
||||
if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) {
|
||||
return '"providers" 必须是对象';
|
||||
}
|
||||
|
||||
for (const [name, provider] of Object.entries(root.providers as Record<string, unknown>)) {
|
||||
if (!provider || typeof provider !== "object" || Array.isArray(provider)) {
|
||||
return `"providers.${name}" 必须是对象`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderNameList(label: string, items: string[] | undefined) {
|
||||
if (!Array.isArray(items) || !items.length) return null;
|
||||
return (
|
||||
@@ -56,6 +96,13 @@ function extensionTogglePath(extension: ExtensionInfo): string {
|
||||
return extension.resolvedPath || extension.path || "";
|
||||
}
|
||||
|
||||
// Only local extensions can be enabled/disabled (by moving the directory between
|
||||
// extensions/ and extensions-disabled/). npm extensions are not managed here.
|
||||
function extensionToggleable(extension: ExtensionInfo): boolean {
|
||||
if (extension.toggleable === false) return false;
|
||||
return extensionCategory(extension) === "local";
|
||||
}
|
||||
|
||||
function ExtensionCard({
|
||||
extension,
|
||||
toggling,
|
||||
@@ -67,10 +114,11 @@ function ExtensionCard({
|
||||
disabled: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}) {
|
||||
const meta =
|
||||
extension.kind || [extension.scope, extension.source].filter(Boolean).join(" · ");
|
||||
const meta = [extension.kind, extension.scope, extension.source].filter(Boolean).join(" · ");
|
||||
const enabled = extension.enabled !== false;
|
||||
const toggleable = extensionToggleable(extension);
|
||||
const togglePath = extensionTogglePath(extension);
|
||||
const versionLabel = extension.version ? `v${extension.version}` : "";
|
||||
const counts = (
|
||||
[
|
||||
["命令", extension.commands?.length || 0],
|
||||
@@ -89,19 +137,23 @@ function ExtensionCard({
|
||||
<div className={styles.extensionHeader}>
|
||||
<div className={styles.extensionTitleRow}>
|
||||
<div className={styles.itemName}>{extension.name || extension.path || "未命名"}</div>
|
||||
<label className={styles.skillToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={!togglePath || toggling || disabled}
|
||||
onChange={(e) => {
|
||||
if (!togglePath) return;
|
||||
onToggle(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.skillToggleUi} aria-hidden />
|
||||
<span className={styles.skillToggleLabel}>{enabled ? "已启用" : "已禁用"}</span>
|
||||
</label>
|
||||
{toggleable ? (
|
||||
<label className={styles.skillToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={!togglePath || toggling || disabled}
|
||||
onChange={(e) => {
|
||||
if (!togglePath) return;
|
||||
onToggle(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.skillToggleUi} aria-hidden />
|
||||
<span className={styles.skillToggleLabel}>{enabled ? "已启用" : "已禁用"}</span>
|
||||
</label>
|
||||
) : versionLabel ? (
|
||||
<div className={styles.itemMeta}>{versionLabel}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{meta ? <div className={styles.extensionSubtitle}>{meta}</div> : null}
|
||||
</div>
|
||||
@@ -248,6 +300,7 @@ export function SettingsPage() {
|
||||
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
|
||||
const [systemPrompt, setSystemPrompt] = useState("");
|
||||
const [systemPromptPath, setSystemPromptPath] = useState("");
|
||||
const [systemPromptPreview, setSystemPromptPreview] = useState(false);
|
||||
const [modelsConfig, setModelsConfig] = useState("");
|
||||
const [modelsConfigPath, setModelsConfigPath] = useState("");
|
||||
const [userAvatarUrl, setUserAvatarUrl] = useState("");
|
||||
@@ -269,6 +322,9 @@ export function SettingsPage() {
|
||||
const [togglingExtensionPath, setTogglingExtensionPath] = useState<string | null>(null);
|
||||
const [envInfo, setEnvInfo] = useState<EnvironmentInfo | null>(null);
|
||||
const [loadingEnv, setLoadingEnv] = useState(false);
|
||||
const [terminalPlatform, setTerminalPlatform] = useState("");
|
||||
const [terminalRequested, setTerminalRequested] = useState(false);
|
||||
const sidebarRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let initial: SettingsPaneId = "prompt";
|
||||
@@ -290,6 +346,9 @@ export function SettingsPage() {
|
||||
|
||||
const showPane = (paneId: SettingsPaneId) => {
|
||||
setActivePane(paneId);
|
||||
if (paneId !== "prompt") {
|
||||
setSystemPromptPreview(false);
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(SETTINGS_PANE_KEY, paneId);
|
||||
} catch {
|
||||
@@ -338,6 +397,17 @@ export function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadTerminalInfo = async () => {
|
||||
try {
|
||||
const data = await settingsApi.fetchTerminalInfo();
|
||||
if (data.platform) {
|
||||
setTerminalPlatform(data.platform);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus(`加载终端信息失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const loadEnvInfo = async () => {
|
||||
setLoadingEnv(true);
|
||||
try {
|
||||
@@ -354,6 +424,17 @@ export function SettingsPage() {
|
||||
if (activePane === "env" && !envInfo && !loadingEnv) {
|
||||
void loadEnvInfo();
|
||||
}
|
||||
if (activePane === "terminal" && !terminalPlatform) {
|
||||
void loadTerminalInfo();
|
||||
}
|
||||
if (activePane === "terminal") {
|
||||
setTerminalRequested(true);
|
||||
}
|
||||
}, [activePane]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeTab = sidebarRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
|
||||
activeTab?.scrollIntoView({ behavior: "smooth", inline: "nearest", block: "nearest" });
|
||||
}, [activePane]);
|
||||
|
||||
const toggleSkillEnabled = async (path: string, enabled: boolean) => {
|
||||
@@ -471,6 +552,12 @@ export function SettingsPage() {
|
||||
};
|
||||
|
||||
const saveModelsConfig = async () => {
|
||||
const validationError = validateModelsConfigJson(modelsConfig);
|
||||
if (validationError) {
|
||||
setStatus(validationError, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingModels(true);
|
||||
setStatus("保存中...");
|
||||
try {
|
||||
@@ -521,6 +608,11 @@ export function SettingsPage() {
|
||||
const toggleableSkills = skills.filter(isSkillToggleable);
|
||||
const enabledSkillCount = toggleableSkills.filter((skill) => skill.enabled !== false).length;
|
||||
const enabledExtensionCount = extensions.filter((extension) => extension.enabled !== false).length;
|
||||
const modelsConfigError = useMemo(() => validateModelsConfigJson(modelsConfig), [modelsConfig]);
|
||||
const systemPromptHtml = useMemo(
|
||||
() => (systemPromptPreview ? renderMarkdown(systemPrompt) : ""),
|
||||
[systemPromptPreview, systemPrompt],
|
||||
);
|
||||
const statusClass =
|
||||
statusKind === "ok" ? styles.statusOk : statusKind === "error" ? styles.statusError : "";
|
||||
|
||||
@@ -541,7 +633,7 @@ export function SettingsPage() {
|
||||
|
||||
<div className={styles.shell}>
|
||||
<div className={styles.layout}>
|
||||
<nav className={styles.sidebar} role="tablist" aria-label="设置分区">
|
||||
<nav className={styles.sidebar} ref={sidebarRef} role="tablist" aria-label="设置分区">
|
||||
{(
|
||||
[
|
||||
["prompt", "系统提示词"],
|
||||
@@ -550,6 +642,7 @@ export function SettingsPage() {
|
||||
["mcp", "MCP工具"],
|
||||
["extensions", "插件扩展"],
|
||||
["other", "其他设置"],
|
||||
["terminal", "终端"],
|
||||
["env", "环境信息"],
|
||||
] as const
|
||||
).map(([id, label]) => (
|
||||
@@ -577,22 +670,44 @@ export function SettingsPage() {
|
||||
<h2>系统提示词</h2>
|
||||
<p>{systemPromptPath}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
disabled={saving}
|
||||
onClick={() => void saveSystemPrompt()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<div className={styles.panelHeaderActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.secondaryBtn} ${systemPromptPreview ? styles.headerBtnActive : ""}`}
|
||||
onClick={() => setSystemPromptPreview((preview) => !preview)}
|
||||
>
|
||||
{systemPromptPreview ? "编辑" : "预览"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
disabled={saving}
|
||||
onClick={() => void saveSystemPrompt()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
spellCheck={false}
|
||||
aria-label="系统提示词"
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
/>
|
||||
{systemPromptPreview ? (
|
||||
<div className={styles.markdownPreview}>
|
||||
{systemPrompt.trim() ? (
|
||||
<div
|
||||
className={`markdown-body ${styles.markdownBody}`}
|
||||
dangerouslySetInnerHTML={{ __html: systemPromptHtml }}
|
||||
/>
|
||||
) : (
|
||||
<div className={`${styles.empty} ${styles.emptyCompact}`}>暂无内容</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
spellCheck={false}
|
||||
aria-label="系统提示词"
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{statusText && activePane === "prompt" ? (
|
||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||
) : null}
|
||||
@@ -611,19 +726,25 @@ export function SettingsPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
disabled={savingModels}
|
||||
disabled={savingModels || Boolean(modelsConfigError)}
|
||||
onClick={() => void saveModelsConfig()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
className={`${styles.textarea} ${modelsConfigError ? styles.textareaInvalid : ""}`}
|
||||
spellCheck={false}
|
||||
aria-label="模型配置"
|
||||
aria-invalid={modelsConfigError ? true : undefined}
|
||||
value={modelsConfig}
|
||||
onChange={(e) => setModelsConfig(e.target.value)}
|
||||
/>
|
||||
{modelsConfigError ? (
|
||||
<div className={styles.jsonError} role="alert">
|
||||
{modelsConfigError}
|
||||
</div>
|
||||
) : null}
|
||||
{statusText && activePane === "models" ? (
|
||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||
) : null}
|
||||
@@ -945,6 +1066,23 @@ export function SettingsPage() {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${styles.terminalPanel} ${activePane === "terminal" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "terminal"}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>终端</h2>
|
||||
</div>
|
||||
</div>
|
||||
{terminalRequested ? (
|
||||
<Suspense fallback={<div className={styles.status}>正在加载终端…</div>}>
|
||||
<WebTerminal active={activePane === "terminal"} platform={terminalPlatform} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "env" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
@@ -953,7 +1091,7 @@ export function SettingsPage() {
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>环境信息</h2>
|
||||
<p>Node.js 运行时 · 操作系统 · 进程信息</p>
|
||||
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -985,6 +1123,7 @@ export function SettingsPage() {
|
||||
["空闲内存", envInfo.freeMemMb !== undefined ? envInfo.freeMemMb + " MB" : undefined],
|
||||
["Node 路径", envInfo.execPath],
|
||||
["工作目录", envInfo.cwd],
|
||||
["SproutClaw 根目录", envInfo.repoRoot],
|
||||
] as [string, string | undefined][]
|
||||
)
|
||||
.filter(([, v]) => v !== undefined && v !== "")
|
||||
@@ -1008,3 +1147,5 @@ export function SettingsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsPage;
|
||||
|
||||
13
frontend/src/styles/fonts.css
Normal file
13
frontend/src/styles/fonts.css
Normal file
@@ -0,0 +1,13 @@
|
||||
@font-face {
|
||||
font-family: "LXGW WenKai Mono";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: optional;
|
||||
src: url("/fonts/lxgwwenkaimono-medium.woff2") format("woff2");
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-family: "LXGW WenKai Mono", "PingFang SC", "Microsoft YaHei UI", ui-monospace, monospace;
|
||||
--font-ui: var(--font-family);
|
||||
--font-mono: var(--font-family);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
:root {
|
||||
--app-height: 100vh;
|
||||
--font-family: "Maple Mono CN", ui-monospace, monospace;
|
||||
--font-letter-spacing: -0.05em;
|
||||
--font-family: "PingFang SC", "Microsoft YaHei UI", ui-monospace, monospace;
|
||||
--font-letter-spacing: 0;
|
||||
--font-ui: var(--font-family);
|
||||
--font-mono: var(--font-family);
|
||||
|
||||
|
||||
@@ -184,6 +184,7 @@ export interface ExtensionInfo {
|
||||
location?: string;
|
||||
version?: string;
|
||||
enabled?: boolean;
|
||||
toggleable?: boolean;
|
||||
commands?: string[];
|
||||
tools?: string[];
|
||||
handlers?: string[];
|
||||
@@ -212,7 +213,7 @@ export interface AvatarSettings {
|
||||
agentAvatarUrl: string;
|
||||
}
|
||||
|
||||
export type SettingsPaneId = "prompt" | "models" | "skills" | "mcp" | "extensions" | "other" | "env";
|
||||
export type SettingsPaneId = "prompt" | "models" | "skills" | "mcp" | "extensions" | "other" | "terminal" | "env";
|
||||
|
||||
export interface EnvironmentInfo {
|
||||
nodeVersion?: string;
|
||||
@@ -223,6 +224,7 @@ export interface EnvironmentInfo {
|
||||
hostname?: string;
|
||||
pid?: number;
|
||||
cwd?: string;
|
||||
repoRoot?: string;
|
||||
uptime?: number;
|
||||
totalMemMb?: number;
|
||||
freeMemMb?: number;
|
||||
|
||||
@@ -78,7 +78,7 @@ export function messagesToMinimalHtml(title: string, messages: ChatMessage[]): s
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escHtml(title)}</title>
|
||||
<style>
|
||||
body{font-family:"Maple Mono CN",ui-monospace,monospace;letter-spacing:-0.05em;max-width:720px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#111;background:#fff}
|
||||
body{font-family:"LXGW WenKai Mono","PingFang SC","Microsoft YaHei UI",ui-monospace,monospace;max-width:720px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#111;background:#fff}
|
||||
h1{font-size:1.25rem;font-weight:600;margin:0 0 1.25rem}
|
||||
.block{margin:0 0 1rem}
|
||||
h2{font-size:.75rem;font-weight:600;color:#6b7280;margin:0 0 .35rem;text-transform:uppercase;letter-spacing:.04em}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formatToolCall,
|
||||
formatToolResult,
|
||||
getToolCalls,
|
||||
upsertToolCallMessage,
|
||||
} from "./toolCall";
|
||||
|
||||
function appendAssistantBlocks(m: { content?: string | unknown[] }, result: ChatMessage[]): void {
|
||||
@@ -31,11 +32,12 @@ function appendAssistantBlocks(m: { content?: string | unknown[] }, result: Chat
|
||||
} else if (b.type === "toolCall") {
|
||||
const tc = block as ToolCallBlock;
|
||||
const tid = tc.toolCallId || tc.id;
|
||||
result.push({
|
||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||
const tidStr = tid ? String(tid) : undefined;
|
||||
upsertToolCallMessage(result, {
|
||||
id: tidStr ? `tool-${tidStr}` : nextMessageId(),
|
||||
role: "tool_call",
|
||||
content: formatToolCall(tc),
|
||||
toolCallId: tid ? String(tid) : undefined,
|
||||
toolCallId: tidStr,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -47,11 +49,12 @@ function appendAssistantBlocks(m: { content?: string | unknown[] }, result: Chat
|
||||
}
|
||||
for (const tc of getToolCalls(m.content as string | undefined)) {
|
||||
const tid = tc.toolCallId || tc.id;
|
||||
result.push({
|
||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||
const tidStr = tid ? String(tid) : undefined;
|
||||
upsertToolCallMessage(result, {
|
||||
id: tidStr ? `tool-${tidStr}` : nextMessageId(),
|
||||
role: "tool_call",
|
||||
content: formatToolCall(tc),
|
||||
toolCallId: tid ? String(tid) : undefined,
|
||||
toolCallId: tidStr,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -75,11 +78,12 @@ export function buildHistoryMessages(payload: SessionHistoryPayload): ChatMessag
|
||||
} else if (m.role === "toolResult" || m.role === "tool") {
|
||||
const text = formatToolResult({ content: m.content });
|
||||
if (text.trim()) {
|
||||
result.push({
|
||||
id: m.toolCallId ? `tool-${m.toolCallId}` : nextMessageId(),
|
||||
const tid = m.toolCallId ? String(m.toolCallId) : undefined;
|
||||
upsertToolCallMessage(result, {
|
||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||
role: "tool_call",
|
||||
content: `✅ tool\n${text}`,
|
||||
toolCallId: m.toolCallId ? String(m.toolCallId) : undefined,
|
||||
toolCallId: tid,
|
||||
});
|
||||
}
|
||||
} else if (m.role === "bashExecution") {
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { ContentBlock, ImageContent, ToolCallBlock } from "../types/message";
|
||||
import type { ChatMessage, ContentBlock, ImageContent, ToolCallBlock } from "../types/message";
|
||||
|
||||
export function isToolCallComplete(content: string): boolean {
|
||||
const trimmed = content.trimStart();
|
||||
return trimmed.startsWith("✅") || trimmed.startsWith("❌");
|
||||
}
|
||||
|
||||
export function preferToolCallMessage(existing: ChatMessage, incoming: ChatMessage): ChatMessage {
|
||||
const existingComplete = isToolCallComplete(existing.content);
|
||||
const incomingComplete = isToolCallComplete(incoming.content);
|
||||
let content = existing.content;
|
||||
if (incomingComplete && !existingComplete) {
|
||||
content = incoming.content;
|
||||
} else if (incomingComplete === existingComplete) {
|
||||
content =
|
||||
incoming.content.length >= existing.content.length ? incoming.content : existing.content;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
content,
|
||||
toolCallId: existing.toolCallId ?? incoming.toolCallId,
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertToolCallMessage(result: ChatMessage[], message: ChatMessage): void {
|
||||
if (message.toolCallId) {
|
||||
const idx = result.findIndex(
|
||||
(msg) => msg.role === "tool_call" && msg.toolCallId === message.toolCallId,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
result[idx] = preferToolCallMessage(result[idx], message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const idIdx = result.findIndex((msg) => msg.id === message.id);
|
||||
if (idIdx >= 0) {
|
||||
result[idIdx] = preferToolCallMessage(result[idIdx], message);
|
||||
return;
|
||||
}
|
||||
result.push(message);
|
||||
}
|
||||
|
||||
export function extractContent(content: string | ContentBlock[] | undefined): string {
|
||||
if (!content) return "";
|
||||
|
||||
@@ -45,7 +45,8 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: "index.html",
|
||||
globPatterns: ["**/*.{js,css,html,png,ico,woff2}"],
|
||||
globPatterns: ["**/*.{js,css,html,png,ico}"],
|
||||
globIgnores: ["**/fonts/**"],
|
||||
runtimeCaching: isDesktop
|
||||
? [
|
||||
{
|
||||
@@ -70,10 +71,33 @@ export default defineConfig(({ mode }) => {
|
||||
build: {
|
||||
outDir: isDesktop ? "dist-desketop" : "dist",
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes("node_modules/react-dom") || id.includes("node_modules/react/")) {
|
||||
return "react-vendor";
|
||||
}
|
||||
if (id.includes("node_modules/react-router") || id.includes("node_modules/@remix-run/router")) {
|
||||
return "router-vendor";
|
||||
}
|
||||
if (id.includes("node_modules/@xterm")) {
|
||||
return "xterm-vendor";
|
||||
}
|
||||
if (id.includes("node_modules/highlight.js") || id.includes("node_modules/marked")) {
|
||||
return "markdown-vendor";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:19133",
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:19133",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user