chore: add sproutclaw git workflow and track local extensions
Some checks failed
CI / build-check-test (push) Has been cancelled

Document main/upstream-sync/feature branch strategy, add sync/push
scripts, track .pi/agent extensions and webui in git, and disable
startup changelog via showChangelogOnStartup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-05-22 20:38:20 +08:00
parent a8daa1d8ff
commit 0a91cc99d0
80 changed files with 15061 additions and 64 deletions

View File

@@ -0,0 +1,17 @@
/**
* Exit Command Extension
*
* 添加 /exit 命令用于退出 pi Agent
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.registerCommand("exit", {
description: "退出 pi Agent",
handler: async (_args, ctx) => {
ctx.ui.notify("正在退出...", "info");
ctx.shutdown();
},
});
}

View File

@@ -0,0 +1,158 @@
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Container, Text } from "@earendil-works/pi-tui";
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
type PromptMatch = {
kind: "pr" | "issue";
url: string;
};
type GhMetadata = {
title?: string;
author?: {
login?: string;
name?: string | null;
};
};
function extractPromptMatch(prompt: string): PromptMatch | undefined {
const prMatch = prompt.match(PR_PROMPT_PATTERN);
if (prMatch?.[1]) {
return { kind: "pr", url: prMatch[1].trim() };
}
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
if (issueMatch?.[1]) {
return { kind: "issue", url: issueMatch[1].trim() };
}
return undefined;
}
async function fetchGhMetadata(
pi: ExtensionAPI,
kind: PromptMatch["kind"],
url: string,
): Promise<GhMetadata | undefined> {
const args =
kind === "pr" ? ["pr", "view", url, "--json", "title,author"] : ["issue", "view", url, "--json", "title,author"];
try {
const result = await pi.exec("gh", args);
if (result.code !== 0 || !result.stdout) return undefined;
return JSON.parse(result.stdout) as GhMetadata;
} catch {
return undefined;
}
}
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
if (!author) return undefined;
const name = author.name?.trim();
const login = author.login?.trim();
if (name && login) return `${name} (@${login})`;
if (login) return `@${login}`;
if (name) return name;
return undefined;
}
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
const setWidget = (ctx: ExtensionContext, match: PromptMatch, title?: string, authorText?: string) => {
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url);
const authorLine = authorText ? thm.fg("muted", authorText) : undefined;
const urlLine = thm.fg("dim", match.url);
const lines = [titleText];
if (authorLine) lines.push(authorLine);
lines.push(urlLine);
const container = new Container();
container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s)));
container.addChild(new Text(lines.join("\n"), 1, 0));
return container;
});
};
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => {
const label = match.kind === "pr" ? "PR" : "Issue";
const trimmedTitle = title?.trim();
const fallbackName = `${label}: ${match.url}`;
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
const currentName = pi.getSessionName()?.trim();
if (!currentName) {
pi.setSessionName(desiredName);
return;
}
if (currentName === match.url || currentName === fallbackName) {
pi.setSessionName(desiredName);
}
};
pi.on("before_agent_start", async (event, ctx) => {
if (!ctx.hasUI) return;
const match = extractPromptMatch(event.prompt);
if (!match) {
return;
}
setWidget(ctx, match);
applySessionName(ctx, match);
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
const title = meta?.title?.trim();
const authorText = formatAuthor(meta?.author);
setWidget(ctx, match, title, authorText);
applySessionName(ctx, match, title);
});
});
pi.on("session_switch", async (_event, ctx) => {
rebuildFromSession(ctx);
});
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
if (!content) return "";
if (typeof content === "string") return content;
return (
content
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("\n") ?? ""
);
};
const rebuildFromSession = (ctx: ExtensionContext) => {
if (!ctx.hasUI) return;
const entries = ctx.sessionManager.getEntries();
const lastMatch = [...entries].reverse().find((entry) => {
if (entry.type !== "message" || entry.message.role !== "user") return false;
const text = getUserText(entry.message.content);
return !!extractPromptMatch(text);
});
const content =
lastMatch?.type === "message" && lastMatch.message.role === "user" ? lastMatch.message.content : undefined;
const text = getUserText(content);
const match = text ? extractPromptMatch(text) : undefined;
if (!match) {
ctx.ui.setWidget("prompt-url", undefined);
return;
}
setWidget(ctx, match);
applySessionName(ctx, match);
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
const title = meta?.title?.trim();
const authorText = formatAuthor(meta?.author);
setWidget(ctx, match, title, authorText);
applySessionName(ctx, match, title);
});
};
pi.on("session_start", async (_event, ctx) => {
rebuildFromSession(ctx);
});
}

View File

@@ -0,0 +1,24 @@
/**
* Redraws Extension
*
* Exposes /tui to show TUI redraw stats.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
export default function (pi: ExtensionAPI) {
pi.registerCommand("tui", {
description: "Show TUI stats",
handler: async (_args, ctx) => {
if (!ctx.hasUI) return;
let redraws = 0;
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
redraws = tui.fullRedraws;
done(undefined);
return new Text("", 0, 0);
});
ctx.ui.notify(`TUI full redraws: ${redraws}`, "info");
},
});
}

View File

@@ -0,0 +1,63 @@
/**
* sproutclaw / mengya 命令安装扩展
*
* 启动后自动在 /usr/local/bin/ 下创建 mengya 和 sproutclaw 命令,
* 方便快速启动 sproutclawcd 到项目目录并执行 ./pi-test.sh
*
* 命令 /install-commands 可随时手动重装。
*/
import { existsSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const SPROUTCLAW_DIR = "/shumengya/project/agent/sproutclaw";
const BIN_DIR = "/usr/local/bin";
const COMMANDS = ["mengya", "sproutclaw"];
/** 创建或修复命令脚本 */
function installCommand(name: string): boolean {
const path = `${BIN_DIR}/${name}`;
const script = `#!/usr/bin/env bash
set -euo pipefail
cd ${SPROUTCLAW_DIR}
exec ./pi-test.sh "$@"
`;
if (existsSync(path)) {
try {
const current = readFileSync(path, "utf-8");
if (current === script) return false;
} catch {
// Rewrite unreadable or invalid command files below.
}
}
writeFileSync(path, script, "utf-8");
chmodSync(path, 0o755);
return true;
}
export default function (pi: ExtensionAPI) {
// 启动时自动安装
for (const name of COMMANDS) {
const created = installCommand(name);
if (created) {
console.log(`[sproutclaw-setup] Created /usr/local/bin/${name}`);
}
}
// 命令:手动重装
pi.registerCommand("install-commands", {
description: "Install mengya and sproutclaw commands to /usr/local/bin",
handler: async (_args, ctx) => {
let count = 0;
for (const name of COMMANDS) {
if (installCommand(name)) count++;
}
if (count > 0) {
ctx.ui.notify(`Installed ${count} command(s): mengya, sproutclaw`, "success");
} else {
ctx.ui.notify("Both commands already exist", "info");
}
},
});
}

View File

@@ -0,0 +1,159 @@
/**
* 启动界面中文翻译扩展
*
* 将 pi 的初始启动界面中的介绍和引导文字翻译为中文,
* 快捷键和命令提示保持原样。
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { keyHint, keyText, rawKeyHint, VERSION } from "@mariozechner/pi-coding-agent";
const SPROUTCLAW_LOGO = [
"███████╗██████╗ ██████╗ ██████╗ ██╗ ██╗████████╗ ██████╗██╗ █████╗ ██╗ ██╗",
"██╔════╝██╔══██╗██╔══██╗██╔═══██╗██║ ██║╚══██╔══╝██╔════╝██║ ██╔══██╗██║ ██║",
"███████╗██████╔╝██████╔╝██║ ██║██║ ██║ ██║ ██║ ██║ ███████║██║ █╗ ██║",
"╚════██║██╔═══╝ ██╔══██╗██║ ██║██║ ██║ ██║ ██║ ██║ ██╔══██║██║███╗██║",
"███████║██║ ██║ ██║╚██████╔╝╚██████╔╝ ██║ ╚██████╗███████╗██║ ██║╚███╔███╔╝",
"╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ",
];
const LOGO_PALETTE = [
[125, 255, 230],
[154, 255, 140],
[255, 245, 135],
[255, 176, 235],
[150, 210, 255],
[190, 170, 255],
];
function rgb(text: string, color: number[]): string {
return `\x1b[38;2;${color[0]};${color[1]};${color[2]}m${text}\x1b[39m`;
}
function lerp(a: number, b: number, t: number): number {
return Math.round(a + (b - a) * t);
}
function gradientText(text: string, offset = 0): string {
const chars = [...text];
const last = Math.max(1, chars.length - 1);
return chars
.map((char, index) => {
if (char === " ") return char;
const position = (index / last + offset) % 1;
const scaled = position * (LOGO_PALETTE.length - 1);
const startIndex = Math.floor(scaled);
const endIndex = Math.min(LOGO_PALETTE.length - 1, startIndex + 1);
const t = scaled - startIndex;
const start = LOGO_PALETTE[startIndex];
const end = LOGO_PALETTE[endIndex];
return rgb(char, [
lerp(start[0], end[0], t),
lerp(start[1], end[1], t),
lerp(start[2], end[2], t),
]);
})
.join("");
}
function startupLine(label: string, text: string, color: number[]): string {
return `${rgb(label, color)} ${rgb(text, [210, 235, 255])}`;
}
function keyTextOr(action: string, fallback: string): string {
const text = keyText(action as any).trim();
return text || fallback;
}
function renderSproutclawLogo(theme: any, width: number): string[] {
if (width < 96) {
return [
[
theme.bold(gradientText("SproutClaw")),
theme.fg("dim", ` v${VERSION}`),
].join(""),
];
}
const lines: string[] = [];
for (let i = 0; i < SPROUTCLAW_LOGO.length; i++) {
lines.push(theme.bold(gradientText(SPROUTCLAW_LOGO[i], i * 0.08)));
}
return lines;
}
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
if (!ctx.hasUI) return;
ctx.ui.setHeader((_tui, theme) => {
const expandedInstructions = [
keyHint("app.interrupt", "中断当前任务"),
keyHint("app.clear", "清空输入"),
rawKeyHint(`${keyText("app.clear")} twice`, "退出"),
keyHint("app.exit", "空输入退出"),
keyHint("app.suspend", "挂起进程"),
keyHint("tui.editor.deleteToLineEnd" as any, "删除到行尾"),
keyHint("app.thinking.cycle", "切换思考强度"),
rawKeyHint(
`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`,
"切换模型",
),
keyHint("app.model.select", "选择模型"),
keyHint("app.tools.expand", "展开工具输出"),
keyHint("app.thinking.toggle", "展开思考过程"),
keyHint("app.editor.external", "外部编辑器"),
rawKeyHint("/", "Agent 命令"),
rawKeyHint("!", "执行 shell"),
rawKeyHint("!!", "执行 shell 且不进上下文"),
keyHint("app.message.followUp", "追加后续消息"),
keyHint("app.message.dequeue", "编辑队列消息"),
keyHint("app.clipboard.pasteImage", "粘贴图片"),
rawKeyHint("drop files", "附加文件"),
].join("\n");
const compactInstructions = [
keyHint("app.interrupt", "中断"),
rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "清空/退出"),
rawKeyHint("/", "命令"),
rawKeyHint("!", "shell"),
rawKeyHint(keyTextOr("app.tools.expand", "Ctrl+O"), "详情"),
].join(rgb(" • ", [165, 205, 255]));
const compactOnboarding = startupLine(
"工作台",
`描述要处理的代码、部署或服务器问题,我会执行命令、修改文件并同步进度。按 ${keyTextOr("app.tools.expand", "Ctrl+O")} 展开详情。`,
[150, 255, 210],
);
const onboarding = [
startupLine("SproutClaw", "树萌芽运维开发助手,负责代码修改、服务部署、服务器操作、排障和知识检索。", [255, 232, 140]),
rgb("直接输入任务即可开始。", [255, 190, 235]),
].join("\n");
let expanded = false;
return {
render(width: number): string[] {
const lines: string[] = [
...renderSproutclawLogo(theme, width),
rgb("萌芽运维开发 Agent 助手", [170, 235, 255]),
"",
];
if (expanded) {
lines.push(expandedInstructions);
lines.push("");
lines.push(onboarding);
} else {
lines.push(compactInstructions);
lines.push(compactOnboarding);
lines.push("");
lines.push(onboarding);
}
return lines;
},
invalidate() {},
setExpanded(v: boolean) {
expanded = v;
},
};
});
});
}

View File

@@ -0,0 +1,40 @@
/**
* Status Line Extension
*
* Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer.
* Shows turn progress with themed colors.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: ExtensionAPI) {
let turnCount = 0;
pi.on("session_start", async (_event, ctx) => {
const theme = ctx.ui.theme;
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
});
pi.on("turn_start", async (_event, ctx) => {
turnCount++;
const theme = ctx.ui.theme;
const spinner = theme.fg("accent", "●");
const text = theme.fg("dim", ` Turn ${turnCount}...`);
ctx.ui.setStatus("status-demo", spinner + text);
});
pi.on("turn_end", async (_event, ctx) => {
const theme = ctx.ui.theme;
const check = theme.fg("success", "✓");
const text = theme.fg("dim", ` Turn ${turnCount} complete`);
ctx.ui.setStatus("status-demo", check + text);
});
pi.on("session_switch", async (event, ctx) => {
if (event.reason === "new") {
turnCount = 0;
const theme = ctx.ui.theme;
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
}
});
}

View File

@@ -0,0 +1,47 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
function isAssistantMessage(message: unknown): message is AssistantMessage {
if (!message || typeof message !== "object") return false;
const role = (message as { role?: unknown }).role;
return role === "assistant";
}
export default function (pi: ExtensionAPI) {
let agentStartMs: number | null = null;
pi.on("agent_start", () => {
agentStartMs = Date.now();
});
pi.on("agent_end", (event, ctx) => {
if (!ctx.hasUI) return;
if (agentStartMs === null) return;
const elapsedMs = Date.now() - agentStartMs;
agentStartMs = null;
if (elapsedMs <= 0) return;
let input = 0;
let output = 0;
let cacheRead = 0;
let cacheWrite = 0;
let totalTokens = 0;
for (const message of event.messages) {
if (!isAssistantMessage(message)) continue;
input += message.usage.input || 0;
output += message.usage.output || 0;
cacheRead += message.usage.cacheRead || 0;
cacheWrite += message.usage.cacheWrite || 0;
totalTokens += message.usage.totalTokens || 0;
}
if (output <= 0) return;
const elapsedSeconds = elapsedMs / 1000;
const tokensPerSecond = output / elapsedSeconds;
const message = `TPS ${tokensPerSecond.toFixed(1)} tok/s. out ${output.toLocaleString()}, in ${input.toLocaleString()}, cache r/w ${cacheRead.toLocaleString()}/${cacheWrite.toLocaleString()}, total ${totalTokens.toLocaleString()}, ${elapsedSeconds.toFixed(1)}s`;
ctx.ui.notify(message, "info");
});
}

4
.pi/agent/extensions/webui/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
dist/
data/
*.db
webui-settings.json.migrated

View File

@@ -0,0 +1,224 @@
/**
* WebUI 专有 SQLite 配置存储
*
* 数据库文件位于扩展目录 data/webui.db与 pi-Agent 配置完全隔离。
*/
import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
const SCHEMA_VERSION = 1;
export interface WebuiDbInfo {
dataDir: string;
dbPath: string;
}
export interface WebuiAvatarSettings {
userAvatarUrl: string;
agentAvatarUrl: string;
}
let db: DatabaseSync | null = null;
function requireDb(): DatabaseSync {
if (!db?.isOpen) {
throw new Error("WebUI 数据库未初始化");
}
return db;
}
function getMeta(key: string): string | null {
const row = requireDb()
.prepare("SELECT value FROM webui_meta WHERE key = ?")
.get(key) as { value: string } | undefined;
return row?.value ?? null;
}
function setMeta(key: string, value: string): void {
requireDb()
.prepare(
`INSERT INTO webui_meta (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
)
.run(key, value);
}
function migrateLegacyJsonIfNeeded(extensionDir: string): void {
if (getMeta("legacy_json_migrated") === "1") return;
const legacyFile = join(extensionDir, "webui-settings.json");
if (!existsSync(legacyFile)) {
setMeta("legacy_json_migrated", "1");
return;
}
try {
const raw = JSON.parse(readFileSync(legacyFile, "utf8")) as Partial<WebuiAvatarSettings>;
if (typeof raw.userAvatarUrl === "string" && raw.userAvatarUrl && !getWebuiConfig("userAvatarUrl")) {
setWebuiConfig("userAvatarUrl", raw.userAvatarUrl);
}
if (typeof raw.agentAvatarUrl === "string" && raw.agentAvatarUrl && !getWebuiConfig("agentAvatarUrl")) {
setWebuiConfig("agentAvatarUrl", raw.agentAvatarUrl);
}
const migratedPath = `${legacyFile}.migrated`;
if (!existsSync(migratedPath)) {
renameSync(legacyFile, migratedPath);
}
console.log("[webui] 已从 webui-settings.json 迁移配置到 SQLite");
} catch (err) {
console.warn("[webui] 迁移 webui-settings.json 失败:", err);
}
setMeta("legacy_json_migrated", "1");
}
export function initWebuiDatabase(extensionDir: string): WebuiDbInfo {
const dataDir = join(extensionDir, "data");
const dbPath = join(dataDir, "webui.db");
mkdirSync(dataDir, { recursive: true });
db = new DatabaseSync(dbPath);
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS webui_meta (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS webui_config (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
if (!getMeta("schema_version")) {
setMeta("schema_version", String(SCHEMA_VERSION));
}
migrateLegacyJsonIfNeeded(extensionDir);
return { dataDir, dbPath };
}
export function getWebuiConfig(key: string): string | null {
const row = requireDb()
.prepare("SELECT value FROM webui_config WHERE key = ?")
.get(key) as { value: string } | undefined;
return row?.value ?? null;
}
export function setWebuiConfig(key: string, value: string): void {
const now = new Date().toISOString();
requireDb()
.prepare(
`INSERT INTO webui_config (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
.run(key, value, now);
}
export function setWebuiConfigMany(entries: Record<string, string>): void {
const stmt = requireDb().prepare(
`INSERT INTO webui_config (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
);
const now = new Date().toISOString();
for (const [key, value] of Object.entries(entries)) {
stmt.run(key, value, now);
}
}
export function getAllWebuiConfig(): Record<string, string> {
const rows = requireDb()
.prepare("SELECT key, value FROM webui_config ORDER BY key")
.all() as Array<{ key: string; value: string }>;
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}
export function deleteWebuiConfig(key: string): boolean {
const result = requireDb().prepare("DELETE FROM webui_config WHERE key = ?").run(key);
return result.changes > 0;
}
export function readWebuiAvatarSettings(): WebuiAvatarSettings {
return {
userAvatarUrl: getWebuiConfig("userAvatarUrl") ?? "",
agentAvatarUrl: getWebuiConfig("agentAvatarUrl") ?? "",
};
}
export function writeWebuiAvatarSettings(settings: WebuiAvatarSettings): void {
setWebuiConfigMany({
userAvatarUrl: settings.userAvatarUrl,
agentAvatarUrl: settings.agentAvatarUrl,
});
}
export function closeWebuiDatabase(): void {
if (db?.isOpen) {
db.close();
db = null;
}
}
export function getWebuiDatabasePath(): string | null {
return db?.location() ?? null;
}
const PINNED_SESSIONS_CONFIG_KEY = "pinnedSessionPaths";
export function readPinnedSessionPaths(): string[] {
const raw = getWebuiConfig(PINNED_SESSIONS_CONFIG_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
} catch {
return [];
}
}
function writePinnedSessionPaths(paths: string[]): void {
const unique: string[] = [];
for (const path of paths) {
if (!unique.includes(path)) unique.push(path);
}
setWebuiConfig(PINNED_SESSIONS_CONFIG_KEY, JSON.stringify(unique));
}
export function prunePinnedSessionPaths(validPaths: Iterable<string>): string[] {
const valid = new Set(validPaths);
const current = readPinnedSessionPaths();
const pruned = current.filter((path) => valid.has(path));
if (pruned.length !== current.length) {
writePinnedSessionPaths(pruned);
}
return pruned;
}
export function pinSessionPath(path: string): string[] {
const current = readPinnedSessionPaths();
if (current.includes(path)) return current;
writePinnedSessionPaths([path, ...current]);
return readPinnedSessionPaths();
}
export function unpinSessionPath(path: string): string[] {
const next = readPinnedSessionPaths().filter((entry) => entry !== path);
writePinnedSessionPaths(next);
return next;
}
export function removePinnedSessionPath(path: string): void {
if (readPinnedSessionPaths().includes(path)) {
unpinSessionPath(path);
}
}
export function setSessionPinned(path: string, pinned: boolean): string[] {
return pinned ? pinSessionPath(path) : unpinSessionPath(path);
}

View File

@@ -0,0 +1 @@
node_modules

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#f7f8fb" />
<meta name="description" content="树萌芽智能 AI 助手 Web 客户端" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="萌小芽" />
<title>萌小芽</title>
<link rel="icon" href="/logo.png" type="image/png" sizes="any" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/lxgwwenkaimono-regular.css"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/lxgwwenkaimono-bold.css"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "sproutclaw-webui-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"generate-icons": "node scripts/generate-icons.mjs",
"dev": "vite",
"build": "npm run generate-icons && tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"highlight.js": "^11.11.1",
"marked": "^15.0.12",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"sharp": "^0.34.2",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

View File

@@ -0,0 +1,22 @@
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const __dirname = dirname(fileURLToPath(import.meta.url));
const publicDir = join(__dirname, "..", "public");
const source = join(publicDir, "logo.png");
if (!existsSync(source)) {
console.warn("[generate-icons] logo.png not found, skipping");
process.exit(0);
}
for (const size of [192, 512]) {
const out = join(publicDir, `logo${size}.png`);
await sharp(source)
.resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toFile(out);
console.log(`[generate-icons] wrote ${out}`);
}

View File

@@ -0,0 +1,33 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt";
import { ChatLayout } from "./components/layout/ChatLayout";
import { AvatarProvider } from "./context/AvatarContext";
import { BootstrapProvider } from "./context/BootstrapContext";
import { ChatProvider } from "./context/ChatContext";
import { ChatPage } from "./routes/ChatPage";
import { SettingsPage } from "./routes/SettingsPage";
export function App() {
return (
<BrowserRouter>
<BootstrapProvider>
<AvatarProvider>
<Routes>
<Route
path="/"
element={
<ChatProvider>
<ChatLayout />
</ChatProvider>
}
>
<Route index element={<ChatPage />} />
</Route>
<Route path="/settings" element={<SettingsPage />} />
</Routes>
<PwaUpdatePrompt />
</AvatarProvider>
</BootstrapProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,30 @@
import { apiGet, apiPost } from "./client";
import type { ImageContent, ModelInfo, SessionState } from "../types/message";
export function sendChat(message: string, images?: ImageContent[]): Promise<{ ok: boolean }> {
return apiPost("/api/chat", { message, images });
}
export function abortChat(): Promise<{ ok: boolean }> {
return apiPost("/api/abort");
}
export function fetchSessionState(): Promise<SessionState> {
return apiGet("/api/session-state");
}
export function fetchModels(): Promise<{ models: ModelInfo[] }> {
return apiGet("/api/models");
}
export function setModel(provider: string, modelId: string): Promise<{ ok: boolean }> {
return apiPost("/api/model", { provider, modelId });
}
export function setThinkingLevel(level: string): Promise<{ ok: boolean }> {
return apiPost("/api/thinking", { level });
}
export function createNewSession(): Promise<{ sessionFile?: string }> {
return apiPost("/api/new-session");
}

View File

@@ -0,0 +1,30 @@
export class ApiError extends Error {
constructor(
message: string,
public status?: number,
) {
super(message);
this.name = "ApiError";
}
}
export async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
const data = (await res.json().catch(() => ({}))) as T & { error?: string };
if (!res.ok || (data && "error" in data && data.error)) {
throw new ApiError((data as { error?: string }).error || res.statusText, res.status);
}
return data;
}
export async function apiPost<T>(url: string, body?: unknown): Promise<T> {
return apiFetch<T>(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
export async function apiGet<T>(url: string): Promise<T> {
return apiFetch<T>(url);
}

View File

@@ -0,0 +1,51 @@
import { apiGet, apiPost } from "./client";
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
export function fetchSessions(): Promise<{ sessions: SessionSummary[] }> {
return apiGet("/api/sessions");
}
export function fetchSessionHistory(path: string): Promise<SessionHistoryPayload> {
return apiPost("/api/sessions/history", { path });
}
export async function activateSessionBackend(path: string): Promise<void> {
const activateRes = await fetch("/api/sessions/activate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
const activateData = (await activateRes.json().catch(() => ({}))) as { error?: string };
if (activateRes.ok && !activateData.error) return;
const activateError = activateData.error || activateRes.statusText || "Unknown error";
if (activateRes.status !== 404 && !/not found/i.test(activateError)) {
throw new Error(activateError);
}
const fallbackRes = await fetch("/api/sessions/load", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
const fallbackData = (await fallbackRes.json().catch(() => ({}))) as { error?: string };
if (!fallbackRes.ok || fallbackData.error) {
throw new Error(fallbackData.error || fallbackRes.statusText);
}
}
export function loadSession(path: string): Promise<{ ok?: boolean; error?: string }> {
return apiPost("/api/sessions/load", { path });
}
export function deleteSession(path: string): Promise<{ ok?: boolean }> {
return apiPost("/api/sessions/delete", { path });
}
export function renameSession(path: string, name: string): Promise<{ ok?: boolean }> {
return apiPost("/api/sessions/name", { path, name });
}
export function setSessionPinned(path: string, pinned: boolean): Promise<{ ok?: boolean; pinned?: boolean; pinnedPaths?: string[] }> {
return apiPost("/api/sessions/pin", { path, pinned });
}

View File

@@ -0,0 +1,22 @@
import { apiGet, apiPost } from "./client";
import type { AvatarSettings, SettingsData } from "../types/events";
export function fetchSettings(): Promise<SettingsData> {
return apiGet("/api/settings");
}
export function fetchAvatars(): Promise<AvatarSettings> {
return apiGet("/api/avatars");
}
export function reloadSettings(): Promise<{ ok?: boolean }> {
return apiPost("/api/settings/reload");
}
export function saveSystemPrompt(systemPrompt: string): Promise<{ systemPromptPath?: string }> {
return apiPost("/api/settings/system-prompt", { systemPrompt });
}
export function saveAvatars(avatars: AvatarSettings): Promise<AvatarSettings & { ok?: boolean }> {
return apiPost("/api/settings/avatars", avatars);
}

View File

@@ -0,0 +1,25 @@
import { apiGet, apiPost } from "./client";
export interface WebuiConfigResponse {
config?: Record<string, string>;
dbPath?: string;
key?: string;
value?: string | null;
}
export function fetchWebuiConfig(key?: string): Promise<WebuiConfigResponse> {
const query = key ? `?key=${encodeURIComponent(key)}` : "";
return apiGet(`/api/webui/config${query}`);
}
export function saveWebuiConfig(key: string, value: string): Promise<{ ok?: boolean; key?: string; value?: string }> {
return apiPost("/api/webui/config", { key, value });
}
export function saveWebuiConfigMany(entries: Record<string, string>): Promise<{ ok?: boolean; config?: Record<string, string> }> {
return apiPost("/api/webui/config", { entries });
}
export function deleteWebuiConfig(key: string): Promise<{ ok?: boolean; deleted?: boolean; key?: string }> {
return apiPost("/api/webui/config/delete", { key });
}

View File

@@ -0,0 +1,153 @@
.inputArea {
padding: 10px 16px 12px;
border-top: 1px solid #f0f0f0;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(10px);
}
.inputAreaDragOver {
background: rgba(239, 246, 255, 0.96);
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 720px;
margin: 0 auto 8px;
}
.attachmentItem {
position: relative;
width: 72px;
height: 72px;
}
.attachmentThumb {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
border: 1px solid #e5e7eb;
background: #f9fafb;
}
.attachmentRemove {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
border: none;
border-radius: 999px;
background: rgba(15, 23, 42, 0.78);
color: #fff;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.fileInput {
display: none;
}
.wrapper {
display: flex;
gap: 8px;
align-items: flex-end;
max-width: 720px;
margin: 0 auto;
background: #f6f7fb;
border: 1px solid #e5e5e5;
border-radius: 14px;
padding: 4px 4px 4px 8px;
transition: border-color 0.15s;
}
.wrapper:focus-within {
border-color: #2563eb;
background: #fff;
}
.input {
flex: 1;
background: transparent;
border: none;
color: #1a1a1a;
padding: 8px 0;
font-size: 16px;
font-family: inherit;
resize: none;
outline: none;
min-height: 24px;
max-height: 120px;
line-height: 1.45;
}
.input::placeholder {
color: #bbb;
}
.attachBtn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 11px;
color: #64748b;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
}
.attachBtn:hover:not(:disabled) {
background: #eef2ff;
color: #2563eb;
}
.attachBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.sendBtn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #2563eb;
border: none;
border-radius: 11px;
color: #fff;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s, opacity 0.15s;
touch-action: manipulation;
}
.sendBtn:hover {
background: #1d4ed8;
}
.sendBtn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
@media (max-width: 768px) {
.inputArea {
position: sticky;
bottom: 0;
z-index: 5;
padding: 8px 10px calc(10px + env(safe-area-inset-bottom));
}
.wrapper {
padding: 2px 2px 2px 10px;
}
}

View File

@@ -0,0 +1,217 @@
import { useRef, useState, type ClipboardEvent, type DragEvent, type KeyboardEvent } from "react";
import { useChatContext } from "../../context/ChatContext";
import type { ImageContent } from "../../types/message";
import { isTouchLike } from "../../utils/format";
import {
clipboardItemsToImages,
fileToImageContent,
imageToDataUrl,
MAX_CHAT_IMAGES,
} from "../../utils/images";
import styles from "./ChatInput.module.css";
export function ChatInput() {
const { isStreaming, sendMessage, addSystemMessage } = useChatContext();
const [value, setValue] = useState("");
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const touchLike = isTouchLike();
const canSend = (value.trim().length > 0 || pendingImages.length > 0) && !isStreaming;
const addImages = async (files: FileList | File[]) => {
const list = Array.from(files);
if (!list.length) return;
const remaining = MAX_CHAT_IMAGES - pendingImages.length;
if (remaining <= 0) {
addSystemMessage(`最多只能附加 ${MAX_CHAT_IMAGES} 张图片`);
return;
}
const next: ImageContent[] = [];
for (const file of list.slice(0, remaining)) {
try {
next.push(await fileToImageContent(file));
} catch (err) {
addSystemMessage(err instanceof Error ? err.message : String(err));
}
}
if (next.length) {
setPendingImages((prev) => [...prev, ...next].slice(0, MAX_CHAT_IMAGES));
}
};
const removePendingImage = (index: number) => {
setPendingImages((prev) => prev.filter((_, i) => i !== index));
};
const handleSend = async () => {
const text = value.trim();
if ((!text && pendingImages.length === 0) || isStreaming) return;
const images = pendingImages.length ? pendingImages : undefined;
setValue("");
setPendingImages([]);
if (inputRef.current) inputRef.current.style.height = "auto";
await sendMessage(text, images);
if (touchLike) inputRef.current?.blur();
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
};
const handleBeforeInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
const native = e.nativeEvent as InputEvent;
if (touchLike && native.inputType === "insertLineBreak") {
e.preventDefault();
void handleSend();
}
};
const handleInput = () => {
const el = inputRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
};
const handlePaste = (e: ClipboardEvent<HTMLElement>) => {
const items = e.clipboardData?.items;
if (!items) return;
const hasImage = Array.from(items).some((item) => item.type.startsWith("image/"));
if (!hasImage) return;
e.preventDefault();
void clipboardItemsToImages(items)
.then((images) => {
if (!images.length) return;
setPendingImages((prev) => {
const remaining = MAX_CHAT_IMAGES - prev.length;
if (remaining <= 0) {
addSystemMessage(`最多只能附加 ${MAX_CHAT_IMAGES} 张图片`);
return prev;
}
return [...prev, ...images.slice(0, remaining)];
});
})
.catch((err) => {
addSystemMessage(err instanceof Error ? err.message : String(err));
});
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files?.length) void addImages(files);
e.target.value = "";
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
setDragOver(false);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
if (files.length) void addImages(files);
};
return (
<div
className={`${styles.inputArea} ${dragOver ? styles.inputAreaDragOver : ""}`}
onPaste={handlePaste}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{pendingImages.length > 0 ? (
<div className={styles.attachments}>
{pendingImages.map((img, index) => (
<div key={`${img.mimeType}-${index}`} className={styles.attachmentItem}>
<img
className={styles.attachmentThumb}
src={imageToDataUrl(img)}
alt=""
/>
<button
type="button"
className={styles.attachmentRemove}
onClick={() => removePendingImage(index)}
aria-label="移除图片"
>
×
</button>
</div>
))}
</div>
) : null}
<div className={styles.wrapper}>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
className={styles.fileInput}
onChange={handleFileChange}
tabIndex={-1}
aria-hidden
/>
<button
type="button"
className={styles.attachBtn}
onClick={() => fileInputRef.current?.click()}
disabled={isStreaming || pendingImages.length >= MAX_CHAT_IMAGES}
title="上传图片"
aria-label="上传图片"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
</button>
<textarea
ref={inputRef}
className={styles.input}
rows={1}
value={value}
onChange={(e) => setValue(e.target.value)}
onInput={handleInput}
onKeyDown={handleKeyDown}
onBeforeInput={handleBeforeInput}
placeholder="输入消息Ctrl+V 粘贴图片..."
aria-label="消息输入框"
enterKeyHint="send"
inputMode="text"
/>
<button
className={styles.sendBtn}
type="button"
onClick={() => void handleSend()}
disabled={!canSend}
aria-label="发送"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
.wrap {
position: relative;
flex-shrink: 0;
}
.btn {
display: flex;
align-items: center;
gap: 5px;
height: 30px;
padding: 0 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #374151;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn:hover:not(:disabled) {
background: #f9fafb;
border-color: #d1d5db;
}
.btn:disabled {
color: #9ca3af;
cursor: not-allowed;
background: #f9fafb;
}
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 148px;
padding: 4px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
z-index: 120;
}
.menuItem {
display: block;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 7px;
background: transparent;
color: #374151;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.menuItem:hover {
background: #f3f4f6;
color: #111827;
}
.menuItemDesc {
display: block;
margin-top: 2px;
font-size: 10px;
color: #9ca3af;
font-weight: 400;
}
@media (max-width: 768px) {
.menu {
right: auto;
left: 0;
}
}

View File

@@ -0,0 +1,134 @@
import { useEffect, useRef, useState } from "react";
import { fetchSessionHistory } from "../../api/sessions";
import { useChatContext } from "../../context/ChatContext";
import type { ChatMessage } from "../../types/message";
import { extractContent, formatToolCall, getToolCalls } from "../../utils/toolCall";
import { nextMessageId } from "../../utils/format";
import {
downloadTextFile,
messagesToMarkdown,
messagesToMinimalHtml,
safeExportFilename,
} from "../../utils/exportConversation";
import styles from "./ExportMenu.module.css";
function historyToExportMessages(
messages: NonNullable<Awaited<ReturnType<typeof fetchSessionHistory>>["messages"]>,
): ChatMessage[] {
const result: ChatMessage[] = [];
for (const m of messages) {
if (m.role === "user") {
result.push({ id: nextMessageId(), role: "user", content: extractContent(m.content) });
} else if (m.role === "assistant") {
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() {
const { headerTitle, messages, activeSessionPath, isStreaming, addSystemMessage } = useChatContext();
const [open, setOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const hasMessages = messages.some((m) => !m.streaming && m.content.trim());
const canExport = hasMessages && !isStreaming && !exporting;
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
const resolveExportMessages = async (): Promise<ChatMessage[]> => {
if (activeSessionPath) {
try {
const payload = await fetchSessionHistory(activeSessionPath);
if (payload.messages?.length) {
return historyToExportMessages(payload.messages);
}
} catch {
/* fallback to in-memory messages */
}
}
return messages;
};
const handleExport = async (format: "markdown" | "html") => {
if (!canExport) return;
setExporting(true);
setOpen(false);
try {
const exportMessages = await resolveExportMessages();
const filtered = exportMessages.filter((m) => !m.streaming && m.content.trim());
if (!filtered.length) {
addSystemMessage("当前对话没有可导出的内容");
return;
}
const title = headerTitle.trim() || "对话";
const base = safeExportFilename(title);
if (format === "markdown") {
downloadTextFile(`${base}.md`, messagesToMarkdown(title, filtered), "text/markdown");
} else {
downloadTextFile(`${base}.html`, messagesToMinimalHtml(title, filtered), "text/html");
}
} catch (err) {
addSystemMessage(`导出失败: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setExporting(false);
}
};
return (
<div className={styles.wrap} ref={wrapRef}>
<button
type="button"
className={styles.btn}
disabled={!canExport}
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((value) => !value)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 3v12M7 8l5 5 5-5M5 21h14" />
</svg>
</button>
{open ? (
<div className={styles.menu} role="menu">
<button type="button" className={styles.menuItem} role="menuitem" onClick={() => void handleExport("markdown")}>
Markdown
<span className={styles.menuItemDesc}>.md Markdown </span>
</button>
<button type="button" className={styles.menuItem} role="menuitem" onClick={() => void handleExport("html")}>
HTML
<span className={styles.menuItemDesc}> HTML便</span>
</button>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { useState } from "react";
import type { ChatMessage } from "../../types/message";
import { useAvatars } from "../../context/AvatarContext";
import {
downloadTextFile,
messageMarkdownFilename,
singleAssistantMarkdown,
} from "../../utils/exportConversation";
import { renderMarkdown } from "../../utils/markdown";
import { imageToDataUrl } from "../../utils/images";
import { ToolCallBlock } from "./ToolCallBlock";
import styles from "./MessageList.module.css";
interface MessageBubbleProps {
message: ChatMessage;
showAgentAvatar?: boolean;
agentAvatarSpacer?: boolean;
}
function ChatAvatar({ src, className }: { src: string; className: string }) {
const [failed, setFailed] = useState(false);
if (!src || failed) return null;
return (
<img
className={className}
src={src}
width={32}
height={32}
alt=""
decoding="async"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
/>
);
}
function AssistantActions({ content }: { content: string }) {
const [copied, setCopied] = useState(false);
const markdown = singleAssistantMarkdown(content);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(markdown);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
const handleDownload = () => {
downloadTextFile(messageMarkdownFilename(content), markdown, "text/markdown");
};
return (
<div className={styles.assistantToolbar}>
<button
type="button"
className={`${styles.msgActionBtn} ${copied ? styles.msgActionBtnCopied : ""}`}
onClick={() => void handleCopy()}
title="复制 Markdown"
>
{copied ? "已复制" : "复制"}
</button>
<button
type="button"
className={styles.msgActionBtn}
onClick={handleDownload}
title="下载 Markdown"
>
</button>
</div>
);
}
export function MessageBubble({
message,
showAgentAvatar = false,
agentAvatarSpacer = false,
}: MessageBubbleProps) {
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
if (message.role === "tool_call") {
return (
<div className={styles.assistantRow}>
{showAgentAvatar ? (
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
) : agentAvatarSpacer ? (
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
) : null}
<div className={`${styles.msg} ${styles.toolCall}`}>
<ToolCallBlock content={message.content} />
</div>
</div>
);
}
const classNames = [styles.msg];
if (message.role === "user") classNames.push(styles.user);
if (message.role === "assistant") classNames.push(styles.assistant);
if (message.role === "system") classNames.push(styles.system);
if (message.streaming) classNames.push(styles.streaming);
if (message.role === "assistant") {
const showActions = !message.streaming && message.content.trim().length > 0;
return (
<div className={styles.assistantRow}>
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
<div className={classNames.join(" ")}>
{showActions ? <AssistantActions content={message.content} /> : null}
<div
className={styles.assistantBody}
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
/>
</div>
</div>
);
}
if (message.role === "user") {
return (
<div className={styles.userRow}>
<div className={classNames.join(" ")}>
{message.content ? <div>{message.content}</div> : null}
{message.images?.length ? (
<div className={styles.userImages}>
{message.images.map((img, index) => (
<img
key={`${img.mimeType}-${index}`}
className={styles.userImage}
src={imageToDataUrl(img)}
alt=""
loading="lazy"
/>
))}
</div>
) : null}
</div>
<ChatAvatar src={userAvatarUrl} className={styles.userAvatar} />
</div>
);
}
return <div className={classNames.join(" ")}>{message.content}</div>;
}

View File

@@ -0,0 +1,407 @@
.chat {
flex: 1;
overflow-y: auto;
padding: 12px 16px 10px;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
}
.chat::-webkit-scrollbar {
width: 6px;
}
.chat::-webkit-scrollbar-track {
background: transparent;
}
.chat::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 3px;
}
.chat::-webkit-scrollbar-thumb:hover {
background: #ccc;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
color: #bbb;
text-align: center;
gap: 8px;
}
.emptyIcon {
color: #ddd;
}
.empty p {
font-size: 13px;
}
.emptyHint {
font-size: 11px;
color: #ccc;
}
.msg {
padding: 10px 12px;
line-height: 1.4;
font-size: 16px;
word-wrap: break-word;
white-space: pre-wrap;
}
.user {
max-width: min(700px, 85%);
background: linear-gradient(180deg, #f0f7ff 0%, #eaf2ff 100%);
border: 1px solid #dbeafe;
border-radius: 14px 14px 4px 14px;
margin: 0;
color: #1a1a1a;
}
.userRow {
align-self: flex-end;
display: flex;
align-items: flex-start;
justify-content: flex-end;
gap: 10px;
width: 100%;
max-width: min(700px, 85%);
margin: 0 0 2px;
}
.userAvatar {
width: 32px;
height: 32px;
flex-shrink: 0;
margin-top: 8px;
object-fit: cover;
border-radius: 999px;
}
.userImages {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.userImages:first-child {
margin-top: 0;
}
.userImage {
max-width: min(280px, 100%);
max-height: 240px;
border-radius: 10px;
object-fit: contain;
border: 1px solid #dbeafe;
background: #fff;
}
.assistantRow {
align-self: flex-start;
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
max-width: 100%;
margin: 0 0 2px;
}
.assistantAvatar {
width: 32px;
height: 32px;
flex-shrink: 0;
margin-top: 8px;
object-fit: cover;
border-radius: 999px;
}
.assistantAvatarSpacer {
width: 32px;
flex-shrink: 0;
}
.assistant {
flex: 1;
min-width: 0;
max-width: 100%;
padding: 10px 12px;
background: #fff;
border: 1px solid #e8ecf0;
border-radius: 14px 14px 14px 4px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
color: #1a1a1a;
line-height: 1.46;
white-space: normal;
}
.assistantToolbar {
display: flex;
justify-content: flex-end;
gap: 4px;
margin: -2px -2px 6px 0;
min-height: 22px;
opacity: 0;
transition: opacity 0.15s ease;
}
.assistant:hover .assistantToolbar,
.assistant:focus-within .assistantToolbar {
opacity: 1;
}
.msgActionBtn {
height: 22px;
padding: 0 7px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: rgba(255, 255, 255, 0.95);
color: #6b7280;
font-size: 10px;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.msgActionBtn:hover {
background: #f3f4f6;
color: #374151;
border-color: #d1d5db;
}
.msgActionBtnCopied {
color: #15803d;
border-color: #bbf7d0;
background: #f0fdf4;
}
.assistantBody {
min-width: 0;
}
.system {
align-self: center;
color: #aaa;
font-size: 11px;
text-align: center;
padding: 6px 10px;
}
.toolCall {
flex: 1;
min-width: 0;
max-width: 100%;
font-size: 13px;
font-family: var(--font-mono);
color: #666;
padding: 0;
margin: 0 0 4px;
background: #fafafa;
border-radius: 10px;
border: 1px solid #eee;
line-height: 1.4;
}
.streaming::after {
content: "▊";
animation: blink 0.8s step-end infinite;
color: #2563eb;
margin-left: 1px;
}
.assistantRow + .assistantRow {
margin-top: 2px;
}
.assistant :global(h1),
.assistant :global(h2),
.assistant :global(h3),
.assistant :global(h4),
.assistant :global(h5),
.assistant :global(h6) {
margin: 0.78em 0 0.3em;
font-weight: 600;
line-height: 1.25;
}
.assistant :global(h1) { font-size: 1.18em; }
.assistant :global(h2) { font-size: 1.1em; }
.assistant :global(h3) { font-size: 1.03em; }
.assistant :global(h4) { font-size: 1em; }
.assistant > :global(:first-child) { margin-top: 0; }
.assistant > :global(:last-child) { margin-bottom: 0; }
.assistant :global(p) {
margin: 0 0 0.46em;
}
.assistant :global(p + p) {
margin-top: 0.3em;
}
.assistant :global(strong) { font-weight: 600; }
.assistant :global(em) { font-style: italic; }
.assistant :global(a) {
color: #2563eb;
text-decoration: none;
}
.assistant :global(a:hover) { text-decoration: underline; }
.assistant :global(ul),
.assistant :global(ol) {
margin: 0.32em 0 0.48em;
padding-left: 1.18em;
}
.assistant :global(li) {
margin: 0.08em 0;
padding-left: 0.04em;
}
.assistant :global(li > p) {
margin: 0;
}
.assistant :global(blockquote) {
margin: 0.36em 0;
padding: 3px 9px;
border-left: 2px solid #e5e5e5;
color: #666;
}
.assistant :global(hr) {
border: none;
border-top: 1px solid #eee;
margin: 0.58em 0;
}
.assistant :global(table) {
border-collapse: collapse;
margin: 0.42em 0;
font-size: 13px;
width: 100%;
}
.assistant :global(th),
.assistant :global(td) {
border: 1px solid #e5e5e5;
padding: 4px 7px;
text-align: left;
}
.assistant :global(th) {
background: #fafafa;
font-weight: 600;
}
.assistant :global(code:not(.hljs)) {
font-size: 0.9em;
background: #f5f5f5;
padding: 1px 5px;
border-radius: 4px;
color: #d63384;
word-break: break-word;
}
.assistant :global(pre.assistant-pre) {
margin: 0.5em 0 0.6em;
padding: 0;
background: transparent;
border: 1px solid #e8edf2;
border-radius: 8px;
overflow-x: auto;
line-height: 1.48;
}
.assistant :global(pre.assistant-pre code.hljs) {
display: block;
padding: 10px 12px;
background: none;
border: none;
color: inherit;
font-size: 13px;
line-height: 1.48;
word-break: normal;
tab-size: 2;
}
.assistant :global(img) {
max-width: 100%;
border-radius: 8px;
margin: 0.42em 0;
}
@media (min-width: 769px) {
.assistantRow {
max-width: 66.6667%;
box-sizing: border-box;
}
.assistantRow .toolCall {
max-width: none;
width: auto;
box-sizing: border-box;
}
}
@media (max-width: 768px) {
.chat {
padding: 10px 12px 8px;
}
.msg {
padding: 11px 12px;
font-size: 16px;
}
.userRow {
max-width: 88%;
}
.user {
max-width: 100%;
}
.toolCall {
font-size: 12px;
}
.assistantAvatarSpacer {
width: 28px;
}
.assistantAvatar {
width: 28px;
height: 28px;
margin-top: 10px;
}
.userAvatar {
width: 28px;
height: 28px;
margin-top: 10px;
}
.assistantToolbar {
opacity: 1;
}
}
@media (max-width: 480px) {
.userRow {
max-width: 92%;
}
}

View File

@@ -0,0 +1,96 @@
import { useEffect, useRef } from "react";
import { useChatContext } from "../../context/ChatContext";
import { MessageBubble } from "./MessageBubble";
import styles from "./MessageList.module.css";
const NEAR_BOTTOM_THRESHOLD = 96;
function isNearBottom(el: HTMLElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_THRESHOLD;
}
function scrollToBottom(el: HTMLElement): void {
el.scrollTop = el.scrollHeight;
}
export function MessageList() {
const { messages } = useChatContext();
const chatRef = useRef<HTMLDivElement>(null);
const stickToBottomRef = useRef(true);
const prevMessageCountRef = useRef(0);
useEffect(() => {
const el = chatRef.current;
if (!el) return;
const onScroll = () => {
stickToBottomRef.current = isNearBottom(el);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
const el = chatRef.current;
if (!el) return;
const prevCount = prevMessageCountRef.current;
const messageCount = messages.length;
prevMessageCountRef.current = messageCount;
const lastMessage = messages[messageCount - 1];
const userJustSent = messageCount > prevCount && lastMessage?.role === "user";
if (userJustSent || prevCount === 0) {
stickToBottomRef.current = true;
}
if (!stickToBottomRef.current) return;
requestAnimationFrame(() => {
if (chatRef.current) scrollToBottom(chatRef.current);
});
}, [messages]);
if (messages.length === 0) {
return (
<div className={styles.chat} ref={chatRef}>
<div className={styles.empty}>
<div className={styles.emptyIcon}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</div>
<p></p>
<p className={styles.emptyHint}> Enter · Shift+Enter </p>
</div>
</div>
);
}
return (
<div className={styles.chat} ref={chatRef}>
{messages.map((msg, index) => {
const prev = messages[index - 1];
const showAgentAvatar =
msg.role === "assistant" ||
(msg.role === "tool_call" &&
prev?.role !== "tool_call" &&
prev?.role !== "assistant");
const agentAvatarSpacer =
msg.role === "tool_call" &&
(prev?.role === "tool_call" || prev?.role === "assistant");
return (
<MessageBubble
key={msg.id}
message={msg}
showAgentAvatar={showAgentAvatar}
agentAvatarSpacer={agentAvatarSpacer}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,61 @@
.bar {
flex-shrink: 0;
font-size: 11px;
line-height: 1.42;
color: #5c6578;
border: none;
background: transparent;
padding: 0;
max-width: 100%;
text-align: right;
}
.row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: flex-end;
gap: 10px;
}
.left {
flex: 0 1 auto;
max-width: 100%;
text-align: right;
font-variant-numeric: tabular-nums;
word-break: break-word;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.secondary {
margin-top: 2px;
font-size: 10px;
color: #8b95a8;
}
.secondary:empty {
display: none;
}
.ctxWarn {
color: #b45309;
font-weight: 500;
}
.ctxDanger {
color: #b91c1c;
font-weight: 500;
}
@media (max-width: 768px) {
.bar {
font-size: 10px;
}
.secondary {
font-size: 9px;
}
}

View File

@@ -0,0 +1,56 @@
import { useChatContext } from "../../context/ChatContext";
import { formatFooterTokens } from "../../utils/format";
import styles from "./SessionContextBar.module.css";
export function SessionContextBar() {
const { sessionState, isStreaming } = useChatContext();
const data = sessionState;
if (!data || data.error || !data.stats || !data.model) {
return null;
}
const tok = data.stats.tokens || {};
const parts: string[] = [];
if (tok.input) parts.push(`${formatFooterTokens(tok.input)}`);
if (tok.output) parts.push(`${formatFooterTokens(tok.output)}`);
if (tok.cacheRead) parts.push(`R${formatFooterTokens(tok.cacheRead)}`);
if (tok.cacheWrite) parts.push(`W${formatFooterTokens(tok.cacheWrite)}`);
const costNum = Number(data.stats.cost ?? 0);
parts.push(`$${costNum.toFixed(3)}`);
const cx = data.stats.contextUsage;
const cw = cx?.contextWindow ?? data.model.contextWindow ?? 0;
const pctRaw = cx?.percent;
const pctStr = pctRaw != null ? Number(pctRaw).toFixed(1) : "?";
const pctNum = pctRaw != null ? Number(pctRaw) : null;
const autoInd = data.autoCompactionEnabled ? " (auto)" : "";
const ctxTxt =
pctStr === "?" && cw
? `?/${formatFooterTokens(cw)}${autoInd}`
: `${pctStr}%/${formatFooterTokens(cw)}${autoInd}`;
const liveStreaming = isStreaming || data.isStreaming;
let sub = "";
if (data.isCompacting) sub = "⚡ 正在整理上下文…";
else if (liveStreaming) sub = "⋯ 回复中…";
else if ((data.turnIndex ?? 0) > 0) sub = `✓ Turn ${data.turnIndex} complete`;
let ctxClass = "";
if (pctNum != null) {
if (pctNum > 90) ctxClass = styles.ctxDanger;
else if (pctNum > 70) ctxClass = styles.ctxWarn;
}
return (
<div className={styles.bar}>
<div className={styles.row}>
<div className={styles.left}>
{parts.join(" ")} <span className={ctxClass}>{ctxTxt}</span>
</div>
</div>
{sub ? <div className={styles.secondary}>{sub}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,58 @@
.collapsible {
margin: 0;
}
.summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
user-select: none;
font-weight: 500;
color: #555;
}
.summary::-webkit-details-marker {
display: none;
}
.summary::before {
content: "";
width: 0;
height: 0;
border-left: 5px solid #888;
border-top: 3.5px solid transparent;
border-bottom: 3.5px solid transparent;
flex-shrink: 0;
transform: rotate(0deg);
transition: transform 0.15s ease;
}
.collapsible[open] > .summary::before {
transform: rotate(90deg);
}
.summaryText {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail {
margin: 0;
padding: 6px 10px 8px;
border-top: 1px solid #eee;
}
.detailPre {
margin: 0;
padding: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.45;
}

View File

@@ -0,0 +1,19 @@
import { deriveToolCallSummary } from "../../utils/toolCall";
import styles from "./ToolCallBlock.module.css";
interface ToolCallBlockProps {
content: string;
}
export function ToolCallBlock({ content }: ToolCallBlockProps) {
return (
<details className={styles.collapsible}>
<summary className={styles.summary}>
<span className={styles.summaryText}>{deriveToolCallSummary(content)}</span>
</summary>
<div className={styles.detail}>
<pre className={styles.detailPre}>{content}</pre>
</div>
</details>
);
}

View File

@@ -0,0 +1,32 @@
.app {
display: flex;
height: var(--app-height);
min-height: 0;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: #fff;
}
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 99;
}
.overlayOpen {
display: block;
}
@media (max-width: 768px) {
.app {
height: var(--app-height);
}
}

View File

@@ -0,0 +1,22 @@
import { Outlet } from "react-router-dom";
import { useChatContext } from "../../context/ChatContext";
import { Sidebar } from "./Sidebar";
import layoutStyles from "./AppLayout.module.css";
export function ChatLayout() {
const { sidebarOpen, toggleSidebar } = useChatContext();
return (
<div className={layoutStyles.app}>
<Sidebar />
<main className={layoutStyles.main}>
<Outlet />
</main>
<div
className={`${layoutStyles.overlay} ${sidebarOpen ? layoutStyles.overlayOpen : ""}`}
onClick={toggleSidebar}
role="presentation"
/>
</div>
);
}

View File

@@ -0,0 +1,147 @@
.header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(10px);
flex-wrap: wrap;
}
.trailing {
margin-left: auto;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
max-width: min(68%, 680px);
min-width: 0;
}
.menuBtn {
display: none;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 8px;
color: #555;
cursor: pointer;
flex-shrink: 0;
}
.menuBtn:hover {
background: #f0f0f0;
color: #1a1a1a;
}
.info {
flex: 1 1 auto;
min-width: 0;
}
.info h1 {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
.meta {
font-size: 11px;
color: #999;
}
.abortBtn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #dc2626;
font-size: 12px;
cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
}
.abortBtn:hover {
background: #fee2e2;
}
.modelControls {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.modelSelect,
.thinkingSelect {
height: 30px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #374151;
font-size: 11px;
outline: none;
}
.modelSelect {
width: min(260px, 28vw);
}
.thinkingSelect {
width: 86px;
}
.modelSelect:focus,
.thinkingSelect:focus {
border-color: #93c5fd;
box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.22);
}
.modelSelect:disabled,
.thinkingSelect:disabled {
color: #9ca3af;
background: #f9fafb;
cursor: not-allowed;
}
@media (max-width: 768px) {
.header {
padding: 10px 12px;
align-items: flex-start;
}
.menuBtn {
display: flex;
}
.trailing {
max-width: 100%;
width: 100%;
flex-basis: 100%;
margin-left: 0;
justify-content: flex-start;
padding-left: 44px;
box-sizing: border-box;
flex-wrap: wrap;
}
.modelControls {
width: 100%;
}
.modelSelect {
width: min(100%, 260px);
flex: 1 1 180px;
}
}

View File

@@ -0,0 +1,96 @@
import { useChatContext } from "../../context/ChatContext";
import { modelKey, modelLabel } from "../../utils/sessionTitle";
import { ExportMenu } from "../chat/ExportMenu";
import { SessionContextBar } from "../chat/SessionContextBar";
import styles from "./Header.module.css";
export function Header() {
const {
connected,
isStreaming,
headerTitle,
headerMeta,
toggleSidebar,
abortStream,
availableModels,
currentModelKey,
thinkingLevel,
availableThinkingLevels,
isApplyingModelSettings,
applyModelSelection,
applyThinkingSelection,
} = useChatContext();
const metaText =
headerMeta ||
(!connected ? "未连接" : isStreaming ? "回复中" : "");
return (
<header className={styles.header}>
<button type="button" className={styles.menuBtn} onClick={toggleSidebar} aria-label="切换菜单">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 12h18M3 6h18M3 18h18" />
</svg>
</button>
<div className={styles.info}>
<h1>{headerTitle}</h1>
{metaText ? <span className={styles.meta}>{metaText}</span> : null}
</div>
<div className={styles.trailing}>
<div className={styles.modelControls}>
<select
className={styles.modelSelect}
aria-label="选择模型"
title="选择模型"
value={currentModelKey}
disabled={isStreaming || isApplyingModelSettings}
onChange={(e) => void applyModelSelection(e.target.value)}
>
{availableModels.length === 0 ? (
<option value="">...</option>
) : (
availableModels.map((m) => (
<option key={modelKey(m)} value={modelKey(m)}>
{modelLabel(m)}
</option>
))
)}
</select>
<select
className={styles.thinkingSelect}
aria-label="选择推理强度"
title="推理强度"
value={
availableThinkingLevels.includes(thinkingLevel as typeof availableThinkingLevels[number])
? thinkingLevel
: availableThinkingLevels[0] || "off"
}
disabled={
!availableThinkingLevels.length ||
availableThinkingLevels.length === 1 ||
isStreaming ||
isApplyingModelSettings
}
onChange={(e) => void applyThinkingSelection(e.target.value)}
>
{availableThinkingLevels.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</div>
<SessionContextBar />
<ExportMenu />
{isStreaming ? (
<button type="button" className={styles.abortBtn} onClick={() => void abortStream()} title="中止回复">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
) : null}
</div>
</header>
);
}

View File

@@ -0,0 +1,170 @@
.sidebar {
width: 236px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
border-right: 1px solid #e5e5e5;
display: flex;
flex-direction: column;
flex-shrink: 0;
z-index: 100;
transition: transform 0.25s ease;
}
.header {
padding: 18px 16px 10px;
border-bottom: 1px solid #f0f0f0;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.logo {
width: 36px;
height: 36px;
object-fit: contain;
flex-shrink: 0;
}
.title {
font-size: 17px;
font-weight: 700;
color: #1a1a1a;
letter-spacing: -0.3px;
margin: 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px 6px;
font-size: 11px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sectionRefresh {
background: none;
border: none;
color: #bbb;
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
transition: color 0.15s, background 0.15s;
}
.sectionRefresh:hover {
color: #666;
background: #f0f0f0;
}
.actions {
padding: 8px 10px 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.actionBtn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
background: transparent;
border: none;
border-radius: 10px;
color: #555;
font-size: 13px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
text-decoration: none;
}
.actionBtn:hover {
background: #f0f0f0;
color: #1a1a1a;
}
.actionBtn svg {
flex-shrink: 0;
}
.status {
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
}
.statusRow {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #888;
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.connected {
background: #22c55e;
}
.streaming {
background: #f59e0b;
animation: pulse 1.2s ease-in-out infinite;
}
.disconnected {
background: #ef4444;
}
.statusModel {
font-size: 10px;
color: #bbb;
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.open {
transform: translateX(0);
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
transform: translateX(-100%);
}
.open {
transform: translateX(0);
}
}

View File

@@ -0,0 +1,63 @@
import { Link } from "react-router-dom";
import { useChatContext } from "../../context/ChatContext";
import { SessionList } from "../session/SessionList";
import styles from "./Sidebar.module.css";
export function Sidebar() {
const { connected, isStreaming, sidebarOpen, loadSessions, newSession } = useChatContext();
const statusDotClass = connected
? isStreaming
? `${styles.statusDot} ${styles.streaming}`
: `${styles.statusDot} ${styles.connected}`
: `${styles.statusDot} ${styles.disconnected}`;
const statusText = !connected ? "未连接" : isStreaming ? "输入中..." : "就绪";
return (
<aside className={`${styles.sidebar} ${sidebarOpen ? styles.open : ""}`}>
<div className={styles.header}>
<div className={styles.brand}>
<img className={styles.logo} src="/logo.png" width={36} height={36} alt="" decoding="async" />
<h2 className={styles.title}></h2>
</div>
</div>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span></span>
<button type="button" className={styles.sectionRefresh} onClick={() => void loadSessions()} title="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 4v6h6M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" />
</svg>
</button>
</div>
<SessionList />
</div>
<div className={styles.actions}>
<button type="button" className={styles.actionBtn} onClick={newSession}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<Link to="/settings" className={styles.actionBtn}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 00.3 1.9l.1.1a2 2 0 01-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.9-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 01-4 0v-.1a1.7 1.7 0 00-1-1.5 1.7 1.7 0 00-1.9.3l-.1.1A2 2 0 014.2 17l.1-.1A1.7 1.7 0 004.6 15a1.7 1.7 0 00-1.5-1H3a2 2 0 010-4h.1a1.7 1.7 0 001.5-1 1.7 1.7 0 00-.3-1.9L4.2 7A2 2 0 017 4.2l.1.1A1.7 1.7 0 009 4.6a1.7 1.7 0 001-1.5V3a2 2 0 014 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.9-.3l.1-.1A2 2 0 0119.8 7l-.1.1a1.7 1.7 0 00-.3 1.9 1.7 1.7 0 001.5 1h.1a2 2 0 010 4h-.1a1.7 1.7 0 00-1.5 1z" />
</svg>
</Link>
</div>
<div className={styles.status}>
<div className={styles.statusRow}>
<span className={statusDotClass} />
<span>{statusText}</span>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,74 @@
.bar {
position: fixed;
left: 50%;
bottom: calc(16px + env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 9999;
display: flex;
align-items: center;
gap: 12px;
max-width: min(92vw, 420px);
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #e5e7eb;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
backdrop-filter: blur(10px);
}
.text {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1.4;
color: #374151;
}
.actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.primary,
.secondary {
height: 32px;
padding: 0 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
}
.primary {
color: #fff;
background: #2563eb;
border-color: #2563eb;
}
.primary:hover {
background: #1d4ed8;
}
.secondary {
color: #4b5563;
background: #fff;
border-color: #d1d5db;
}
.secondary:hover {
background: #f3f4f6;
}
@media (max-width: 480px) {
.bar {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.actions {
justify-content: flex-end;
}
}

View File

@@ -0,0 +1,39 @@
import { useRegisterSW } from "virtual:pwa-register/react";
import styles from "./PwaUpdatePrompt.module.css";
export function PwaUpdatePrompt() {
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegistered(registration) {
if (!registration) return;
window.setInterval(() => {
void registration.update();
}, 60 * 60 * 1000);
},
onRegisterError(error) {
console.error("[PWA] service worker registration failed:", error);
},
});
if (!needRefresh) return null;
return (
<div className={styles.bar} role="status" aria-live="polite">
<div className={styles.text}>使</div>
<div className={styles.actions}>
<button type="button" className={styles.secondary} onClick={() => setNeedRefresh(false)}>
</button>
<button
type="button"
className={styles.primary}
onClick={() => void updateServiceWorker(true)}
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,186 @@
.splash {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
background:
radial-gradient(circle at 18% 12%, rgba(37, 99, 235, 0.12), transparent 34%),
radial-gradient(circle at 82% 88%, rgba(34, 197, 94, 0.08), transparent 32%),
linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%);
opacity: 1;
transition: opacity 0.42s ease;
}
.splashExiting {
opacity: 0;
pointer-events: none;
}
.bgPulse {
position: absolute;
inset: -20%;
background:
radial-gradient(circle at 50% 45%, rgba(37, 99, 235, 0.14), transparent 42%),
radial-gradient(circle at 50% 55%, rgba(34, 197, 94, 0.1), transparent 48%);
animation: bgPulse 3.6s ease-in-out infinite;
pointer-events: none;
}
.bgGlow {
position: absolute;
width: min(72vw, 420px);
height: min(72vw, 420px);
border-radius: 50%;
background: radial-gradient(circle, rgba(37, 99, 235, 0.12) 0%, transparent 68%);
filter: blur(8px);
animation: glowDrift 4.8s ease-in-out infinite;
pointer-events: none;
}
.content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
padding: 24px;
}
.logoWrap {
position: relative;
width: 132px;
height: 132px;
display: flex;
align-items: center;
justify-content: center;
}
.ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid rgba(37, 99, 235, 0.28);
opacity: 0;
animation: ringExpand 2.4s ease-out infinite;
}
.ring2 {
animation-delay: 0.8s;
}
.ring3 {
animation-delay: 1.6s;
}
.logo {
width: 96px;
height: 96px;
object-fit: contain;
border-radius: 22px;
box-shadow:
0 10px 28px rgba(37, 99, 235, 0.18),
0 2px 8px rgba(15, 23, 42, 0.08);
animation: logoFloat 2.8s ease-in-out infinite;
}
.titleBlock {
text-align: center;
}
.title {
font-size: clamp(28px, 6vw, 36px);
font-weight: 700;
letter-spacing: -0.4px;
color: #111827;
line-height: 1.15;
}
.subtitle {
margin-top: 6px;
font-size: 14px;
color: #6b7280;
letter-spacing: 0.2px;
}
.dots {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 4px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
animation: dotPulse 1.2s ease-in-out infinite;
}
.dot2 {
animation-delay: 0.2s;
}
.dot3 {
animation-delay: 0.4s;
}
@keyframes bgPulse {
0%, 100% { transform: scale(1); opacity: 0.72; }
50% { transform: scale(1.06); opacity: 1; }
}
@keyframes glowDrift {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-10px) scale(1.04); }
}
@keyframes logoFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes ringExpand {
0% {
transform: scale(0.72);
opacity: 0.65;
}
70% {
opacity: 0.12;
}
100% {
transform: scale(1.45);
opacity: 0;
}
}
@keyframes dotPulse {
0%, 80%, 100% {
transform: scale(0.72);
opacity: 0.45;
}
40% {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.bgPulse,
.bgGlow,
.logo,
.ring,
.dot {
animation: none !important;
}
.ring {
opacity: 0.2;
}
}

View File

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

View File

@@ -0,0 +1,154 @@
.list {
flex: 1;
overflow-y: auto;
padding: 0 8px 6px;
}
.list::-webkit-scrollbar {
width: 4px;
}
.list::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 2px;
}
.empty {
padding: 16px 10px;
color: #bbb;
font-size: 12px;
text-align: center;
}
.item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
width: 100%;
background: transparent;
border: none;
border-radius: 10px;
transition: background 0.15s;
margin-bottom: 3px;
}
.item:hover {
background: #f5f5f5;
}
.active {
background: #f0f7ff;
}
.pinned {
background: #fffbeb;
}
.pinned.active {
background: #fef3c7;
}
.main {
min-width: 0;
text-align: left;
padding: 8px 4px 8px 10px;
background: transparent;
border: none;
cursor: pointer;
width: 100%;
}
.name {
font-size: 13px;
font-weight: 500;
color: #1a1a1a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.titleRow {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.pinnedBadge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
border: none;
background: transparent;
color: #ca8a04;
cursor: pointer;
opacity: 0.75;
}
.pinnedBadge:hover {
opacity: 1;
color: #a16207;
}
.titleRow .name {
flex: 1;
min-width: 0;
}
.meta {
font-size: 10px;
color: #bbb;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 4px;
}
.actions {
display: flex;
align-items: center;
gap: 2px;
margin-right: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.item:hover .actions,
.active .actions,
.actions:focus-within {
opacity: 1;
}
.pinBtn,
.renameBtn,
.deleteBtn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 6px;
color: #bbb;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.pinBtn:hover {
color: #ca8a04;
background: #fef9c3;
}
.renameBtn:hover {
color: #2563eb;
background: #eff6ff;
}
.deleteBtn:hover {
color: #dc2626;
background: #fee2e2;
}

View File

@@ -0,0 +1,102 @@
import { useChatContext } from "../../context/ChatContext";
import { formatTimeAgo } from "../../utils/format";
import { formatSessionTitle } from "../../utils/sessionTitle";
import styles from "./SessionList.module.css";
export function SessionList() {
const { sessions, activeSessionPath, loadSession, deleteSession, renameSession, toggleSessionPin } =
useChatContext();
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);
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}>
{isPinned ? (
<button
type="button"
className={styles.pinnedBadge}
title="取消置顶"
aria-label={`取消置顶 ${title}`}
onClick={(e) => {
e.stopPropagation();
void toggleSessionPin(s.path, false);
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M16 3H5a1 1 0 00-.8 1.6l4.6 6.12-4.6 6.12A1 1 0 005 18h11l4 3V3l-4 0z" />
</svg>
</button>
) : null}
<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}>
{!isPinned ? (
<button
type="button"
className={styles.pinBtn}
title="置顶会话"
aria-label={`置顶 ${title}`}
onClick={(e) => {
e.stopPropagation();
void toggleSessionPin(s.path, true);
}}
>
<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>
) : null}
<button
type="button"
className={styles.renameBtn}
title="重命名会话"
aria-label={`重命名 ${title}`}
onClick={(e) => {
e.stopPropagation();
void renameSession(s.path, 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>
);
}

View File

@@ -0,0 +1,66 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import * as settingsApi from "../api/settings";
import type { AvatarSettings } from "../types/events";
interface AvatarContextValue extends AvatarSettings {
loaded: boolean;
refreshAvatars: () => Promise<void>;
updateAvatars: (next: AvatarSettings) => void;
}
const AvatarContext = createContext<AvatarContextValue | null>(null);
export function AvatarProvider({ children }: { children: ReactNode }) {
const [userAvatarUrl, setUserAvatarUrl] = useState("");
const [agentAvatarUrl, setAgentAvatarUrl] = useState("");
const [loaded, setLoaded] = useState(false);
const refreshAvatars = useCallback(async () => {
try {
const data = await settingsApi.fetchAvatars();
setUserAvatarUrl(data.userAvatarUrl || "");
setAgentAvatarUrl(data.agentAvatarUrl || "");
} catch {
/* ignore */
} finally {
setLoaded(true);
}
}, []);
const updateAvatars = useCallback((next: AvatarSettings) => {
setUserAvatarUrl(next.userAvatarUrl || "");
setAgentAvatarUrl(next.agentAvatarUrl || "");
setLoaded(true);
}, []);
useEffect(() => {
void refreshAvatars();
}, [refreshAvatars]);
const value = useMemo(
(): AvatarContextValue => ({
userAvatarUrl,
agentAvatarUrl,
loaded,
refreshAvatars,
updateAvatars,
}),
[userAvatarUrl, agentAvatarUrl, loaded, refreshAvatars, updateAvatars],
);
return <AvatarContext.Provider value={value}>{children}</AvatarContext.Provider>;
}
export function useAvatars(): AvatarContextValue {
const ctx = useContext(AvatarContext);
if (!ctx) throw new Error("useAvatars must be used within AvatarProvider");
return ctx;
}

View File

@@ -0,0 +1,89 @@
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 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;
let warmBoot = false;
try {
warmBoot = sessionStorage.getItem(SKIP_SPLASH_KEY) === "1";
sessionStorage.setItem(SKIP_SPLASH_KEY, "1");
} catch {
/* ignore */
}
if (warmBoot) {
setSplashVisible(false);
setSplashExiting(false);
return;
}
const elapsed = Date.now() - mountTime.current;
const remaining = Math.max(0, MIN_SPLASH_MS - elapsed);
const timer = window.setTimeout(() => {
setSplashExiting(true);
window.setTimeout(() => setSplashVisible(false), 420);
}, remaining);
return () => window.clearTimeout(timer);
}, [readyRoutes, route]);
const value = useMemo(() => ({ markRouteReady }), [markRouteReady]);
return (
<BootstrapContext.Provider value={value}>
{children}
{splashVisible ? <SplashScreen exiting={splashExiting} /> : null}
</BootstrapContext.Provider>
);
}
export function useBootstrap(): BootstrapContextValue {
const ctx = useContext(BootstrapContext);
if (!ctx) throw new Error("useBootstrap must be used within BootstrapProvider");
return ctx;
}

View File

@@ -0,0 +1,937 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import * as chatApi from "../api/chat";
import * as sessionsApi from "../api/sessions";
import { useBootstrap } from "./BootstrapContext";
import type { SseEvent } from "../types/events";
import type { ChatMessage, ImageContent, ModelInfo, SessionState, ThinkingLevel } from "../types/message";
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
import { nextMessageId, syncViewportHeight } from "../utils/format";
import {
extractContent,
extractImages,
formatToolCall,
getToolCalls,
buildToolCallUpdateText,
} from "../utils/toolCall";
import {
formatSessionTitle,
isMachineSessionLabel,
modelKey,
titleFromFirstUserMessage,
} from "../utils/sessionTitle";
interface ChatContextValue {
connected: boolean;
isStreaming: boolean;
messages: ChatMessage[];
sessions: SessionSummary[];
activeSessionPath: string | null;
headerTitle: string;
headerMeta: string;
sidebarOpen: boolean;
sessionState: SessionState | null;
availableModels: ModelInfo[];
currentModelKey: string;
thinkingLevel: string;
availableThinkingLevels: ThinkingLevel[];
isApplyingModelSettings: boolean;
toggleSidebar: () => void;
closeSidebar: () => void;
loadSessions: () => Promise<void>;
loadSession: (path: string) => Promise<void>;
deleteSession: (path: string) => Promise<void>;
renameSession: (path: string, currentTitle: string) => Promise<void>;
toggleSessionPin: (path: string, pinned: boolean) => Promise<void>;
newSession: () => void;
sendMessage: (text: string, images?: ImageContent[]) => Promise<void>;
abortStream: () => Promise<void>;
applyModelSelection: (key: string) => Promise<void>;
applyThinkingSelection: (level: string) => Promise<void>;
addSystemMessage: (text: string) => void;
}
const ChatContext = createContext<ChatContextValue | null>(null);
const THINKING_LEVELS_LIST: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
function getAvailableThinkingLevels(model: ModelInfo | undefined): ThinkingLevel[] {
if (!model?.reasoning) return ["off"];
return THINKING_LEVELS_LIST.filter((level) => {
const mapped = model.thinkingLevelMap?.[level];
if (mapped === null) return false;
if (level === "xhigh") return mapped !== undefined;
return true;
});
}
function historyToMessages(payload: SessionHistoryPayload): ChatMessage[] {
const result: ChatMessage[] = [];
for (const m of payload.messages || []) {
if (m.role === "user") {
const text = extractContent(m.content);
const images = extractImages(m.content);
if (text || images.length) {
result.push({
id: nextMessageId(),
role: "user",
content: text,
...(images.length ? { images } : {}),
});
}
} else if (m.role === "assistant") {
const text = extractContent(m.content);
const toolCalls = getToolCalls(m.content);
if (text) {
result.push({ id: nextMessageId(), role: "assistant", content: text });
}
for (const tc of toolCalls) {
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;
}
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 finalizeAssistantInList(
prev: ChatMessage[],
targetId: string,
assistantText: string,
): ChatMessage[] {
let next = prev.map((msg) =>
msg.id === targetId ? { ...msg, content: assistantText, streaming: false } : msg,
);
if (!assistantText.trim()) {
next = next.filter((msg) => msg.id !== targetId);
}
return next;
}
export function ChatProvider({ children }: { children: ReactNode }) {
const { markRouteReady } = useBootstrap();
const [connected, setConnected] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [activeSessionPath, setActiveSessionPath] = useState<string | null>(null);
const [backendSessionPath, setBackendSessionPath] = useState<string | null>(null);
const [headerTitle, setHeaderTitle] = useState("聊天");
const [headerMeta, setHeaderMeta] = useState("");
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sessionState, setSessionState] = useState<SessionState | null>(null);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [currentModelKey, setCurrentModelKey] = useState("");
const [thinkingLevel, setThinkingLevel] = useState("off");
const [isApplyingModelSettings, setIsApplyingModelSettings] = useState(false);
const sessionCache = useRef(new Map<string, SessionHistoryPayload>());
const sessionActivationPromise = useRef<Promise<void> | null>(null);
const newSessionPromise = useRef<Promise<void> | null>(null);
const freshSessionBootRef = useRef<Promise<void> | null>(null);
const streamingAssistantId = useRef<string | null>(null);
const optimisticUserIdRef = useRef<string | null>(null);
const sessionDashboardRaf = useRef<number | null>(null);
const backendSessionPathRef = useRef(backendSessionPath);
const activeSessionPathRef = useRef(activeSessionPath);
const isStreamingRef = useRef(isStreaming);
useEffect(() => {
backendSessionPathRef.current = backendSessionPath;
}, [backendSessionPath]);
useEffect(() => {
activeSessionPathRef.current = activeSessionPath;
}, [activeSessionPath]);
useEffect(() => {
isStreamingRef.current = isStreaming;
}, [isStreaming]);
const availableThinkingLevels = useMemo(
() => getAvailableThinkingLevels(sessionState?.model),
[sessionState?.model],
);
const addSystemMessage = useCallback((text: string) => {
setMessages((prev) => [...prev, { id: nextMessageId(), role: "system", content: text }]);
}, []);
const scheduleSessionDashboardRefresh = useCallback(() => {
if (sessionDashboardRaf.current != null) return;
sessionDashboardRaf.current = requestAnimationFrame(() => {
sessionDashboardRaf.current = null;
void chatApi.fetchSessionState().then((data) => {
if (data.error) return;
setSessionState(data);
if (data.model) setCurrentModelKey(modelKey(data.model));
if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
if (!activeSessionPathRef.current) {
return;
}
if (backendSessionPathRef.current !== activeSessionPathRef.current) {
return;
}
const sid = data.sessionId != null ? String(data.sessionId) : "";
if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) {
setHeaderTitle(formatSessionTitle(data.sessionName));
}
});
});
}, []);
const fetchSessionStateFull = useCallback(async () => {
try {
const data = await chatApi.fetchSessionState();
if (data.error) return;
setSessionState(data);
if (data.model) setCurrentModelKey(modelKey(data.model));
if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
if (!activeSessionPathRef.current) {
return;
}
if (backendSessionPathRef.current !== activeSessionPathRef.current) {
return;
}
const sid = data.sessionId != null ? String(data.sessionId) : "";
const pathKey = 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 (row) {
if (row.name && !isMachineSessionLabel(row.name, sid)) {
setHeaderTitle(formatSessionTitle(row.name));
} else if (row.firstMessage && row.firstMessage !== "(空)") {
setHeaderTitle(formatSessionTitle(titleFromFirstUserMessage(row.firstMessage)));
}
}
return currentSessions;
});
} catch {
/* ignore */
}
}, []);
const activateSessionBackend = useCallback(async (path: string) => {
await sessionsApi.activateSessionBackend(path);
setBackendSessionPath(path);
}, []);
const loadSessions = useCallback(async () => {
try {
const data = await sessionsApi.fetchSessions();
setSessions(data.sessions || []);
} catch (err) {
addSystemMessage(`会话列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
}
}, [addSystemMessage]);
const clearChat = useCallback((title = "聊天") => {
setMessages([]);
streamingAssistantId.current = null;
setActiveSessionPath(null);
setHeaderTitle(title);
setSessionState(null);
}, []);
const ensureFreshSessionOnBoot = useCallback(() => {
if (!freshSessionBootRef.current) {
freshSessionBootRef.current = (async () => {
const data = await chatApi.createNewSession();
setBackendSessionPath(data.sessionFile || null);
setActiveSessionPath(null);
setMessages([]);
streamingAssistantId.current = null;
setHeaderTitle("聊天");
sessionActivationPromise.current = null;
})().catch((err) => {
freshSessionBootRef.current = null;
addSystemMessage(
`初始化新会话失败: ${err instanceof Error ? err.message : String(err)}`,
);
throw err;
});
}
return freshSessionBootRef.current;
}, [addSystemMessage]);
const loadSession = useCallback(
async (path: string) => {
setSidebarOpen(false);
setActiveSessionPath(path);
setHeaderMeta("加载中...");
try {
let payload = sessionCache.current.get(path);
const poisonName = (p: SessionHistoryPayload) =>
typeof p?.session?.name === "string" &&
p.session.name.trim() &&
isMachineSessionLabel(p.session.name.trim(), String(p.session?.id ?? ""));
if (payload && poisonName(payload)) {
sessionCache.current.delete(path);
payload = undefined;
}
if (!payload) {
payload = await sessionsApi.fetchSessionHistory(path);
if (payload.error) throw new Error(payload.error);
sessionCache.current.set(path, payload);
}
const sid = payload.session?.id != null ? String(payload.session.id) : "";
let headerLabel = "";
if (
typeof payload.session?.name === "string" &&
payload.session.name.trim() &&
!isMachineSessionLabel(payload.session.name.trim(), sid)
) {
headerLabel = payload.session.name.trim();
}
if (!headerLabel) {
for (const m of payload.messages || []) {
if (m.role === "user") {
const fb = titleFromFirstUserMessage(extractContent(m.content));
if (fb) {
headerLabel = fb;
break;
}
if (extractImages(m.content).length) {
headerLabel = `[${extractImages(m.content).length} 张图片]`;
break;
}
}
}
}
setHeaderTitle(formatSessionTitle(headerLabel));
setMessages(historyToMessages(payload));
setHeaderMeta("");
scheduleSessionDashboardRefresh();
} catch (err) {
setHeaderMeta("");
addSystemMessage(`加载会话失败: ${err instanceof Error ? err.message : String(err)}`);
}
},
[addSystemMessage, scheduleSessionDashboardRefresh],
);
const deleteSessionHandler = useCallback(
async (path: string) => {
if (isStreamingRef.current) {
addSystemMessage("正在回复中,暂不能删除会话");
return;
}
const row = sessions.find((s) => s.path === path);
const title = formatSessionTitle(row?.name || row?.firstMessage || "该会话");
if (!confirm(`删除会话「${title}」?此操作不可恢复。`)) return;
try {
await sessionsApi.deleteSession(path);
sessionCache.current.delete(path);
setSessions((prev) => prev.filter((s) => s.path !== path));
if (activeSessionPathRef.current === path) {
clearChat();
setBackendSessionPath(null);
sessionActivationPromise.current = null;
}
} catch (err) {
addSystemMessage(`删除会话失败: ${err instanceof Error ? err.message : String(err)}`);
}
},
[sessions, addSystemMessage, clearChat],
);
const renameSessionHandler = useCallback(
async (path: string, currentTitle: string) => {
if (isStreamingRef.current) {
addSystemMessage("正在回复中,暂不能重命名会话");
return;
}
const row = sessions.find((s) => s.path === path);
const fallback = formatSessionTitle(row?.name || row?.firstMessage || currentTitle);
const next = window.prompt("重命名会话", fallback);
if (next === null) return;
const trimmed = next.trim();
if (!trimmed) {
addSystemMessage("会话名称不能为空");
return;
}
try {
await sessionsApi.renameSession(path, trimmed);
sessionCache.current.delete(path);
setSessions((prev) =>
prev.map((s) => (s.path === path ? { ...s, name: trimmed } : s)),
);
if (activeSessionPathRef.current === path) {
setHeaderTitle(formatSessionTitle(trimmed));
}
} catch (err) {
addSystemMessage(`重命名失败: ${err instanceof Error ? err.message : String(err)}`);
}
},
[sessions, addSystemMessage],
);
const toggleSessionPinHandler = useCallback(
async (path: string, pinned: boolean) => {
if (isStreamingRef.current) {
addSystemMessage("正在回复中,暂不能修改置顶");
return;
}
try {
await sessionsApi.setSessionPinned(path, pinned);
await loadSessions();
} catch (err) {
addSystemMessage(`${pinned ? "置顶" : "取消置顶"}失败: ${err instanceof Error ? err.message : String(err)}`);
}
},
[addSystemMessage, loadSessions],
);
const newSession = useCallback(() => {
if (isStreamingRef.current) {
addSystemMessage("正在回复中,暂不能创建新会话");
return;
}
if (newSessionPromise.current) return;
setBackendSessionPath(null);
setActiveSessionPath(null);
sessionActivationPromise.current = null;
setHeaderMeta("");
clearChat("新会话");
const promise = (async () => {
const data = await chatApi.createNewSession();
setBackendSessionPath(data.sessionFile || null);
await fetchSessionStateFull();
})();
newSessionPromise.current = promise;
void promise
.catch((err) => {
setHeaderMeta("");
addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`);
})
.finally(() => {
newSessionPromise.current = null;
});
}, [addSystemMessage, clearChat, fetchSessionStateFull]);
const sendMessage = useCallback(
async (text: string, images?: ImageContent[]) => {
const trimmed = text.trim();
const hasImages = Array.isArray(images) && images.length > 0;
if ((!trimmed && !hasImages) || isStreamingRef.current) return;
if (newSessionPromise.current) {
try {
await newSessionPromise.current;
} catch {
return;
}
}
if (freshSessionBootRef.current) {
try {
await freshSessionBootRef.current;
} catch {
return;
}
}
if (!backendSessionPathRef.current && !activeSessionPathRef.current) {
try {
const data = await chatApi.createNewSession();
setBackendSessionPath(data.sessionFile || null);
} catch (err) {
addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`);
return;
}
}
if (activeSessionPathRef.current && backendSessionPathRef.current !== activeSessionPathRef.current) {
setHeaderMeta("正在切换会话...");
if (sessionActivationPromise.current) {
try {
await sessionActivationPromise.current;
} catch {
setHeaderMeta("");
return;
}
} else {
try {
const activationPromise = activateSessionBackend(activeSessionPathRef.current);
sessionActivationPromise.current = activationPromise;
await activationPromise;
} catch (err) {
setHeaderMeta("");
addSystemMessage(`切换会话失败: ${err instanceof Error ? err.message : String(err)}`);
return;
} finally {
sessionActivationPromise.current = null;
}
}
setHeaderMeta("");
}
try {
const userId = nextMessageId();
optimisticUserIdRef.current = userId;
setMessages((prev) => [
...prev,
{
id: userId,
role: "user",
content: trimmed,
...(hasImages ? { images } : {}),
},
]);
await chatApi.sendChat(trimmed, hasImages ? images : undefined);
} catch (err) {
const failedId = optimisticUserIdRef.current;
optimisticUserIdRef.current = null;
if (failedId) {
setMessages((prev) => prev.filter((msg) => msg.id !== failedId));
}
addSystemMessage(`发送失败: ${err instanceof Error ? err.message : String(err)}`);
}
},
[activateSessionBackend, addSystemMessage],
);
const abortStream = useCallback(async () => {
if (!isStreamingRef.current) return;
try {
await chatApi.abortChat();
} catch {
/* ignore */
}
}, []);
const applyModelSelection = useCallback(
async (key: string) => {
if (!key || isApplyingModelSettings) return;
const [provider, ...idParts] = key.split("/");
const modelId = idParts.join("/");
if (!provider || !modelId) return;
setIsApplyingModelSettings(true);
setHeaderMeta("正在切换模型");
try {
await chatApi.setModel(provider, modelId);
await fetchSessionStateFull();
setHeaderMeta("模型已切换");
setTimeout(() => setHeaderMeta(""), 1000);
} catch (err) {
setHeaderMeta("");
addSystemMessage(`切换模型失败: ${err instanceof Error ? err.message : String(err)}`);
await fetchSessionStateFull();
} finally {
setIsApplyingModelSettings(false);
await fetchSessionStateFull();
}
},
[isApplyingModelSettings, addSystemMessage, fetchSessionStateFull],
);
const applyThinkingSelection = useCallback(
async (level: string) => {
if (isApplyingModelSettings) return;
setIsApplyingModelSettings(true);
setHeaderMeta("正在设置推理强度");
try {
await chatApi.setThinkingLevel(level);
await fetchSessionStateFull();
setHeaderMeta("推理强度已设置");
setTimeout(() => setHeaderMeta(""), 1000);
} catch (err) {
setHeaderMeta("");
addSystemMessage(`设置推理强度失败: ${err instanceof Error ? err.message : String(err)}`);
await fetchSessionStateFull();
} finally {
setIsApplyingModelSettings(false);
await fetchSessionStateFull();
}
},
[isApplyingModelSettings, addSystemMessage, fetchSessionStateFull],
);
const handleSseEvent = useCallback(
(ev: SseEvent) => {
switch (ev.type) {
case "connected":
setConnected(true);
scheduleSessionDashboardRefresh();
break;
case "agent_start":
setIsStreaming(true);
setConnected(true);
streamingAssistantId.current = null;
scheduleSessionDashboardRefresh();
break;
case "agent_end":
setIsStreaming(false);
setConnected(true);
setMessages((prev) => {
const streamId =
streamingAssistantId.current ?? findStreamingAssistantId(prev);
if (!streamId) return prev;
return prev.map((msg) =>
msg.id === streamId && msg.streaming ? { ...msg, streaming: false } : msg,
);
});
if (backendSessionPathRef.current) {
sessionCache.current.delete(backendSessionPathRef.current);
}
setTimeout(() => void loadSessions(), 1000);
void fetchSessionStateFull();
break;
case "message_start": {
const m = ev.message;
if (m.role === "user") {
const userText = extractContent(m.content);
const userImages = extractImages(m.content);
if (optimisticUserIdRef.current) {
const optId = optimisticUserIdRef.current;
optimisticUserIdRef.current = null;
setMessages((prev) =>
prev.map((msg) =>
msg.id === optId
? {
...msg,
content: userText || msg.content,
images: userImages.length ? userImages : msg.images,
}
: msg,
),
);
break;
}
setMessages((prev) => [
...prev,
{
id: nextMessageId(),
role: "user",
content: userText,
...(userImages.length ? { images: userImages } : {}),
},
]);
}
break;
}
case "message_update": {
const m = ev.message;
if (m.role === "assistant") {
const assistantText = extractContent(m.content);
if (!streamingAssistantId.current) {
streamingAssistantId.current = nextMessageId();
}
const streamId = streamingAssistantId.current;
setMessages((prev) => {
if (prev.some((msg) => msg.id === streamId)) {
return prev.map((msg) =>
msg.id === streamId
? { ...msg, content: assistantText, streaming: true }
: msg,
);
}
return [
...prev,
{ id: streamId, role: "assistant", content: assistantText, streaming: true },
];
});
}
break;
}
case "message_end": {
const m = ev.message;
if (m.role === "assistant") {
const assistantText = extractContent(m.content);
const toolCalls = getToolCalls(m.content);
setMessages((prev) => {
const streamId =
streamingAssistantId.current ?? findStreamingAssistantId(prev);
let next = prev;
if (streamId) {
next = finalizeAssistantInList(prev, streamId, assistantText);
} else if (assistantText.trim()) {
let lastAssistantIdx = -1;
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].role === "assistant") {
lastAssistantIdx = i;
break;
}
}
if (lastAssistantIdx >= 0) {
const last = prev[lastAssistantIdx];
if (last.content.trim() === assistantText.trim()) {
next = prev;
} else {
next = prev.map((msg, i) =>
i === lastAssistantIdx
? { ...msg, content: assistantText, streaming: false }
: msg,
);
}
} else {
next = [
...prev,
{ id: nextMessageId(), role: "assistant", content: assistantText },
];
}
}
for (const tc of toolCalls) {
const tid = tc.toolCallId || tc.id;
const tidStr = tid ? String(tid) : undefined;
const text = formatToolCall(tc);
const existingIdx = tidStr
? next.findIndex((msg) => msg.toolCallId === tidStr)
: -1;
if (existingIdx >= 0) {
const cur = next[existingIdx].content.trimStart();
const running =
cur.startsWith("🔧") || cur.startsWith("✅") || cur.startsWith("❌");
if (!running) {
next = next.map((msg, i) =>
i === existingIdx ? { ...msg, content: text } : msg,
);
}
} else {
next = [
...next,
{
id: tidStr ? `tool-${tidStr}` : nextMessageId(),
role: "tool_call" as const,
content: text,
toolCallId: tidStr,
},
];
}
}
return next;
});
streamingAssistantId.current = null;
scheduleSessionDashboardRefresh();
}
break;
}
case "tool_execution_start": {
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`;
setMessages((prev) => {
const existingIdx = tid
? prev.findIndex((msg) => msg.toolCallId === tid)
: -1;
if (existingIdx >= 0) {
return prev.map((msg, i) =>
i === existingIdx ? { ...msg, content: label } : msg,
);
}
return [
...prev,
{
id: tid ? `tool-${tid}` : nextMessageId(),
role: "tool_call" as const,
content: label,
toolCallId: tid,
},
];
});
break;
}
case "tool_execution_update":
case "tool_execution_end": {
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
const isError = ev.type === "tool_execution_end" ? ev.isError : undefined;
const full = buildToolCallUpdateText(ev, isError);
setMessages((prev) => {
let idx = tid ? prev.findIndex((msg) => msg.toolCallId === tid) : -1;
if (idx < 0) {
const toolCalls = prev.filter((m) => m.role === "tool_call");
if (toolCalls.length > 0) {
idx = prev.findIndex((m) => m.id === toolCalls[toolCalls.length - 1].id);
}
}
if (idx < 0) return prev;
return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg));
});
if (ev.type === "tool_execution_end" && ev.isError) {
addSystemMessage(`工具 ${ev.toolName} 执行出错`);
}
break;
}
}
},
[scheduleSessionDashboardRefresh, loadSessions, fetchSessionStateFull, addSystemMessage],
);
useEffect(() => {
syncViewportHeight();
const onResize = () => {
syncViewportHeight();
if (window.innerWidth > 768) setSidebarOpen(false);
};
window.addEventListener("resize", onResize);
window.visualViewport?.addEventListener("resize", syncViewportHeight);
window.visualViewport?.addEventListener("scroll", syncViewportHeight);
window.addEventListener("orientationchange", syncViewportHeight);
const bootstrap = {
sessions: false,
models: false,
sessionState: false,
sse: false,
};
const tryMarkBootstrapReady = () => {
if (bootstrap.sessions && bootstrap.models && bootstrap.sessionState && bootstrap.sse) {
markRouteReady("chat");
}
};
void loadSessions()
.catch(() => {})
.finally(() => {
bootstrap.sessions = true;
tryMarkBootstrapReady();
});
void chatApi
.fetchModels()
.then((data) => setAvailableModels(data.models || []))
.catch((err) => {
addSystemMessage(`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
})
.finally(() => {
bootstrap.models = true;
tryMarkBootstrapReady();
});
let eventSource: EventSource | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
const connect = () => {
if (eventSource) eventSource.close();
eventSource = new EventSource("/api/events");
eventSource.onopen = () => {
setConnected(true);
if (!bootstrap.sse) {
bootstrap.sse = true;
tryMarkBootstrapReady();
}
};
eventSource.onmessage = (e) => {
try {
handleSseEvent(JSON.parse(e.data) as SseEvent);
} catch {
/* ignore */
}
};
eventSource.onerror = () => {
setConnected(false);
setIsStreaming(false);
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, 2000);
};
};
connect();
void fetchSessionStateFull()
.catch(() => {})
.finally(() => {
bootstrap.sessionState = true;
tryMarkBootstrapReady();
});
void ensureFreshSessionOnBoot()
.catch(() => {})
.finally(() => {
void fetchSessionStateFull();
});
return () => {
window.removeEventListener("resize", onResize);
window.visualViewport?.removeEventListener("resize", syncViewportHeight);
window.visualViewport?.removeEventListener("scroll", syncViewportHeight);
window.removeEventListener("orientationchange", syncViewportHeight);
if (reconnectTimer) clearTimeout(reconnectTimer);
eventSource?.close();
};
}, [loadSessions, fetchSessionStateFull, handleSseEvent, addSystemMessage, markRouteReady, ensureFreshSessionOnBoot]);
const value: ChatContextValue = {
connected,
isStreaming,
messages,
sessions,
activeSessionPath,
headerTitle,
headerMeta,
sidebarOpen,
sessionState,
availableModels,
currentModelKey,
thinkingLevel,
availableThinkingLevels,
isApplyingModelSettings,
toggleSidebar: () => setSidebarOpen((o) => !o),
closeSidebar: () => setSidebarOpen(false),
loadSessions,
loadSession,
deleteSession: deleteSessionHandler,
renameSession: renameSessionHandler,
toggleSessionPin: toggleSessionPinHandler,
newSession,
sendMessage,
abortStream,
applyModelSelection,
applyThinkingSelection,
addSystemMessage,
};
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
}
export function useChatContext(): ChatContextValue {
const ctx = useContext(ChatContext);
if (!ctx) throw new Error("useChatContext must be used within ChatProvider");
return ctx;
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/global.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,13 @@
import { ChatInput } from "../components/chat/ChatInput";
import { MessageList } from "../components/chat/MessageList";
import { Header } from "../components/layout/Header";
export function ChatPage() {
return (
<>
<Header />
<MessageList />
<ChatInput />
</>
);
}

View File

@@ -0,0 +1,637 @@
.page {
display: flex;
flex-direction: column;
min-height: var(--app-height);
height: var(--app-height);
overflow: hidden;
background: #fff;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
padding: 0;
}
.pageHeader {
flex-shrink: 0;
width: 100%;
margin: 0;
padding: max(14px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) 14px max(18px, env(safe-area-inset-left));
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid #eef0f3;
background: #fafbfc;
}
.pageTitle {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.logo {
width: 34px;
height: 34px;
object-fit: contain;
flex-shrink: 0;
}
.pageTitle h1 {
font-size: 18px;
font-weight: 700;
color: #111827;
line-height: 1.2;
}
.pageTitle p {
margin-top: 2px;
font-size: 12px;
color: #8b95a8;
}
.shell {
flex: 1;
min-height: 0;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.layout {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: row;
align-items: stretch;
overflow: hidden;
}
.sidebar {
flex-shrink: 0;
width: 220px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 12px;
padding-left: max(12px, env(safe-area-inset-left));
background: #f4f6f9;
border-right: 1px solid #e5e7eb;
}
.navBtn {
display: flex;
align-items: center;
width: 100%;
margin: 0;
padding: 11px 14px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
font-family: inherit;
font-size: 14px;
font-weight: 500;
color: #4b5563;
text-align: left;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
touch-action: manipulation;
}
.navBtn:hover {
background: rgba(255, 255, 255, 0.72);
color: #111827;
}
.navBtnActive {
background: #fff;
color: #2563eb;
border-color: #e5e7eb;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.navBtn:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 2px;
}
.panes {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 14px max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) 14px;
}
.pane {
flex: 1;
min-height: 0;
display: none;
flex-direction: column;
}
.paneActive {
display: flex;
}
.panel {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
min-width: 0;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.panelHeader {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
row-gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid #eef0f3;
background: #fafbfc;
}
.panelHeader h2 {
font-size: 14px;
line-height: 1.3;
font-weight: 600;
color: #111827;
}
.panelHeader p {
margin-top: 3px;
font-size: 11px;
color: #8b95a8;
word-break: break-all;
}
.primaryBtn,
.secondaryBtn,
.linkBtn {
height: 30px;
padding: 0 12px;
border-radius: 7px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
}
.linkBtn {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: #374151;
background: #fff;
border: 1px solid #d1d5db;
}
.linkBtn:hover {
background: #f3f4f6;
}
.primaryBtn {
color: #fff;
background: #2563eb;
border: 1px solid #2563eb;
}
.primaryBtn:hover {
background: #1d4ed8;
border-color: #1d4ed8;
}
.primaryBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.secondaryBtn {
color: #374151;
background: #fff;
border: 1px solid #d1d5db;
}
.secondaryBtn:hover {
background: #f3f4f6;
}
.textarea {
display: block;
width: 100%;
flex: 1 1 auto;
min-height: 0;
padding: 14px;
border: none;
outline: none;
resize: none;
color: #111827;
background: #fff;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.55;
white-space: pre;
overflow: auto;
}
.textarea:focus {
box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.18);
}
.formBody {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 16px 14px;
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.fieldLabel {
font-size: 13px;
font-weight: 600;
color: #111827;
}
.textInput {
width: 100%;
height: 38px;
padding: 0 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: #111827;
font-family: inherit;
font-size: 13px;
outline: none;
}
.textInput:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.fieldHint {
margin: 0;
font-size: 12px;
color: #8b95a8;
line-height: 1.5;
}
.avatarPreviewRow {
display: flex;
align-items: center;
gap: 10px;
}
.avatarPreview {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: #f9fafb;
}
.previewHint {
font-size: 12px;
color: #9ca3af;
}
.status {
flex-shrink: 0;
min-height: 30px;
padding: 7px 14px 9px;
border-top: 1px solid #eef0f3;
color: #8b95a8;
font-size: 12px;
}
.statusOk {
color: #15803d;
}
.statusError {
color: #b91c1c;
}
.list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 8px;
}
.empty {
padding: 20px 10px;
color: #9ca3af;
font-size: 12px;
text-align: center;
}
.emptyCompact {
padding: 10px 4px 2px;
text-align: left;
}
.skillItem,
.mcpServerItem,
.extensionItem {
padding: 10px 10px 9px;
border: 1px solid #edf0f4;
border-radius: 8px;
background: #fff;
}
.skillItem + .skillItem,
.mcpServerItem + .mcpServerItem,
.extensionItem + .extensionItem {
margin-top: 7px;
}
.titleRow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.itemName {
min-width: 0;
color: #111827;
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemMeta {
flex-shrink: 0;
color: #9ca3af;
font-size: 10px;
}
.itemDesc {
margin-top: 5px;
color: #4b5563;
font-size: 12px;
line-height: 1.45;
}
.itemPath {
margin-top: 6px;
color: #9ca3af;
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.4;
word-break: break-all;
}
.mcpToolList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 6px;
margin-top: 8px;
}
.mcpServerHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.mcpServerMain {
min-width: 0;
}
.mcpServerSub {
margin-top: 3px;
color: #9ca3af;
font-size: 10px;
}
.mcpServerBadges {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 5px;
flex-shrink: 0;
}
.badge {
display: inline-flex;
align-items: center;
height: 19px;
padding: 0 6px;
border-radius: 999px;
background: #f3f4f6;
color: #6b7280;
font-size: 10px;
line-height: 1;
}
.mcpToolItem {
min-width: 0;
padding: 7px 8px;
border: 1px solid #f1f3f6;
border-radius: 7px;
background: #fbfcfe;
}
.mcpToolItem[open] {
background: #fff;
}
.mcpToolSummary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
cursor: pointer;
list-style: none;
}
.mcpToolSummary::-webkit-details-marker {
display: none;
}
.mcpToolName {
min-width: 0;
color: #111827;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mcpToolDesc {
margin-top: 6px;
color: #6b7280;
font-size: 11px;
line-height: 1.4;
}
.mcpParamList {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 6px;
}
.mcpParamList code {
padding: 2px 6px;
border-radius: 5px;
background: #fff;
border: 1px solid #edf0f4;
color: #374151;
font-family: var(--font-mono);
font-size: 10px;
word-break: break-all;
}
.extensionCounts {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 7px;
}
.extensionCounts span {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 7px;
border-radius: 999px;
background: #f3f4f6;
color: #4b5563;
font-size: 11px;
line-height: 1;
}
.extensionDetails {
margin-top: 8px;
border-top: 1px solid #f1f3f6;
padding-top: 7px;
}
.extensionDetails > summary {
cursor: pointer;
color: #6b7280;
font-size: 11px;
user-select: none;
}
.extensionDetails > summary:hover {
color: #2563eb;
}
.nameList {
margin-top: 8px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 8px;
align-items: start;
font-size: 11px;
color: #8b95a8;
}
.nameList > div {
display: flex;
flex-wrap: wrap;
gap: 5px;
min-width: 0;
}
.nameList code {
padding: 2px 6px;
border-radius: 5px;
background: #f8fafc;
border: 1px solid #edf0f4;
color: #374151;
font-family: var(--font-mono);
font-size: 10px;
word-break: break-all;
}
@media (max-width: 768px) {
.pageHeader {
align-items: flex-start;
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));
}
.layout {
flex-direction: column;
}
.sidebar {
width: 100%;
flex-direction: row;
align-items: stretch;
gap: 8px;
padding: 10px max(12px, env(safe-area-inset-left)) 10px max(12px, env(safe-area-inset-right));
border-right: none;
border-bottom: 1px solid #e5e7eb;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.sidebar::-webkit-scrollbar {
height: 0;
width: 0;
}
.navBtn {
flex: 1 1 0;
min-width: 0;
justify-content: center;
text-align: center;
padding: 12px 8px;
font-size: 13px;
}
.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));
}
.textarea {
font-size: 12px;
}
}

View File

@@ -0,0 +1,535 @@
import { useCallback, useEffect, 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 { ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "../types/events";
import type { McpToolInfo } from "../types/events";
import styles from "./SettingsPage.module.css";
const PANE_IDS: SettingsPaneId[] = ["prompt", "skills", "mcp", "extensions", "other"];
const SETTINGS_PANE_KEY = "sproutclaw-settings-pane";
function renderNameList(label: string, items: string[] | undefined) {
if (!Array.isArray(items) || !items.length) return null;
return (
<div className={styles.nameList}>
<span>{label}</span>
<div>
{items.map((item) => (
<code key={item}>{item}</code>
))}
</div>
</div>
);
}
function McpToolItem({ tool }: { tool: McpToolInfo }) {
const params = Array.isArray(tool.parameters) ? tool.parameters : [];
const required = new Set(Array.isArray(tool.required) ? tool.required : []);
const description = String(tool.description || "").replace(/\s+/g, " ").trim();
const shortDescription =
description.length > 86 ? `${description.slice(0, 86).trimEnd()}...` : description;
const title = [tool.name, description, params.length ? `参数: ${params.join(", ")}` : ""]
.filter(Boolean)
.join("\n");
return (
<details className={styles.mcpToolItem} title={title}>
<summary className={styles.mcpToolSummary}>
<span className={styles.mcpToolName}>{tool.name || "未命名工具"}</span>
{params.length ? <span className={styles.badge}>{params.length} </span> : null}
</summary>
{shortDescription ? <div className={styles.mcpToolDesc}>{shortDescription}</div> : null}
{params.length ? (
<div className={styles.mcpParamList}>
{params.map((param) => (
<code key={param}>
{param}
{required.has(param) ? "*" : ""}
</code>
))}
</div>
) : null}
</details>
);
}
export function SettingsPage() {
const { markRouteReady } = useBootstrap();
const { updateAvatars } = useAvatars();
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
const [systemPrompt, setSystemPrompt] = useState("");
const [systemPromptPath, setSystemPromptPath] = useState("");
const [userAvatarUrl, setUserAvatarUrl] = useState("");
const [agentAvatarUrl, setAgentAvatarUrl] = useState("");
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [mcpTools, setMcpTools] = useState<McpServerInfo[]>([]);
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
const [extensionsPath, setExtensionsPath] = useState("");
const [statusText, setStatusText] = useState("");
const [statusKind, setStatusKind] = useState<"" | "ok" | "error">("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [savingAvatars, setSavingAvatars] = useState(false);
const [reloading, setReloading] = useState(false);
useEffect(() => {
let initial: SettingsPaneId = "prompt";
try {
const saved = sessionStorage.getItem(SETTINGS_PANE_KEY);
if (saved && PANE_IDS.includes(saved as SettingsPaneId)) {
initial = saved as SettingsPaneId;
}
} catch {
/* ignore */
}
setActivePane(initial);
}, []);
const setStatus = (text: string, kind: "" | "ok" | "error" = "") => {
setStatusText(text);
setStatusKind(kind);
};
const showPane = (paneId: SettingsPaneId) => {
setActivePane(paneId);
try {
sessionStorage.setItem(SETTINGS_PANE_KEY, paneId);
} catch {
/* ignore */
}
};
const loadSettings = useCallback(async () => {
setLoading(true);
setStatus("");
try {
const data = await settingsApi.fetchSettings();
setSystemPrompt(data.systemPrompt || "");
setSystemPromptPath(data.systemPromptPath || "");
setUserAvatarUrl(data.userAvatarUrl || "");
setAgentAvatarUrl(data.agentAvatarUrl || "");
updateAvatars({
userAvatarUrl: data.userAvatarUrl || "",
agentAvatarUrl: data.agentAvatarUrl || "",
});
setSkills(data.skills || []);
setMcpTools(data.mcpTools || []);
setExtensions(data.extensions || []);
setExtensionsPath(data.extensionsPath || "");
} catch (err) {
setStatus(`加载设置失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setLoading(false);
markRouteReady("settings");
}
}, [markRouteReady, updateAvatars]);
const reloadSettings = async () => {
setReloading(true);
setStatus("正在重新加载 Agent 资源...");
try {
await settingsApi.reloadSettings();
await loadSettings();
setStatus("已重新加载并刷新设置", "ok");
} catch (err) {
setStatus(`重新加载失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setReloading(false);
}
};
const saveSystemPrompt = async () => {
setSaving(true);
setStatus("保存中...");
try {
const data = await settingsApi.saveSystemPrompt(systemPrompt);
if (data.systemPromptPath) setSystemPromptPath(data.systemPromptPath);
setStatus("已保存Agent 已重新加载", "ok");
} catch (err) {
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setSaving(false);
}
};
const saveAvatars = async () => {
setSavingAvatars(true);
setStatus("保存中...");
try {
const data = await settingsApi.saveAvatars({ userAvatarUrl, agentAvatarUrl });
const next = {
userAvatarUrl: data.userAvatarUrl || "",
agentAvatarUrl: data.agentAvatarUrl || "",
};
setUserAvatarUrl(next.userAvatarUrl);
setAgentAvatarUrl(next.agentAvatarUrl);
updateAvatars(next);
setStatus("头像设置已保存", "ok");
} catch (err) {
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setSavingAvatars(false);
}
};
useEffect(() => {
void loadSettings();
}, [loadSettings]);
const toolTotal = mcpTools.reduce((sum, server) => sum + (server.toolCount || 0), 0);
const statusClass =
statusKind === "ok" ? styles.statusOk : statusKind === "error" ? styles.statusError : "";
return (
<div className={styles.page}>
<main className={styles.main}>
<header className={styles.pageHeader}>
<div className={styles.pageTitle}>
<img className={styles.logo} src="/logo.png" width={34} height={34} alt="" decoding="async" />
<div>
<h1></h1>
<p> Agent</p>
</div>
</div>
<Link to="/" className={`${styles.secondaryBtn} ${styles.linkBtn}`}>
</Link>
</header>
<div className={styles.shell}>
<div className={styles.layout}>
<nav className={styles.sidebar} role="tablist" aria-label="设置分区">
{(
[
["prompt", "系统提示词"],
["skills", "Skills"],
["mcp", "MCP 工具"],
["extensions", "插件扩展"],
["other", "其他设置"],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={activePane === id}
className={`${styles.navBtn} ${activePane === id ? styles.navBtnActive : ""}`}
onClick={() => showPane(id)}
>
{label}
</button>
))}
</nav>
<div className={styles.panes}>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "prompt" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "prompt"}
>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<p>{systemPromptPath}</p>
</div>
<button
type="button"
className={styles.primaryBtn}
disabled={saving}
onClick={() => void saveSystemPrompt()}
>
</button>
</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}
</section>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "skills" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "skills"}
>
<div className={styles.panelHeader}>
<div>
<h2>Skills</h2>
<p>{skills.length} </p>
</div>
<button
type="button"
className={styles.secondaryBtn}
disabled={reloading}
onClick={() => void reloadSettings()}
>
</button>
</div>
<div className={styles.list}>
{loading ? (
<div className={styles.empty}>...</div>
) : !skills.length ? (
<div className={styles.empty}> skills</div>
) : (
skills.map((skill) => {
const meta = [skill.scope, skill.source].filter(Boolean).join(" · ");
return (
<div key={skill.path || skill.name || skill.command} className={styles.skillItem}>
<div className={styles.titleRow}>
<div className={styles.itemName}>
{skill.name || skill.command || "未命名"}
</div>
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
</div>
{skill.description ? (
<div className={styles.itemDesc}>{skill.description}</div>
) : null}
{skill.path ? <div className={styles.itemPath}>{skill.path}</div> : null}
</div>
);
})
)}
</div>
</section>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "mcp" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "mcp"}
>
<div className={styles.panelHeader}>
<div>
<h2>MCP </h2>
<p>
{mcpTools.length} Server · {toolTotal}
</p>
</div>
<button
type="button"
className={styles.secondaryBtn}
disabled={reloading}
onClick={() => void reloadSettings()}
>
</button>
</div>
<div className={styles.list}>
{loading ? (
<div className={styles.empty}>...</div>
) : !mcpTools.length ? (
<div className={styles.empty}> MCP server </div>
) : (
mcpTools.map((server) => {
const tools = Array.isArray(server.tools) ? server.tools : [];
const status = server.cached ? "已缓存" : "未连接";
const cachedAt = server.cachedAt
? new Date(server.cachedAt).toLocaleString()
: "";
return (
<div key={server.name} className={styles.mcpServerItem}>
<div className={styles.mcpServerHead}>
<div className={styles.mcpServerMain}>
<div className={styles.itemName}>{server.name || "未命名 Server"}</div>
{cachedAt ? (
<div className={styles.mcpServerSub}> {cachedAt}</div>
) : null}
</div>
<div className={styles.mcpServerBadges}>
<span className={styles.badge}>{status}</span>
<span className={styles.badge}>{server.toolCount || 0} </span>
{server.resourceCount ? (
<span className={styles.badge}>{server.resourceCount} </span>
) : null}
</div>
</div>
{tools.length ? (
<div className={styles.mcpToolList}>
{tools.map((tool) => (
<McpToolItem key={tool.name} tool={tool} />
))}
</div>
) : (
<div className={`${styles.empty} ${styles.emptyCompact}`}>
/mcp reconnect
</div>
)}
</div>
);
})
)}
</div>
</section>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "extensions" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "extensions"}
>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<p>
{extensionsPath
? `${extensions.length} 个已加载 · ${extensionsPath}`
: `${extensions.length} 个已加载`}
</p>
</div>
<button
type="button"
className={styles.secondaryBtn}
disabled={reloading}
onClick={() => void reloadSettings()}
>
</button>
</div>
<div className={styles.list}>
{loading ? (
<div className={styles.empty}>...</div>
) : !extensions.length ? (
<div className={styles.empty}></div>
) : (
extensions.map((extension) => {
const meta =
extension.kind ||
[extension.scope, extension.source].filter(Boolean).join(" · ");
const counts = (
[
["命令", extension.commands?.length || 0],
["工具", extension.tools?.length || 0],
["事件", extension.handlers?.length || 0],
["Flags", extension.flags?.length || 0],
["快捷键", extension.shortcuts?.length || 0],
] as const
).filter(([, count]) => count > 0);
const hasDetails = ["commands", "tools", "handlers", "flags", "shortcuts"].some(
(key) => (extension[key as keyof ExtensionInfo] as string[] | undefined)?.length,
);
return (
<div
key={extension.location || extension.name || extension.path}
className={styles.extensionItem}
>
<div className={styles.titleRow}>
<div className={styles.itemName}>
{extension.name || extension.path || "未命名"}
</div>
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
</div>
{counts.length ? (
<div className={styles.extensionCounts}>
{counts.map(([label, count]) => (
<span key={label}>
{label} {count}
</span>
))}
</div>
) : null}
{hasDetails ? (
<details className={styles.extensionDetails}>
<summary></summary>
{renderNameList("命令", extension.commands)}
{renderNameList("工具", extension.tools)}
{renderNameList("事件", extension.handlers)}
{renderNameList("Flags", extension.flags)}
{renderNameList("快捷键", extension.shortcuts)}
</details>
) : null}
{extension.location ? (
<div className={styles.itemPath}>{extension.location}</div>
) : null}
</div>
);
})
)}
</div>
</section>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "other" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "other"}
>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<p>使</p>
</div>
<button
type="button"
className={styles.primaryBtn}
disabled={savingAvatars}
onClick={() => void saveAvatars()}
>
</button>
</div>
<div className={styles.formBody}>
<label className={styles.field}>
<span className={styles.fieldLabel}></span>
<input
className={styles.textInput}
type="url"
inputMode="url"
placeholder="https://example.com/user-avatar.png"
value={userAvatarUrl}
onChange={(e) => setUserAvatarUrl(e.target.value)}
/>
{userAvatarUrl.trim() ? (
<div className={styles.avatarPreviewRow}>
<img
className={styles.avatarPreview}
src={userAvatarUrl.trim()}
alt=""
decoding="async"
/>
<span className={styles.previewHint}></span>
</div>
) : null}
</label>
<label className={styles.field}>
<span className={styles.fieldLabel}>Agent </span>
<input
className={styles.textInput}
type="url"
inputMode="url"
placeholder="https://example.com/agent-avatar.png"
value={agentAvatarUrl}
onChange={(e) => setAgentAvatarUrl(e.target.value)}
/>
{agentAvatarUrl.trim() ? (
<div className={styles.avatarPreviewRow}>
<img
className={styles.avatarPreview}
src={agentAvatarUrl.trim()}
alt=""
decoding="async"
/>
<span className={styles.previewHint}></span>
</div>
) : null}
</label>
<p className={styles.fieldHint}>
http / https
</p>
</div>
{statusText && activePane === "other" ? (
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
) : null}
</section>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,55 @@
@import "./variables.css";
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
font-family: var(--font-family);
}
body {
font-family: inherit;
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.06), transparent 28%),
linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%);
color: #1a1a1a;
height: var(--app-height);
overflow: hidden;
}
button,
input,
textarea,
select,
optgroup {
font-family: inherit;
}
[hidden] {
display: none !important;
}
code,
kbd,
samp,
pre {
font-family: inherit;
}
.hljs,
pre code.hljs,
code.hljs {
font-family: inherit !important;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,15 @@
:root {
--app-height: 100vh;
--font-family: "LXGW WenKai Mono", "霞鹜文楷等宽", ui-monospace, monospace;
--font-ui: var(--font-family);
--font-mono: var(--font-family);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes blink {
50% { opacity: 0; }
}

View File

@@ -0,0 +1,122 @@
import type { RpcMessage } from "./message";
export interface ConnectedEvent {
type: "connected";
}
export interface AgentStartEvent {
type: "agent_start";
}
export interface AgentEndEvent {
type: "agent_end";
}
export interface MessageStartEvent {
type: "message_start";
message: RpcMessage;
}
export interface MessageUpdateEvent {
type: "message_update";
message: RpcMessage;
}
export interface MessageEndEvent {
type: "message_end";
message: RpcMessage;
}
export interface ToolExecutionStartEvent {
type: "tool_execution_start";
toolCallId?: string;
toolName: string;
args?: unknown;
}
export interface ToolExecutionUpdateEvent {
type: "tool_execution_update";
toolCallId?: string;
toolName: string;
partialResult?: { content?: unknown };
}
export interface ToolExecutionEndEvent {
type: "tool_execution_end";
toolCallId?: string;
toolName: string;
isError?: boolean;
result?: { content?: unknown };
}
export type SseEvent =
| ConnectedEvent
| AgentStartEvent
| AgentEndEvent
| MessageStartEvent
| MessageUpdateEvent
| MessageEndEvent
| ToolExecutionStartEvent
| ToolExecutionUpdateEvent
| ToolExecutionEndEvent;
export interface SkillInfo {
name?: string;
command?: string;
description?: string;
path?: string;
scope?: string;
source?: string;
}
export interface McpToolInfo {
name?: string;
description?: string;
parameters?: string[];
required?: string[];
}
export interface McpServerInfo {
name?: string;
configured?: boolean;
cached?: boolean;
toolCount?: number;
resourceCount?: number;
cachedAt?: string;
tools?: McpToolInfo[];
}
export interface ExtensionInfo {
name?: string;
path?: string;
kind?: string;
scope?: string;
source?: string;
location?: string;
commands?: string[];
tools?: string[];
handlers?: string[];
flags?: string[];
shortcuts?: string[];
}
export interface SettingsData {
systemPrompt?: string;
systemPromptPath?: string;
userAvatarUrl?: string;
agentAvatarUrl?: string;
skills?: SkillInfo[];
mcpTools?: McpServerInfo[];
mcpConfigPath?: string;
mcpCachePath?: string;
extensions?: ExtensionInfo[];
extensionsPath?: string;
error?: string;
}
export interface AvatarSettings {
userAvatarUrl: string;
agentAvatarUrl: string;
}
export type SettingsPaneId = "prompt" | "skills" | "mcp" | "extensions" | "other";

View File

@@ -0,0 +1,95 @@
export type MessageRole = "user" | "assistant" | "system" | "tool_call";
export interface ContentBlock {
type: string;
text?: string;
data?: string;
mimeType?: string;
toolName?: string;
name?: string;
id?: string;
toolCallId?: string;
arguments?: unknown;
args?: unknown;
input?: unknown;
}
export interface ImageContent {
type: "image";
data: string;
mimeType: string;
}
export interface RpcMessage {
role: string;
content?: string | ContentBlock[];
}
export interface ToolCallBlock {
type: "toolCall";
toolName?: string;
name?: string;
id?: string;
toolCallId?: string;
arguments?: unknown;
args?: unknown;
input?: unknown;
}
export interface ChatMessage {
id: string;
role: MessageRole;
content: string;
images?: ImageContent[];
toolCallId?: string;
streaming?: boolean;
}
export interface ModelInfo {
provider: string;
id: string;
reasoning?: boolean;
contextWindow?: number;
thinkingLevelMap?: Record<string, string | null>;
}
export interface SessionSummary {
path: string;
name?: string;
firstMessage?: string;
messageCount?: number;
modified?: string;
created?: string;
}
export interface SessionHistoryPayload {
session?: { id?: string | number; name?: string };
messages?: RpcMessage[];
error?: string;
}
export interface SessionState {
error?: string;
sessionId?: string | number;
sessionName?: string;
model?: ModelInfo;
thinkingLevel?: string;
stats?: {
tokens?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
};
cost?: number;
contextUsage?: { percent?: number; contextWindow?: number };
};
autoCompactionEnabled?: boolean;
isStreaming?: boolean;
isCompacting?: boolean;
turnIndex?: number;
}
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];

View File

@@ -0,0 +1,22 @@
import type { RpcMessage } from "./message";
export interface SessionSummary {
path: string;
name?: string;
firstMessage?: string;
messageCount?: number;
modified?: string;
created?: string;
pinned?: boolean;
}
export interface SessionHistoryPayload {
session?: { id?: string | number; name?: string };
messages?: RpcMessage[];
error?: string;
}
export interface NewSessionResponse {
sessionFile?: string;
error?: string;
}

View File

@@ -0,0 +1,99 @@
import type { ChatMessage } from "../types/message";
import { escHtml } from "./format";
function roleLabel(role: ChatMessage["role"]): string {
switch (role) {
case "user":
return "用户";
case "assistant":
return "助手";
case "tool_call":
return "工具";
case "system":
return "系统";
}
}
function exportableMessages(messages: ChatMessage[]): ChatMessage[] {
return messages.filter((m) => !m.streaming && m.content.trim());
}
export function messagesToMarkdown(title: string, messages: ChatMessage[]): string {
const lines = [`# ${title}`, ""];
for (const m of exportableMessages(messages)) {
switch (m.role) {
case "user":
lines.push("## 用户", "", m.content, "");
break;
case "assistant":
lines.push("## 助手", "", m.content, "");
break;
case "tool_call":
lines.push("## 工具", "", "```", m.content, "```", "");
break;
case "system":
lines.push(`> ${m.content}`, "");
break;
}
}
return `${lines.join("\n").trimEnd()}\n`;
}
export function messagesToMinimalHtml(title: string, messages: ChatMessage[]): string {
const sections = exportableMessages(messages)
.map((m) => {
const label = roleLabel(m.role);
return `<section class="block"><h2>${escHtml(label)}</h2><pre>${escHtml(m.content)}</pre></section>`;
})
.join("\n");
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escHtml(title)}</title>
<style>
body{font-family:"LXGW WenKai Mono","霞鹜文楷等宽",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}
pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:.9375rem;font-family:inherit}
</style>
</head>
<body>
<h1>${escHtml(title)}</h1>
${sections}
</body>
</html>
`;
}
export function safeExportFilename(title: string): string {
const cleaned = title.replace(/[\\/:*?"<>|]/g, "_").trim();
return cleaned || "conversation";
}
export function downloadTextFile(filename: string, content: string, mime: string): void {
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
export function singleAssistantMarkdown(content: string): string {
return content.trimEnd() + (content.endsWith("\n") ? "" : "\n");
}
export function messageMarkdownFilename(content: string): string {
const snippet =
content
.replace(/\s+/g, " ")
.trim()
.slice(0, 24)
.replace(/[\\/:*?"<>|]/g, "_") || "message";
return `${snippet}.md`;
}

View File

@@ -0,0 +1,47 @@
export function formatFooterTokens(count: number | undefined): string {
const n = Number(count) || 0;
if (n <= 0) return "0";
if (n < 1000) return String(Math.round(n));
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
return `${Math.round(n / 1_000_000)}M`;
}
export function formatTimeAgo(isoStr: string | undefined): string {
if (!isoStr) return "";
const d = new Date(isoStr);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return "刚刚";
if (mins < 60) return `${mins} 分钟前`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} 天前`;
return d.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
}
export function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function syncViewportHeight(): void {
const viewportHeight = window.visualViewport?.height || window.innerHeight;
document.documentElement.style.setProperty("--app-height", `${viewportHeight}px`);
}
export function isTouchLike(): boolean {
return window.matchMedia("(max-width: 768px)").matches || navigator.maxTouchPoints > 0;
}
let messageIdCounter = 0;
export function nextMessageId(): string {
messageIdCounter += 1;
return `msg-${Date.now()}-${messageIdCounter}`;
}

View File

@@ -0,0 +1,58 @@
export interface ImageContent {
type: "image";
data: string;
mimeType: string;
}
export const MAX_CHAT_IMAGES = 8;
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export const ALLOWED_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
export function imageToDataUrl(image: ImageContent): string {
return `data:${image.mimeType};base64,${image.data}`;
}
export async function fileToImageContent(file: File): Promise<ImageContent> {
if (!ALLOWED_IMAGE_MIME.has(file.type)) {
throw new Error(`不支持的图片类型: ${file.type || "unknown"}`);
}
if (file.size > MAX_IMAGE_BYTES) {
throw new Error(`图片过大(最大 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB`);
}
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(file);
});
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!match) throw new Error("无效图片数据");
return { type: "image", mimeType: match[1], data: match[2] };
}
export async function clipboardItemsToImages(items: DataTransferItemList): Promise<ImageContent[]> {
const images: ImageContent[] = [];
for (const item of items) {
if (!item.type.startsWith("image/")) continue;
const file = item.getAsFile();
if (!file) continue;
images.push(await fileToImageContent(file));
}
return images;
}
export function validateImagePayload(image: ImageContent): ImageContent {
if (image.type !== "image") throw new Error("无效图片对象");
if (!ALLOWED_IMAGE_MIME.has(image.mimeType)) {
throw new Error(`不支持的图片类型: ${image.mimeType}`);
}
if (!image.data) throw new Error("图片数据为空");
const padding = image.data.endsWith("==") ? 2 : image.data.endsWith("=") ? 1 : 0;
const size = Math.floor((image.data.length * 3) / 4) - padding;
if (size > MAX_IMAGE_BYTES) {
throw new Error(`图片过大(最大 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB`);
}
return { type: "image", mimeType: image.mimeType, data: image.data };
}

View File

@@ -0,0 +1,67 @@
import hljs from "highlight.js/lib/core";
import javascript from "highlight.js/lib/languages/javascript";
import typescript from "highlight.js/lib/languages/typescript";
import python from "highlight.js/lib/languages/python";
import bash from "highlight.js/lib/languages/bash";
import json from "highlight.js/lib/languages/json";
import markdown from "highlight.js/lib/languages/markdown";
import css from "highlight.js/lib/languages/css";
import xml from "highlight.js/lib/languages/xml";
import { Marked, type Tokens } from "marked";
import "highlight.js/styles/github.min.css";
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("python", python);
hljs.registerLanguage("bash", bash);
hljs.registerLanguage("json", json);
hljs.registerLanguage("markdown", markdown);
hljs.registerLanguage("css", css);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("html", xml);
function escapeMarkdownCode(raw: string): string {
return String(raw)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderAssistantCodeBlock(token: Tokens.Code): string {
const langSlug = (((token.lang || "").match(/^\S+/) || [""])[0]).trim().toLowerCase();
const codeRaw = String(token.text ?? "").replace(/\n+$/, "");
const skipHl = !!token.escaped;
let innerHtml: string;
if (skipHl) {
innerHtml = codeRaw;
} else {
try {
if (langSlug && hljs.getLanguage(langSlug)) {
innerHtml = hljs.highlight(codeRaw, { language: langSlug }).value;
} else {
innerHtml = hljs.highlightAuto(codeRaw).value;
}
} catch {
innerHtml = escapeMarkdownCode(codeRaw);
}
}
const safeLangSlug = /^[a-z][a-z0-9_-]*$/i.test(langSlug) ? langSlug : "";
const codeClass = safeLangSlug ? `hljs language-${safeLangSlug}` : "hljs";
return `<pre class="assistant-pre"><code class="${codeClass}">${innerHtml}</code></pre>\n`;
}
const marked = new Marked({
breaks: true,
gfm: true,
renderer: {
code: renderAssistantCodeBlock,
},
});
export function renderMarkdown(text: string): string {
if (!text) return "";
return marked.parse(text) as string;
}

View File

@@ -0,0 +1,35 @@
export function formatSessionTitle(title: string | undefined): string {
const text = typeof title === "string" ? title.trim() : "";
return text || "未命名";
}
/** 与服务端一致的机器标签(会话 id、纯 hex 段等),不写进界面标题 */
export function isMachineSessionLabel(text: string | undefined, headerId: string): boolean {
const t = (text ?? "").trim();
if (!t) return true;
if (headerId && t === headerId) return true;
if (/^[0-9a-f]{8,}$/i.test(t)) return true;
if (/^[0-9]{10,}$/.test(t)) return true;
return false;
}
/** 首句截取(侧边栏兜底与顶栏回填,需与 server.ts 保持一致逻辑) */
export function titleFromFirstUserMessage(text: string | undefined, maxChars = 56): string {
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
if (!cleaned) return "";
const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/);
let candidate = sentenceMatch?.[1] ? sentenceMatch[1].trim() : cleaned;
if (candidate.length > maxChars) {
candidate = `${candidate.slice(0, maxChars).trimEnd()}`;
}
return candidate;
}
export function modelKey(model: { provider: string; id: string } | undefined): string {
return model ? `${model.provider}/${model.id}` : "";
}
export function modelLabel(model: { provider: string; id: string } | undefined): string {
if (!model) return "";
return `${model.provider}/${model.id}`;
}

View File

@@ -0,0 +1,82 @@
import type { ContentBlock, ImageContent, ToolCallBlock } from "../types/message";
export function extractContent(content: string | ContentBlock[] | undefined): string {
if (!content) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((c) => c.type === "text")
.map((c) => c.text ?? "")
.join("");
}
return String(content);
}
export function extractImages(content: string | ContentBlock[] | undefined): ImageContent[] {
if (!Array.isArray(content)) return [];
return content
.filter((c): c is ImageContent => c.type === "image" && typeof c.data === "string" && !!c.mimeType)
.map((c) => ({ type: "image", data: c.data, mimeType: c.mimeType }));
}
export function getToolCalls(content: string | ContentBlock[] | undefined): ToolCallBlock[] {
if (!Array.isArray(content)) return [];
return content.filter((c): c is ToolCallBlock => c.type === "toolCall");
}
export function formatToolCall(tc: ToolCallBlock): string {
const name = tc.toolName || tc.name || "tool";
const args = tc.arguments ?? tc.args ?? tc.input ?? {};
let text = `${name}(${JSON.stringify(args)})`;
const shortId = tc.toolCallId || tc.id;
if (shortId) text = `# ${String(shortId).slice(0, 8)}\n${text}`;
return text;
}
export function formatToolResult(tr: { content?: unknown }): string {
let text = "";
const content = tr.content;
if (typeof content === "string") text = content;
else if (Array.isArray(content)) {
text = content.map((c) => (c && typeof c === "object" && "text" in c ? String(c.text) : JSON.stringify(c))).join("\n");
} else if (content && typeof content === "object" && "text" in content) {
text = String((content as { text: string }).text);
} else {
text = JSON.stringify(content || tr, null, 2);
}
return text.length > 800 ? `${text.slice(0, 800)}\n… (已截断)` : text;
}
export function deriveToolCallSummary(fullText: string): string {
const raw = String(fullText || "").trim();
if (!raw) return "tool";
let icon = "";
if (raw.startsWith("🔧")) icon = "🔧";
else if (raw.startsWith("✅")) icon = "✅";
else if (raw.startsWith("❌")) icon = "❌";
const rest = icon ? raw.slice(icon.length).trimStart() : raw;
const idLine = rest.split("\n").find((l) => l.trimStart().startsWith("# "));
const idPart = idLine ? idLine.replace(/^\s*#\s*/, "").trim().split(/\s/)[0] : "";
const oneLine = rest.replace(/\s+/g, " ");
const nameMatch = oneLine.match(/(\w+)\s*\(/);
const name = nameMatch ? nameMatch[1] : "tool";
let label = idPart ? `${name} · ${idPart}` : name;
if (icon) label = `${icon} ${label}`;
return label;
}
export function buildToolCallUpdateText(
ev: { toolName: string; partialResult?: { content?: unknown }; result?: { content?: unknown } },
isError?: boolean,
): string {
const icon = isError === undefined ? "🔧" : isError ? "❌" : "✅";
let detail = "";
if (ev.partialResult?.content) {
detail = `\n${formatToolResult(ev.partialResult)}`;
}
if (isError !== undefined) {
const resultText = ev.result?.content ? `\n${formatToolResult(ev.result)}` : "";
return `${icon} ${ev.toolName}${resultText || detail}`;
}
return `${icon} ${ev.toolName}${detail}`;
}

View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,65 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "prompt",
includeAssets: ["logo.png", "logo192.png", "logo512.png"],
manifest: {
name: "萌小芽",
short_name: "萌小芽",
description: "树萌芽智能 AI 助手 Web 客户端",
theme_color: "#f7f8fb",
background_color: "#f7f8fb",
display: "standalone",
orientation: "portrait-primary",
scope: "/",
start_url: "/",
lang: "zh-CN",
icons: [
{
src: "logo192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "logo512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "logo512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
workbox: {
navigateFallback: "index.html",
globPatterns: ["**/*.{js,css,html,png,ico,woff2}"],
runtimeCaching: [
{
urlPattern: /^\/api\//,
handler: "NetworkOnly",
},
],
},
devOptions: {
enabled: false,
},
}),
],
build: {
outDir: "../dist",
emptyOutDir: true,
},
server: {
proxy: {
"/api": "http://localhost:19133",
},
},
});

View File

@@ -0,0 +1,364 @@
/**
* WebUI 扩展 —— 在浏览器中通过网页与 pi 对话
*
* 安装:
* 将本文件放入 .pi/extensions/webui/index.ts
*
* 用法:
* /webui on → 启动网页服务(默认端口 19133
* /webui off → 停止网页服务
* /webui on 8080 → 指定端口启动
*/
import { spawn, spawnSync, execSync, type ChildProcess } from "node:child_process";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PID_FILE = join(__dirname, ".webui.pid");
const DIST_DIR = join(__dirname, "dist");
const FRONTEND_DIR = join(__dirname, "frontend");
let serverProcess: ChildProcess | null = null;
let serverPort = 19133;
function findTsx(): string {
// 从扩展所在目录向上找 repo 根目录
let dir = __dirname;
for (let i = 0; i < 10; i++) {
const candidate = join(dir, "node_modules", ".bin", "tsx");
if (existsSync(candidate)) return candidate;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
// fallback
return join(__dirname, "..", "..", "..", "node_modules", ".bin", "tsx");
}
function findRepoRoot(): string {
let dir = __dirname;
for (let i = 0; i < 12; i++) {
if (
existsSync(join(dir, "package.json")) &&
existsSync(join(dir, "packages", "coding-agent", "src", "cli.ts"))
) {
return dir;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return process.cwd();
}
function getAgentDir(repoRoot: string): string {
return process.env.PI_CODING_AGENT_DIR || join(repoRoot, ".pi", "agent");
}
function readPidFile(): number | null {
if (!existsSync(PID_FILE)) return null;
try {
const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
return Number.isFinite(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
}
function writePidFile(pid: number): void {
try {
writeFileSync(PID_FILE, String(pid), "utf8");
} catch {
// 忽略PID 文件只是辅助信息
}
}
function clearPidFile(): void {
try {
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
} catch {
// 忽略
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function waitForStartup(port: number, child: ChildProcess): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false;
let timeout: NodeJS.Timeout | null = null;
const finish = (err?: Error) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
if (err) reject(err);
else resolve();
};
const check = () => {
if (child.exitCode !== null || child.signalCode !== null) {
finish(new Error(`webui 子进程已退出code=${child.exitCode ?? "null"}`));
return;
}
const http = require("node:http");
const req = http.get(
{ hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1500 },
(res: any) => {
res.resume();
if (res.statusCode === 200) finish();
else setTimeout(check, 200);
},
);
req.on("error", () => setTimeout(check, 200));
req.on("timeout", () => {
req.destroy();
setTimeout(check, 200);
});
};
timeout = setTimeout(() => finish(new Error("webui 启动超时")), 10000);
setTimeout(check, 300);
});
}
function probePort(port: number): Promise<"free" | "running" | "occupied"> {
return new Promise((resolve) => {
const http = require("node:http");
const req = http.get(
{ hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1200 },
(res: any) => {
res.resume();
resolve(res.statusCode === 200 ? "running" : "occupied");
},
);
req.on("error", () => resolve("free"));
req.on("timeout", () => {
req.destroy();
resolve("free");
});
});
}
async function ensureBuilt(ctx: { ui: { notify: (msg: string, kind: string) => void } }): Promise<boolean> {
const indexHtml = join(DIST_DIR, "index.html");
if (existsSync(indexHtml)) return true;
ctx.ui.notify("正在构建 WebUI 前端...", "info");
if (!existsSync(join(FRONTEND_DIR, "node_modules"))) {
const install = spawnSync("npm", ["install"], { cwd: FRONTEND_DIR, stdio: "inherit" });
if (install.status !== 0) {
ctx.ui.notify("WebUI 前端依赖安装失败", "error");
return false;
}
}
const build = spawnSync("npm", ["run", "build"], { cwd: FRONTEND_DIR, stdio: "inherit" });
if (build.status !== 0 || !existsSync(indexHtml)) {
ctx.ui.notify("WebUI 前端构建失败", "error");
return false;
}
ctx.ui.notify("WebUI 前端构建完成", "info");
return true;
}
async function startServer(port: number, ctx: any): Promise<void> {
if (serverProcess) {
ctx.ui.notify(`网页服务已在端口 ${serverPort} 运行`, "error");
return;
}
const oldPid = readPidFile();
if (oldPid && !isProcessAlive(oldPid)) {
clearPidFile();
}
const state = await probePort(port);
if (state === "running") {
serverPort = port;
const pids = findPidsByPort(port);
if (pids.length) writePidFile(pids[0]);
ctx.ui.notify(`网页服务已在端口 ${port} 运行`, "info");
return;
}
if (state === "occupied") {
ctx.ui.notify(`端口 ${port} 已被其他进程占用`, "error");
return;
}
const repoRoot = findRepoRoot();
const tsxBin = findTsx();
const serverFile = join(__dirname, "server.ts");
if (!existsSync(serverFile)) {
ctx.ui.notify(`服务器文件未找到: ${serverFile}`, "error");
return;
}
if (!(await ensureBuilt(ctx))) {
return;
}
serverPort = port;
serverProcess = spawn(tsxBin, [serverFile, "--port", String(port)], {
cwd: repoRoot,
stdio: ["ignore", "inherit", "inherit"],
env: {
...process.env,
PI_CODING_AGENT_DIR: getAgentDir(repoRoot),
},
});
serverProcess.on("exit", (code) => {
serverProcess = null;
clearPidFile();
});
if (serverProcess.pid) {
writePidFile(serverProcess.pid);
}
try {
await waitForStartup(port, serverProcess);
} catch (err: any) {
clearPidFile();
serverProcess = null;
ctx.ui.notify(`网页服务启动失败: ${err.message}`, "error");
return;
}
ctx.ui.notify(`网页服务已启动 → http://smallmengya:${port}`, "info");
// 显示局域网地址
const { networkInterfaces } = require("node:os");
const nets = networkInterfaces();
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
if (net.family === "IPv4" && !net.internal) {
ctx.ui.notify(` 局域网: http://${net.address}:${port}`, "info");
}
}
}
}
function findPidsByPort(port: number): number[] {
try {
const out = execSync(`ss -tlnp 'sport = :${port}'`, { encoding: "utf8" });
const pids = new Set<number>();
for (const match of out.matchAll(/pid=(\d+)/g)) {
const pid = parseInt(match[1], 10);
if (Number.isFinite(pid) && pid > 0) pids.add(pid);
}
return [...pids];
} catch {
return [];
}
}
function killProcessTree(pid: number): void {
try {
process.kill(pid, "SIGTERM");
} catch {
/* ignore */
}
}
async function waitForPortFree(port: number, timeoutMs = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if ((await probePort(port)) === "free") return true;
await new Promise((r) => setTimeout(r, 200));
}
return (await probePort(port)) === "free";
}
async function stopByPort(port: number, ctx: any): Promise<boolean> {
const pids = findPidsByPort(port);
if (!pids.length) return false;
for (const pid of pids) {
killProcessTree(pid);
}
await waitForPortFree(port);
if ((await probePort(port)) === "free") {
clearPidFile();
ctx.ui.notify(`网页服务已停止(端口 ${port}PID ${pids.join(", ")}`, "info");
return true;
}
ctx.ui.notify(`端口 ${port} 上的网页服务未能完全停止`, "error");
return false;
}
async function stopServer(ctx: any, port = serverPort || 19133): Promise<void> {
if (serverProcess) {
serverProcess.kill("SIGTERM");
serverProcess = null;
serverPort = 0;
clearPidFile();
ctx.ui.notify("网页服务已停止", "info");
return;
}
const pid = readPidFile();
if (pid && isProcessAlive(pid)) {
try {
process.kill(pid, "SIGTERM");
await waitForPortFree(port);
clearPidFile();
ctx.ui.notify(`网页服务已停止PID ${pid}`, "info");
return;
} catch {
clearPidFile();
}
}
const state = await probePort(port);
if (state === "running" || state === "occupied") {
if (await stopByPort(port, ctx)) return;
ctx.ui.notify(`端口 ${port} 仍被占用,请手动检查进程`, "error");
return;
}
clearPidFile();
ctx.ui.notify("网页服务未运行", "info");
}
export default function (pi: ExtensionAPI) {
pi.registerCommand("webui", {
description: "通过 /webui on 启动、/webui off 停止 Web 聊天界面",
handler: async (args, ctx) => {
const trimmed = args.trim();
const [command = "", value = ""] = trimmed.split(/\s+/, 2);
if (command === "off" || command === "stop" || command === "0") {
const stopPort = value ? parseInt(value, 10) : serverPort || 19133;
await stopServer(ctx, Number.isFinite(stopPort) ? stopPort : 19133);
return;
}
const portInput = command === "on" ? value : command;
const port = portInput ? parseInt(portInput, 10) : 19133;
if (isNaN(port) || port < 1 || port > 65535) {
ctx.ui.notify(`无效端口: ${trimmed || "(空)"},使用 19133`, "error");
return;
}
await startServer(port, ctx);
},
});
}

View File

@@ -0,0 +1,928 @@
#!/usr/bin/env node
/**
* pi-mono WebUI Server
*
* 被 webui 扩展启动,作为独立子进程运行。
* 内嵌 HTTP 服务 + pi RPC 子进程,提供浏览器聊天界面。
*
* 用法(由扩展自动调用):
* tsx server.ts --port 19133
*
* 前端静态文件位于同目录 dist/ 下Vite 构建产物)。
*/
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { appendFileSync, readFileSync, readdirSync, statSync, existsSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname, resolve, basename } from "node:path";
import { fileURLToPath } from "node:url";
import {
closeWebuiDatabase,
deleteWebuiConfig,
getAllWebuiConfig,
getWebuiConfig,
initWebuiDatabase,
prunePinnedSessionPaths,
readWebuiAvatarSettings,
removePinnedSessionPath,
setSessionPinned,
setWebuiConfig,
setWebuiConfigMany,
writeWebuiAvatarSettings,
} from "./db.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = join(__dirname, "dist");
const PID_FILE = join(__dirname, ".webui.pid");
// 从 CWD 确定 repo 根目录(启动时 CWD 为 repo 根目录)
const REPO_ROOT = process.cwd();
const AGENT_DIR = resolve(process.env.PI_CODING_AGENT_DIR || join(REPO_ROOT, ".pi", "agent"));
const SESSIONS_DIR = join(AGENT_DIR, "sessions");
const AGENT_EXTENSIONS_DIR = join(AGENT_DIR, "extensions");
const SYSTEM_PROMPT_FILE = join(AGENT_DIR, "AGENTS.md");
const MCP_CACHE_FILE = join(AGENT_DIR, "mcp-cache.json");
const MCP_CONFIG_FILE = join(AGENT_DIR, "mcp.json");
const TSX_BIN = join(REPO_ROOT, "node_modules", ".bin", "tsx");
const PI_CLI_DIST = join(REPO_ROOT, "packages", "coding-agent", "dist", "cli.js");
const PI_CLI_SRC = join(REPO_ROOT, "packages", "coding-agent", "src", "cli.ts");
function resolvePiRpcLaunch(): { command: string; args: string[]; mode: "dist" | "src" } {
const rpcArgs = ["--mode", "rpc"];
if (existsSync(PI_CLI_DIST)) {
return { command: process.execPath, args: [PI_CLI_DIST, ...rpcArgs], mode: "dist" };
}
console.warn(`[webui] 未找到 ${PI_CLI_DIST},回退 tsx 源码启动(较慢)。请运行: npm run build --workspace=@earendil-works/pi-coding-agent`);
return { command: TSX_BIN, args: [PI_CLI_SRC, ...rpcArgs], mode: "src" };
}
// ─── 解析 CLI 参数 ─────────────────────────────────────────────────
const args = process.argv.slice(2);
function getArg(flag: string): string | undefined {
const idx = args.indexOf(flag);
return idx !== -1 ? args[idx + 1] : undefined;
}
const PORT = parseInt(getArg("--port") || "19133", 10);
function writePidFile(): void {
try {
writeFileSync(PID_FILE, String(process.pid), "utf8");
} catch {
/* ignore */
}
}
function clearPidFile(): void {
try {
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
} catch {
/* ignore */
}
}
function shutdown(exitCode = 0): never {
closeWebuiDatabase();
clearPidFile();
process.exit(exitCode);
}
const webuiDb = initWebuiDatabase(__dirname);
console.log(`[webui] 配置数据库: ${webuiDb.dbPath}`);
process.on("SIGTERM", () => shutdown(0));
process.on("SIGINT", () => shutdown(0));
writePidFile();
// ─── 启动 pi RPC 子进程 ────────────────────────────────────────────
const piLaunch = resolvePiRpcLaunch();
console.log(`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${piLaunch.args.join(" ")}`);
const pi = spawn(piLaunch.command, piLaunch.args, {
cwd: REPO_ROOT,
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
PI_CODING_AGENT_DIR: AGENT_DIR,
},
});
pi.stderr.on("data", (data) => process.stderr.write(`[pi] ${data}`));
pi.on("exit", (code) => {
console.log(`[webui] pi 退出, code=${code}`);
shutdown(1);
});
// ─── JSONL 行协议 ──────────────────────────────────────────────────
let buffer = "";
const pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }>();
const sseClients = new Set<any>();
let reqId = 0;
function onLine(line: string) {
if (!line.trim()) return;
try {
const msg = JSON.parse(line);
if (msg.type === "response" && msg.id && pending.has(msg.id)) {
const p = pending.get(msg.id)!;
pending.delete(msg.id);
p.resolve(msg);
return;
}
const data = `data: ${JSON.stringify(msg)}\n\n`;
for (const res of sseClients) res.write(data);
} catch {
// 忽略非 JSON 行
}
}
pi.stdout.on("data", (chunk: Buffer) => {
buffer += chunk.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) onLine(line);
});
function sendCmd(command: Record<string, unknown>): Promise<any> {
return new Promise((resolve, reject) => {
const id = `req_${++reqId}`;
const line = JSON.stringify({ ...command, id }) + "\n";
pending.set(id, { resolve, reject });
pi.stdin.write(line);
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`命令超时: ${command.type}`));
}
}, 60000);
});
}
// ─── 会话文件读取 ──────────────────────────────────────────────────
function listSessionFiles(): string[] {
if (!existsSync(SESSIONS_DIR)) return [];
const files: string[] = [];
const visit = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
visit(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
files.push(fullPath);
}
}
};
visit(SESSIONS_DIR);
return files
.sort()
.reverse();
}
function readSessionSummary(filePath: string): Record<string, unknown> | null {
try {
const content = readFileSync(filePath, "utf8");
const lines = content.trim().split("\n");
if (!lines.length) return null;
const header = JSON.parse(lines[0]);
if (header.type !== "session") return null;
let nameFromInfo = "";
let messageCount = 0;
let firstMessage = "";
const stats = statSync(filePath);
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.type === "session_info" && entry.name) {
const n = String(entry.name).trim();
if (n && !isMachineSessionLabel(n, header.id)) {
nameFromInfo = n;
}
}
if (entry.type === "message") {
messageCount++;
if (!firstMessage && entry.message?.role === "user") {
firstMessage = extractPreview(entry.message);
}
}
} catch { /* skip */ }
}
const fromFirstUser = titleFromFirstUserMessage(firstMessage);
let name = nameFromInfo;
if (!name || isMachineSessionLabel(name, header.id)) {
name = fromFirstUser || "";
}
return {
path: filePath,
id: header.id,
name,
created: header.timestamp,
modified: stats.mtime.toISOString(),
messageCount,
firstMessage: firstMessage || "(空)",
};
} catch {
return null;
}
}
function isMachineSessionLabel(text: string, sessionHeaderId: string): boolean {
const t = (text ?? "").trim();
if (!t) return true;
if (sessionHeaderId && t === sessionHeaderId) return true;
if (/^[0-9a-f]{8,}$/i.test(t)) return true;
if (/^[0-9]{10,}$/.test(t)) return true;
return false;
}
/** 侧边栏标题:优先用户首句(遇句号等截断),否则整段截取 */
function titleFromFirstUserMessage(text: string, maxChars = 56): string {
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
if (!cleaned) return "";
const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/);
let candidate =
sentenceMatch && sentenceMatch[1] ? sentenceMatch[1].trim() : cleaned;
if (candidate.length > maxChars) {
candidate = `${candidate.slice(0, maxChars).trimEnd()}`;
}
return candidate;
}
function readSessionMessages(filePath: string): unknown[] {
try {
const content = readFileSync(filePath, "utf8");
return content
.trim()
.split("\n")
.map((l) => { try { const e = JSON.parse(l); return e.type === "message" ? e.message : null; } catch { return null; } })
.filter(Boolean);
} catch {
return [];
}
}
function extractPreview(msg: any): string {
const c = msg.content;
if (!c) return "";
if (typeof c === "string") return c.slice(0, 200);
if (Array.isArray(c)) {
const text = c.filter((x: any) => x.type === "text").map((x: any) => x.text).join("").slice(0, 200);
if (text) return text;
const imageCount = c.filter((x: any) => x.type === "image").length;
if (imageCount > 0) return `[${imageCount} 张图片]`;
}
return "";
}
const ALLOWED_CHAT_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const MAX_CHAT_IMAGES = 8;
const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
function normalizeChatImages(images: unknown): Array<{ type: "image"; data: string; mimeType: string }> | undefined {
if (!Array.isArray(images) || images.length === 0) return undefined;
const out: Array<{ type: "image"; data: string; mimeType: string }> = [];
for (const raw of images.slice(0, MAX_CHAT_IMAGES)) {
if (!raw || typeof raw !== "object") continue;
const mimeType = String((raw as any).mimeType || "");
const data = String((raw as any).data || "");
if ((raw as any).type !== "image" || !ALLOWED_CHAT_IMAGE_MIME.has(mimeType) || !data) {
throw new Error(`不支持的图片类型: ${mimeType || "unknown"}`);
}
const size = Buffer.from(data, "base64").length;
if (size > MAX_CHAT_IMAGE_BYTES) {
throw new Error(`图片过大(最大 ${Math.round(MAX_CHAT_IMAGE_BYTES / 1024 / 1024)}MB`);
}
out.push({ type: "image", mimeType, data });
}
return out.length ? out : undefined;
}
function resolveSessionFile(filePath: string): string {
const resolved = resolve(filePath);
const sessionsRoot = resolve(SESSIONS_DIR);
if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) {
throw new Error("无效会话路径");
}
return resolved;
}
function appendSessionName(filePath: string, name: string): string {
const sessionPath = resolveSessionFile(filePath);
if (!existsSync(sessionPath)) throw new Error("会话不存在");
const trimmed = name.trim();
if (!trimmed) throw new Error("会话名称不能为空");
const lines = readFileSync(sessionPath, "utf8").trim().split("\n");
const ids = new Set<string>();
let leafId: string | null = null;
for (const line of lines) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (typeof entry.id === "string") {
ids.add(entry.id);
leafId = entry.id;
}
} catch {
/* skip */
}
}
if (!leafId) throw new Error("无效会话文件");
let id = randomUUID().slice(0, 8);
for (let i = 0; i < 100 && ids.has(id); i++) {
id = randomUUID().slice(0, 8);
}
const entry = {
type: "session_info",
id,
parentId: leafId,
timestamp: new Date().toISOString(),
name: trimmed,
};
appendFileSync(sessionPath, `\n${JSON.stringify(entry)}`, "utf8");
return trimmed;
}
function readSystemPrompt(): string {
if (!existsSync(SYSTEM_PROMPT_FILE)) return "";
return readFileSync(SYSTEM_PROMPT_FILE, "utf8");
}
function writeSystemPrompt(content: string): void {
mkdirSync(dirname(SYSTEM_PROMPT_FILE), { recursive: true });
writeFileSync(SYSTEM_PROMPT_FILE, content, "utf8");
}
function normalizeAvatarUrl(value: unknown): string {
if (typeof value !== "string") return "";
const url = value.trim();
if (!url) return "";
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`无效头像链接: ${url}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("头像链接仅支持 http 或 https");
}
return parsed.toString();
}
function normalizeConfigKey(key: unknown): string {
if (typeof key !== "string") throw new Error("配置键必须是字符串");
const trimmed = key.trim();
if (!trimmed) throw new Error("配置键不能为空");
if (trimmed.length > 128) throw new Error("配置键过长");
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(trimmed)) {
throw new Error("配置键格式无效");
}
return trimmed;
}
function normalizeConfigValue(value: unknown): string {
if (typeof value !== "string") throw new Error("配置值必须是字符串");
if (value.length > 65536) throw new Error("配置值过长");
return value;
}
function normalizeSkillCommand(command: any): Record<string, unknown> {
const rawName = String(command.name || "");
const sourceInfo = command.sourceInfo || {};
return {
name: rawName.replace(/^skill:/, ""),
command: rawName,
description: command.description || "",
scope: sourceInfo.scope || "",
source: sourceInfo.source || "",
path: sourceInfo.path || "",
};
}
async function listLoadedSkills(): Promise<Record<string, unknown>[]> {
const response = await sendCmd({ type: "get_commands" });
if (!response.success) throw new Error(response.error || "读取 skills 失败");
const commands = response.data?.commands || [];
return commands
.filter((command: any) => command?.source === "skill")
.map(normalizeSkillCommand)
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
}
function normalizeExtension(extension: any): Record<string, unknown> {
const sourceInfo = extension.sourceInfo || {};
const pathValue = String(extension.path || "");
const source = String(sourceInfo.source || "");
const resolvedPath = String(extension.resolvedPath || "");
return {
name: displayExtensionName(pathValue, source),
rawName: extension.name || "",
path: pathValue,
resolvedPath,
scope: sourceInfo.scope || "",
source,
sourcePath: sourceInfo.path || "",
location: displayExtensionLocation(pathValue, resolvedPath),
kind: displayExtensionKind(sourceInfo.scope, source),
commands: extension.commands || [],
tools: extension.tools || [],
flags: extension.flags || [],
shortcuts: extension.shortcuts || [],
handlers: extension.handlers || [],
};
}
function isProjectExtension(extension: any): boolean {
const pathValue = String(extension.path || extension.resolvedPath || "");
if (!pathValue) return false;
const resolved = resolve(pathValue);
const agentExtensionsRoot = resolve(AGENT_EXTENSIONS_DIR);
return resolved === agentExtensionsRoot || resolved.startsWith(`${agentExtensionsRoot}/`);
}
function displayExtensionName(extensionPath: string, source: string): string {
const sourceMatch = source.match(/^npm:(.+)$/);
if (sourceMatch?.[1]) return sourceMatch[1];
const normalized = extensionPath.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
const file = parts[parts.length - 1] || normalized;
if (/^index\.[tj]s$/i.test(file) && parts.length >= 2) {
return cleanExtensionName(parts[parts.length - 2]);
}
return cleanExtensionName(file);
}
function cleanExtensionName(name: string): string {
return basename(name).replace(/\.[cm]?[tj]s$/i, "");
}
function displayExtensionKind(scope: string, source: string): string {
const scopeText = scope === "project" ? "项目" : scope === "user" ? "用户" : scope || "未知";
if (source.startsWith("npm:")) return `${scopeText} NPM`;
if (source === "auto") return `${scopeText}本地`;
return source ? `${scopeText} · ${source}` : scopeText;
}
function displayExtensionLocation(extensionPath: string, resolvedPath: string): string {
const pathValue = extensionPath || resolvedPath;
if (!pathValue) return "";
return pathValue.replace(REPO_ROOT, ".");
}
async function listLoadedExtensions(): Promise<Record<string, unknown>[]> {
const response = await sendCmd({ type: "get_extensions" });
if (!response.success) throw new Error(response.error || "读取扩展失败");
return (response.data?.extensions || [])
.filter(isProjectExtension)
.map(normalizeExtension)
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
}
function readJsonFile(filePath: string): any | null {
if (!existsSync(filePath)) return null;
try {
return JSON.parse(readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
function readMcpConfig(): any {
const projectConfig = readJsonFile(MCP_CONFIG_FILE);
if (projectConfig?.mcpServers && typeof projectConfig.mcpServers === "object") {
return projectConfig;
}
return { mcpServers: {} };
}
function normalizeMcpTool(serverName: string, tool: any): Record<string, unknown> {
const schema = tool?.inputSchema && typeof tool.inputSchema === "object" ? tool.inputSchema : {};
const properties = schema && typeof schema.properties === "object" ? Object.keys(schema.properties) : [];
const required = Array.isArray(schema.required) ? schema.required.map(String) : [];
return {
server: serverName,
name: String(tool?.name || ""),
description: String(tool?.description || ""),
parameters: properties,
required,
hasSchema: Object.keys(schema).length > 0,
};
}
function listMcpTools(): Record<string, unknown>[] {
const config = readMcpConfig();
const cache = readJsonFile(MCP_CACHE_FILE);
const configuredServers = config?.mcpServers && typeof config.mcpServers === "object"
? Object.keys(config.mcpServers)
: [];
const cachedServers = cache?.servers && typeof cache.servers === "object" ? cache.servers : {};
const serverNames = Array.from(new Set([...configuredServers, ...Object.keys(cachedServers)])).sort();
return serverNames.map((serverName) => {
const entry = cachedServers[serverName] || {};
const tools = Array.isArray(entry.tools)
? entry.tools.map((tool: any) => normalizeMcpTool(serverName, tool)).filter((tool: any) => tool.name)
: [];
const resources = Array.isArray(entry.resources) ? entry.resources : [];
return {
name: serverName,
configured: configuredServers.includes(serverName),
cached: Boolean(cachedServers[serverName]),
toolCount: tools.length,
resourceCount: resources.length,
cachedAt: entry.cachedAt ? new Date(entry.cachedAt).toISOString() : "",
tools: tools.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name))),
};
});
}
function sortSessionSummaries(
summaries: Array<Record<string, unknown>>,
pinnedPaths: string[],
): Array<Record<string, unknown>> {
const pinnedOrder = new Map(pinnedPaths.map((path, index) => [path, index]));
return [...summaries].sort((a, b) => {
const aPath = String(a.path);
const bPath = String(b.path);
const aPin = pinnedOrder.get(aPath);
const bPin = pinnedOrder.get(bPath);
if (aPin !== undefined && bPin !== undefined) return aPin - bPin;
if (aPin !== undefined) return -1;
if (bPin !== undefined) return 1;
return new Date(String(b.modified || b.created || 0)).getTime() - new Date(String(a.modified || a.created || 0)).getTime();
});
}
function annotatePinnedSessions(
summaries: Array<Record<string, unknown>>,
pinnedPaths: string[],
): Array<Record<string, unknown>> {
const pinnedSet = new Set(pinnedPaths);
return summaries.map((summary) => ({
...summary,
pinned: pinnedSet.has(String(summary.path)),
}));
}
function buildSessionListResponse() {
const summaries = listSessionFiles()
.map(readSessionSummary)
.filter(Boolean) as Array<Record<string, unknown>>;
const pinnedPaths = prunePinnedSessionPaths(summaries.map((summary) => String(summary.path)));
const sorted = sortSessionSummaries(summaries, pinnedPaths);
return {
sessions: annotatePinnedSessions(sorted, pinnedPaths),
pinnedPaths,
};
}
// ─── HTTP 服务 ─────────────────────────────────────────────────────
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".woff2": "font/woff2",
".woff": "font/woff",
".ico": "image/x-icon",
".webmanifest": "application/manifest+json",
".json": "application/json",
};
function serveStatic(urlPath: string, res: any, spaFallback = false): void {
const file = urlPath === "/" ? "/index.html" : urlPath;
const full = join(PUBLIC_DIR, file);
if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end("Forbidden"); return; }
if (!existsSync(full)) {
if (spaFallback && !urlPath.includes(".")) {
return serveStatic("/index.html", res, false);
}
res.writeHead(404); res.end("Not found"); return;
}
const ext = file.match(/\.\w+$/)?.[0] || ".html";
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
res.end(readFileSync(full));
}
function json(res: any, data: unknown, status = 200): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
function readBody(req: any): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (c: Buffer) => (body += c.toString()));
req.on("end", () => { try { resolve(JSON.parse(body)); } catch { reject(new Error("无效 JSON")); } });
req.on("error", reject);
});
}
const server = createServer((req, res) => {
const url = new URL(req.url!, `http://localhost:${PORT}`);
// 静态文件
if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
return serveStatic(url.pathname, res, true);
}
// POST /api/chat
if (req.method === "POST" && url.pathname === "/api/chat") {
return readBody(req)
.then(({ message, images }) => {
const msg = typeof message === "string" ? message : "";
const imgs = normalizeChatImages(images);
if (!msg.trim() && !imgs?.length) throw new Error("消息不能为空");
if (imgs?.length) console.log(`[webui] chat: ${imgs.length} image(s), message=${msg.length} chars`);
return sendCmd({ type: "prompt", message: msg, images: imgs }).then(() => json(res, { ok: true }));
})
.catch((err) => json(res, { error: err.message }, 500));
}
// GET /api/events (SSE)
if (req.method === "GET" && url.pathname === "/api/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.write('data: {"type":"connected"}\n\n');
sseClients.add(res);
req.on("close", () => sseClients.delete(res));
return;
}
// POST /api/messages
if (req.method === "POST" && url.pathname === "/api/messages") {
return sendCmd({ type: "get_messages" })
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/new-session
if (req.method === "POST" && url.pathname === "/api/new-session") {
return sendCmd({ type: "new_session" })
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/abort
if (req.method === "POST" && url.pathname === "/api/abort") {
return sendCmd({ type: "abort" })
.then(() => json(res, { ok: true }))
.catch((err) => json(res, { error: err.message }, 500));
}
// GET /api/models
if (req.method === "GET" && url.pathname === "/api/models") {
return sendCmd({ type: "get_available_models" })
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/model
if (req.method === "POST" && url.pathname === "/api/model") {
return readBody(req)
.then(({ provider, modelId }) =>
sendCmd({ type: "set_model", provider, modelId }).then((r: any) =>
r.success ? json(res, { model: r.data }) : json(res, { error: r.error }, 500),
),
)
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/thinking
if (req.method === "POST" && url.pathname === "/api/thinking") {
return readBody(req)
.then(({ level }) =>
sendCmd({ type: "set_thinking_level", level }).then((r: any) =>
r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500),
),
)
.catch((err) => json(res, { error: err.message }, 500));
}
// GET /api/avatars
if (req.method === "GET" && url.pathname === "/api/avatars") {
return json(res, readWebuiAvatarSettings());
}
// GET /api/webui/config
if (req.method === "GET" && url.pathname === "/api/webui/config") {
const key = url.searchParams.get("key");
if (key) {
const normalized = normalizeConfigKey(key);
return json(res, { key: normalized, value: getWebuiConfig(normalized) });
}
return json(res, { config: getAllWebuiConfig(), dbPath: webuiDb.dbPath });
}
// POST /api/webui/config
if (req.method === "POST" && url.pathname === "/api/webui/config") {
return readBody(req)
.then(({ key, value, entries }) => {
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
const normalized: Record<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(entries as Record<string, unknown>)) {
normalized[normalizeConfigKey(rawKey)] = normalizeConfigValue(rawValue);
}
setWebuiConfigMany(normalized);
return json(res, { ok: true, config: getAllWebuiConfig() });
}
if (typeof key !== "string") throw new Error("缺少配置键 key");
const normalizedKey = normalizeConfigKey(key);
const normalizedValue = normalizeConfigValue(value);
setWebuiConfig(normalizedKey, normalizedValue);
return json(res, { ok: true, key: normalizedKey, value: normalizedValue });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/webui/config/delete
if (req.method === "POST" && url.pathname === "/api/webui/config/delete") {
return readBody(req)
.then(({ key }) => {
const normalized = normalizeConfigKey(key);
const deleted = deleteWebuiConfig(normalized);
json(res, { ok: true, deleted, key: normalized });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// GET /api/settings
if (req.method === "GET" && url.pathname === "/api/settings") {
return Promise.all([listLoadedSkills(), listLoadedExtensions()])
.then(([skills, extensions]) => json(res, {
...readWebuiAvatarSettings(),
webuiDbPath: webuiDb.dbPath,
systemPrompt: readSystemPrompt(),
systemPromptPath: SYSTEM_PROMPT_FILE,
extensionsPath: AGENT_EXTENSIONS_DIR,
mcpCachePath: MCP_CACHE_FILE,
mcpConfigPath: MCP_CONFIG_FILE,
skills,
extensions,
mcpTools: listMcpTools(),
}))
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/settings/reload
if (req.method === "POST" && url.pathname === "/api/settings/reload") {
return sendCmd({ type: "reload" })
.then((r: any) => (r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/settings/system-prompt
if (req.method === "POST" && url.pathname === "/api/settings/system-prompt") {
return readBody(req)
.then(async ({ systemPrompt }) => {
if (typeof systemPrompt !== "string") {
throw new Error("systemPrompt 必须是字符串");
}
writeSystemPrompt(systemPrompt);
const reload = await sendCmd({ type: "reload" });
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
json(res, { ok: true, systemPromptPath: SYSTEM_PROMPT_FILE });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/settings/avatars
if (req.method === "POST" && url.pathname === "/api/settings/avatars") {
return readBody(req)
.then(({ userAvatarUrl, agentAvatarUrl }) => {
const settings = {
userAvatarUrl: normalizeAvatarUrl(userAvatarUrl),
agentAvatarUrl: normalizeAvatarUrl(agentAvatarUrl),
};
writeWebuiAvatarSettings(settings);
json(res, { ok: true, ...settings });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// GET /api/sessions
if (req.method === "GET" && url.pathname === "/api/sessions") {
return json(res, buildSessionListResponse());
}
// POST /api/sessions/history
if (req.method === "POST" && url.pathname === "/api/sessions/history") {
return readBody(req)
.then(({ path: sp }) => {
const messages = readSessionMessages(sp as string);
const summary = readSessionSummary(sp as string);
json(res, { messages, session: summary });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/sessions/delete
if (req.method === "POST" && url.pathname === "/api/sessions/delete") {
return readBody(req)
.then(({ path: sp }) => {
const sessionPath = resolveSessionFile(sp as string);
if (!existsSync(sessionPath)) throw new Error("会话不存在");
unlinkSync(sessionPath);
removePinnedSessionPath(sessionPath);
json(res, { ok: true });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/sessions/pin
if (req.method === "POST" && url.pathname === "/api/sessions/pin") {
return readBody(req)
.then(({ path: sp, pinned }) => {
const sessionPath = resolveSessionFile(sp as string);
if (!existsSync(sessionPath)) throw new Error("会话不存在");
const pinnedPaths = setSessionPinned(sessionPath, Boolean(pinned));
json(res, { ok: true, path: sessionPath, pinned: Boolean(pinned), pinnedPaths });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/sessions/load
if (req.method === "POST" && url.pathname === "/api/sessions/load") {
return readBody(req)
.then(async ({ path: sp }) => {
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
if (!sw.success) throw new Error(sw.error);
const mr = await sendCmd({ type: "get_messages" });
if (!mr.success) throw new Error(mr.error);
const summary = readSessionSummary(sp as string);
json(res, { messages: mr.data.messages, session: summary });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/sessions/activate
if (req.method === "POST" && url.pathname === "/api/sessions/activate") {
return readBody(req)
.then(async ({ path: sp }) => {
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
if (!sw.success) throw new Error(sw.error);
json(res, { ok: true });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// POST /api/sessions/name
if (req.method === "POST" && url.pathname === "/api/sessions/name") {
return readBody(req)
.then(async ({ path: sp, name }) => {
if (typeof sp !== "string" || !sp.trim()) throw new Error("无效会话路径");
const savedName = appendSessionName(sp, typeof name === "string" ? name : "");
const sessionPath = resolveSessionFile(sp);
const state = await sendCmd({ type: "get_state" });
if (state.success && state.data?.sessionFile === sessionPath) {
const rename = await sendCmd({ type: "set_session_name", name: savedName });
if (!rename.success) throw new Error(rename.error || "同步当前会话名称失败");
}
json(res, { ok: true, name: savedName });
})
.catch((err) => json(res, { error: err.message }, 500));
}
// GET /api/session-state
if (req.method === "GET" && url.pathname === "/api/session-state") {
return sendCmd({ type: "get_state" })
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
.catch((err) => json(res, { error: err.message }, 500));
}
res.writeHead(404);
res.end("Not found");
});
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
console.error(`[webui] 端口 ${PORT} 已被占用`);
} else {
console.error(`[webui] HTTP 服务启动失败: ${err.message}`);
}
process.exit(1);
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`[webui] HTTP 服务已启动: http://localhost:${PORT}`);
console.log(`[webui] 局域网访问: http://smallmengya:${PORT}`);
});