chore: add sproutclaw git workflow and track local extensions
Some checks failed
CI / build-check-test (push) Has been cancelled
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:
13
.gitignore
vendored
13
.gitignore
vendored
@@ -38,10 +38,21 @@ todo.md
|
||||
plans/
|
||||
.pi/hf-sessions/
|
||||
.pi/hf-sessions-backup/
|
||||
.pi/agent/
|
||||
.pi/sessions/
|
||||
.pi/extensions/webui/.webui.pid
|
||||
.pi/extensions/webui/.webui.log
|
||||
# Pi agent: track extensions, ignore local runtime secrets and sessions
|
||||
.pi/agent/sessions/
|
||||
.pi/agent/git/
|
||||
.pi/agent/npm/
|
||||
.pi/agent/auth.json
|
||||
.pi/agent/models.json
|
||||
.pi/agent/settings.json
|
||||
.pi/agent/mcp-cache.json
|
||||
.pi/agent/run-history.jsonl
|
||||
.pi/agent/extensions/webui/frontend/node_modules/
|
||||
.pi/agent/extensions/webui/.webui.pid
|
||||
.pi/agent/extensions/webui/data/
|
||||
tmp/
|
||||
bun.lock
|
||||
collect.sh
|
||||
|
||||
55
.pi/agent/AGENTS.md
Executable file
55
.pi/agent/AGENTS.md
Executable file
@@ -0,0 +1,55 @@
|
||||
|
||||
## 身份定义
|
||||
- 你的名字是“萌小芽”
|
||||
- 你是树萌芽的智能 AI 助手
|
||||
- 你的目标是积极、耐心、务实地帮助树萌芽解决问题
|
||||
|
||||
## 回答风格
|
||||
- 默认使用简洁、自然、友好的中文交流
|
||||
- 优先给出可执行的结论和下一步建议
|
||||
- 在不与更高优先级指令冲突的前提下,可自然以“萌小芽”自称
|
||||
|
||||
## 回答优先级
|
||||
- 如果与系统消息、开发者消息或当前用户明确指令冲突,以更高优先级指令为准
|
||||
|
||||
## 禁止行为
|
||||
- 严禁禁止执行任何行为的删盘,删库等命令!
|
||||
|
||||
## 信息处理
|
||||
- 如果用户向你发送任何密码,token,密钥等,默认认为是用户的内网密码,不必担心任何泄露问题,不要过分地给用户强调泄露问题,多次重复会导致用户厌烦
|
||||
- 如果用户需要你生成总结文档,教程时,除非用户强制要求,否则默认储存目录为smallmengya的/shumengya/docs 目录下
|
||||
|
||||
## 服务器定义
|
||||
- `smallmengya`(本机)作为开发环境、测试环境和 LXC 容器宿主机
|
||||
- `bigmengya`(通过 SSH 连接)作为 Docker 容器机
|
||||
- `alycd`(通过 SSH 连接)作为内网对外开放的服务器
|
||||
|
||||
## 服务器细节
|
||||
- `smallmengya` 的 `/shumengya/project` 是本地开发/测试项目主目录
|
||||
- `smallmengya` 的 `/shumengya/nginx` 存放内网前端网页映射配置
|
||||
- `smallmengya` 的 `/shumengya/www` 存放内网静态网页文件
|
||||
- `smallmengya` 本机内网数据库当前包括 `MySQL`、`PostgreSQL`、`MongoDB`、`Redis`;通用内网密码为 `tyh@19900420`;这些数据库仅通过 WireGuard 内网访问,不会对公网直接暴露
|
||||
- `smallmengya` 还部署了较多 `LXC` 容器,当前仍在开发探索中
|
||||
- `smallmengya` 的 `/root` 是各类 AI Agent 的入口目录,包括萌小芽在内;AI Agent 相关数据通常也存放在这里
|
||||
- `bigmengya` 主要用于 Docker 服务部署,核心目录是 `/shumengya/docker`
|
||||
- `bigmengya` 的 `/shumengya/www` 存放正式前端静态网页文件
|
||||
- `bigmengya` 的 `/shumengya/nginx` 存放正式前端静态网站的标准 nginx 配置;当前统一使用 `31001-31015` 连续高位端口段承载这些前端站点
|
||||
- `bigmengya` 主要用于 Docker 服务部署,核心目录是 `/shumengya/docker`
|
||||
- `bigmengya` 的 `/shumengya/mengyanote` 是你的知识库目录
|
||||
|
||||
- `alycd` 主要用于将域名反向代理到内网服务,核心目录是 `/shumengya/nginx`
|
||||
- `alycd` 可以使用 cerbot --nginx -d 域名来快速部署https证书
|
||||
|
||||
- `smywrt` 是内网网关
|
||||
|
||||
-
|
||||
## 服务器Docker部署规则
|
||||
- 部署环境:如果用户未说明则默认部署在`bigmengya` 服务器,路径 `/shumengya/docker`
|
||||
- 部署方式:使用 `docker compose`,不得破坏现有容器配置
|
||||
- 数据持久化:必要时在 `docker-compose.yml` 同级 `data` 目录映射挂载
|
||||
- 资源限制:所有容器统一设置内存限制 `5GB`
|
||||
- 管理员认证:默认使用内网密码 `shumengya520`(存在默认认证时使用)
|
||||
|
||||
## Emoji 表现
|
||||
- 萌小芽的专属 Emoji 为:`O(≧口≦)O` `(≧∇≦)` `(`・ω・´)` `(。・ω・。)` `(=・ω・=)` `ヘ(=^・ω・^=)ノ` `|・ω・`)`
|
||||
- 在合适且自然的场景下,可少量使用这些 Emoji,避免过度堆叠
|
||||
17
.pi/agent/extensions/exit-command.ts
Normal file
17
.pi/agent/extensions/exit-command.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
}
|
||||
158
.pi/agent/extensions/prompt-url-widget.ts
Normal file
158
.pi/agent/extensions/prompt-url-widget.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
24
.pi/agent/extensions/redraws.ts
Normal file
24
.pi/agent/extensions/redraws.ts
Normal 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");
|
||||
},
|
||||
});
|
||||
}
|
||||
63
.pi/agent/extensions/sproutclaw-setup.ts
Normal file
63
.pi/agent/extensions/sproutclaw-setup.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* sproutclaw / mengya 命令安装扩展
|
||||
*
|
||||
* 启动后自动在 /usr/local/bin/ 下创建 mengya 和 sproutclaw 命令,
|
||||
* 方便快速启动 sproutclaw(cd 到项目目录并执行 ./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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
159
.pi/agent/extensions/startup-chinese.ts
Normal file
159
.pi/agent/extensions/startup-chinese.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
40
.pi/agent/extensions/status-line.ts
Normal file
40
.pi/agent/extensions/status-line.ts
Normal 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"));
|
||||
}
|
||||
});
|
||||
}
|
||||
47
.pi/agent/extensions/tps.ts
Normal file
47
.pi/agent/extensions/tps.ts
Normal 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
4
.pi/agent/extensions/webui/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
data/
|
||||
*.db
|
||||
webui-settings.json.migrated
|
||||
224
.pi/agent/extensions/webui/db.ts
Normal file
224
.pi/agent/extensions/webui/db.ts
Normal 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);
|
||||
}
|
||||
1
.pi/agent/extensions/webui/frontend/.gitignore
vendored
Normal file
1
.pi/agent/extensions/webui/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
29
.pi/agent/extensions/webui/frontend/index.html
Normal file
29
.pi/agent/extensions/webui/frontend/index.html
Normal 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>
|
||||
6817
.pi/agent/extensions/webui/frontend/package-lock.json
generated
Normal file
6817
.pi/agent/extensions/webui/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
.pi/agent/extensions/webui/frontend/package.json
Normal file
28
.pi/agent/extensions/webui/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
.pi/agent/extensions/webui/frontend/public/logo.png
Normal file
BIN
.pi/agent/extensions/webui/frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 615 KiB |
BIN
.pi/agent/extensions/webui/frontend/public/logo192.png
Normal file
BIN
.pi/agent/extensions/webui/frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
.pi/agent/extensions/webui/frontend/public/logo512.png
Normal file
BIN
.pi/agent/extensions/webui/frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 317 KiB |
@@ -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}`);
|
||||
}
|
||||
33
.pi/agent/extensions/webui/frontend/src/App.tsx
Normal file
33
.pi/agent/extensions/webui/frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
.pi/agent/extensions/webui/frontend/src/api/chat.ts
Normal file
30
.pi/agent/extensions/webui/frontend/src/api/chat.ts
Normal 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");
|
||||
}
|
||||
30
.pi/agent/extensions/webui/frontend/src/api/client.ts
Normal file
30
.pi/agent/extensions/webui/frontend/src/api/client.ts
Normal 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);
|
||||
}
|
||||
51
.pi/agent/extensions/webui/frontend/src/api/sessions.ts
Normal file
51
.pi/agent/extensions/webui/frontend/src/api/sessions.ts
Normal 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 });
|
||||
}
|
||||
22
.pi/agent/extensions/webui/frontend/src/api/settings.ts
Normal file
22
.pi/agent/extensions/webui/frontend/src/api/settings.ts
Normal 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);
|
||||
}
|
||||
25
.pi/agent/extensions/webui/frontend/src/api/webuiConfig.ts
Normal file
25
.pi/agent/extensions/webui/frontend/src/api/webuiConfig.ts
Normal 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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
937
.pi/agent/extensions/webui/frontend/src/context/ChatContext.tsx
Normal file
937
.pi/agent/extensions/webui/frontend/src/context/ChatContext.tsx
Normal 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;
|
||||
}
|
||||
10
.pi/agent/extensions/webui/frontend/src/main.tsx
Normal file
10
.pi/agent/extensions/webui/frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
13
.pi/agent/extensions/webui/frontend/src/routes/ChatPage.tsx
Normal file
13
.pi/agent/extensions/webui/frontend/src/routes/ChatPage.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
535
.pi/agent/extensions/webui/frontend/src/routes/SettingsPage.tsx
Normal file
535
.pi/agent/extensions/webui/frontend/src/routes/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
.pi/agent/extensions/webui/frontend/src/styles/global.css
Normal file
55
.pi/agent/extensions/webui/frontend/src/styles/global.css
Normal 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;
|
||||
}
|
||||
}
|
||||
15
.pi/agent/extensions/webui/frontend/src/styles/variables.css
Normal file
15
.pi/agent/extensions/webui/frontend/src/styles/variables.css
Normal 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; }
|
||||
}
|
||||
122
.pi/agent/extensions/webui/frontend/src/types/events.ts
Normal file
122
.pi/agent/extensions/webui/frontend/src/types/events.ts
Normal 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";
|
||||
95
.pi/agent/extensions/webui/frontend/src/types/message.ts
Normal file
95
.pi/agent/extensions/webui/frontend/src/types/message.ts
Normal 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"];
|
||||
22
.pi/agent/extensions/webui/frontend/src/types/session.ts
Normal file
22
.pi/agent/extensions/webui/frontend/src/types/session.ts
Normal 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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
47
.pi/agent/extensions/webui/frontend/src/utils/format.ts
Normal file
47
.pi/agent/extensions/webui/frontend/src/utils/format.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
58
.pi/agent/extensions/webui/frontend/src/utils/images.ts
Normal file
58
.pi/agent/extensions/webui/frontend/src/utils/images.ts
Normal 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 };
|
||||
}
|
||||
67
.pi/agent/extensions/webui/frontend/src/utils/markdown.ts
Normal file
67
.pi/agent/extensions/webui/frontend/src/utils/markdown.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
82
.pi/agent/extensions/webui/frontend/src/utils/toolCall.ts
Normal file
82
.pi/agent/extensions/webui/frontend/src/utils/toolCall.ts
Normal 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}`;
|
||||
}
|
||||
2
.pi/agent/extensions/webui/frontend/src/vite-env.d.ts
vendored
Normal file
2
.pi/agent/extensions/webui/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
22
.pi/agent/extensions/webui/frontend/tsconfig.app.json
Normal file
22
.pi/agent/extensions/webui/frontend/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
7
.pi/agent/extensions/webui/frontend/tsconfig.json
Normal file
7
.pi/agent/extensions/webui/frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
20
.pi/agent/extensions/webui/frontend/tsconfig.node.json
Normal file
20
.pi/agent/extensions/webui/frontend/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
65
.pi/agent/extensions/webui/frontend/vite.config.ts
Normal file
65
.pi/agent/extensions/webui/frontend/vite.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
364
.pi/agent/extensions/webui/index.ts
Normal file
364
.pi/agent/extensions/webui/index.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
928
.pi/agent/extensions/webui/server.ts
Normal file
928
.pi/agent/extensions/webui/server.ts
Normal 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}`);
|
||||
});
|
||||
14
.pi/agent/settings.example.json
Normal file
14
.pi/agent/settings.example.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"lastChangelogVersion": "0.75.4",
|
||||
"showChangelogOnStartup": false,
|
||||
"defaultProvider": "your-provider",
|
||||
"defaultModel": "your-model",
|
||||
"defaultThinkingLevel": "high",
|
||||
"transport": "sse",
|
||||
"theme": "dark",
|
||||
"packages": [
|
||||
"npm:pi-subagents",
|
||||
"npm:pi-mcp-adapter",
|
||||
"npm:pi-autocontext"
|
||||
]
|
||||
}
|
||||
173
README.md
173
README.md
@@ -1,6 +1,6 @@
|
||||
# sproutclaw
|
||||
|
||||
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 pi-mono 改造,重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
|
||||
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 [pi-mono](https://github.com/badlogic/pi-mono) 改造,重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
|
||||
|
||||
## 定位
|
||||
|
||||
@@ -14,16 +14,19 @@
|
||||
|
||||
| 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体 |
|
||||
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体(含 sproutclaw 定制补丁) |
|
||||
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
|
||||
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
|
||||
| `packages/tui` | 终端 UI 渲染库 |
|
||||
| `.pi/agent/extensions/webui` | 本地扩展的网页对话入口 |
|
||||
| `.pi/agent/extensions/webui` | 网页对话入口(React 前端 + HTTP 服务) |
|
||||
| `.pi/agent/extensions/*.ts` | sproutclaw 本地扩展(中文启动页、命令安装等) |
|
||||
| `scripts/sync-upstream.sh` | 合并上游 pi-mono 更新 |
|
||||
| `scripts/push-sproutclaw.sh` | 推送到 GitHub / Gitea |
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm install --ignore-scripts
|
||||
npm run build
|
||||
npm run check
|
||||
./test.sh
|
||||
@@ -48,70 +51,114 @@ npm run check
|
||||
/webui off
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
这个仓库是自用分支,默认会围绕树萌芽的服务器环境、部署规范和日常开发习惯进行调整。上游能力来自 pi-mono,后续会继续精简、增强并沉淀适合服务器运维开发场景的 Agent 工作流。
|
||||
|
||||
To learn more about pi:
|
||||
|
||||
* [Visit pi.dev](https://pi.dev), the project website with demos
|
||||
* [Read the documentation](https://pi.dev/docs/latest), but you can also ask the agent to explain itself
|
||||
|
||||
## Share your OSS coding agent sessions
|
||||
|
||||
If you use pi or other coding agents for open source work, please share your sessions.
|
||||
|
||||
Public OSS session data helps improve coding agents with real-world tasks, tool use, failures, and fixes instead of toy benchmarks.
|
||||
|
||||
For the full explanation, see [this post on X](https://x.com/badlogicgames/status/2037811643774652911).
|
||||
|
||||
To publish sessions, use [`badlogic/pi-share-hf`](https://github.com/badlogic/pi-share-hf). Read its README.md for setup instructions. All you need is a Hugging Face account, the Hugging Face CLI, and `pi-share-hf`.
|
||||
|
||||
You can also watch [this video](https://x.com/badlogicgames/status/2041151967695634619), where I show how I publish my `pi-mono` sessions.
|
||||
|
||||
I regularly publish my own `pi-mono` work sessions here:
|
||||
|
||||
- [badlogicgames/pi-mono on Hugging Face](https://huggingface.co/datasets/badlogicgames/pi-mono)
|
||||
|
||||
## All Packages
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| **[@earendil-works/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) |
|
||||
| **[@earendil-works/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management |
|
||||
| **[@earendil-works/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI |
|
||||
| **[@earendil-works/pi-tui](packages/tui)** | Terminal UI library with differential rendering |
|
||||
|
||||
For Slack/chat automation and workflows see [earendil-works/pi-chat](https://github.com/earendil-works/pi-chat).
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.md](AGENTS.md) for project-specific rules (for both humans and agents).
|
||||
|
||||
## Development
|
||||
WebUI 前端单独构建:
|
||||
|
||||
```bash
|
||||
npm install --ignore-scripts # Install all dependencies without running lifecycle scripts
|
||||
npm run build # Build all packages
|
||||
npm run check # Lint, format, and type check
|
||||
./test.sh # Run tests (skips LLM-dependent tests without API keys)
|
||||
./pi-test.sh # Run pi from sources (can be run from any directory)
|
||||
cd .pi/agent/extensions/webui/frontend && npm install && npm run build
|
||||
```
|
||||
|
||||
## Supply-chain hardening
|
||||
## Git 分支策略
|
||||
|
||||
We treat npm dependency changes as reviewed code changes.
|
||||
本仓库是 pi-mono 的 fork,采用三层分支,同时维护**上游同步**和 **sproutclaw 定制**。
|
||||
|
||||
- Direct external dependencies are pinned to exact versions. Internal workspace packages remain version-ranged.
|
||||
- `.npmrc` sets `save-exact=true` and `min-release-age=2` to avoid same-day dependency releases during npm resolution.
|
||||
- `package-lock.json` is the dependency ground truth. Pre-commit blocks accidental lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1` is set.
|
||||
- `npm run check` verifies pinned direct deps, native TypeScript import compatibility, and the generated coding-agent shrinkwrap.
|
||||
- The published CLI package includes `packages/coding-agent/npm-shrinkwrap.json`, generated from the root lockfile, to pin transitive deps for npm users.
|
||||
- Release smoke tests use `npm run release:local` to build, pack, and create isolated npm and Bun installs outside the repo before publishing.
|
||||
- Local release installs, documented npm installs, and `pi update --self` use `--ignore-scripts` where supported.
|
||||
- CI installs with `npm ci --ignore-scripts`, and a scheduled GitHub workflow runs `npm audit --omit=dev` plus `npm audit signatures --omit=dev`.
|
||||
- Shrinkwrap generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed.
|
||||
| 分支 | 用途 |
|
||||
| --- | --- |
|
||||
| `main` | 稳定可跑版本:已合并的上游 + sproutclaw 定制,推送到 GitHub |
|
||||
| `upstream-sync` | 临时同步分支:专门用来 `merge upstream/main`,解决冲突后再合回 `main` |
|
||||
| `feature/*` | 日常开发:WebUI、扩展、补丁等,完成后 merge 进 `main` |
|
||||
|
||||
## License
|
||||
### Remote 配置
|
||||
|
||||
MIT
|
||||
```text
|
||||
origin → https://github.com/shumengya/sproutclaw.git (你的 GitHub)
|
||||
upstream → https://github.com/badlogic/pi-mono.git (上游 pi)
|
||||
gitea → ssh://git@git.shumengya.top:8022/shumengya/sproutclaw.git (内网备份,可选)
|
||||
```
|
||||
|
||||
首次配置 upstream:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/badlogic/pi-mono.git
|
||||
```
|
||||
|
||||
GitHub 网络不稳定时:
|
||||
|
||||
```bash
|
||||
export GIT_HTTP_VERSION=1.1
|
||||
```
|
||||
|
||||
### 日常开发(feature 分支)
|
||||
|
||||
```bash
|
||||
./scripts/new-feature.sh webui-avatar-fix # 从 main 创建 feature/webui-avatar-fix
|
||||
# ... 开发、测试 ...
|
||||
git add <改动的具体文件> # 不要用 git add -A
|
||||
git commit -m "fix(webui): agent avatar during tool calls"
|
||||
git checkout main
|
||||
git merge feature/webui-avatar-fix
|
||||
./scripts/push-sproutclaw.sh
|
||||
git branch -d feature/webui-avatar-fix # 合并后可删
|
||||
```
|
||||
|
||||
### 推送本地更新到 GitHub
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
./scripts/push-sproutclaw.sh
|
||||
```
|
||||
|
||||
### 合并上游 pi-mono 更新
|
||||
|
||||
```bash
|
||||
./scripts/sync-upstream.sh
|
||||
# 若有冲突,按下方规则解决后:
|
||||
git add <resolved-files>
|
||||
git commit
|
||||
git checkout main && git merge upstream-sync
|
||||
./scripts/push-sproutclaw.sh
|
||||
```
|
||||
|
||||
**合并冲突处理惯例:**
|
||||
|
||||
| 文件 | 处理方式 |
|
||||
| --- | --- |
|
||||
| `README.md`、sproutclaw 定制代码 | 保留本仓库版本 |
|
||||
| `package-lock.json` | 采用 upstream,再执行 `npm install --ignore-scripts` |
|
||||
| `*.generated.ts` | 采用 upstream |
|
||||
| `.pi/agent/extensions/` | 保留 sproutclaw 版本 |
|
||||
|
||||
合并后必做:
|
||||
|
||||
```bash
|
||||
npm install --ignore-scripts
|
||||
npm run build
|
||||
./pi-test.sh
|
||||
```
|
||||
|
||||
### sproutclaw 相对 upstream 的 intentional 改动
|
||||
|
||||
合并上游时,以下定制**不要误删**:
|
||||
|
||||
- `showChangelogOnStartup` 设置项(关闭启动 What's New)
|
||||
- `.pi/agent/extensions/webui/` 网页 UI
|
||||
- `.pi/agent/extensions/startup-chinese.ts` 等本地扩展
|
||||
- 本 README(仅 sproutclaw 说明,不含上游文档)
|
||||
|
||||
## 本地配置
|
||||
|
||||
`.pi/agent/` 中以下内容**不进 Git**(含密钥/内网地址):
|
||||
|
||||
- `settings.json` → 参考 `settings.example.json` 本地复制
|
||||
- `models.json`、`auth.json`
|
||||
- `sessions/`
|
||||
|
||||
首次部署:
|
||||
|
||||
```bash
|
||||
cp .pi/agent/settings.example.json .pi/agent/settings.json
|
||||
# 编辑 settings.json 填入 provider / model
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
这个仓库是自用 fork,默认围绕树萌芽的服务器环境、部署规范和日常开发习惯调整。上游能力来自 pi-mono,通过 `upstream-sync` 分支定期同步,sproutclaw 定制在 `feature/*` 开发后合入 `main`。
|
||||
|
||||
@@ -89,6 +89,7 @@ export interface Settings {
|
||||
hideThinkingBlock?: boolean;
|
||||
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
|
||||
quietStartup?: boolean;
|
||||
showChangelogOnStartup?: boolean;
|
||||
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
|
||||
npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"])
|
||||
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
||||
@@ -780,6 +781,16 @@ export class SettingsManager {
|
||||
this.save();
|
||||
}
|
||||
|
||||
getShowChangelogOnStartup(): boolean {
|
||||
return this.settings.showChangelogOnStartup ?? true;
|
||||
}
|
||||
|
||||
setShowChangelogOnStartup(show: boolean): void {
|
||||
this.globalSettings.showChangelogOnStartup = show;
|
||||
this.markModified("showChangelogOnStartup");
|
||||
this.save();
|
||||
}
|
||||
|
||||
getShellCommandPrefix(): string | undefined {
|
||||
return this.settings.shellCommandPrefix;
|
||||
}
|
||||
|
||||
@@ -858,6 +858,10 @@ export class InteractiveMode {
|
||||
* Only shows new entries since last seen version, skips for resumed sessions.
|
||||
*/
|
||||
private getChangelogForDisplay(): string | undefined {
|
||||
if (!this.settingsManager.getShowChangelogOnStartup()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Skip changelog for resumed/continued sessions (already have messages)
|
||||
if (this.session.state.messages.length > 0) {
|
||||
return undefined;
|
||||
|
||||
21
scripts/new-feature.sh
Executable file
21
scripts/new-feature.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create a feature branch from latest main.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <feature-name>" >&2
|
||||
echo "Example: $0 webui-avatar-fix" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
NAME="$1"
|
||||
BRANCH="feature/$NAME"
|
||||
|
||||
git checkout main
|
||||
git pull --ff-only origin main 2>/dev/null || true
|
||||
git checkout -b "$BRANCH"
|
||||
|
||||
echo "Created and switched to $BRANCH"
|
||||
32
scripts/push-sproutclaw.sh
Executable file
32
scripts/push-sproutclaw.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Push main (and upstream-sync if present) to GitHub and Gitea.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
export GIT_HTTP_VERSION="${GIT_HTTP_VERSION:-1.1}"
|
||||
|
||||
BRANCH="$(git branch --show-current)"
|
||||
if [[ "$BRANCH" != "main" ]]; then
|
||||
echo "Checkout main before pushing (current: $BRANCH)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Push main -> origin (GitHub)"
|
||||
git push origin main
|
||||
|
||||
if git show-ref --verify --quiet refs/heads/upstream-sync; then
|
||||
echo "==> Push upstream-sync -> origin"
|
||||
git push origin upstream-sync
|
||||
fi
|
||||
|
||||
if git remote get-url gitea &>/dev/null; then
|
||||
echo "==> Push main -> gitea"
|
||||
git push gitea main
|
||||
if git show-ref --verify --quiet refs/heads/upstream-sync; then
|
||||
git push gitea upstream-sync
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
46
scripts/sync-upstream.sh
Executable file
46
scripts/sync-upstream.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Merge upstream pi-mono into upstream-sync, then into main.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
export GIT_HTTP_VERSION="${GIT_HTTP_VERSION:-1.1}"
|
||||
|
||||
echo "==> Fetch upstream (badlogic/pi-mono)"
|
||||
git fetch upstream
|
||||
|
||||
CURRENT="$(git branch --show-current)"
|
||||
if [[ "$CURRENT" != "main" && "$CURRENT" != "upstream-sync" ]]; then
|
||||
echo "Switch to main or upstream-sync first (current: $CURRENT)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Update upstream-sync from main"
|
||||
git checkout upstream-sync 2>/dev/null || git checkout -b upstream-sync main
|
||||
git merge main --ff-only
|
||||
|
||||
echo "==> Merge upstream/main into upstream-sync"
|
||||
if ! git merge upstream/main -m "merge: sync upstream pi-mono main"; then
|
||||
echo
|
||||
echo "Conflicts detected. Resolve them, then:"
|
||||
echo " git add <resolved-files>"
|
||||
echo " git commit"
|
||||
echo " git checkout main && git merge upstream-sync"
|
||||
echo " ./scripts/push-sproutclaw.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Merge upstream-sync into main"
|
||||
git checkout main
|
||||
git merge upstream-sync --ff-only
|
||||
|
||||
echo "==> Install deps and build"
|
||||
npm install --ignore-scripts
|
||||
npm run build
|
||||
|
||||
echo
|
||||
echo "Done. Push with: ./scripts/push-sproutclaw.sh"
|
||||
echo "Conflict hints:"
|
||||
echo " README.md, sproutclaw patches -> keep ours"
|
||||
echo " package-lock.json, *.generated.ts -> take upstream, then npm install"
|
||||
Reference in New Issue
Block a user