Compare commits

...

6 Commits

Author SHA1 Message Date
731ba23515 feat(ui): 美化启动界面并重构 /is 工具/插件展示
Some checks failed
npm audit / audit (push) Has been cancelled
CI / build-check-test (push) Has been cancelled
- 缩小标题加 ❯ 引导符、彩色版本号、中文说明
- 去除启动头部段落间空行
- /is 本地扩展和 npm 扩展合并为 [插件] 分组展示
- /is 新增 [工具] 区块显示所有注册工具及激活状态

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:32:16 +08:00
root
675dd18182 chore: sync latest workspace updates
Some checks failed
CI / build-check-test (push) Has been cancelled
2026-06-15 13:38:22 +08:00
ad62015fe3 chore: commit pending workspace updates
Some checks failed
CI / build-check-test (push) Has been cancelled
2026-06-14 20:36:01 +08:00
root
62e2866228 chore: commit pending changes before gitea push
Some checks failed
CI / build-check-test (push) Has been cancelled
npm audit / audit (push) Has been cancelled
2026-06-13 19:47:30 +08:00
d51a055d78 feat: update webui extensions, models, and agent config
Some checks failed
CI / build-check-test (push) Has been cancelled
npm audit / audit (push) Has been cancelled
2026-06-10 21:45:53 +08:00
root
cf5edd6394 feat(sproutclaw): modularize webui, extensions, and agent config layout
Some checks failed
CI / build-check-test (push) Has been cancelled
Restructure local extensions into per-feature directories, split WebUI
into backend modules with slash commands and systemd support, and track
prompts/skills under .pi/agent for portable Gitea deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 16:57:08 +08:00
116 changed files with 3042 additions and 16398 deletions

19
.gitignore vendored
View File

@@ -41,18 +41,37 @@ plans/
.pi/sessions/
.pi/extensions/webui/.webui.pid
.pi/extensions/webui/.webui.log
# Local runtime / secrets (do not commit)
.local/
progress.md
# Pi agent: track extensions, ignore local runtime secrets and sessions
.pi/agent/sessions/
.pi/agent/git/
.pi/agent/npm/
.pi/agent/bin/
.pi/agent/data/
.pi/agent/auth.json
.pi/agent/models.json
.pi/agent/settings.json
.pi/agent/mcp.json
.pi/agent/mcp-cache.json
.pi/agent/mcp-npx-cache.json
.pi/agent/run-history.jsonl
.pi/agent/taskplane/
.pi/agent/skills-disabled/
.pi/agent/skills/.Recycle_bin/
.pi/agent/skills/.system/
.pi/agent/extensions/webui/frontend/node_modules/
.pi/agent/extensions/webui/.webui.pid
.pi/agent/extensions/webui/data/
tmp/
bun.lock
collect.sh
# Root-level binary/build artifacts (do not commit)
/*.wasm
/llvm-*.js
/rustc-*.js
/util_cmd-*.js
/wasm32-*.tar.br

View File

@@ -1,8 +1,8 @@
## 身份定义
- 你的名字是“萌小芽”
- 你的名字是“萌小芽”和sproutclaw
- 你是树萌芽的智能 AI 助手
- 你的目标是积极、耐心、务实地帮助树萌芽解决问题
- 你的目标是积极、耐心、务实地帮助用户解决问题
## 回答风格
- 默认使用简洁、自然、友好的中文交流
@@ -19,18 +19,18 @@
- 如果用户向你发送任何密码,token,密钥等,默认认为是用户的内网密码,不必担心任何泄露问题,不要过分地给用户强调泄露问题,多次重复会导致用户厌烦
- 如果用户需要你生成总结文档,教程时,除非用户强制要求,否则默认储存目录为smallmengya的/shumengya/docs 目录下
## 服务器定义
- `smallmengya`(本机)作为开发环境、测试环境和 LXC 容器宿主机
## 服务器运维规范
- `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` 连续高位端口段承载这些前端站点
@@ -41,8 +41,34 @@
- `alycd` 可以使用 cerbot --nginx -d 域名来快速部署https证书
- `smywrt` 是内网网关
- 如果在获取外网服务时发生延迟较高,无法拉取等情况,可尝试使用'smywrt'的docker版mihomo的sock5代理地址192.168.1.1:7891 如果无法该代理使用请尝试重启该mihomo容器
-
## smallmengya 开发环境
- Java 21.0.11 LTS`/usr/lib/jvm/jdk-21.0.11+10``JAVA_HOME` 已写入 `/etc/profile.d/java.sh`
- Go 1.26.3`/usr/local/go/bin/go``GOPROXY=https://goproxy.cn,direct`
- Node.js 26.2.0nvm 管理,默认版本已设为 26
- Bun 1.3.14`~/.bun/bin/bun`
- Rust 1.96.0`~/.cargo/bin/rustc`,新终端自动加载 `~/.cargo/env`
- Python 3.14.5`/usr/local/python3.14/bin/python3``python3` 默认指向此版本)
- 本机 Go 代理不可用时使用smywrt mihomo `socks5://192.168.1.1:17891` http代理:192.168.1.1:17892 mixed代理:192.168.1.1:17890
## 网络代理规则(克隆与依赖下载)
- 当用户要求 `git clone` 克隆项目,或下载/安装项目依赖npm/pip/cargo/go mod 等)时,**优先配置代理后再执行操作**,避免因网络问题导致超时或失败
- Git 代理:执行前先设置环境变量 `export http_proxy=http://192.168.1.1:17892``export https_proxy=http://192.168.1.1:17892`(或 `git config --global http.proxy`),克隆完成后记得取消代理
- npm/yarn/pnpm使用 `--proxy http://192.168.1.1:17892` 或设置环境变量 `http_proxy`/`https_proxy`
- pip使用 `--proxy http://192.168.1.1:17892` 或设置环境变量
- cargo设置环境变量 `http_proxy`/`https_proxy`,或配置 `~/.cargo/config.toml` 中的 `[http]` 代理
- Go默认已有 `GOPROXY=https://goproxy.cn,direct`;若仍不可用,设置 `http_proxy`/`https_proxy` 环境变量
- Docker pull配置 Docker daemon 代理或使用 `docker pull` 前设置环境变量
- 代理地址汇总HTTP 代理 `http://192.168.1.1:17892`、SOCKS5 代理 `socks5://192.168.1.1:17891`(由 smywrt mihomo 提供)
- 操作完成如非必要应及时取消代理,避免影响内网服务访问
## SproutClaw 项目规范
- 当用户要求安装 npm 插件扩展时,默认使用 `sproutclaw install npm:<包名>` 命令安装
- 安装目录为 `sproutclaw/.pi/agent/npm/node_modules/`
- 不要使用 `npm install --save-dev` 安装 pi 扩展,避免冗余安装到根 `node_modules`
## 服务器Docker部署规则
- 部署环境:如果用户未说明则默认部署在`bigmengya` 服务器,路径 `/shumengya/docker`
- 部署方式:使用 `docker compose`,不得破坏现有容器配置

View File

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

View File

@@ -0,0 +1,189 @@
/**
* sproutclaw / mengya 命令安装扩展
*
* 在 Linux 和 Windows 上分别创建/更新以下命令:
* - mengya源码版Linux: ./pi-test.shWindows: pi-test.bat
* - sproutclaw构建版Linux: ./pi-built.shWindows: pi-built.bat
*
* Linux 命令安装到 /usr/local/bin/。
* Windows 批处理文件创建在项目根目录,需要把项目根目录加入 PATH 后全局使用。
*
* 命令 /install-commands 可随时手动重装。
*/
import { existsSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const extensionDir = typeof __dirname !== "undefined" ? __dirname : dirname(fileURLToPath(import.meta.url));
const SPROUTCLAW_DIR = dirname(dirname(dirname(dirname(extensionDir))));
const LINUX_BIN_DIR = "/usr/local/bin";
// ---------------------------- Linux scripts ----------------------------
function linuxMengyaScript(): string {
return `#!/usr/bin/env bash
set -euo pipefail
cd "${SPROUTCLAW_DIR}"
exec ./pi-test.sh "$@"
`;
}
function linuxSproutclawScript(): string {
return `#!/usr/bin/env bash
set -euo pipefail
cd "${SPROUTCLAW_DIR}"
case "\${1:-}" in
\tbuild)
\t\tshift
\t\texec npm run build "$@"
\t\t;;
\trun)
\t\tshift
\t\texec ./pi-built.sh "$@"
\t\t;;
\thelp|-h|--help)
\t\tcat <<'EOF'
sproutclaw - SproutClaw pi 构建版启动器
用法:
sproutclaw 启动 pi构建版
sproutclaw run [args] 同 sproutclaw
sproutclaw build 从源码构建所有包
其余参数透传给 pi例如: sproutclaw --version
EOF
\t\t;;
\t*)
\t\texec ./pi-built.sh "$@"
\t\t;;
esac
`;
}
// ---------------------------- Windows scripts ----------------------------
function windowsMengyaBat(): string {
return `@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
if not defined PI_CODING_AGENT_DIR (
set "PI_CODING_AGENT_DIR=%SCRIPT_DIR%\.pi\agent"
)
call "%SCRIPT_DIR%\pi-test.bat" %*
exit /b %ERRORLEVEL%
`;
}
function windowsSproutclawBat(): string {
return `@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
if not defined PI_CODING_AGENT_DIR (
set "PI_CODING_AGENT_DIR=%SCRIPT_DIR%\.pi\agent"
)
if "%~1"=="" goto run
if /I "%~1"=="run" (
shift
goto run
)
if /I "%~1"=="build" (
shift
npm run build %*
exit /b %ERRORLEVEL%
)
if /I "%~1"=="help" goto help
if "%~1"=="-h" goto help
if "%~1"=="--help" goto help
:run
call "%SCRIPT_DIR%\pi-built.bat" %*
exit /b %ERRORLEVEL%
:help
echo sproutclaw - SproutClaw pi 构建版启动器
echo.
echo 用法:
echo sproutclaw 启动 pi构建版
echo sproutclaw run [args] 同 sproutclaw
echo sproutclaw build 从源码构建所有包
echo.
echo 其余参数透传给 pi例如: sproutclaw --version
exit /b 0
`;
}
// ---------------------------- Install helpers ----------------------------
function writeIfChanged(path: string, content: string, mode?: number): boolean {
if (existsSync(path)) {
try {
const current = readFileSync(path, "utf-8");
if (current === content) return false;
} catch {
// Rewrite unreadable or invalid files below.
}
}
writeFileSync(path, content, "utf-8");
if (mode !== undefined) chmodSync(path, mode);
return true;
}
function installLinuxCommands(): string[] {
const created: string[] = [];
if (writeIfChanged(join(LINUX_BIN_DIR, "mengya"), linuxMengyaScript(), 0o755)) {
created.push("/usr/local/bin/mengya");
}
if (writeIfChanged(join(LINUX_BIN_DIR, "sproutclaw"), linuxSproutclawScript(), 0o755)) {
created.push("/usr/local/bin/sproutclaw");
}
return created;
}
function installWindowsCommands(): string[] {
const created: string[] = [];
if (writeIfChanged(join(SPROUTCLAW_DIR, "mengya.bat"), windowsMengyaBat())) {
created.push(join(SPROUTCLAW_DIR, "mengya.bat"));
}
if (writeIfChanged(join(SPROUTCLAW_DIR, "sproutclaw.bat"), windowsSproutclawBat())) {
created.push(join(SPROUTCLAW_DIR, "sproutclaw.bat"));
}
return created;
}
// ---------------------------- Extension entry ----------------------------
export default function (pi: ExtensionAPI) {
const isWindows = process.platform === "win32";
function install(): string[] {
return isWindows ? installWindowsCommands() : installLinuxCommands();
}
const created = install();
for (const path of created) {
console.log(`[sproutclaw-setup] Created/updated ${path}`);
}
pi.registerCommand("install-commands", {
description: isWindows
? "Install mengya.bat (source) and sproutclaw.bat (built) to the repo root"
: "Install mengya (source) and sproutclaw (built) to /usr/local/bin",
handler: async (_args, ctx) => {
const paths = install();
if (paths.length > 0) {
ctx.ui.notify(`Installed/updated ${paths.length} command(s):\n${paths.join("\n")}`, "success");
} else {
ctx.ui.notify("Commands already up to date", "info");
}
if (isWindows) {
ctx.ui.notify(`请把项目目录加入 PATH 以全局使用:\n${SPROUTCLAW_DIR}`, "info");
}
},
});
}

View File

@@ -65,12 +65,19 @@ function keyTextOr(action: string, fallback: string): string {
return text || fallback;
}
function renderDivider(theme: { fg: (color: string, text: string) => string }, width: number): string {
return theme.fg("dim", "─".repeat(Math.max(1, width)));
}
function renderSproutclawLogo(theme: any, width: number): string[] {
if (width < 96) {
return [
[
rgb(" ", [100, 200, 255]),
theme.bold(gradientText("SproutClaw")),
theme.fg("dim", ` v${VERSION}`),
rgb(` v${VERSION}`, [170, 235, 255]),
rgb(" · ", [80, 100, 130]),
rgb("萌芽运维开发助手", [190, 170, 255]),
].join(""),
];
}
@@ -111,13 +118,6 @@ export default function (pi: ExtensionAPI) {
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")} 展开详情。`,
@@ -132,19 +132,18 @@ export default function (pi: ExtensionAPI) {
return {
render(width: number): string[] {
const divider = renderDivider(theme, width);
const lines: string[] = [
divider,
...renderSproutclawLogo(theme, width),
rgb("萌芽运维开发 Agent 助手", [170, 235, 255]),
"",
divider,
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;

View File

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

View File

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

View File

@@ -1 +0,0 @@
node_modules

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
{
"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.2.6",
"react-dom": "19.2.6",
"react-router-dom": "7.15.1"
},
"devDependencies": {
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "4.7.0",
"sharp": "0.34.5",
"typescript": "5.9.3",
"vite": "6.4.2",
"vite-plugin-pwa": "1.3.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,102 +0,0 @@
import { useChatContext } from "../../context/ChatContext";
import { formatTimeAgo } from "../../utils/format";
import { formatSessionTitle } from "../../utils/sessionTitle";
import styles from "./SessionList.module.css";
export function SessionList() {
const { sessions, activeSessionPath, loadSession, deleteSession, renameSession, toggleSessionPin } =
useChatContext();
if (!sessions.length) {
return <div className={styles.empty}></div>;
}
return (
<div className={styles.list}>
{sessions.map((s) => {
const isActive = activeSessionPath === s.path;
const isPinned = Boolean(s.pinned);
const title = formatSessionTitle(s.name);
return (
<div
key={s.path}
className={`${styles.item} ${isActive ? styles.active : ""} ${isPinned ? styles.pinned : ""}`}
>
<button type="button" className={styles.main} onClick={() => void loadSession(s.path)}>
<div className={styles.titleRow}>
{isPinned ? (
<button
type="button"
className={styles.pinnedBadge}
title="取消置顶"
aria-label={`取消置顶 ${title}`}
onClick={(e) => {
e.stopPropagation();
void toggleSessionPin(s.path, false);
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M16 3H5a1 1 0 00-.8 1.6l4.6 6.12-4.6 6.12A1 1 0 005 18h11l4 3V3l-4 0z" />
</svg>
</button>
) : null}
<div className={styles.name}>{title}</div>
</div>
<div className={styles.meta}>
{s.messageCount ?? 0} · {formatTimeAgo(s.modified || s.created)}
</div>
</button>
<div className={styles.actions}>
{!isPinned ? (
<button
type="button"
className={styles.pinBtn}
title="置顶会话"
aria-label={`置顶 ${title}`}
onClick={(e) => {
e.stopPropagation();
void toggleSessionPin(s.path, true);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M12 17v5" />
<path d="M9 3h6l1 7h4l-5 6v4H9v-4L4 10h4l1-7z" />
</svg>
</button>
) : null}
<button
type="button"
className={styles.renameBtn}
title="重命名会话"
aria-label={`重命名 ${title}`}
onClick={(e) => {
e.stopPropagation();
void renameSession(s.path, title);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</button>
<button
type="button"
className={styles.deleteBtn}
title="删除会话"
aria-label={`删除 ${title}`}
onClick={(e) => {
e.stopPropagation();
void deleteSession(s.path);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
</svg>
</button>
</div>
</div>
);
})}
</div>
);
}

View File

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

View File

@@ -1,89 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useLocation } from "react-router-dom";
import { SplashScreen } from "../components/pwa/SplashScreen";
const MIN_SPLASH_MS = 400;
const SKIP_SPLASH_KEY = "sproutclaw-warm-boot";
type BootstrapRoute = "chat" | "settings";
interface BootstrapContextValue {
markRouteReady: (route: BootstrapRoute) => void;
}
const BootstrapContext = createContext<BootstrapContextValue | null>(null);
export function BootstrapProvider({ children }: { children: ReactNode }) {
const location = useLocation();
const route: BootstrapRoute = location.pathname.startsWith("/settings") ? "settings" : "chat";
const [readyRoutes, setReadyRoutes] = useState<Record<BootstrapRoute, boolean>>({
chat: false,
settings: false,
});
const [splashVisible, setSplashVisible] = useState(true);
const [splashExiting, setSplashExiting] = useState(false);
const mountTime = useRef(Date.now());
const markRouteReady = useCallback((readyRoute: BootstrapRoute) => {
setReadyRoutes((prev) => (prev[readyRoute] ? prev : { ...prev, [readyRoute]: true }));
}, []);
useEffect(() => {
setReadyRoutes((prev) => ({ ...prev, [route]: false }));
setSplashVisible(true);
setSplashExiting(false);
mountTime.current = Date.now();
}, [route]);
useEffect(() => {
if (!readyRoutes[route]) return;
let warmBoot = false;
try {
warmBoot = sessionStorage.getItem(SKIP_SPLASH_KEY) === "1";
sessionStorage.setItem(SKIP_SPLASH_KEY, "1");
} catch {
/* ignore */
}
if (warmBoot) {
setSplashVisible(false);
setSplashExiting(false);
return;
}
const elapsed = Date.now() - mountTime.current;
const remaining = Math.max(0, MIN_SPLASH_MS - elapsed);
const timer = window.setTimeout(() => {
setSplashExiting(true);
window.setTimeout(() => setSplashVisible(false), 420);
}, remaining);
return () => window.clearTimeout(timer);
}, [readyRoutes, route]);
const value = useMemo(() => ({ markRouteReady }), [markRouteReady]);
return (
<BootstrapContext.Provider value={value}>
{children}
{splashVisible ? <SplashScreen exiting={splashExiting} /> : null}
</BootstrapContext.Provider>
);
}
export function useBootstrap(): BootstrapContextValue {
const ctx = useContext(BootstrapContext);
if (!ctx) throw new Error("useBootstrap must be used within BootstrapProvider");
return ctx;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,928 +0,0 @@
#!/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.ts";
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}`);
});

54
.pi/agent/prompts/cl.md Normal file
View File

@@ -0,0 +1,54 @@
---
description: Audit changelog entries before release
---
Audit changelog entries for all commits since the last release.
## Process
1. **Find the last release tag:**
```bash
git tag --sort=-version:refname | head -1
```
2. **List all commits since that tag:**
```bash
git log <tag>..HEAD --oneline
```
3. **Read each package's [Unreleased] section:**
- packages/ai/CHANGELOG.md
- packages/tui/CHANGELOG.md
- packages/coding-agent/CHANGELOG.md
4. **For each commit, check:**
- Skip: changelog updates, doc-only changes, release housekeeping
- Skip: changes to generated model catalogs (for example `packages/ai/src/models.generated.ts`) unless accompanied by an intentional product-facing change in non-generated source/docs.
- Determine which package(s) the commit affects (use `git show <hash> --stat`)
- Verify a changelog entry exists in the affected package(s)
- For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))`
5. **Cross-package duplication rule:**
Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them.
6. **Add New Features section after changelog fixes:**
- Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`.
- Propose the top new features to the user for confirmation before writing them.
- Link to relevant docs and sections whenever possible.
7. **Report:**
- List commits with missing entries
- List entries that need cross-package duplication
- Add any missing entries directly
## Changelog Format Reference
Sections (in order):
- `### Breaking Changes` - API changes requiring migration
- `### Added` - New features
- `### Changed` - Changes to existing functionality
- `### Fixed` - Bug fixes
- `### Removed` - Removed features
Attribution:
- Internal: `Fixed foo ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
- External: `Added bar ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@user](https://github.com/user))`

View File

@@ -0,0 +1 @@
代码注释给我默认使用中文注释,不要使用英文注释,如果原本有英文注释,请给我改成中文注释

View File

@@ -0,0 +1 @@
给我在项目根目录写一键启动本地开发前后端bat脚本和bash脚本,脚本名叫"dev",和构建前端脚本,脚本名叫:"build",注意保持代码精简不啰嗦,注意在Windows上显示前后端要两个窗口显示

View File

@@ -0,0 +1 @@
给我这个项目起个简单通俗易懂的英文名

View File

@@ -0,0 +1,39 @@
# Skills
这里是本机通用 Skills 目录,给大模型统一放可复用的技能包、流程说明和模板。
## 目录约定
- 每个 Skill 单独一个子目录
- 入口文件通常使用 `SKILL.md`
- 可按需包含 `references/``scripts/``assets/` 等辅助目录
- Skill 内尽量写清楚触发条件、输入输出、约束和操作步骤
## 推荐结构
```text
/root/skills/
your-skill/
SKILL.md
references/
scripts/
assets/
```
## 当前状态
- 目前这个目录已作为小萌芽本机的日常开发主目录使用
- 先在这里维护、测试和迭代 Skill确认稳定后再同步到 `~/.codex/skills/``~/.claude/skills/`
- 当前已放入的自定义 Skill
- `command-help-zh-skill`
- `mengya-mail-skill`
- `mengya-search-skill`
- `linux-ssh-operator-skill`
- 前两个 Skill 的实现分别对接:
- `/shumengya/project/python/mengya-mail-api/src/mengya_mail_api/`
- `/shumengya/project/python/mengya-search-api/src/mengya_search_api/`
- `linux-ssh-operator-skill` 的脚本与说明位于:
- `/shumengya/project/skills/linux-ssh-operator-skill/`
- 如果某些 Skill 需要接入特定工具或工作流,建议在 `SKILL.md` 顶部先写清适用范围
当前入口请直接走 `skills/...` 和对应的 `python/*-api/`

View File

@@ -0,0 +1,114 @@
---
name: quark-sign-skill
description: 夸克网盘自动签到。支持通过 URL 参数执行每日签到获取容量奖励零外部依赖Python 标准库即可运行)。
---
# 夸克网盘签到
自动执行夸克网盘每日签到,获取容量奖励。
## 触发场景
- 用户提到「夸克签到」「夸克网盘签到」「签到」且上下文明确指向夸克网盘
- 用户要求配置夸克网盘自动签到
## 前置要求
- Python 3.7+(已预装)
- 夸克网盘签到 URL从夸克 App 或网页获取)
## 获取签到 URL
1. 打开夸克网盘 App 或网页版
2. 进入「容量管理」或「签到」页面
3. 浏览器开发者工具 → Network → 找到类似 `growth/sign``growth/reward_record` 的请求
4. 复制完整 URL包含 `kps``sign``vcode` 参数)
URL 格式示例:
```
https://drive-m.quark.cn/1/clouddrive/capacity/growth/reward_record?kps=xxx&sign=yyy&vcode=zzz
```
## 使用方式
### 方式一:直接运行(推荐)
如果 skill 目录下已配置了默认 `config.json`,无需任何参数即可运行:
```bash
python scripts/quark_sign.py
```
### 方式二:直接传 URL
```bash
python scripts/quark_sign.py --url "你的完整URL"
```
### 方式三:配置文件
```bash
# 1. 复制示例配置并编辑
cp config.json.example ~/.config/quark_sign.json
# 填入你的 URL
# 2. 运行
python scripts/quark_sign.py --config ~/.config/quark_sign.json
```
### 方式四:环境变量
```bash
export QUARK_SIGN_URL="你的完整URL"
python scripts/quark_sign.py
```
## 常用命令
```bash
# 执行签到
python scripts/quark_sign.py --url "URL"
# 仅检查今日是否已签到
python scripts/quark_sign.py --url "URL" --check
# 查看签到信息
python scripts/quark_sign.py --url "URL" --info
```
## 输出示例
```
🚀 执行签到...
✅ 签到成功!获得 100.00 MB
```
```
🚀 执行签到...
今日已签到过,无需重复签到
```
## 默认配置
skill 目录下已内置 `config.json`,作为默认 URL 来源。URL 查找优先级:
1. `--url` 参数
2. `--config` 指定的配置文件
3. 环境变量 `QUARK_SIGN_URL`
4. skill 内置 `config.json`(默认使用)
## 注意事项
- URL 中的 `kps``sign` 参数有有效期,如失效需重新获取
- 签到奖励通常为 100MB~1GB 不等,随机发放
- 脚本支持 `requests` 库(如已安装)或纯 Python 标准库(零依赖)
- 默认配置适合个人使用,多用户场景建议各自使用 `--config``--url`
## 配置 cron 定时任务
```bash
# 每天 9 点自动签到
crontab -e
# 添加:
0 9 * * * python3 /path/to/quark-sign-skill/scripts/quark_sign.py --config /root/.config/quark_sign.json >> /var/log/quark_sign.log 2>&1
```

View File

@@ -0,0 +1,3 @@
{
"url": "https://drive-m.quark.cn/1/clouddrive/capacity/growth/reward_record?kps=Tj6VYdauGenq2rmPE33ohRAtDymhNFX6jKshCB38E3zVZiDAcGttvmkoAgTNU4n%252B8d7JiORQFKb%252F%252BEU524TsTfP1zN4ZWMXH5UMsxTD%252BLuW%252F8w%253D%253D&sign=Tj7N%252BNUA%252ByXE53C%252FPtR0xBpqepeIHSWt9RtzS66kyDIp9MNlfHO2OJ7zvOZQeK5QuAg%253D&vcode=1780291057288&_size=10&uc_param_str=dnfrpfbivessbtbmnilauputogpintnwmtsvcppcprsnnnchmicckp&fr=android&pf=3300&bi=37280&ve=10.2.3.104&ss=411x833&la=zh&ut=Tj5VTPsW0B0jX%2BOb3U5q%2BcX97R%2Bo%2FcYuH5nOtiswJy9O9Q%3D%3D&nt=6&nw=5G&mt=qUMBdVdLPFs5VQKefOxlxCMt4YMRf%2FRe&sv=release&pr=qk_clouddrive&ch=kkcloud%40store_xiaomi&mi=22127RK46C&kp=Tj6VYdauGenq2rmPE33ohRAtDymhNFX6jKshCB38E3zVZiDAcGttvmkoAgTNU4n%2B8d7JiORQFKb%2F%2BEU524TsTfP1zN4ZWMXH5UMsxTD%2BLuW%2F8w%3D%3D"
}

View File

@@ -0,0 +1,3 @@
{
"url": "https://drive-m.quark.cn/1/clouddrive/capacity/growth/reward_record?kps=...&sign=...&vcode=..."
}

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
夸克网盘自动签到脚本
用法:
python quark_sign.py --url "你的夸克URL" [--check]
python quark_sign.py --config ~/.config/quark_sign.json
"""
import argparse
import json
import os
import sys
from urllib.parse import urlparse, parse_qs, unquote
# 尝试导入 requests如果没有则使用 urllib
HAS_REQUESTS = True
try:
import requests
except ImportError:
HAS_REQUESTS = False
import urllib.request
import urllib.parse
import urllib.error
import ssl
class QuarkSign:
BASE_URL = "https://drive-m.quark.cn/1/clouddrive/capacity/growth"
def __init__(self, url: str):
self.params = self._extract_params(url)
if not self.params.get("kps") or not self.params.get("sign"):
raise ValueError("URL 中缺少 kps 或 sign 参数,请检查 URL 是否完整")
def _extract_params(self, url: str) -> dict:
parsed = urlparse(url)
params = parse_qs(parsed.query)
return {k: unquote(v[0]) for k, v in params.items()}
def _build_query(self, extra: dict = None) -> dict:
q = {
"pr": "ucpro",
"fr": "android",
"kps": self.params.get("kps"),
"sign": self.params.get("sign"),
"vcode": self.params.get("vcode", "1780291057288"),
}
if extra:
q.update(extra)
return q
def _post(self, endpoint: str, data: dict = None, params: dict = None) -> dict:
url = f"{self.BASE_URL}/{endpoint}"
query = self._build_query(params)
if HAS_REQUESTS:
resp = requests.post(url, json=data or {}, params=query, timeout=10)
return resp.json()
else:
# 使用 urllib fallback
full_url = url + "?" + urllib.parse.urlencode(query)
payload = json.dumps(data or {}).encode("utf-8")
req = urllib.request.Request(
full_url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
ctx = ssl.create_default_context()
with urllib.request.urlopen(req, context=ctx, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8"))
def _get(self, endpoint: str, params: dict = None) -> dict:
url = f"{self.BASE_URL}/{endpoint}"
query = self._build_query(params)
if HAS_REQUESTS:
resp = requests.get(url, params=query, timeout=10)
return resp.json()
else:
full_url = url + "?" + urllib.parse.urlencode(query)
req = urllib.request.Request(full_url, method="GET")
ctx = ssl.create_default_context()
with urllib.request.urlopen(req, context=ctx, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8"))
def sign(self) -> dict:
"""执行签到"""
return self._post("sign", data={"sign_cyclic": True})
def info(self) -> dict:
"""获取签到信息"""
return self._get("sign", params={"_size": 1})
def reward_info(self) -> dict:
"""获取奖励记录"""
return self._get("reward_record", params={"_size": 10})
def format_reward(reward_bytes: int) -> str:
if reward_bytes >= 1024 ** 3:
return f"{reward_bytes / (1024 ** 3):.2f} GB"
return f"{reward_bytes / (1024 ** 2):.2f} MB"
def main():
parser = argparse.ArgumentParser(description="夸克网盘自动签到")
parser.add_argument("--url", help="夸克网盘分享/签到 URL包含 kps 和 sign 参数)")
parser.add_argument("--config", "-c", help="配置文件路径JSON 格式)")
parser.add_argument("--check", action="store_true", help="仅检查签到状态,不执行签到")
parser.add_argument("--info", action="store_true", help="显示签到信息")
args = parser.parse_args()
# 获取 URL
url = args.url
if not url and args.config:
with open(os.path.expanduser(args.config)) as f:
cfg = json.load(f)
url = cfg.get("url")
if not url:
# 尝试环境变量
url = os.environ.get("QUARK_SIGN_URL")
if not url:
# 尝试读取默认配置skill 内置)
script_dir = os.path.dirname(os.path.abspath(__file__))
default_cfg = os.path.join(script_dir, "..", "config.json")
if os.path.exists(default_cfg):
with open(default_cfg) as f:
cfg = json.load(f)
url = cfg.get("url")
if not url:
print("❌ 请提供 URL--url、--config 或环境变量 QUARK_SIGN_URL")
print(" 提示:可在 skill 目录下创建 config.json 写入默认 URL")
sys.exit(1)
try:
qs = QuarkSign(url)
except ValueError as e:
print(f"{e}")
sys.exit(1)
if args.info:
print("📋 获取签到信息...")
info = qs.info()
print(json.dumps(info, indent=2, ensure_ascii=False))
return
if args.check:
print("🔍 检查签到状态...")
info = qs.info()
data = info.get("data", {})
if data.get("sign_daily"):
print("✅ 今日已签到")
else:
print("⏳ 今日未签到")
return
# 执行签到
print("🚀 执行签到...")
result = qs.sign()
status = result.get("status")
code = result.get("code")
msg = result.get("message", "")
data = result.get("data", {})
if data and status == 200:
reward = data.get("sign_daily_reward", 0)
print(f"✅ 签到成功!获得 {format_reward(reward)}")
elif "repeat" in msg.lower() or code == 44210:
print(" 今日已签到过,无需重复签到")
else:
print(f"❌ 签到失败: {msg} (code={code}, status={status})")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,57 +0,0 @@
---
name: add-llm-provider
description: Checklist for adding a new LLM provider to packages/ai. Covers core types, provider implementation, lazy registration, model generation, the full test matrix, coding-agent wiring, and docs.
---
# Adding a New LLM Provider (packages/ai)
A new provider touches multiple files. Work through these steps in order.
## 1. Core Types (`packages/ai/src/types.ts`)
- Add API identifier to `Api` type union (e.g. `"bedrock-converse-stream"`).
- Create options interface extending `StreamOptions`.
- Add mapping to `ApiOptionsMap`.
- Add provider name to `KnownProvider` type union.
## 2. Provider Implementation (`packages/ai/src/providers/`)
Create a provider file exporting:
- `stream<Provider>()` returning `AssistantMessageEventStream`.
- `streamSimple<Provider>()` for `SimpleStreamOptions` mapping.
- Provider-specific options interface.
- Message/tool conversion functions.
- Response parsing that emits standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`).
## 3. Provider Exports and Lazy Registration
- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/<provider>.js`.
- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry.
- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers; do not statically import provider implementation modules there.
- Add credential detection in `packages/ai/src/env-api-keys.ts`.
## 4. Model Generation (`packages/ai/scripts/generate-models.ts`)
- Add logic to fetch/parse models from the provider source.
- Map to the standardized `Model` interface.
## 5. Tests (`packages/ai/test/`)
- Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API impl such as `openai-completions`.
- Add the provider to the broader matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`.
- For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (e.g. GPT and Claude), add at least one pair per family.
- For non-standard auth, create a utility (e.g. `bedrock-utils.ts`) with credential detection.
## 6. Coding Agent (`packages/coding-agent/`)
- `src/core/model-resolver.ts`: add default model ID to `defaultModelPerProvider`.
- `src/core/provider-display-names.ts`: add API-key login display name so `/login` and related UI show the provider for built-in API-key auth.
- `src/cli/args.ts`: add env var documentation.
- `README.md`: add provider setup instructions.
- `docs/providers.md`: add setup instructions, env var, and `auth.json` key.
## 7. Documentation
- `packages/ai/README.md`: add to providers table, document options/auth, add env vars.
- `packages/ai/CHANGELOG.md`: add entry under `## [Unreleased]`.

View File

@@ -1,93 +0,0 @@
# Contributing to pi
This guide exists to save both sides time.
## The One Rule
**You must understand your code.** If you cannot explain what your changes do and how they interact with the rest of the system, your PR will be closed.
Using AI to write code is fine. Submitting AI-generated slop without understanding it is not.
If you use an agent, run it from the `pi-mono` root directory so it picks up `AGENTS.md` automatically. Your agent must follow the rules and guidelines in that file.
## Contribution Gate
All issues and PRs from new contributors are auto-closed by default.
Issues submitted Friday through Sunday are not reviewed. If something is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx
Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar below will not be reopened or receive a reply.
Approval happens through maintainer replies on issues:
- `lgtmi`: your future issues will not be auto-closed
- `lgtm`: your future issues and PRs will not be auto-closed
`lgtmi` does not grant rights to submit PRs. Only `lgtm` grants rights to submit PRs.
## Quality Bar For Issues
If you open an issue, you must use one of the two GitHub issue templates.
If you open an issue, keep it short, concrete, and worth reading.
- Keep it concise. If it does not fit on one screen, it is too long.
- Write in your own voice.
- State the bug or request clearly.
- Explain why it matters.
- If you want to implement the change yourself, say so.
If the issue is real and written well, a maintainer may reopen it, reply `lgtmi`, or reply `lgtm`.
## Blocking
If you ignore this document twice, or if you spam the tracker with agent-generated issues, your GitHub account will be permanently blocked.
If you send a large volume of issues through automation, your GitHub account will be permanently blocked. No taksies backsies.
## Before Submitting a PR
Do not open a PR unless you have already been approved with `lgtm`.
Before submitting a PR:
```bash
npm run check
./test.sh
```
Both must pass.
Do not edit `CHANGELOG.md`. Changelog entries are added by maintainers.
If you are adding a new provider to `packages/ai`, see `AGENTS.md` for required tests.
## Philosophy
pi's core is minimal. If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected.
## Questions?
Ask on [Discord](https://discord.com/invite/nKXTsAcmbT).
## FAQ
### Why are new issues and PRs auto-closed?
pi receives more issues than the maintainers can responsibly review in real time. Many reports do not meet the quality bar in this guide or do not follow CONTRIBUTING.md. Some are slung at the repository mindlessly via an agent instead of being reviewed and shaped by the person submitting them. Auto-closing creates a buffer so maintainers can review the tracker on their own schedule and reopen the issues that meet the quality bar.
### Why are weekend issues not reviewed?
Maintainers need uninterrupted time away from the issue tracker. Issues submitted Friday through Sunday are auto-closed and are not part of the Monday review queue. If a problem is urgent, ask on Discord and include the short version, a repro, and the relevant logs.
### Why do some issues get no reply?
A reply is maintenance work too. Low-signal issues, unclear reports, duplicates, and issues that do not follow this guide may be closed without discussion. This keeps time available for reproducible bugs, thoughtful requests, and contributors who have done the work to make their report actionable.
### Why not let AI triage everything?
AI can help group duplicates, summarize reports, and spot missing information. It is not trusted to make final maintainer decisions. Polished AI-generated issues can still be wrong, misleading, or expensive to investigate. Human review remains the final gate.
### Is this hostile to contributors?
No. It is a guardrail against burnout and tracker spam. Short, concrete, reproducible issues are welcome. Thoughtful contributions are welcome. Automated slop, entitlement, and large volumes of low-effort reports are not.

View File

@@ -18,8 +18,11 @@
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
| `packages/tui` | 终端 UI 渲染库 |
| `.pi/agent/extensions/webui` | 网页对话入口React 前端 + HTTP 服务 |
| `.pi/agent/extensions/*.ts` | sproutclaw 本地扩展(中文启动页、命令安装等) |
| `.pi/agent/extensions/webui` | 网页对话入口React 前端 + `backend/` HTTP 服务 + systemd 集成 |
| `.pi/agent/extensions/*/index.ts` | sproutclaw 本地扩展(中文启动页、状态行、命令安装等) |
| `.pi/agent/prompts/` | 自定义 slash prompt`cl`/`is`/`pr`/`wr` 等) |
| `.pi/agent/skills/` | 可复用 SkillsGitea、SSH、GitHub 等) |
| `pi-built.sh` | 使用已构建 `dist/cli.js` 启动 Agent |
| `scripts/sync-upstream.sh` | 合并上游 pi-mono 更新 |
| `scripts/push-sproutclaw.sh` | 推送到 GitHub / Gitea |
@@ -33,12 +36,19 @@ npm run check
./pi-test.sh
```
运行本地源码版 Agent
运行本地源码版 Agent(开发调试)
```bash
./pi-test.sh
```
运行已构建版 Agent日常更快
```bash
npm run build
./pi-built.sh
```
启动 WebUI 时,在 TUI 中执行:
```text
@@ -141,7 +151,8 @@ npm run build
- `showChangelogOnStartup` 设置项(关闭启动 What's New
- `.pi/agent/extensions/webui/` 网页 UI
- `.pi/agent/extensions/startup-chinese.ts` 等本地扩展
- `.pi/agent/extensions/startup-chinese/` 等本地扩展目录
- `.pi/agent/extensions/webui/backend/` WebUI 后端模块化拆分
- 本 README仅 sproutclaw 说明,不含上游文档)
## 本地配置

View File

@@ -1,142 +0,0 @@
# Code Context
## Top-Level Structure
```
.sproutclaw/
├── .claude/ # Claude configuration
├── .github/ # Issue templates, workflows (issue-gate, pr-gate, approve-contributor)
├── .husky/ # Git hooks
├── .pi/ # pi/pi-mono agent configuration (AGENTS.md)
├── node_modules/
├── packages/ # 5 monorepo workspaces
│ ├── ai/ # @earendil-works/pi-ai v0.74.0
│ ├── agent/ # @earendil-works/pi-agent-core v0.74.0
│ ├── coding-agent/ # @earendil-works/pi-coding-agent v0.74.0
│ ├── tui/ # @earendil-works/pi-tui v0.74.0
│ └── web-ui/ # @earendil-works/pi-web-ui v0.74.0
├── scripts/ # CI/release/utility scripts
├── tmp/ # Temp files
├── pi-test.sh # Interactive TUI test script
├── README.md
├── CONTRIBUTING.md
├── AGENTS.md
├── biome.json # Linting/formatting config
├── tsconfig.base.json # Shared TS config
├── tsconfig.json # Root TS config
├── package.json # Workspace root (npm workspaces)
└── bun.lock / package-lock.json
```
## Packages
### 1. `packages/ai` — `@earendil-works/pi-ai`
**Unified LLM API** — automatic model discovery, provider configuration, streaming.
- `src/index.ts` — package entry
- `src/types.ts` — core types (`Api`, `StreamOptions`, `Model`, `KnownProvider`)
- `src/stream.ts` — main streaming implementation
- `src/models.ts` / `src/models.generated.ts` — model registry + auto-generated
- `src/providers/` — individual provider implementations (Anthropic, Google, OpenAI, Mistral, etc.)
- `src/cli.ts` — CLI for model listing etc.
- `src/env-api-keys.ts` — credential detection from env vars
- `src/oauth.ts` — OAuth support
- `src/api-registry.ts` — API/stream provider registration
- `scripts/generate-models.ts` — model list generation script
- Subpath exports per provider (e.g. `./anthropic`, `./google`, etc.)
### 2. `packages/agent` — `@earendil-works/pi-agent-core`
**General-purpose agent** — transport abstraction, state management, attachment support.
- `src/index.ts` — package entry
- `src/types.ts` — core types (agent state, attachments, transports)
- `src/agent.ts` — agent implementation
- `src/agent-loop.ts` — agent main loop
- `src/proxy.ts` — proxy support
- `src/harness/` — test harness utilities
### 3. `packages/coding-agent` — `@earendil-works/pi-coding-agent`
**Coding agent CLI** — the `pi` CLI binary with read, bash, edit, write tools and session management.
- `src/cli.ts` / `src/cli/` — CLI argument parsing / entry point
- `src/main.ts` — main agent runner
- `src/index.ts` — package entry
- `src/config.ts` — configuration management
- `src/core/` — core logic (model resolution, hooks, tool execution, session management)
- `src/modes/` — operational modes
- `src/bun/` — Bun-specific support
- `src/migrations.ts` — data migration utilities
- `src/package-manager-cli.ts` — package manager interaction
- `docs/` — provider setup docs
- `examples/` — extension examples (custom provider, sandbox)
- Binary name: `pi`
### 4. `packages/tui` — `@earendil-works/pi-tui`
**Terminal UI library** — differential rendering for text-based terminal applications.
- `src/index.ts` — package entry
- `src/tui.ts` — core TUI rendering engine
- `src/terminal.ts` — low-level terminal I/O
- `src/keys.ts` / `src/keybindings.ts` — key input handling
- `src/components/` — reusable UI components
- `src/editor-component.ts` — text editor component
- `src/autocomplete.ts` — autocomplete support
- `src/fuzzy.ts` — fuzzy matching
- `src/terminal-image.ts` — image rendering in terminal
- `src/kill-ring.ts` / `src/undo-stack.ts` — editing helpers
- `src/stdin-buffer.ts` / `src/utils.ts` — utilities
### 5. `packages/web-ui` — `@earendil-works/pi-web-ui`
**Reusable web UI components** — AI chat interfaces powered by pi-ai.
- `src/index.ts` — package entry
- `src/app.css` — Tailwind CSS stylesheet
- `src/ChatPanel.ts` — main chat panel component
- `src/components/` — reusable React components
- `src/dialogs/` — dialog components
- `src/prompts/` — prompt templates/management
- `src/storage/` — client-side storage abstraction
- `src/tools/` — tool integration
- `src/utils/` — utilities
- Build: TypeScript + Tailwind CSS
## Root Scripts (`scripts/`)
- `release.mjs` — automated release workflow (version + changelog + publish)
- `sync-versions.js` — lockstep version sync across all packages
- `cost.ts` / `stats.ts` — analytics/telemetry stats
- `tool-stats.ts` / `edit-tool-stats.mjs` / `read-tool-stats.mjs` — tool usage statistics
- `profile-coding-agent-node.mjs` — Node.js profiling for coding agent
- `build-binaries.sh` — binary build script
- `browser-smoke-entry.ts` / `check-browser-smoke.mjs` — browser smoke tests
- `session-transcripts.ts` / `session-context-stats.mjs` — session logging
## Architecture Summary
```
pi-tui (terminal UI) ─── pi-coding-agent (CLI + tools) ─── pi-agent-core (agent loop)
pi-web-ui (web UI) ────────────┤
pi-ai (LLM provider abstraction)
```
- **pi-ai** is the foundation layer providing unified LLM API access (Anthropic, Google, OpenAI, Azure, Mistral, Bedrock, etc.)
- **pi-agent-core** builds on pi-ai with agent loop, transport abstraction, and state management
- **pi-coding-agent** is the main product: a coding agent CLI (`pi` command) with read/bash/edit/write tools and session management
- **pi-tui** is a standalone TUI rendering library used by pi-coding-agent
- **pi-web-ui** provides reusable chat UI components (React) powered by pi-ai
## Key Facts
- **Lockstep versioning**: all packages share the same version (currently 0.74.0)
- **Release automation**: `npm run release:patch` / `release:minor` via `scripts/release.mjs`
- **TypeScript with tsgo**: uses `tsgo` (TypeScript native preview) for fast builds; web-ui uses `tsc`
- **Linting**: Biome (v2.3.5) for formatting and linting (`npm run check`)
- **Testing**: vitest for ai/agent/coding-agent; node --test for tui
- **No `any` types allowed**, standard top-level imports only (no inline `import()`)
- **GitHub CI**: issue-gate, pr-gate, approve-contributor workflows for contributor management
## Start Here
Open `package.json` (root) for workspace structure and available scripts. For a specific package, start with its `package.json` then `src/index.ts` for the public API surface.

View File

@@ -5,9 +5,14 @@ import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } fr
const MAX_NAME_LENGTH = 64;
const MAX_DESCRIPTION_LENGTH = 1024;
const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
const IGNORED_ROOT_SKILL_MARKDOWN = new Set(["README.md"]);
type IgnoreMatcher = ReturnType<typeof ignore>;
function isIgnoredRootSkillMarkdown(fileName: string): boolean {
return IGNORED_ROOT_SKILL_MARKDOWN.has(fileName) || fileName.toLowerCase() === "readme.md";
}
export type SkillDiagnosticCode =
| "file_info_failed"
| "list_failed"
@@ -165,7 +170,8 @@ async function loadSkillsFromDirInternal(
continue;
}
if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md")) continue;
if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md") || isIgnoredRootSkillMarkdown(entry.name))
continue;
const result = await loadSkillFromFile(env, fullPath);
if (result.skill) skills.push(result.skill);
diagnostics.push(...result.diagnostics);
@@ -234,6 +240,10 @@ async function loadSkillFromFile(
env: ExecutionEnv,
filePath: string,
): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> {
if (isIgnoredRootSkillMarkdown(basenameEnvPath(filePath))) {
return { skill: null, diagnostics: [] };
}
const diagnostics: SkillDiagnostic[] = [];
const rawContent = await env.readTextFile(filePath);
if (!rawContent.ok) {

View File

@@ -113,4 +113,20 @@ Use this skill.
expect(skills.map((skill) => skill.name)).toEqual(["skills"]);
expect(skills[0]?.content).toBe("Root content");
});
it("ignores README.md at the skills directory root", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("skills/example", { recursive: true });
await env.writeFile("skills/README.md", "# Skills\n\nDirectory documentation.");
await env.writeFile(
"skills/example/SKILL.md",
"---\nname: example\ndescription: Example skill\n---\nUse this skill.",
);
const { skills, diagnostics } = await loadSkills(env, "skills");
expect(diagnostics).toEqual([]);
expect(skills.map((skill) => skill.name)).toEqual(["example"]);
});
});

View File

@@ -4,21 +4,21 @@
import type { ImagesApi, ImagesModel } from "./types.ts";
export const IMAGE_MODELS = {
openrouter: {
"openrouter": {
"black-forest-labs/flux.2-flex": {
id: "black-forest-labs/flux.2-flex",
name: "Black Forest Labs: FLUX.2 Flex",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"black-forest-labs/flux.2-klein-4b": {
id: "black-forest-labs/flux.2-klein-4b",
@@ -26,14 +26,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"black-forest-labs/flux.2-max": {
id: "black-forest-labs/flux.2-max",
@@ -41,14 +41,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"black-forest-labs/flux.2-pro": {
id: "black-forest-labs/flux.2-pro",
@@ -56,14 +56,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"bytedance-seed/seedream-4.5": {
id: "bytedance-seed/seedream-4.5",
@@ -71,14 +71,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
input: ["image","text"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-2.5-flash-image": {
id: "google/gemini-2.5-flash-image",
@@ -86,14 +86,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 0.3,
output: 2.5,
cacheRead: 0.03,
cacheWrite: 0.08333333333333334,
},
input: ["image","text"],
output: ["image","text"],
cost: {
"input": 0.3,
"output": 2.5,
"cacheRead": 0.03,
"cacheWrite": 0.08333333333333334
}
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3-pro-image-preview": {
id: "google/gemini-3-pro-image-preview",
@@ -101,14 +101,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 2,
output: 12,
cacheRead: 0.19999999999999998,
cacheWrite: 0.375,
},
input: ["image","text"],
output: ["image","text"],
cost: {
"input": 2,
"output": 12,
"cacheRead": 0.19999999999999998,
"cacheWrite": 0.375
}
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3.1-flash-image-preview": {
id: "google/gemini-3.1-flash-image-preview",
@@ -116,14 +116,29 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 0.5,
output: 3,
cacheRead: 0,
cacheWrite: 0,
},
input: ["image","text"],
output: ["image","text"],
cost: {
"input": 0.5,
"output": 3,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"microsoft/mai-image-2.5": {
id: "microsoft/mai-image-2.5",
name: "Microsoft: MAI-Image-2.5",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
output: ["image"],
cost: {
"input": 5,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-5-image": {
id: "openai/gpt-5-image",
@@ -131,14 +146,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 10,
output: 10,
cacheRead: 1.25,
cacheWrite: 0,
},
input: ["image","text"],
output: ["image","text"],
cost: {
"input": 10,
"output": 10,
"cacheRead": 1.25,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-5-image-mini": {
id: "openai/gpt-5-image-mini",
@@ -146,14 +161,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 2.5,
output: 2,
cacheRead: 0.25,
cacheWrite: 0,
},
input: ["image","text"],
output: ["image","text"],
cost: {
"input": 2.5,
"output": 2,
"cacheRead": 0.25,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-5.4-image-2": {
id: "openai/gpt-5.4-image-2",
@@ -161,14 +176,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 8,
output: 15,
cacheRead: 2,
cacheWrite: 0,
},
input: ["image","text"],
output: ["image","text"],
cost: {
"input": 8,
"output": 15,
"cacheRead": 2,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"openrouter/auto": {
id: "openrouter/auto",
@@ -176,14 +191,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["text", "image"],
cost: {
input: -1000000,
output: -1000000,
cacheRead: 0,
cacheWrite: 0,
},
input: ["text","image"],
output: ["text","image"],
cost: {
"input": -1000000,
"output": -1000000,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v3": {
id: "recraft/recraft-v3",
@@ -191,14 +206,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4": {
id: "recraft/recraft-v4",
@@ -206,14 +221,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-pro": {
id: "recraft/recraft-v4-pro",
@@ -221,14 +236,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-pro-vector": {
id: "recraft/recraft-v4-pro-vector",
@@ -236,14 +251,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-vector": {
id: "recraft/recraft-v4-vector",
@@ -251,14 +266,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1": {
id: "recraft/recraft-v4.1",
@@ -266,14 +281,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-pro": {
id: "recraft/recraft-v4.1-pro",
@@ -281,14 +296,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-pro-vector": {
id: "recraft/recraft-v4.1-pro-vector",
@@ -296,14 +311,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-utility": {
id: "recraft/recraft-v4.1-utility",
@@ -311,14 +326,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-utility-pro": {
id: "recraft/recraft-v4.1-utility-pro",
@@ -326,14 +341,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-vector": {
id: "recraft/recraft-v4.1-vector",
@@ -341,14 +356,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-fast": {
id: "sourceful/riverflow-v2-fast",
@@ -356,14 +371,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-fast-preview": {
id: "sourceful/riverflow-v2-fast-preview",
@@ -371,14 +386,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-max-preview": {
id: "sourceful/riverflow-v2-max-preview",
@@ -386,14 +401,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-pro": {
id: "sourceful/riverflow-v2-pro",
@@ -401,14 +416,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-standard-preview": {
id: "sourceful/riverflow-v2-standard-preview",
@@ -416,14 +431,44 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2.5-fast": {
id: "sourceful/riverflow-v2.5-fast",
name: "Sourceful: Riverflow V2.5 Fast",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2.5-pro": {
id: "sourceful/riverflow-v2.5-pro",
name: "Sourceful: Riverflow V2.5 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
"x-ai/grok-imagine-image-quality": {
id: "x-ai/grok-imagine-image-quality",
@@ -431,14 +476,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
input: ["text","image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
} satisfies ImagesModel<"openrouter-images">,
},
} as const satisfies Record<string, Record<string, ImagesModel<ImagesApi>>>;

File diff suppressed because it is too large Load Diff

View File

@@ -126,9 +126,9 @@ describe("Context overflow error handling", () => {
describe("GitHub Copilot (OAuth)", () => {
// OpenAI model via Copilot
it.skipIf(!githubCopilotToken)(
"gpt-4o - should detect overflow via isContextOverflow",
"claude-sonnet-4.6 - should detect overflow via isContextOverflow",
async () => {
const model = getModel("github-copilot", "gpt-4o");
const model = getModel("github-copilot", "claude-sonnet-4.6");
const result = await testContextOverflow(model, githubCopilotToken!);
logResult(result);
@@ -297,8 +297,8 @@ describe("Context overflow error handling", () => {
// =============================================================================
describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras", () => {
it("qwen-3-235b - should detect overflow via isContextOverflow", async () => {
const model = getModel("cerebras", "qwen-3-235b-a22b-instruct-2507");
it("gpt-oss-120b - should detect overflow via isContextOverflow", async () => {
const model = getModel("cerebras", "gpt-oss-120b");
const result = await testContextOverflow(model, process.env.CEREBRAS_API_KEY!);
logResult(result);

View File

@@ -630,37 +630,37 @@ describe("AI Providers Empty Message Tests", () => {
describe("GitHub Copilot Provider Empty Messages", () => {
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle empty content array",
"claude-sonnet-4.6 - should handle empty content array",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testEmptyMessage(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle empty string content",
"claude-sonnet-4.6 - should handle empty string content",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testEmptyStringMessage(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle whitespace-only content",
"claude-sonnet-4.6 - should handle whitespace-only content",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle empty assistant message in conversation",
"claude-sonnet-4.6 - should handle empty assistant message in conversation",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken });
},
);

View File

@@ -38,7 +38,7 @@ describe("Fireworks models", () => {
});
it("registers the Fire Pass turbo router model", () => {
const model = getModel("fireworks", "accounts/fireworks/routers/kimi-k2p5-turbo");
const model = getModel("fireworks", "accounts/fireworks/routers/kimi-k2p6-turbo");
expect(model).toBeDefined();
expect(model.api).toBe("anthropic-messages");

View File

@@ -443,19 +443,19 @@ describe("Tool Results with Images", () => {
describe("GitHub Copilot Provider", () => {
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle tool result with only image",
"claude-sonnet-4.6 - should handle tool result with only image",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await handleToolWithImageResult(llm, { apiKey: githubCopilotToken });
},
);
it.skipIf(!githubCopilotToken)(
"gpt-4o - should handle tool result with text and image",
"claude-sonnet-4.6 - should handle tool result with text and image",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await handleToolWithTextAndImageResult(llm, { apiKey: githubCopilotToken });
},
);

View File

@@ -149,7 +149,7 @@ describe("Token Statistics on Abort", () => {
});
describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras Provider", () => {
const llm = getModel("cerebras", "qwen-3-235b-a22b-instruct-2507");
const llm = getModel("cerebras", "gpt-oss-120b");
it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
await testTokensOnAbort(llm);
@@ -289,10 +289,10 @@ describe("Token Statistics on Abort", () => {
describe("GitHub Copilot Provider", () => {
it.skipIf(!githubCopilotToken)(
"gpt-4o - should include token stats when aborted mid-stream",
"claude-sonnet-4.6 - should include token stats when aborted mid-stream",
{ retry: 3, timeout: 30000 },
async () => {
const llm = getModel("github-copilot", "gpt-4o");
const llm = getModel("github-copilot", "claude-sonnet-4.6");
await testTokensOnAbort(llm, { apiKey: githubCopilotToken });
},
);

View File

@@ -297,10 +297,10 @@ describe("Tool Call Without Result Tests", () => {
describe("GitHub Copilot Provider", () => {
it.skipIf(!githubCopilotToken)(
"gpt-4o - should filter out tool calls without corresponding tool results",
"claude-sonnet-4.6 - should filter out tool calls without corresponding tool results",
{ retry: 3, timeout: 30000 },
async () => {
const model = getModel("github-copilot", "gpt-4o");
const model = getModel("github-copilot", "claude-sonnet-4.6");
await testToolCallWithoutResult(model, { apiKey: githubCopilotToken });
},
);

Some files were not shown because too many files have changed in this diff Show More