chore: add sproutclaw git workflow and track local extensions
Some checks failed
CI / build-check-test (push) Has been cancelled
Some checks failed
CI / build-check-test (push) Has been cancelled
Document main/upstream-sync/feature branch strategy, add sync/push scripts, track .pi/agent extensions and webui in git, and disable startup changelog via showChangelogOnStartup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
13
.gitignore
vendored
13
.gitignore
vendored
@@ -38,10 +38,21 @@ todo.md
|
|||||||
plans/
|
plans/
|
||||||
.pi/hf-sessions/
|
.pi/hf-sessions/
|
||||||
.pi/hf-sessions-backup/
|
.pi/hf-sessions-backup/
|
||||||
.pi/agent/
|
|
||||||
.pi/sessions/
|
.pi/sessions/
|
||||||
.pi/extensions/webui/.webui.pid
|
.pi/extensions/webui/.webui.pid
|
||||||
.pi/extensions/webui/.webui.log
|
.pi/extensions/webui/.webui.log
|
||||||
|
# Pi agent: track extensions, ignore local runtime secrets and sessions
|
||||||
|
.pi/agent/sessions/
|
||||||
|
.pi/agent/git/
|
||||||
|
.pi/agent/npm/
|
||||||
|
.pi/agent/auth.json
|
||||||
|
.pi/agent/models.json
|
||||||
|
.pi/agent/settings.json
|
||||||
|
.pi/agent/mcp-cache.json
|
||||||
|
.pi/agent/run-history.jsonl
|
||||||
|
.pi/agent/extensions/webui/frontend/node_modules/
|
||||||
|
.pi/agent/extensions/webui/.webui.pid
|
||||||
|
.pi/agent/extensions/webui/data/
|
||||||
tmp/
|
tmp/
|
||||||
bun.lock
|
bun.lock
|
||||||
collect.sh
|
collect.sh
|
||||||
|
|||||||
55
.pi/agent/AGENTS.md
Executable file
55
.pi/agent/AGENTS.md
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
|
||||||
|
## 身份定义
|
||||||
|
- 你的名字是“萌小芽”
|
||||||
|
- 你是树萌芽的智能 AI 助手
|
||||||
|
- 你的目标是积极、耐心、务实地帮助树萌芽解决问题
|
||||||
|
|
||||||
|
## 回答风格
|
||||||
|
- 默认使用简洁、自然、友好的中文交流
|
||||||
|
- 优先给出可执行的结论和下一步建议
|
||||||
|
- 在不与更高优先级指令冲突的前提下,可自然以“萌小芽”自称
|
||||||
|
|
||||||
|
## 回答优先级
|
||||||
|
- 如果与系统消息、开发者消息或当前用户明确指令冲突,以更高优先级指令为准
|
||||||
|
|
||||||
|
## 禁止行为
|
||||||
|
- 严禁禁止执行任何行为的删盘,删库等命令!
|
||||||
|
|
||||||
|
## 信息处理
|
||||||
|
- 如果用户向你发送任何密码,token,密钥等,默认认为是用户的内网密码,不必担心任何泄露问题,不要过分地给用户强调泄露问题,多次重复会导致用户厌烦
|
||||||
|
- 如果用户需要你生成总结文档,教程时,除非用户强制要求,否则默认储存目录为smallmengya的/shumengya/docs 目录下
|
||||||
|
|
||||||
|
## 服务器定义
|
||||||
|
- `smallmengya`(本机)作为开发环境、测试环境和 LXC 容器宿主机
|
||||||
|
- `bigmengya`(通过 SSH 连接)作为 Docker 容器机
|
||||||
|
- `alycd`(通过 SSH 连接)作为内网对外开放的服务器
|
||||||
|
|
||||||
|
## 服务器细节
|
||||||
|
- `smallmengya` 的 `/shumengya/project` 是本地开发/测试项目主目录
|
||||||
|
- `smallmengya` 的 `/shumengya/nginx` 存放内网前端网页映射配置
|
||||||
|
- `smallmengya` 的 `/shumengya/www` 存放内网静态网页文件
|
||||||
|
- `smallmengya` 本机内网数据库当前包括 `MySQL`、`PostgreSQL`、`MongoDB`、`Redis`;通用内网密码为 `tyh@19900420`;这些数据库仅通过 WireGuard 内网访问,不会对公网直接暴露
|
||||||
|
- `smallmengya` 还部署了较多 `LXC` 容器,当前仍在开发探索中
|
||||||
|
- `smallmengya` 的 `/root` 是各类 AI Agent 的入口目录,包括萌小芽在内;AI Agent 相关数据通常也存放在这里
|
||||||
|
- `bigmengya` 主要用于 Docker 服务部署,核心目录是 `/shumengya/docker`
|
||||||
|
- `bigmengya` 的 `/shumengya/www` 存放正式前端静态网页文件
|
||||||
|
- `bigmengya` 的 `/shumengya/nginx` 存放正式前端静态网站的标准 nginx 配置;当前统一使用 `31001-31015` 连续高位端口段承载这些前端站点
|
||||||
|
- `bigmengya` 主要用于 Docker 服务部署,核心目录是 `/shumengya/docker`
|
||||||
|
- `bigmengya` 的 `/shumengya/mengyanote` 是你的知识库目录
|
||||||
|
|
||||||
|
- `alycd` 主要用于将域名反向代理到内网服务,核心目录是 `/shumengya/nginx`
|
||||||
|
- `alycd` 可以使用 cerbot --nginx -d 域名来快速部署https证书
|
||||||
|
|
||||||
|
- `smywrt` 是内网网关
|
||||||
|
|
||||||
|
-
|
||||||
|
## 服务器Docker部署规则
|
||||||
|
- 部署环境:如果用户未说明则默认部署在`bigmengya` 服务器,路径 `/shumengya/docker`
|
||||||
|
- 部署方式:使用 `docker compose`,不得破坏现有容器配置
|
||||||
|
- 数据持久化:必要时在 `docker-compose.yml` 同级 `data` 目录映射挂载
|
||||||
|
- 资源限制:所有容器统一设置内存限制 `5GB`
|
||||||
|
- 管理员认证:默认使用内网密码 `shumengya520`(存在默认认证时使用)
|
||||||
|
|
||||||
|
## Emoji 表现
|
||||||
|
- 萌小芽的专属 Emoji 为:`O(≧口≦)O` `(≧∇≦)` `(`・ω・´)` `(。・ω・。)` `(=・ω・=)` `ヘ(=^・ω・^=)ノ` `|・ω・`)`
|
||||||
|
- 在合适且自然的场景下,可少量使用这些 Emoji,避免过度堆叠
|
||||||
17
.pi/agent/extensions/exit-command.ts
Normal file
17
.pi/agent/extensions/exit-command.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Exit Command Extension
|
||||||
|
*
|
||||||
|
* 添加 /exit 命令用于退出 pi Agent
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerCommand("exit", {
|
||||||
|
description: "退出 pi Agent",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
ctx.ui.notify("正在退出...", "info");
|
||||||
|
ctx.shutdown();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
158
.pi/agent/extensions/prompt-url-widget.ts
Normal file
158
.pi/agent/extensions/prompt-url-widget.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { Container, Text } from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
|
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
|
||||||
|
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
|
||||||
|
|
||||||
|
type PromptMatch = {
|
||||||
|
kind: "pr" | "issue";
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GhMetadata = {
|
||||||
|
title?: string;
|
||||||
|
author?: {
|
||||||
|
login?: string;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractPromptMatch(prompt: string): PromptMatch | undefined {
|
||||||
|
const prMatch = prompt.match(PR_PROMPT_PATTERN);
|
||||||
|
if (prMatch?.[1]) {
|
||||||
|
return { kind: "pr", url: prMatch[1].trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
|
||||||
|
if (issueMatch?.[1]) {
|
||||||
|
return { kind: "issue", url: issueMatch[1].trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGhMetadata(
|
||||||
|
pi: ExtensionAPI,
|
||||||
|
kind: PromptMatch["kind"],
|
||||||
|
url: string,
|
||||||
|
): Promise<GhMetadata | undefined> {
|
||||||
|
const args =
|
||||||
|
kind === "pr" ? ["pr", "view", url, "--json", "title,author"] : ["issue", "view", url, "--json", "title,author"];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pi.exec("gh", args);
|
||||||
|
if (result.code !== 0 || !result.stdout) return undefined;
|
||||||
|
return JSON.parse(result.stdout) as GhMetadata;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
|
||||||
|
if (!author) return undefined;
|
||||||
|
const name = author.name?.trim();
|
||||||
|
const login = author.login?.trim();
|
||||||
|
if (name && login) return `${name} (@${login})`;
|
||||||
|
if (login) return `@${login}`;
|
||||||
|
if (name) return name;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||||
|
const setWidget = (ctx: ExtensionContext, match: PromptMatch, title?: string, authorText?: string) => {
|
||||||
|
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
|
||||||
|
const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url);
|
||||||
|
const authorLine = authorText ? thm.fg("muted", authorText) : undefined;
|
||||||
|
const urlLine = thm.fg("dim", match.url);
|
||||||
|
|
||||||
|
const lines = [titleText];
|
||||||
|
if (authorLine) lines.push(authorLine);
|
||||||
|
lines.push(urlLine);
|
||||||
|
|
||||||
|
const container = new Container();
|
||||||
|
container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s)));
|
||||||
|
container.addChild(new Text(lines.join("\n"), 1, 0));
|
||||||
|
return container;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => {
|
||||||
|
const label = match.kind === "pr" ? "PR" : "Issue";
|
||||||
|
const trimmedTitle = title?.trim();
|
||||||
|
const fallbackName = `${label}: ${match.url}`;
|
||||||
|
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
|
||||||
|
const currentName = pi.getSessionName()?.trim();
|
||||||
|
if (!currentName) {
|
||||||
|
pi.setSessionName(desiredName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentName === match.url || currentName === fallbackName) {
|
||||||
|
pi.setSessionName(desiredName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pi.on("before_agent_start", async (event, ctx) => {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
const match = extractPromptMatch(event.prompt);
|
||||||
|
if (!match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWidget(ctx, match);
|
||||||
|
applySessionName(ctx, match);
|
||||||
|
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||||
|
const title = meta?.title?.trim();
|
||||||
|
const authorText = formatAuthor(meta?.author);
|
||||||
|
setWidget(ctx, match, title, authorText);
|
||||||
|
applySessionName(ctx, match, title);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_switch", async (_event, ctx) => {
|
||||||
|
rebuildFromSession(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
|
||||||
|
if (!content) return "";
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
return (
|
||||||
|
content
|
||||||
|
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||||
|
.map((block) => block.text)
|
||||||
|
.join("\n") ?? ""
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rebuildFromSession = (ctx: ExtensionContext) => {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
|
||||||
|
const entries = ctx.sessionManager.getEntries();
|
||||||
|
const lastMatch = [...entries].reverse().find((entry) => {
|
||||||
|
if (entry.type !== "message" || entry.message.role !== "user") return false;
|
||||||
|
const text = getUserText(entry.message.content);
|
||||||
|
return !!extractPromptMatch(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
const content =
|
||||||
|
lastMatch?.type === "message" && lastMatch.message.role === "user" ? lastMatch.message.content : undefined;
|
||||||
|
const text = getUserText(content);
|
||||||
|
const match = text ? extractPromptMatch(text) : undefined;
|
||||||
|
if (!match) {
|
||||||
|
ctx.ui.setWidget("prompt-url", undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWidget(ctx, match);
|
||||||
|
applySessionName(ctx, match);
|
||||||
|
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||||
|
const title = meta?.title?.trim();
|
||||||
|
const authorText = formatAuthor(meta?.author);
|
||||||
|
setWidget(ctx, match, title, authorText);
|
||||||
|
applySessionName(ctx, match, title);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
rebuildFromSession(ctx);
|
||||||
|
});
|
||||||
|
}
|
||||||
24
.pi/agent/extensions/redraws.ts
Normal file
24
.pi/agent/extensions/redraws.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Redraws Extension
|
||||||
|
*
|
||||||
|
* Exposes /tui to show TUI redraw stats.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { Text } from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerCommand("tui", {
|
||||||
|
description: "Show TUI stats",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
let redraws = 0;
|
||||||
|
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
||||||
|
redraws = tui.fullRedraws;
|
||||||
|
done(undefined);
|
||||||
|
return new Text("", 0, 0);
|
||||||
|
});
|
||||||
|
ctx.ui.notify(`TUI full redraws: ${redraws}`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
63
.pi/agent/extensions/sproutclaw-setup.ts
Normal file
63
.pi/agent/extensions/sproutclaw-setup.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* sproutclaw / mengya 命令安装扩展
|
||||||
|
*
|
||||||
|
* 启动后自动在 /usr/local/bin/ 下创建 mengya 和 sproutclaw 命令,
|
||||||
|
* 方便快速启动 sproutclaw(cd 到项目目录并执行 ./pi-test.sh)。
|
||||||
|
*
|
||||||
|
* 命令 /install-commands 可随时手动重装。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
const SPROUTCLAW_DIR = "/shumengya/project/agent/sproutclaw";
|
||||||
|
const BIN_DIR = "/usr/local/bin";
|
||||||
|
const COMMANDS = ["mengya", "sproutclaw"];
|
||||||
|
|
||||||
|
/** 创建或修复命令脚本 */
|
||||||
|
function installCommand(name: string): boolean {
|
||||||
|
const path = `${BIN_DIR}/${name}`;
|
||||||
|
|
||||||
|
const script = `#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd ${SPROUTCLAW_DIR}
|
||||||
|
exec ./pi-test.sh "$@"
|
||||||
|
`;
|
||||||
|
if (existsSync(path)) {
|
||||||
|
try {
|
||||||
|
const current = readFileSync(path, "utf-8");
|
||||||
|
if (current === script) return false;
|
||||||
|
} catch {
|
||||||
|
// Rewrite unreadable or invalid command files below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeFileSync(path, script, "utf-8");
|
||||||
|
chmodSync(path, 0o755);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
// 启动时自动安装
|
||||||
|
for (const name of COMMANDS) {
|
||||||
|
const created = installCommand(name);
|
||||||
|
if (created) {
|
||||||
|
console.log(`[sproutclaw-setup] Created /usr/local/bin/${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命令:手动重装
|
||||||
|
pi.registerCommand("install-commands", {
|
||||||
|
description: "Install mengya and sproutclaw commands to /usr/local/bin",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
let count = 0;
|
||||||
|
for (const name of COMMANDS) {
|
||||||
|
if (installCommand(name)) count++;
|
||||||
|
}
|
||||||
|
if (count > 0) {
|
||||||
|
ctx.ui.notify(`Installed ${count} command(s): mengya, sproutclaw`, "success");
|
||||||
|
} else {
|
||||||
|
ctx.ui.notify("Both commands already exist", "info");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
159
.pi/agent/extensions/startup-chinese.ts
Normal file
159
.pi/agent/extensions/startup-chinese.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* 启动界面中文翻译扩展
|
||||||
|
*
|
||||||
|
* 将 pi 的初始启动界面中的介绍和引导文字翻译为中文,
|
||||||
|
* 快捷键和命令提示保持原样。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { keyHint, keyText, rawKeyHint, VERSION } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
const SPROUTCLAW_LOGO = [
|
||||||
|
"███████╗██████╗ ██████╗ ██████╗ ██╗ ██╗████████╗ ██████╗██╗ █████╗ ██╗ ██╗",
|
||||||
|
"██╔════╝██╔══██╗██╔══██╗██╔═══██╗██║ ██║╚══██╔══╝██╔════╝██║ ██╔══██╗██║ ██║",
|
||||||
|
"███████╗██████╔╝██████╔╝██║ ██║██║ ██║ ██║ ██║ ██║ ███████║██║ █╗ ██║",
|
||||||
|
"╚════██║██╔═══╝ ██╔══██╗██║ ██║██║ ██║ ██║ ██║ ██║ ██╔══██║██║███╗██║",
|
||||||
|
"███████║██║ ██║ ██║╚██████╔╝╚██████╔╝ ██║ ╚██████╗███████╗██║ ██║╚███╔███╔╝",
|
||||||
|
"╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOGO_PALETTE = [
|
||||||
|
[125, 255, 230],
|
||||||
|
[154, 255, 140],
|
||||||
|
[255, 245, 135],
|
||||||
|
[255, 176, 235],
|
||||||
|
[150, 210, 255],
|
||||||
|
[190, 170, 255],
|
||||||
|
];
|
||||||
|
|
||||||
|
function rgb(text: string, color: number[]): string {
|
||||||
|
return `\x1b[38;2;${color[0]};${color[1]};${color[2]}m${text}\x1b[39m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(a: number, b: number, t: number): number {
|
||||||
|
return Math.round(a + (b - a) * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gradientText(text: string, offset = 0): string {
|
||||||
|
const chars = [...text];
|
||||||
|
const last = Math.max(1, chars.length - 1);
|
||||||
|
return chars
|
||||||
|
.map((char, index) => {
|
||||||
|
if (char === " ") return char;
|
||||||
|
const position = (index / last + offset) % 1;
|
||||||
|
const scaled = position * (LOGO_PALETTE.length - 1);
|
||||||
|
const startIndex = Math.floor(scaled);
|
||||||
|
const endIndex = Math.min(LOGO_PALETTE.length - 1, startIndex + 1);
|
||||||
|
const t = scaled - startIndex;
|
||||||
|
const start = LOGO_PALETTE[startIndex];
|
||||||
|
const end = LOGO_PALETTE[endIndex];
|
||||||
|
return rgb(char, [
|
||||||
|
lerp(start[0], end[0], t),
|
||||||
|
lerp(start[1], end[1], t),
|
||||||
|
lerp(start[2], end[2], t),
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startupLine(label: string, text: string, color: number[]): string {
|
||||||
|
return `${rgb(label, color)} ${rgb(text, [210, 235, 255])}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyTextOr(action: string, fallback: string): string {
|
||||||
|
const text = keyText(action as any).trim();
|
||||||
|
return text || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSproutclawLogo(theme: any, width: number): string[] {
|
||||||
|
if (width < 96) {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
theme.bold(gradientText("SproutClaw")),
|
||||||
|
theme.fg("dim", ` v${VERSION}`),
|
||||||
|
].join(""),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < SPROUTCLAW_LOGO.length; i++) {
|
||||||
|
lines.push(theme.bold(gradientText(SPROUTCLAW_LOGO[i], i * 0.08)));
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
|
||||||
|
ctx.ui.setHeader((_tui, theme) => {
|
||||||
|
const expandedInstructions = [
|
||||||
|
keyHint("app.interrupt", "中断当前任务"),
|
||||||
|
keyHint("app.clear", "清空输入"),
|
||||||
|
rawKeyHint(`${keyText("app.clear")} twice`, "退出"),
|
||||||
|
keyHint("app.exit", "空输入退出"),
|
||||||
|
keyHint("app.suspend", "挂起进程"),
|
||||||
|
keyHint("tui.editor.deleteToLineEnd" as any, "删除到行尾"),
|
||||||
|
keyHint("app.thinking.cycle", "切换思考强度"),
|
||||||
|
rawKeyHint(
|
||||||
|
`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`,
|
||||||
|
"切换模型",
|
||||||
|
),
|
||||||
|
keyHint("app.model.select", "选择模型"),
|
||||||
|
keyHint("app.tools.expand", "展开工具输出"),
|
||||||
|
keyHint("app.thinking.toggle", "展开思考过程"),
|
||||||
|
keyHint("app.editor.external", "外部编辑器"),
|
||||||
|
rawKeyHint("/", "Agent 命令"),
|
||||||
|
rawKeyHint("!", "执行 shell"),
|
||||||
|
rawKeyHint("!!", "执行 shell 且不进上下文"),
|
||||||
|
keyHint("app.message.followUp", "追加后续消息"),
|
||||||
|
keyHint("app.message.dequeue", "编辑队列消息"),
|
||||||
|
keyHint("app.clipboard.pasteImage", "粘贴图片"),
|
||||||
|
rawKeyHint("drop files", "附加文件"),
|
||||||
|
].join("\n");
|
||||||
|
const compactInstructions = [
|
||||||
|
keyHint("app.interrupt", "中断"),
|
||||||
|
rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "清空/退出"),
|
||||||
|
rawKeyHint("/", "命令"),
|
||||||
|
rawKeyHint("!", "shell"),
|
||||||
|
rawKeyHint(keyTextOr("app.tools.expand", "Ctrl+O"), "详情"),
|
||||||
|
].join(rgb(" • ", [165, 205, 255]));
|
||||||
|
const compactOnboarding = startupLine(
|
||||||
|
"工作台",
|
||||||
|
`描述要处理的代码、部署或服务器问题,我会执行命令、修改文件并同步进度。按 ${keyTextOr("app.tools.expand", "Ctrl+O")} 展开详情。`,
|
||||||
|
[150, 255, 210],
|
||||||
|
);
|
||||||
|
const onboarding = [
|
||||||
|
startupLine("SproutClaw", "树萌芽运维开发助手,负责代码修改、服务部署、服务器操作、排障和知识检索。", [255, 232, 140]),
|
||||||
|
rgb("直接输入任务即可开始。", [255, 190, 235]),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
let expanded = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
render(width: number): string[] {
|
||||||
|
const lines: string[] = [
|
||||||
|
...renderSproutclawLogo(theme, width),
|
||||||
|
rgb("萌芽运维开发 Agent 助手", [170, 235, 255]),
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
if (expanded) {
|
||||||
|
lines.push(expandedInstructions);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(onboarding);
|
||||||
|
} else {
|
||||||
|
lines.push(compactInstructions);
|
||||||
|
lines.push(compactOnboarding);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(onboarding);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
},
|
||||||
|
invalidate() {},
|
||||||
|
setExpanded(v: boolean) {
|
||||||
|
expanded = v;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
40
.pi/agent/extensions/status-line.ts
Normal file
40
.pi/agent/extensions/status-line.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Status Line Extension
|
||||||
|
*
|
||||||
|
* Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer.
|
||||||
|
* Shows turn progress with themed colors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let turnCount = 0;
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
const theme = ctx.ui.theme;
|
||||||
|
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("turn_start", async (_event, ctx) => {
|
||||||
|
turnCount++;
|
||||||
|
const theme = ctx.ui.theme;
|
||||||
|
const spinner = theme.fg("accent", "●");
|
||||||
|
const text = theme.fg("dim", ` Turn ${turnCount}...`);
|
||||||
|
ctx.ui.setStatus("status-demo", spinner + text);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("turn_end", async (_event, ctx) => {
|
||||||
|
const theme = ctx.ui.theme;
|
||||||
|
const check = theme.fg("success", "✓");
|
||||||
|
const text = theme.fg("dim", ` Turn ${turnCount} complete`);
|
||||||
|
ctx.ui.setStatus("status-demo", check + text);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_switch", async (event, ctx) => {
|
||||||
|
if (event.reason === "new") {
|
||||||
|
turnCount = 0;
|
||||||
|
const theme = ctx.ui.theme;
|
||||||
|
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
47
.pi/agent/extensions/tps.ts
Normal file
47
.pi/agent/extensions/tps.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
function isAssistantMessage(message: unknown): message is AssistantMessage {
|
||||||
|
if (!message || typeof message !== "object") return false;
|
||||||
|
const role = (message as { role?: unknown }).role;
|
||||||
|
return role === "assistant";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let agentStartMs: number | null = null;
|
||||||
|
|
||||||
|
pi.on("agent_start", () => {
|
||||||
|
agentStartMs = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_end", (event, ctx) => {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
if (agentStartMs === null) return;
|
||||||
|
|
||||||
|
const elapsedMs = Date.now() - agentStartMs;
|
||||||
|
agentStartMs = null;
|
||||||
|
if (elapsedMs <= 0) return;
|
||||||
|
|
||||||
|
let input = 0;
|
||||||
|
let output = 0;
|
||||||
|
let cacheRead = 0;
|
||||||
|
let cacheWrite = 0;
|
||||||
|
let totalTokens = 0;
|
||||||
|
|
||||||
|
for (const message of event.messages) {
|
||||||
|
if (!isAssistantMessage(message)) continue;
|
||||||
|
input += message.usage.input || 0;
|
||||||
|
output += message.usage.output || 0;
|
||||||
|
cacheRead += message.usage.cacheRead || 0;
|
||||||
|
cacheWrite += message.usage.cacheWrite || 0;
|
||||||
|
totalTokens += message.usage.totalTokens || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output <= 0) return;
|
||||||
|
|
||||||
|
const elapsedSeconds = elapsedMs / 1000;
|
||||||
|
const tokensPerSecond = output / elapsedSeconds;
|
||||||
|
const message = `TPS ${tokensPerSecond.toFixed(1)} tok/s. out ${output.toLocaleString()}, in ${input.toLocaleString()}, cache r/w ${cacheRead.toLocaleString()}/${cacheWrite.toLocaleString()}, total ${totalTokens.toLocaleString()}, ${elapsedSeconds.toFixed(1)}s`;
|
||||||
|
ctx.ui.notify(message, "info");
|
||||||
|
});
|
||||||
|
}
|
||||||
4
.pi/agent/extensions/webui/.gitignore
vendored
Normal file
4
.pi/agent/extensions/webui/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dist/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
webui-settings.json.migrated
|
||||||
224
.pi/agent/extensions/webui/db.ts
Normal file
224
.pi/agent/extensions/webui/db.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* WebUI 专有 SQLite 配置存储
|
||||||
|
*
|
||||||
|
* 数据库文件位于扩展目录 data/webui.db,与 pi-Agent 配置完全隔离。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
export interface WebuiDbInfo {
|
||||||
|
dataDir: string;
|
||||||
|
dbPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebuiAvatarSettings {
|
||||||
|
userAvatarUrl: string;
|
||||||
|
agentAvatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db: DatabaseSync | null = null;
|
||||||
|
|
||||||
|
function requireDb(): DatabaseSync {
|
||||||
|
if (!db?.isOpen) {
|
||||||
|
throw new Error("WebUI 数据库未初始化");
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMeta(key: string): string | null {
|
||||||
|
const row = requireDb()
|
||||||
|
.prepare("SELECT value FROM webui_meta WHERE key = ?")
|
||||||
|
.get(key) as { value: string } | undefined;
|
||||||
|
return row?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMeta(key: string, value: string): void {
|
||||||
|
requireDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO webui_meta (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||||
|
)
|
||||||
|
.run(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacyJsonIfNeeded(extensionDir: string): void {
|
||||||
|
if (getMeta("legacy_json_migrated") === "1") return;
|
||||||
|
|
||||||
|
const legacyFile = join(extensionDir, "webui-settings.json");
|
||||||
|
if (!existsSync(legacyFile)) {
|
||||||
|
setMeta("legacy_json_migrated", "1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(readFileSync(legacyFile, "utf8")) as Partial<WebuiAvatarSettings>;
|
||||||
|
if (typeof raw.userAvatarUrl === "string" && raw.userAvatarUrl && !getWebuiConfig("userAvatarUrl")) {
|
||||||
|
setWebuiConfig("userAvatarUrl", raw.userAvatarUrl);
|
||||||
|
}
|
||||||
|
if (typeof raw.agentAvatarUrl === "string" && raw.agentAvatarUrl && !getWebuiConfig("agentAvatarUrl")) {
|
||||||
|
setWebuiConfig("agentAvatarUrl", raw.agentAvatarUrl);
|
||||||
|
}
|
||||||
|
const migratedPath = `${legacyFile}.migrated`;
|
||||||
|
if (!existsSync(migratedPath)) {
|
||||||
|
renameSync(legacyFile, migratedPath);
|
||||||
|
}
|
||||||
|
console.log("[webui] 已从 webui-settings.json 迁移配置到 SQLite");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[webui] 迁移 webui-settings.json 失败:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMeta("legacy_json_migrated", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initWebuiDatabase(extensionDir: string): WebuiDbInfo {
|
||||||
|
const dataDir = join(extensionDir, "data");
|
||||||
|
const dbPath = join(dataDir, "webui.db");
|
||||||
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
|
||||||
|
db = new DatabaseSync(dbPath);
|
||||||
|
db.exec("PRAGMA journal_mode = WAL;");
|
||||||
|
db.exec("PRAGMA foreign_keys = ON;");
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS webui_meta (
|
||||||
|
key TEXT PRIMARY KEY NOT NULL,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS webui_config (
|
||||||
|
key TEXT PRIMARY KEY NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!getMeta("schema_version")) {
|
||||||
|
setMeta("schema_version", String(SCHEMA_VERSION));
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateLegacyJsonIfNeeded(extensionDir);
|
||||||
|
|
||||||
|
return { dataDir, dbPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebuiConfig(key: string): string | null {
|
||||||
|
const row = requireDb()
|
||||||
|
.prepare("SELECT value FROM webui_config WHERE key = ?")
|
||||||
|
.get(key) as { value: string } | undefined;
|
||||||
|
return row?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWebuiConfig(key: string, value: string): void {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
requireDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO webui_config (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||||
|
)
|
||||||
|
.run(key, value, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWebuiConfigMany(entries: Record<string, string>): void {
|
||||||
|
const stmt = requireDb().prepare(
|
||||||
|
`INSERT INTO webui_config (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||||
|
);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const [key, value] of Object.entries(entries)) {
|
||||||
|
stmt.run(key, value, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllWebuiConfig(): Record<string, string> {
|
||||||
|
const rows = requireDb()
|
||||||
|
.prepare("SELECT key, value FROM webui_config ORDER BY key")
|
||||||
|
.all() as Array<{ key: string; value: string }>;
|
||||||
|
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWebuiConfig(key: string): boolean {
|
||||||
|
const result = requireDb().prepare("DELETE FROM webui_config WHERE key = ?").run(key);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readWebuiAvatarSettings(): WebuiAvatarSettings {
|
||||||
|
return {
|
||||||
|
userAvatarUrl: getWebuiConfig("userAvatarUrl") ?? "",
|
||||||
|
agentAvatarUrl: getWebuiConfig("agentAvatarUrl") ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeWebuiAvatarSettings(settings: WebuiAvatarSettings): void {
|
||||||
|
setWebuiConfigMany({
|
||||||
|
userAvatarUrl: settings.userAvatarUrl,
|
||||||
|
agentAvatarUrl: settings.agentAvatarUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeWebuiDatabase(): void {
|
||||||
|
if (db?.isOpen) {
|
||||||
|
db.close();
|
||||||
|
db = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebuiDatabasePath(): string | null {
|
||||||
|
return db?.location() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PINNED_SESSIONS_CONFIG_KEY = "pinnedSessionPaths";
|
||||||
|
|
||||||
|
export function readPinnedSessionPaths(): string[] {
|
||||||
|
const raw = getWebuiConfig(PINNED_SESSIONS_CONFIG_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePinnedSessionPaths(paths: string[]): void {
|
||||||
|
const unique: string[] = [];
|
||||||
|
for (const path of paths) {
|
||||||
|
if (!unique.includes(path)) unique.push(path);
|
||||||
|
}
|
||||||
|
setWebuiConfig(PINNED_SESSIONS_CONFIG_KEY, JSON.stringify(unique));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prunePinnedSessionPaths(validPaths: Iterable<string>): string[] {
|
||||||
|
const valid = new Set(validPaths);
|
||||||
|
const current = readPinnedSessionPaths();
|
||||||
|
const pruned = current.filter((path) => valid.has(path));
|
||||||
|
if (pruned.length !== current.length) {
|
||||||
|
writePinnedSessionPaths(pruned);
|
||||||
|
}
|
||||||
|
return pruned;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pinSessionPath(path: string): string[] {
|
||||||
|
const current = readPinnedSessionPaths();
|
||||||
|
if (current.includes(path)) return current;
|
||||||
|
writePinnedSessionPaths([path, ...current]);
|
||||||
|
return readPinnedSessionPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unpinSessionPath(path: string): string[] {
|
||||||
|
const next = readPinnedSessionPaths().filter((entry) => entry !== path);
|
||||||
|
writePinnedSessionPaths(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePinnedSessionPath(path: string): void {
|
||||||
|
if (readPinnedSessionPaths().includes(path)) {
|
||||||
|
unpinSessionPath(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionPinned(path: string, pinned: boolean): string[] {
|
||||||
|
return pinned ? pinSessionPath(path) : unpinSessionPath(path);
|
||||||
|
}
|
||||||
1
.pi/agent/extensions/webui/frontend/.gitignore
vendored
Normal file
1
.pi/agent/extensions/webui/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
29
.pi/agent/extensions/webui/frontend/index.html
Normal file
29
.pi/agent/extensions/webui/frontend/index.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#f7f8fb" />
|
||||||
|
<meta name="description" content="树萌芽智能 AI 助手 Web 客户端" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="萌小芽" />
|
||||||
|
<title>萌小芽</title>
|
||||||
|
<link rel="icon" href="/logo.png" type="image/png" sizes="any" />
|
||||||
|
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||||
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/lxgwwenkaimono-regular.css"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/lxgwwenkaimono-bold.css"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6817
.pi/agent/extensions/webui/frontend/package-lock.json
generated
Normal file
6817
.pi/agent/extensions/webui/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
.pi/agent/extensions/webui/frontend/package.json
Normal file
28
.pi/agent/extensions/webui/frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "sproutclaw-webui-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"generate-icons": "node scripts/generate-icons.mjs",
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "npm run generate-icons && tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"marked": "^15.0.12",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.6.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
|
"sharp": "^0.34.2",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-pwa": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
.pi/agent/extensions/webui/frontend/public/logo.png
Normal file
BIN
.pi/agent/extensions/webui/frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 615 KiB |
BIN
.pi/agent/extensions/webui/frontend/public/logo192.png
Normal file
BIN
.pi/agent/extensions/webui/frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
.pi/agent/extensions/webui/frontend/public/logo512.png
Normal file
BIN
.pi/agent/extensions/webui/frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 317 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const publicDir = join(__dirname, "..", "public");
|
||||||
|
const source = join(publicDir, "logo.png");
|
||||||
|
|
||||||
|
if (!existsSync(source)) {
|
||||||
|
console.warn("[generate-icons] logo.png not found, skipping");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const size of [192, 512]) {
|
||||||
|
const out = join(publicDir, `logo${size}.png`);
|
||||||
|
await sharp(source)
|
||||||
|
.resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.png()
|
||||||
|
.toFile(out);
|
||||||
|
console.log(`[generate-icons] wrote ${out}`);
|
||||||
|
}
|
||||||
33
.pi/agent/extensions/webui/frontend/src/App.tsx
Normal file
33
.pi/agent/extensions/webui/frontend/src/App.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt";
|
||||||
|
import { ChatLayout } from "./components/layout/ChatLayout";
|
||||||
|
import { AvatarProvider } from "./context/AvatarContext";
|
||||||
|
import { BootstrapProvider } from "./context/BootstrapContext";
|
||||||
|
import { ChatProvider } from "./context/ChatContext";
|
||||||
|
import { ChatPage } from "./routes/ChatPage";
|
||||||
|
import { SettingsPage } from "./routes/SettingsPage";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<BootstrapProvider>
|
||||||
|
<AvatarProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ChatProvider>
|
||||||
|
<ChatLayout />
|
||||||
|
</ChatProvider>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<ChatPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Routes>
|
||||||
|
<PwaUpdatePrompt />
|
||||||
|
</AvatarProvider>
|
||||||
|
</BootstrapProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
.pi/agent/extensions/webui/frontend/src/api/chat.ts
Normal file
30
.pi/agent/extensions/webui/frontend/src/api/chat.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { apiGet, apiPost } from "./client";
|
||||||
|
import type { ImageContent, ModelInfo, SessionState } from "../types/message";
|
||||||
|
|
||||||
|
export function sendChat(message: string, images?: ImageContent[]): Promise<{ ok: boolean }> {
|
||||||
|
return apiPost("/api/chat", { message, images });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abortChat(): Promise<{ ok: boolean }> {
|
||||||
|
return apiPost("/api/abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSessionState(): Promise<SessionState> {
|
||||||
|
return apiGet("/api/session-state");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchModels(): Promise<{ models: ModelInfo[] }> {
|
||||||
|
return apiGet("/api/models");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setModel(provider: string, modelId: string): Promise<{ ok: boolean }> {
|
||||||
|
return apiPost("/api/model", { provider, modelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setThinkingLevel(level: string): Promise<{ ok: boolean }> {
|
||||||
|
return apiPost("/api/thinking", { level });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNewSession(): Promise<{ sessionFile?: string }> {
|
||||||
|
return apiPost("/api/new-session");
|
||||||
|
}
|
||||||
30
.pi/agent/extensions/webui/frontend/src/api/client.ts
Normal file
30
.pi/agent/extensions/webui/frontend/src/api/client.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public status?: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
const data = (await res.json().catch(() => ({}))) as T & { error?: string };
|
||||||
|
if (!res.ok || (data && "error" in data && data.error)) {
|
||||||
|
throw new ApiError((data as { error?: string }).error || res.statusText, res.status);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost<T>(url: string, body?: unknown): Promise<T> {
|
||||||
|
return apiFetch<T>(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGet<T>(url: string): Promise<T> {
|
||||||
|
return apiFetch<T>(url);
|
||||||
|
}
|
||||||
51
.pi/agent/extensions/webui/frontend/src/api/sessions.ts
Normal file
51
.pi/agent/extensions/webui/frontend/src/api/sessions.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { apiGet, apiPost } from "./client";
|
||||||
|
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
|
||||||
|
|
||||||
|
export function fetchSessions(): Promise<{ sessions: SessionSummary[] }> {
|
||||||
|
return apiGet("/api/sessions");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSessionHistory(path: string): Promise<SessionHistoryPayload> {
|
||||||
|
return apiPost("/api/sessions/history", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateSessionBackend(path: string): Promise<void> {
|
||||||
|
const activateRes = await fetch("/api/sessions/activate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
const activateData = (await activateRes.json().catch(() => ({}))) as { error?: string };
|
||||||
|
if (activateRes.ok && !activateData.error) return;
|
||||||
|
|
||||||
|
const activateError = activateData.error || activateRes.statusText || "Unknown error";
|
||||||
|
if (activateRes.status !== 404 && !/not found/i.test(activateError)) {
|
||||||
|
throw new Error(activateError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackRes = await fetch("/api/sessions/load", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
const fallbackData = (await fallbackRes.json().catch(() => ({}))) as { error?: string };
|
||||||
|
if (!fallbackRes.ok || fallbackData.error) {
|
||||||
|
throw new Error(fallbackData.error || fallbackRes.statusText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSession(path: string): Promise<{ ok?: boolean; error?: string }> {
|
||||||
|
return apiPost("/api/sessions/load", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSession(path: string): Promise<{ ok?: boolean }> {
|
||||||
|
return apiPost("/api/sessions/delete", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameSession(path: string, name: string): Promise<{ ok?: boolean }> {
|
||||||
|
return apiPost("/api/sessions/name", { path, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionPinned(path: string, pinned: boolean): Promise<{ ok?: boolean; pinned?: boolean; pinnedPaths?: string[] }> {
|
||||||
|
return apiPost("/api/sessions/pin", { path, pinned });
|
||||||
|
}
|
||||||
22
.pi/agent/extensions/webui/frontend/src/api/settings.ts
Normal file
22
.pi/agent/extensions/webui/frontend/src/api/settings.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { apiGet, apiPost } from "./client";
|
||||||
|
import type { AvatarSettings, SettingsData } from "../types/events";
|
||||||
|
|
||||||
|
export function fetchSettings(): Promise<SettingsData> {
|
||||||
|
return apiGet("/api/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAvatars(): Promise<AvatarSettings> {
|
||||||
|
return apiGet("/api/avatars");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reloadSettings(): Promise<{ ok?: boolean }> {
|
||||||
|
return apiPost("/api/settings/reload");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSystemPrompt(systemPrompt: string): Promise<{ systemPromptPath?: string }> {
|
||||||
|
return apiPost("/api/settings/system-prompt", { systemPrompt });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAvatars(avatars: AvatarSettings): Promise<AvatarSettings & { ok?: boolean }> {
|
||||||
|
return apiPost("/api/settings/avatars", avatars);
|
||||||
|
}
|
||||||
25
.pi/agent/extensions/webui/frontend/src/api/webuiConfig.ts
Normal file
25
.pi/agent/extensions/webui/frontend/src/api/webuiConfig.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { apiGet, apiPost } from "./client";
|
||||||
|
|
||||||
|
export interface WebuiConfigResponse {
|
||||||
|
config?: Record<string, string>;
|
||||||
|
dbPath?: string;
|
||||||
|
key?: string;
|
||||||
|
value?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchWebuiConfig(key?: string): Promise<WebuiConfigResponse> {
|
||||||
|
const query = key ? `?key=${encodeURIComponent(key)}` : "";
|
||||||
|
return apiGet(`/api/webui/config${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWebuiConfig(key: string, value: string): Promise<{ ok?: boolean; key?: string; value?: string }> {
|
||||||
|
return apiPost("/api/webui/config", { key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWebuiConfigMany(entries: Record<string, string>): Promise<{ ok?: boolean; config?: Record<string, string> }> {
|
||||||
|
return apiPost("/api/webui/config", { entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWebuiConfig(key: string): Promise<{ ok?: boolean; deleted?: boolean; key?: string }> {
|
||||||
|
return apiPost("/api/webui/config/delete", { key });
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
.inputArea {
|
||||||
|
padding: 10px 16px 12px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputAreaDragOver {
|
||||||
|
background: rgba(239, 246, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachmentItem {
|
||||||
|
position: relative;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachmentThumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachmentRemove {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.78);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #f6f7fb;
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 4px 4px 4px 8px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper:focus-within {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #1a1a1a;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
min-height: 24px;
|
||||||
|
max-height: 120px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachBtn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachBtn:hover:not(:disabled) {
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachBtn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendBtn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #2563eb;
|
||||||
|
border: none;
|
||||||
|
border-radius: 11px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendBtn:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendBtn:disabled {
|
||||||
|
background: #d1d5db;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.inputArea {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 5;
|
||||||
|
padding: 8px 10px calc(10px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
padding: 2px 2px 2px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useRef, useState, type ClipboardEvent, type DragEvent, type KeyboardEvent } from "react";
|
||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import type { ImageContent } from "../../types/message";
|
||||||
|
import { isTouchLike } from "../../utils/format";
|
||||||
|
import {
|
||||||
|
clipboardItemsToImages,
|
||||||
|
fileToImageContent,
|
||||||
|
imageToDataUrl,
|
||||||
|
MAX_CHAT_IMAGES,
|
||||||
|
} from "../../utils/images";
|
||||||
|
import styles from "./ChatInput.module.css";
|
||||||
|
|
||||||
|
export function ChatInput() {
|
||||||
|
const { isStreaming, sendMessage, addSystemMessage } = useChatContext();
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const touchLike = isTouchLike();
|
||||||
|
|
||||||
|
const canSend = (value.trim().length > 0 || pendingImages.length > 0) && !isStreaming;
|
||||||
|
|
||||||
|
const addImages = async (files: FileList | File[]) => {
|
||||||
|
const list = Array.from(files);
|
||||||
|
if (!list.length) return;
|
||||||
|
|
||||||
|
const remaining = MAX_CHAT_IMAGES - pendingImages.length;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
addSystemMessage(`最多只能附加 ${MAX_CHAT_IMAGES} 张图片`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: ImageContent[] = [];
|
||||||
|
for (const file of list.slice(0, remaining)) {
|
||||||
|
try {
|
||||||
|
next.push(await fileToImageContent(file));
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next.length) {
|
||||||
|
setPendingImages((prev) => [...prev, ...next].slice(0, MAX_CHAT_IMAGES));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePendingImage = (index: number) => {
|
||||||
|
setPendingImages((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const text = value.trim();
|
||||||
|
if ((!text && pendingImages.length === 0) || isStreaming) return;
|
||||||
|
|
||||||
|
const images = pendingImages.length ? pendingImages : undefined;
|
||||||
|
setValue("");
|
||||||
|
setPendingImages([]);
|
||||||
|
if (inputRef.current) inputRef.current.style.height = "auto";
|
||||||
|
await sendMessage(text, images);
|
||||||
|
if (touchLike) inputRef.current?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.nativeEvent.isComposing) return;
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBeforeInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||||
|
const native = e.nativeEvent as InputEvent;
|
||||||
|
if (touchLike && native.inputType === "insertLineBreak") {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInput = () => {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e: ClipboardEvent<HTMLElement>) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
const hasImage = Array.from(items).some((item) => item.type.startsWith("image/"));
|
||||||
|
if (!hasImage) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
void clipboardItemsToImages(items)
|
||||||
|
.then((images) => {
|
||||||
|
if (!images.length) return;
|
||||||
|
setPendingImages((prev) => {
|
||||||
|
const remaining = MAX_CHAT_IMAGES - prev.length;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
addSystemMessage(`最多只能附加 ${MAX_CHAT_IMAGES} 张图片`);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [...prev, ...images.slice(0, remaining)];
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
addSystemMessage(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files?.length) void addImages(files);
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!e.dataTransfer.types.includes("Files")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
|
||||||
|
setDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||||
|
if (files.length) void addImages(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.inputArea} ${dragOver ? styles.inputAreaDragOver : ""}`}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{pendingImages.length > 0 ? (
|
||||||
|
<div className={styles.attachments}>
|
||||||
|
{pendingImages.map((img, index) => (
|
||||||
|
<div key={`${img.mimeType}-${index}`} className={styles.attachmentItem}>
|
||||||
|
<img
|
||||||
|
className={styles.attachmentThumb}
|
||||||
|
src={imageToDataUrl(img)}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.attachmentRemove}
|
||||||
|
onClick={() => removePendingImage(index)}
|
||||||
|
aria-label="移除图片"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||||
|
multiple
|
||||||
|
className={styles.fileInput}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.attachBtn}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isStreaming || pendingImages.length >= MAX_CHAT_IMAGES}
|
||||||
|
title="上传图片"
|
||||||
|
aria-label="上传图片"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<path d="M21 15l-5-5L5 21" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
className={styles.input}
|
||||||
|
rows={1}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onInput={handleInput}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBeforeInput={handleBeforeInput}
|
||||||
|
placeholder="输入消息,Ctrl+V 粘贴图片..."
|
||||||
|
aria-label="消息输入框"
|
||||||
|
enterKeyHint="send"
|
||||||
|
inputMode="text"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.sendBtn}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSend()}
|
||||||
|
disabled={!canSend}
|
||||||
|
aria-label="发送"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 148px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
|
||||||
|
z-index: 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItemDesc {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.menu {
|
||||||
|
right: auto;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { fetchSessionHistory } from "../../api/sessions";
|
||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import type { ChatMessage } from "../../types/message";
|
||||||
|
import { extractContent, formatToolCall, getToolCalls } from "../../utils/toolCall";
|
||||||
|
import { nextMessageId } from "../../utils/format";
|
||||||
|
import {
|
||||||
|
downloadTextFile,
|
||||||
|
messagesToMarkdown,
|
||||||
|
messagesToMinimalHtml,
|
||||||
|
safeExportFilename,
|
||||||
|
} from "../../utils/exportConversation";
|
||||||
|
import styles from "./ExportMenu.module.css";
|
||||||
|
|
||||||
|
function historyToExportMessages(
|
||||||
|
messages: NonNullable<Awaited<ReturnType<typeof fetchSessionHistory>>["messages"]>,
|
||||||
|
): ChatMessage[] {
|
||||||
|
const result: ChatMessage[] = [];
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.role === "user") {
|
||||||
|
result.push({ id: nextMessageId(), role: "user", content: extractContent(m.content) });
|
||||||
|
} else if (m.role === "assistant") {
|
||||||
|
const text = extractContent(m.content);
|
||||||
|
if (text) result.push({ id: nextMessageId(), role: "assistant", content: text });
|
||||||
|
for (const tc of getToolCalls(m.content)) {
|
||||||
|
const tid = tc.toolCallId || tc.id;
|
||||||
|
result.push({
|
||||||
|
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||||
|
role: "tool_call",
|
||||||
|
content: formatToolCall(tc),
|
||||||
|
toolCallId: tid ? String(tid) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportMenu() {
|
||||||
|
const { headerTitle, messages, activeSessionPath, isStreaming, addSystemMessage } = useChatContext();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const hasMessages = messages.some((m) => !m.streaming && m.content.trim());
|
||||||
|
const canExport = hasMessages && !isStreaming && !exporting;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onPointerDown = (event: MouseEvent) => {
|
||||||
|
if (!wrapRef.current?.contains(event.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onPointerDown);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onPointerDown);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const resolveExportMessages = async (): Promise<ChatMessage[]> => {
|
||||||
|
if (activeSessionPath) {
|
||||||
|
try {
|
||||||
|
const payload = await fetchSessionHistory(activeSessionPath);
|
||||||
|
if (payload.messages?.length) {
|
||||||
|
return historyToExportMessages(payload.messages);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fallback to in-memory messages */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async (format: "markdown" | "html") => {
|
||||||
|
if (!canExport) return;
|
||||||
|
setExporting(true);
|
||||||
|
setOpen(false);
|
||||||
|
try {
|
||||||
|
const exportMessages = await resolveExportMessages();
|
||||||
|
const filtered = exportMessages.filter((m) => !m.streaming && m.content.trim());
|
||||||
|
if (!filtered.length) {
|
||||||
|
addSystemMessage("当前对话没有可导出的内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const title = headerTitle.trim() || "对话";
|
||||||
|
const base = safeExportFilename(title);
|
||||||
|
if (format === "markdown") {
|
||||||
|
downloadTextFile(`${base}.md`, messagesToMarkdown(title, filtered), "text/markdown");
|
||||||
|
} else {
|
||||||
|
downloadTextFile(`${base}.html`, messagesToMinimalHtml(title, filtered), "text/html");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(`导出失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap} ref={wrapRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.btn}
|
||||||
|
disabled={!canExport}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 3v12M7 8l5 5 5-5M5 21h14" />
|
||||||
|
</svg>
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className={styles.menu} role="menu">
|
||||||
|
<button type="button" className={styles.menuItem} role="menuitem" onClick={() => void handleExport("markdown")}>
|
||||||
|
Markdown
|
||||||
|
<span className={styles.menuItemDesc}>.md 格式,保留 Markdown 结构</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.menuItem} role="menuitem" onClick={() => void handleExport("html")}>
|
||||||
|
HTML 超简洁文本
|
||||||
|
<span className={styles.menuItemDesc}>纯文本风格 HTML,便于阅读与分享</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { ChatMessage } from "../../types/message";
|
||||||
|
import { useAvatars } from "../../context/AvatarContext";
|
||||||
|
import {
|
||||||
|
downloadTextFile,
|
||||||
|
messageMarkdownFilename,
|
||||||
|
singleAssistantMarkdown,
|
||||||
|
} from "../../utils/exportConversation";
|
||||||
|
import { renderMarkdown } from "../../utils/markdown";
|
||||||
|
import { imageToDataUrl } from "../../utils/images";
|
||||||
|
import { ToolCallBlock } from "./ToolCallBlock";
|
||||||
|
import styles from "./MessageList.module.css";
|
||||||
|
|
||||||
|
interface MessageBubbleProps {
|
||||||
|
message: ChatMessage;
|
||||||
|
showAgentAvatar?: boolean;
|
||||||
|
agentAvatarSpacer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatAvatar({ src, className }: { src: string; className: string }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
if (!src || failed) return null;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className={className}
|
||||||
|
src={src}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
alt=""
|
||||||
|
decoding="async"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantActions({ content }: { content: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const markdown = singleAssistantMarkdown(content);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(markdown);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
downloadTextFile(messageMarkdownFilename(content), markdown, "text/markdown");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.assistantToolbar}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.msgActionBtn} ${copied ? styles.msgActionBtnCopied : ""}`}
|
||||||
|
onClick={() => void handleCopy()}
|
||||||
|
title="复制 Markdown"
|
||||||
|
>
|
||||||
|
{copied ? "已复制" : "复制"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.msgActionBtn}
|
||||||
|
onClick={handleDownload}
|
||||||
|
title="下载 Markdown"
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBubble({
|
||||||
|
message,
|
||||||
|
showAgentAvatar = false,
|
||||||
|
agentAvatarSpacer = false,
|
||||||
|
}: MessageBubbleProps) {
|
||||||
|
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
|
||||||
|
|
||||||
|
if (message.role === "tool_call") {
|
||||||
|
return (
|
||||||
|
<div className={styles.assistantRow}>
|
||||||
|
{showAgentAvatar ? (
|
||||||
|
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
||||||
|
) : agentAvatarSpacer ? (
|
||||||
|
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
<div className={`${styles.msg} ${styles.toolCall}`}>
|
||||||
|
<ToolCallBlock content={message.content} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const classNames = [styles.msg];
|
||||||
|
if (message.role === "user") classNames.push(styles.user);
|
||||||
|
if (message.role === "assistant") classNames.push(styles.assistant);
|
||||||
|
if (message.role === "system") classNames.push(styles.system);
|
||||||
|
if (message.streaming) classNames.push(styles.streaming);
|
||||||
|
|
||||||
|
if (message.role === "assistant") {
|
||||||
|
const showActions = !message.streaming && message.content.trim().length > 0;
|
||||||
|
return (
|
||||||
|
<div className={styles.assistantRow}>
|
||||||
|
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
||||||
|
<div className={classNames.join(" ")}>
|
||||||
|
{showActions ? <AssistantActions content={message.content} /> : null}
|
||||||
|
<div
|
||||||
|
className={styles.assistantBody}
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === "user") {
|
||||||
|
return (
|
||||||
|
<div className={styles.userRow}>
|
||||||
|
<div className={classNames.join(" ")}>
|
||||||
|
{message.content ? <div>{message.content}</div> : null}
|
||||||
|
{message.images?.length ? (
|
||||||
|
<div className={styles.userImages}>
|
||||||
|
{message.images.map((img, index) => (
|
||||||
|
<img
|
||||||
|
key={`${img.mimeType}-${index}`}
|
||||||
|
className={styles.userImage}
|
||||||
|
src={imageToDataUrl(img)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ChatAvatar src={userAvatarUrl} className={styles.userAvatar} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={classNames.join(" ")}>{message.content}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
.chat {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat::-webkit-scrollbar-thumb {
|
||||||
|
background: #ddd;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
color: #bbb;
|
||||||
|
text-align: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty p {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyHint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
padding: 10px 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-size: 16px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
max-width: min(700px, 85%);
|
||||||
|
background: linear-gradient(180deg, #f0f7ff 0%, #eaf2ff 100%);
|
||||||
|
border: 1px solid #dbeafe;
|
||||||
|
border-radius: 14px 14px 4px 14px;
|
||||||
|
margin: 0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userRow {
|
||||||
|
align-self: flex-end;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: min(700px, 85%);
|
||||||
|
margin: 0 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAvatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userImages {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userImages:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userImage {
|
||||||
|
max-width: min(280px, 100%);
|
||||||
|
max-height: 240px;
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid #dbeafe;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantRow {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantAvatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantAvatarSpacer {
|
||||||
|
width: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8ecf0;
|
||||||
|
border-radius: 14px 14px 14px 4px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
color: #1a1a1a;
|
||||||
|
line-height: 1.46;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantToolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
margin: -2px -2px 6px 0;
|
||||||
|
min-height: 22px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant:hover .assistantToolbar,
|
||||||
|
.assistant:focus-within .assistantToolbar {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msgActionBtn {
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msgActionBtn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msgActionBtnCopied {
|
||||||
|
color: #15803d;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantBody {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system {
|
||||||
|
align-self: center;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolCall {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: #666;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming::after {
|
||||||
|
content: "▊";
|
||||||
|
animation: blink 0.8s step-end infinite;
|
||||||
|
color: #2563eb;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantRow + .assistantRow {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(h1),
|
||||||
|
.assistant :global(h2),
|
||||||
|
.assistant :global(h3),
|
||||||
|
.assistant :global(h4),
|
||||||
|
.assistant :global(h5),
|
||||||
|
.assistant :global(h6) {
|
||||||
|
margin: 0.78em 0 0.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(h1) { font-size: 1.18em; }
|
||||||
|
.assistant :global(h2) { font-size: 1.1em; }
|
||||||
|
.assistant :global(h3) { font-size: 1.03em; }
|
||||||
|
.assistant :global(h4) { font-size: 1em; }
|
||||||
|
|
||||||
|
.assistant > :global(:first-child) { margin-top: 0; }
|
||||||
|
.assistant > :global(:last-child) { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.assistant :global(p) {
|
||||||
|
margin: 0 0 0.46em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(p + p) {
|
||||||
|
margin-top: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(strong) { font-weight: 600; }
|
||||||
|
.assistant :global(em) { font-style: italic; }
|
||||||
|
|
||||||
|
.assistant :global(a) {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(a:hover) { text-decoration: underline; }
|
||||||
|
|
||||||
|
.assistant :global(ul),
|
||||||
|
.assistant :global(ol) {
|
||||||
|
margin: 0.32em 0 0.48em;
|
||||||
|
padding-left: 1.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(li) {
|
||||||
|
margin: 0.08em 0;
|
||||||
|
padding-left: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(li > p) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(blockquote) {
|
||||||
|
margin: 0.36em 0;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-left: 2px solid #e5e5e5;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
margin: 0.58em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(table) {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.42em 0;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(th),
|
||||||
|
.assistant :global(td) {
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
padding: 4px 7px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(th) {
|
||||||
|
background: #fafafa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(code:not(.hljs)) {
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #d63384;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(pre.assistant-pre) {
|
||||||
|
margin: 0.5em 0 0.6em;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #e8edf2;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: 1.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(pre.assistant-pre code.hljs) {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.48;
|
||||||
|
word-break: normal;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant :global(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0.42em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.assistantRow {
|
||||||
|
max-width: 66.6667%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantRow .toolCall {
|
||||||
|
max-width: none;
|
||||||
|
width: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chat {
|
||||||
|
padding: 10px 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
padding: 11px 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userRow {
|
||||||
|
max-width: 88%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolCall {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantAvatarSpacer {
|
||||||
|
width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantAvatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAvatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantToolbar {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.userRow {
|
||||||
|
max-width: 92%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import { MessageBubble } from "./MessageBubble";
|
||||||
|
import styles from "./MessageList.module.css";
|
||||||
|
|
||||||
|
const NEAR_BOTTOM_THRESHOLD = 96;
|
||||||
|
|
||||||
|
function isNearBottom(el: HTMLElement): boolean {
|
||||||
|
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom(el: HTMLElement): void {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList() {
|
||||||
|
const { messages } = useChatContext();
|
||||||
|
const chatRef = useRef<HTMLDivElement>(null);
|
||||||
|
const stickToBottomRef = useRef(true);
|
||||||
|
const prevMessageCountRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = chatRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
stickToBottomRef.current = isNearBottom(el);
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => el.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = chatRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const prevCount = prevMessageCountRef.current;
|
||||||
|
const messageCount = messages.length;
|
||||||
|
prevMessageCountRef.current = messageCount;
|
||||||
|
|
||||||
|
const lastMessage = messages[messageCount - 1];
|
||||||
|
const userJustSent = messageCount > prevCount && lastMessage?.role === "user";
|
||||||
|
|
||||||
|
if (userJustSent || prevCount === 0) {
|
||||||
|
stickToBottomRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stickToBottomRef.current) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (chatRef.current) scrollToBottom(chatRef.current);
|
||||||
|
});
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.chat} ref={chatRef}>
|
||||||
|
<div className={styles.empty}>
|
||||||
|
<div className={styles.emptyIcon}>
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||||
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p>发送一条消息开始对话</p>
|
||||||
|
<p className={styles.emptyHint}>按 Enter 发送 · Shift+Enter 换行</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.chat} ref={chatRef}>
|
||||||
|
{messages.map((msg, index) => {
|
||||||
|
const prev = messages[index - 1];
|
||||||
|
const showAgentAvatar =
|
||||||
|
msg.role === "assistant" ||
|
||||||
|
(msg.role === "tool_call" &&
|
||||||
|
prev?.role !== "tool_call" &&
|
||||||
|
prev?.role !== "assistant");
|
||||||
|
const agentAvatarSpacer =
|
||||||
|
msg.role === "tool_call" &&
|
||||||
|
(prev?.role === "tool_call" || prev?.role === "assistant");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBubble
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
showAgentAvatar={showAgentAvatar}
|
||||||
|
agentAvatarSpacer={agentAvatarSpacer}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
.bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.42;
|
||||||
|
color: #5c6578;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #8b95a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctxWarn {
|
||||||
|
color: #b45309;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctxDanger {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.bar {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import { formatFooterTokens } from "../../utils/format";
|
||||||
|
import styles from "./SessionContextBar.module.css";
|
||||||
|
|
||||||
|
export function SessionContextBar() {
|
||||||
|
const { sessionState, isStreaming } = useChatContext();
|
||||||
|
const data = sessionState;
|
||||||
|
|
||||||
|
if (!data || data.error || !data.stats || !data.model) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tok = data.stats.tokens || {};
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (tok.input) parts.push(`↑${formatFooterTokens(tok.input)}`);
|
||||||
|
if (tok.output) parts.push(`↓${formatFooterTokens(tok.output)}`);
|
||||||
|
if (tok.cacheRead) parts.push(`R${formatFooterTokens(tok.cacheRead)}`);
|
||||||
|
if (tok.cacheWrite) parts.push(`W${formatFooterTokens(tok.cacheWrite)}`);
|
||||||
|
|
||||||
|
const costNum = Number(data.stats.cost ?? 0);
|
||||||
|
parts.push(`$${costNum.toFixed(3)}`);
|
||||||
|
|
||||||
|
const cx = data.stats.contextUsage;
|
||||||
|
const cw = cx?.contextWindow ?? data.model.contextWindow ?? 0;
|
||||||
|
const pctRaw = cx?.percent;
|
||||||
|
const pctStr = pctRaw != null ? Number(pctRaw).toFixed(1) : "?";
|
||||||
|
const pctNum = pctRaw != null ? Number(pctRaw) : null;
|
||||||
|
const autoInd = data.autoCompactionEnabled ? " (auto)" : "";
|
||||||
|
const ctxTxt =
|
||||||
|
pctStr === "?" && cw
|
||||||
|
? `?/${formatFooterTokens(cw)}${autoInd}`
|
||||||
|
: `${pctStr}%/${formatFooterTokens(cw)}${autoInd}`;
|
||||||
|
|
||||||
|
const liveStreaming = isStreaming || data.isStreaming;
|
||||||
|
let sub = "";
|
||||||
|
if (data.isCompacting) sub = "⚡ 正在整理上下文…";
|
||||||
|
else if (liveStreaming) sub = "⋯ 回复中…";
|
||||||
|
else if ((data.turnIndex ?? 0) > 0) sub = `✓ Turn ${data.turnIndex} complete`;
|
||||||
|
|
||||||
|
let ctxClass = "";
|
||||||
|
if (pctNum != null) {
|
||||||
|
if (pctNum > 90) ctxClass = styles.ctxDanger;
|
||||||
|
else if (pctNum > 70) ctxClass = styles.ctxWarn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.bar}>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<div className={styles.left}>
|
||||||
|
{parts.join(" ")} <span className={ctxClass}>{ctxTxt}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sub ? <div className={styles.secondary}>{sub}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
.collapsible {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary::before {
|
||||||
|
content: "";
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 5px solid #888;
|
||||||
|
border-top: 3.5px solid transparent;
|
||||||
|
border-bottom: 3.5px solid transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible[open] > .summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryText {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 10px 8px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailPre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { deriveToolCallSummary } from "../../utils/toolCall";
|
||||||
|
import styles from "./ToolCallBlock.module.css";
|
||||||
|
|
||||||
|
interface ToolCallBlockProps {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolCallBlock({ content }: ToolCallBlockProps) {
|
||||||
|
return (
|
||||||
|
<details className={styles.collapsible}>
|
||||||
|
<summary className={styles.summary}>
|
||||||
|
<span className={styles.summaryText}>{deriveToolCallSummary(content)}</span>
|
||||||
|
</summary>
|
||||||
|
<div className={styles.detail}>
|
||||||
|
<pre className={styles.detailPre}>{content}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
height: var(--app-height);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayOpen {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app {
|
||||||
|
height: var(--app-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import { Sidebar } from "./Sidebar";
|
||||||
|
import layoutStyles from "./AppLayout.module.css";
|
||||||
|
|
||||||
|
export function ChatLayout() {
|
||||||
|
const { sidebarOpen, toggleSidebar } = useChatContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={layoutStyles.app}>
|
||||||
|
<Sidebar />
|
||||||
|
<main className={layoutStyles.main}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<div
|
||||||
|
className={`${layoutStyles.overlay} ${sidebarOpen ? layoutStyles.overlayOpen : ""}`}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trailing {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: min(68%, 680px);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuBtn {
|
||||||
|
display: none;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #555;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuBtn:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info h1 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abortBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abortBtn:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelSelect,
|
||||||
|
.thinkingSelect {
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 11px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelSelect {
|
||||||
|
width: min(260px, 28vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinkingSelect {
|
||||||
|
width: 86px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelSelect:focus,
|
||||||
|
.thinkingSelect:focus {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelSelect:disabled,
|
||||||
|
.thinkingSelect:disabled {
|
||||||
|
color: #9ca3af;
|
||||||
|
background: #f9fafb;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuBtn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trailing {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-left: 44px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelControls {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelSelect {
|
||||||
|
width: min(100%, 260px);
|
||||||
|
flex: 1 1 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import { modelKey, modelLabel } from "../../utils/sessionTitle";
|
||||||
|
import { ExportMenu } from "../chat/ExportMenu";
|
||||||
|
import { SessionContextBar } from "../chat/SessionContextBar";
|
||||||
|
import styles from "./Header.module.css";
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const {
|
||||||
|
connected,
|
||||||
|
isStreaming,
|
||||||
|
headerTitle,
|
||||||
|
headerMeta,
|
||||||
|
toggleSidebar,
|
||||||
|
abortStream,
|
||||||
|
availableModels,
|
||||||
|
currentModelKey,
|
||||||
|
thinkingLevel,
|
||||||
|
availableThinkingLevels,
|
||||||
|
isApplyingModelSettings,
|
||||||
|
applyModelSelection,
|
||||||
|
applyThinkingSelection,
|
||||||
|
} = useChatContext();
|
||||||
|
|
||||||
|
const metaText =
|
||||||
|
headerMeta ||
|
||||||
|
(!connected ? "未连接" : isStreaming ? "回复中" : "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={styles.header}>
|
||||||
|
<button type="button" className={styles.menuBtn} onClick={toggleSidebar} aria-label="切换菜单">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<h1>{headerTitle}</h1>
|
||||||
|
{metaText ? <span className={styles.meta}>{metaText}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.trailing}>
|
||||||
|
<div className={styles.modelControls}>
|
||||||
|
<select
|
||||||
|
className={styles.modelSelect}
|
||||||
|
aria-label="选择模型"
|
||||||
|
title="选择模型"
|
||||||
|
value={currentModelKey}
|
||||||
|
disabled={isStreaming || isApplyingModelSettings}
|
||||||
|
onChange={(e) => void applyModelSelection(e.target.value)}
|
||||||
|
>
|
||||||
|
{availableModels.length === 0 ? (
|
||||||
|
<option value="">加载模型...</option>
|
||||||
|
) : (
|
||||||
|
availableModels.map((m) => (
|
||||||
|
<option key={modelKey(m)} value={modelKey(m)}>
|
||||||
|
{modelLabel(m)}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className={styles.thinkingSelect}
|
||||||
|
aria-label="选择推理强度"
|
||||||
|
title="推理强度"
|
||||||
|
value={
|
||||||
|
availableThinkingLevels.includes(thinkingLevel as typeof availableThinkingLevels[number])
|
||||||
|
? thinkingLevel
|
||||||
|
: availableThinkingLevels[0] || "off"
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
!availableThinkingLevels.length ||
|
||||||
|
availableThinkingLevels.length === 1 ||
|
||||||
|
isStreaming ||
|
||||||
|
isApplyingModelSettings
|
||||||
|
}
|
||||||
|
onChange={(e) => void applyThinkingSelection(e.target.value)}
|
||||||
|
>
|
||||||
|
{availableThinkingLevels.map((level) => (
|
||||||
|
<option key={level} value={level}>
|
||||||
|
{level}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<SessionContextBar />
|
||||||
|
<ExportMenu />
|
||||||
|
{isStreaming ? (
|
||||||
|
<button type="button" className={styles.abortBtn} onClick={() => void abortStream()} title="中止回复">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||||
|
</svg>
|
||||||
|
中止
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
.sidebar {
|
||||||
|
width: 236px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-right: 1px solid #e5e5e5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 100;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 18px 16px 10px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 16px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #999;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionRefresh {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #bbb;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionRefresh:hover {
|
||||||
|
color: #666;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
padding: 8px 10px 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #555;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connected {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming {
|
||||||
|
background: #f59e0b;
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disconnected {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusModel {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #bbb;
|
||||||
|
margin-top: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import { SessionList } from "../session/SessionList";
|
||||||
|
import styles from "./Sidebar.module.css";
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const { connected, isStreaming, sidebarOpen, loadSessions, newSession } = useChatContext();
|
||||||
|
|
||||||
|
const statusDotClass = connected
|
||||||
|
? isStreaming
|
||||||
|
? `${styles.statusDot} ${styles.streaming}`
|
||||||
|
: `${styles.statusDot} ${styles.connected}`
|
||||||
|
: `${styles.statusDot} ${styles.disconnected}`;
|
||||||
|
|
||||||
|
const statusText = !connected ? "未连接" : isStreaming ? "输入中..." : "就绪";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={`${styles.sidebar} ${sidebarOpen ? styles.open : ""}`}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.brand}>
|
||||||
|
<img className={styles.logo} src="/logo.png" width={36} height={36} alt="" decoding="async" />
|
||||||
|
<h2 className={styles.title}>萌小芽</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<span>历史会话</span>
|
||||||
|
<button type="button" className={styles.sectionRefresh} onClick={() => void loadSessions()} title="刷新">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M1 4v6h6M23 20v-6h-6" />
|
||||||
|
<path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SessionList />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.actionBtn} onClick={newSession}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
新会话
|
||||||
|
</button>
|
||||||
|
<Link to="/settings" className={styles.actionBtn}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.7 1.7 0 00.3 1.9l.1.1a2 2 0 01-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.9-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 01-4 0v-.1a1.7 1.7 0 00-1-1.5 1.7 1.7 0 00-1.9.3l-.1.1A2 2 0 014.2 17l.1-.1A1.7 1.7 0 004.6 15a1.7 1.7 0 00-1.5-1H3a2 2 0 010-4h.1a1.7 1.7 0 001.5-1 1.7 1.7 0 00-.3-1.9L4.2 7A2 2 0 017 4.2l.1.1A1.7 1.7 0 009 4.6a1.7 1.7 0 001-1.5V3a2 2 0 014 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.9-.3l.1-.1A2 2 0 0119.8 7l-.1.1a1.7 1.7 0 00-.3 1.9 1.7 1.7 0 001.5 1h.1a2 2 0 010 4h-.1a1.7 1.7 0 00-1.5 1z" />
|
||||||
|
</svg>
|
||||||
|
设置
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.status}>
|
||||||
|
<div className={styles.statusRow}>
|
||||||
|
<span className={statusDotClass} />
|
||||||
|
<span>{statusText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
.bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: min(92vw, 420px);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary,
|
||||||
|
.secondary {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
color: #fff;
|
||||||
|
background: #2563eb;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
color: #4b5563;
|
||||||
|
background: #fff;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useRegisterSW } from "virtual:pwa-register/react";
|
||||||
|
import styles from "./PwaUpdatePrompt.module.css";
|
||||||
|
|
||||||
|
export function PwaUpdatePrompt() {
|
||||||
|
const {
|
||||||
|
needRefresh: [needRefresh, setNeedRefresh],
|
||||||
|
updateServiceWorker,
|
||||||
|
} = useRegisterSW({
|
||||||
|
onRegistered(registration) {
|
||||||
|
if (!registration) return;
|
||||||
|
window.setInterval(() => {
|
||||||
|
void registration.update();
|
||||||
|
}, 60 * 60 * 1000);
|
||||||
|
},
|
||||||
|
onRegisterError(error) {
|
||||||
|
console.error("[PWA] service worker registration failed:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!needRefresh) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.bar} role="status" aria-live="polite">
|
||||||
|
<div className={styles.text}>发现新版本,更新后即可使用最新功能。</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.secondary} onClick={() => setNeedRefresh(false)}>
|
||||||
|
稍后
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primary}
|
||||||
|
onClick={() => void updateServiceWorker(true)}
|
||||||
|
>
|
||||||
|
立即更新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
.splash {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 12%, rgba(37, 99, 235, 0.12), transparent 34%),
|
||||||
|
radial-gradient(circle at 82% 88%, rgba(34, 197, 94, 0.08), transparent 32%),
|
||||||
|
linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%);
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.42s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splashExiting {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgPulse {
|
||||||
|
position: absolute;
|
||||||
|
inset: -20%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 45%, rgba(37, 99, 235, 0.14), transparent 42%),
|
||||||
|
radial-gradient(circle at 50% 55%, rgba(34, 197, 94, 0.1), transparent 48%);
|
||||||
|
animation: bgPulse 3.6s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgGlow {
|
||||||
|
position: absolute;
|
||||||
|
width: min(72vw, 420px);
|
||||||
|
height: min(72vw, 420px);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(37, 99, 235, 0.12) 0%, transparent 68%);
|
||||||
|
filter: blur(8px);
|
||||||
|
animation: glowDrift 4.8s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoWrap {
|
||||||
|
position: relative;
|
||||||
|
width: 132px;
|
||||||
|
height: 132px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(37, 99, 235, 0.28);
|
||||||
|
opacity: 0;
|
||||||
|
animation: ringExpand 2.4s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring2 {
|
||||||
|
animation-delay: 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring3 {
|
||||||
|
animation-delay: 1.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow:
|
||||||
|
0 10px 28px rgba(37, 99, 235, 0.18),
|
||||||
|
0 2px 8px rgba(15, 23, 42, 0.08);
|
||||||
|
animation: logoFloat 2.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: clamp(28px, 6vw, 36px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.4px;
|
||||||
|
color: #111827;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #22c55e;
|
||||||
|
animation: dotPulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot2 {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot3 {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bgPulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 0.72; }
|
||||||
|
50% { transform: scale(1.06); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glowDrift {
|
||||||
|
0%, 100% { transform: translateY(0) scale(1); }
|
||||||
|
50% { transform: translateY(-10px) scale(1.04); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logoFloat {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-8px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ringExpand {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.72);
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
opacity: 0.12;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.45);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dotPulse {
|
||||||
|
0%, 80%, 100% {
|
||||||
|
transform: scale(0.72);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bgPulse,
|
||||||
|
.bgGlow,
|
||||||
|
.logo,
|
||||||
|
.ring,
|
||||||
|
.dot {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import styles from "./SplashScreen.module.css";
|
||||||
|
|
||||||
|
interface SplashScreenProps {
|
||||||
|
exiting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SplashScreen({ exiting = false }: SplashScreenProps) {
|
||||||
|
return (
|
||||||
|
<div className={`${styles.splash} ${exiting ? styles.splashExiting : ""}`} aria-hidden={exiting}>
|
||||||
|
<div className={styles.bgPulse} />
|
||||||
|
<div className={styles.bgGlow} />
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.logoWrap}>
|
||||||
|
<span className={styles.ring} />
|
||||||
|
<span className={`${styles.ring} ${styles.ring2}`} />
|
||||||
|
<span className={`${styles.ring} ${styles.ring3}`} />
|
||||||
|
<img className={styles.logo} src="/logo192.png" width={96} height={96} alt="" decoding="async" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.titleBlock}>
|
||||||
|
<h1 className={styles.title}>萌小芽</h1>
|
||||||
|
<p className={styles.subtitle}>加载中</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.dots} aria-label="加载中">
|
||||||
|
<span className={styles.dot} />
|
||||||
|
<span className={`${styles.dot} ${styles.dot2}`} />
|
||||||
|
<span className={`${styles.dot} ${styles.dot3}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 8px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list::-webkit-scrollbar-thumb {
|
||||||
|
background: #ddd;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 16px 10px;
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
background: #f0f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned {
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned.active {
|
||||||
|
background: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 4px 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinnedBadge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #ca8a04;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinnedBadge:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: #a16207;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleRow .name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #bbb;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-right: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover .actions,
|
||||||
|
.active .actions,
|
||||||
|
.actions:focus-within {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinBtn,
|
||||||
|
.renameBtn,
|
||||||
|
.deleteBtn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #bbb;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinBtn:hover {
|
||||||
|
color: #ca8a04;
|
||||||
|
background: #fef9c3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renameBtn:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn:hover {
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useChatContext } from "../../context/ChatContext";
|
||||||
|
import { formatTimeAgo } from "../../utils/format";
|
||||||
|
import { formatSessionTitle } from "../../utils/sessionTitle";
|
||||||
|
import styles from "./SessionList.module.css";
|
||||||
|
|
||||||
|
export function SessionList() {
|
||||||
|
const { sessions, activeSessionPath, loadSession, deleteSession, renameSession, toggleSessionPin } =
|
||||||
|
useChatContext();
|
||||||
|
|
||||||
|
if (!sessions.length) {
|
||||||
|
return <div className={styles.empty}>暂无历史会话</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{sessions.map((s) => {
|
||||||
|
const isActive = activeSessionPath === s.path;
|
||||||
|
const isPinned = Boolean(s.pinned);
|
||||||
|
const title = formatSessionTitle(s.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.path}
|
||||||
|
className={`${styles.item} ${isActive ? styles.active : ""} ${isPinned ? styles.pinned : ""}`}
|
||||||
|
>
|
||||||
|
<button type="button" className={styles.main} onClick={() => void loadSession(s.path)}>
|
||||||
|
<div className={styles.titleRow}>
|
||||||
|
{isPinned ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.pinnedBadge}
|
||||||
|
title="取消置顶"
|
||||||
|
aria-label={`取消置顶 ${title}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void toggleSessionPin(s.path, false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M16 3H5a1 1 0 00-.8 1.6l4.6 6.12-4.6 6.12A1 1 0 005 18h11l4 3V3l-4 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.name}>{title}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.meta}>
|
||||||
|
{s.messageCount ?? 0} 条 · {formatTimeAgo(s.modified || s.created)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{!isPinned ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.pinBtn}
|
||||||
|
title="置顶会话"
|
||||||
|
aria-label={`置顶 ${title}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void toggleSessionPin(s.path, true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
|
<path d="M12 17v5" />
|
||||||
|
<path d="M9 3h6l1 7h4l-5 6v4H9v-4L4 10h4l1-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.renameBtn}
|
||||||
|
title="重命名会话"
|
||||||
|
aria-label={`重命名 ${title}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void renameSession(s.path, title);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.deleteBtn}
|
||||||
|
title="删除会话"
|
||||||
|
aria-label={`删除 ${title}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void deleteSession(s.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import * as settingsApi from "../api/settings";
|
||||||
|
import type { AvatarSettings } from "../types/events";
|
||||||
|
|
||||||
|
interface AvatarContextValue extends AvatarSettings {
|
||||||
|
loaded: boolean;
|
||||||
|
refreshAvatars: () => Promise<void>;
|
||||||
|
updateAvatars: (next: AvatarSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvatarContext = createContext<AvatarContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AvatarProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [userAvatarUrl, setUserAvatarUrl] = useState("");
|
||||||
|
const [agentAvatarUrl, setAgentAvatarUrl] = useState("");
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
const refreshAvatars = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await settingsApi.fetchAvatars();
|
||||||
|
setUserAvatarUrl(data.userAvatarUrl || "");
|
||||||
|
setAgentAvatarUrl(data.agentAvatarUrl || "");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateAvatars = useCallback((next: AvatarSettings) => {
|
||||||
|
setUserAvatarUrl(next.userAvatarUrl || "");
|
||||||
|
setAgentAvatarUrl(next.agentAvatarUrl || "");
|
||||||
|
setLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshAvatars();
|
||||||
|
}, [refreshAvatars]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
(): AvatarContextValue => ({
|
||||||
|
userAvatarUrl,
|
||||||
|
agentAvatarUrl,
|
||||||
|
loaded,
|
||||||
|
refreshAvatars,
|
||||||
|
updateAvatars,
|
||||||
|
}),
|
||||||
|
[userAvatarUrl, agentAvatarUrl, loaded, refreshAvatars, updateAvatars],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AvatarContext.Provider value={value}>{children}</AvatarContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAvatars(): AvatarContextValue {
|
||||||
|
const ctx = useContext(AvatarContext);
|
||||||
|
if (!ctx) throw new Error("useAvatars must be used within AvatarProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { SplashScreen } from "../components/pwa/SplashScreen";
|
||||||
|
|
||||||
|
const MIN_SPLASH_MS = 400;
|
||||||
|
const SKIP_SPLASH_KEY = "sproutclaw-warm-boot";
|
||||||
|
|
||||||
|
type BootstrapRoute = "chat" | "settings";
|
||||||
|
|
||||||
|
interface BootstrapContextValue {
|
||||||
|
markRouteReady: (route: BootstrapRoute) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BootstrapContext = createContext<BootstrapContextValue | null>(null);
|
||||||
|
|
||||||
|
export function BootstrapProvider({ children }: { children: ReactNode }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const route: BootstrapRoute = location.pathname.startsWith("/settings") ? "settings" : "chat";
|
||||||
|
const [readyRoutes, setReadyRoutes] = useState<Record<BootstrapRoute, boolean>>({
|
||||||
|
chat: false,
|
||||||
|
settings: false,
|
||||||
|
});
|
||||||
|
const [splashVisible, setSplashVisible] = useState(true);
|
||||||
|
const [splashExiting, setSplashExiting] = useState(false);
|
||||||
|
const mountTime = useRef(Date.now());
|
||||||
|
|
||||||
|
const markRouteReady = useCallback((readyRoute: BootstrapRoute) => {
|
||||||
|
setReadyRoutes((prev) => (prev[readyRoute] ? prev : { ...prev, [readyRoute]: true }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReadyRoutes((prev) => ({ ...prev, [route]: false }));
|
||||||
|
setSplashVisible(true);
|
||||||
|
setSplashExiting(false);
|
||||||
|
mountTime.current = Date.now();
|
||||||
|
}, [route]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!readyRoutes[route]) return;
|
||||||
|
|
||||||
|
let warmBoot = false;
|
||||||
|
try {
|
||||||
|
warmBoot = sessionStorage.getItem(SKIP_SPLASH_KEY) === "1";
|
||||||
|
sessionStorage.setItem(SKIP_SPLASH_KEY, "1");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warmBoot) {
|
||||||
|
setSplashVisible(false);
|
||||||
|
setSplashExiting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Date.now() - mountTime.current;
|
||||||
|
const remaining = Math.max(0, MIN_SPLASH_MS - elapsed);
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setSplashExiting(true);
|
||||||
|
window.setTimeout(() => setSplashVisible(false), 420);
|
||||||
|
}, remaining);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [readyRoutes, route]);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ markRouteReady }), [markRouteReady]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BootstrapContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
{splashVisible ? <SplashScreen exiting={splashExiting} /> : null}
|
||||||
|
</BootstrapContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBootstrap(): BootstrapContextValue {
|
||||||
|
const ctx = useContext(BootstrapContext);
|
||||||
|
if (!ctx) throw new Error("useBootstrap must be used within BootstrapProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
937
.pi/agent/extensions/webui/frontend/src/context/ChatContext.tsx
Normal file
937
.pi/agent/extensions/webui/frontend/src/context/ChatContext.tsx
Normal file
@@ -0,0 +1,937 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import * as chatApi from "../api/chat";
|
||||||
|
import * as sessionsApi from "../api/sessions";
|
||||||
|
import { useBootstrap } from "./BootstrapContext";
|
||||||
|
import type { SseEvent } from "../types/events";
|
||||||
|
import type { ChatMessage, ImageContent, ModelInfo, SessionState, ThinkingLevel } from "../types/message";
|
||||||
|
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
|
||||||
|
import { nextMessageId, syncViewportHeight } from "../utils/format";
|
||||||
|
import {
|
||||||
|
extractContent,
|
||||||
|
extractImages,
|
||||||
|
formatToolCall,
|
||||||
|
getToolCalls,
|
||||||
|
buildToolCallUpdateText,
|
||||||
|
} from "../utils/toolCall";
|
||||||
|
import {
|
||||||
|
formatSessionTitle,
|
||||||
|
isMachineSessionLabel,
|
||||||
|
modelKey,
|
||||||
|
titleFromFirstUserMessage,
|
||||||
|
} from "../utils/sessionTitle";
|
||||||
|
|
||||||
|
interface ChatContextValue {
|
||||||
|
connected: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
activeSessionPath: string | null;
|
||||||
|
headerTitle: string;
|
||||||
|
headerMeta: string;
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
sessionState: SessionState | null;
|
||||||
|
availableModels: ModelInfo[];
|
||||||
|
currentModelKey: string;
|
||||||
|
thinkingLevel: string;
|
||||||
|
availableThinkingLevels: ThinkingLevel[];
|
||||||
|
isApplyingModelSettings: boolean;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
closeSidebar: () => void;
|
||||||
|
loadSessions: () => Promise<void>;
|
||||||
|
loadSession: (path: string) => Promise<void>;
|
||||||
|
deleteSession: (path: string) => Promise<void>;
|
||||||
|
renameSession: (path: string, currentTitle: string) => Promise<void>;
|
||||||
|
toggleSessionPin: (path: string, pinned: boolean) => Promise<void>;
|
||||||
|
newSession: () => void;
|
||||||
|
sendMessage: (text: string, images?: ImageContent[]) => Promise<void>;
|
||||||
|
abortStream: () => Promise<void>;
|
||||||
|
applyModelSelection: (key: string) => Promise<void>;
|
||||||
|
applyThinkingSelection: (level: string) => Promise<void>;
|
||||||
|
addSystemMessage: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatContext = createContext<ChatContextValue | null>(null);
|
||||||
|
|
||||||
|
const THINKING_LEVELS_LIST: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
||||||
|
|
||||||
|
function getAvailableThinkingLevels(model: ModelInfo | undefined): ThinkingLevel[] {
|
||||||
|
if (!model?.reasoning) return ["off"];
|
||||||
|
return THINKING_LEVELS_LIST.filter((level) => {
|
||||||
|
const mapped = model.thinkingLevelMap?.[level];
|
||||||
|
if (mapped === null) return false;
|
||||||
|
if (level === "xhigh") return mapped !== undefined;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyToMessages(payload: SessionHistoryPayload): ChatMessage[] {
|
||||||
|
const result: ChatMessage[] = [];
|
||||||
|
for (const m of payload.messages || []) {
|
||||||
|
if (m.role === "user") {
|
||||||
|
const text = extractContent(m.content);
|
||||||
|
const images = extractImages(m.content);
|
||||||
|
if (text || images.length) {
|
||||||
|
result.push({
|
||||||
|
id: nextMessageId(),
|
||||||
|
role: "user",
|
||||||
|
content: text,
|
||||||
|
...(images.length ? { images } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (m.role === "assistant") {
|
||||||
|
const text = extractContent(m.content);
|
||||||
|
const toolCalls = getToolCalls(m.content);
|
||||||
|
if (text) {
|
||||||
|
result.push({ id: nextMessageId(), role: "assistant", content: text });
|
||||||
|
}
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
const tid = tc.toolCallId || tc.id;
|
||||||
|
result.push({
|
||||||
|
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||||
|
role: "tool_call",
|
||||||
|
content: formatToolCall(tc),
|
||||||
|
toolCallId: tid ? String(tid) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStreamingAssistantId(messages: ChatMessage[]): string | null {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i];
|
||||||
|
if (msg.role === "assistant" && msg.streaming) {
|
||||||
|
return msg.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeAssistantInList(
|
||||||
|
prev: ChatMessage[],
|
||||||
|
targetId: string,
|
||||||
|
assistantText: string,
|
||||||
|
): ChatMessage[] {
|
||||||
|
let next = prev.map((msg) =>
|
||||||
|
msg.id === targetId ? { ...msg, content: assistantText, streaming: false } : msg,
|
||||||
|
);
|
||||||
|
if (!assistantText.trim()) {
|
||||||
|
next = next.filter((msg) => msg.id !== targetId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { markRouteReady } = useBootstrap();
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||||
|
const [activeSessionPath, setActiveSessionPath] = useState<string | null>(null);
|
||||||
|
const [backendSessionPath, setBackendSessionPath] = useState<string | null>(null);
|
||||||
|
const [headerTitle, setHeaderTitle] = useState("聊天");
|
||||||
|
const [headerMeta, setHeaderMeta] = useState("");
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [sessionState, setSessionState] = useState<SessionState | null>(null);
|
||||||
|
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||||
|
const [currentModelKey, setCurrentModelKey] = useState("");
|
||||||
|
const [thinkingLevel, setThinkingLevel] = useState("off");
|
||||||
|
const [isApplyingModelSettings, setIsApplyingModelSettings] = useState(false);
|
||||||
|
|
||||||
|
const sessionCache = useRef(new Map<string, SessionHistoryPayload>());
|
||||||
|
const sessionActivationPromise = useRef<Promise<void> | null>(null);
|
||||||
|
const newSessionPromise = useRef<Promise<void> | null>(null);
|
||||||
|
const freshSessionBootRef = useRef<Promise<void> | null>(null);
|
||||||
|
const streamingAssistantId = useRef<string | null>(null);
|
||||||
|
const optimisticUserIdRef = useRef<string | null>(null);
|
||||||
|
const sessionDashboardRaf = useRef<number | null>(null);
|
||||||
|
const backendSessionPathRef = useRef(backendSessionPath);
|
||||||
|
const activeSessionPathRef = useRef(activeSessionPath);
|
||||||
|
const isStreamingRef = useRef(isStreaming);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
backendSessionPathRef.current = backendSessionPath;
|
||||||
|
}, [backendSessionPath]);
|
||||||
|
useEffect(() => {
|
||||||
|
activeSessionPathRef.current = activeSessionPath;
|
||||||
|
}, [activeSessionPath]);
|
||||||
|
useEffect(() => {
|
||||||
|
isStreamingRef.current = isStreaming;
|
||||||
|
}, [isStreaming]);
|
||||||
|
|
||||||
|
const availableThinkingLevels = useMemo(
|
||||||
|
() => getAvailableThinkingLevels(sessionState?.model),
|
||||||
|
[sessionState?.model],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addSystemMessage = useCallback((text: string) => {
|
||||||
|
setMessages((prev) => [...prev, { id: nextMessageId(), role: "system", content: text }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleSessionDashboardRefresh = useCallback(() => {
|
||||||
|
if (sessionDashboardRaf.current != null) return;
|
||||||
|
sessionDashboardRaf.current = requestAnimationFrame(() => {
|
||||||
|
sessionDashboardRaf.current = null;
|
||||||
|
void chatApi.fetchSessionState().then((data) => {
|
||||||
|
if (data.error) return;
|
||||||
|
setSessionState(data);
|
||||||
|
if (data.model) setCurrentModelKey(modelKey(data.model));
|
||||||
|
if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
|
||||||
|
|
||||||
|
if (!activeSessionPathRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backendSessionPathRef.current !== activeSessionPathRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = data.sessionId != null ? String(data.sessionId) : "";
|
||||||
|
if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) {
|
||||||
|
setHeaderTitle(formatSessionTitle(data.sessionName));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSessionStateFull = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await chatApi.fetchSessionState();
|
||||||
|
if (data.error) return;
|
||||||
|
setSessionState(data);
|
||||||
|
if (data.model) setCurrentModelKey(modelKey(data.model));
|
||||||
|
if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
|
||||||
|
|
||||||
|
if (!activeSessionPathRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backendSessionPathRef.current !== activeSessionPathRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = data.sessionId != null ? String(data.sessionId) : "";
|
||||||
|
const pathKey = activeSessionPathRef.current;
|
||||||
|
setSessions((currentSessions) => {
|
||||||
|
const row = pathKey ? currentSessions.find((s) => s.path === pathKey) : undefined;
|
||||||
|
if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) {
|
||||||
|
setHeaderTitle(formatSessionTitle(data.sessionName));
|
||||||
|
} else if (row) {
|
||||||
|
if (row.name && !isMachineSessionLabel(row.name, sid)) {
|
||||||
|
setHeaderTitle(formatSessionTitle(row.name));
|
||||||
|
} else if (row.firstMessage && row.firstMessage !== "(空)") {
|
||||||
|
setHeaderTitle(formatSessionTitle(titleFromFirstUserMessage(row.firstMessage)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return currentSessions;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activateSessionBackend = useCallback(async (path: string) => {
|
||||||
|
await sessionsApi.activateSessionBackend(path);
|
||||||
|
setBackendSessionPath(path);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSessions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await sessionsApi.fetchSessions();
|
||||||
|
setSessions(data.sessions || []);
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(`会话列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}, [addSystemMessage]);
|
||||||
|
|
||||||
|
const clearChat = useCallback((title = "聊天") => {
|
||||||
|
setMessages([]);
|
||||||
|
streamingAssistantId.current = null;
|
||||||
|
setActiveSessionPath(null);
|
||||||
|
setHeaderTitle(title);
|
||||||
|
setSessionState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ensureFreshSessionOnBoot = useCallback(() => {
|
||||||
|
if (!freshSessionBootRef.current) {
|
||||||
|
freshSessionBootRef.current = (async () => {
|
||||||
|
const data = await chatApi.createNewSession();
|
||||||
|
setBackendSessionPath(data.sessionFile || null);
|
||||||
|
setActiveSessionPath(null);
|
||||||
|
setMessages([]);
|
||||||
|
streamingAssistantId.current = null;
|
||||||
|
setHeaderTitle("聊天");
|
||||||
|
sessionActivationPromise.current = null;
|
||||||
|
})().catch((err) => {
|
||||||
|
freshSessionBootRef.current = null;
|
||||||
|
addSystemMessage(
|
||||||
|
`初始化新会话失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return freshSessionBootRef.current;
|
||||||
|
}, [addSystemMessage]);
|
||||||
|
|
||||||
|
const loadSession = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
setActiveSessionPath(path);
|
||||||
|
setHeaderMeta("加载中...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
let payload = sessionCache.current.get(path);
|
||||||
|
const poisonName = (p: SessionHistoryPayload) =>
|
||||||
|
typeof p?.session?.name === "string" &&
|
||||||
|
p.session.name.trim() &&
|
||||||
|
isMachineSessionLabel(p.session.name.trim(), String(p.session?.id ?? ""));
|
||||||
|
if (payload && poisonName(payload)) {
|
||||||
|
sessionCache.current.delete(path);
|
||||||
|
payload = undefined;
|
||||||
|
}
|
||||||
|
if (!payload) {
|
||||||
|
payload = await sessionsApi.fetchSessionHistory(path);
|
||||||
|
if (payload.error) throw new Error(payload.error);
|
||||||
|
sessionCache.current.set(path, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = payload.session?.id != null ? String(payload.session.id) : "";
|
||||||
|
let headerLabel = "";
|
||||||
|
if (
|
||||||
|
typeof payload.session?.name === "string" &&
|
||||||
|
payload.session.name.trim() &&
|
||||||
|
!isMachineSessionLabel(payload.session.name.trim(), sid)
|
||||||
|
) {
|
||||||
|
headerLabel = payload.session.name.trim();
|
||||||
|
}
|
||||||
|
if (!headerLabel) {
|
||||||
|
for (const m of payload.messages || []) {
|
||||||
|
if (m.role === "user") {
|
||||||
|
const fb = titleFromFirstUserMessage(extractContent(m.content));
|
||||||
|
if (fb) {
|
||||||
|
headerLabel = fb;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (extractImages(m.content).length) {
|
||||||
|
headerLabel = `[${extractImages(m.content).length} 张图片]`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHeaderTitle(formatSessionTitle(headerLabel));
|
||||||
|
setMessages(historyToMessages(payload));
|
||||||
|
setHeaderMeta("");
|
||||||
|
scheduleSessionDashboardRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
setHeaderMeta("");
|
||||||
|
addSystemMessage(`加载会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addSystemMessage, scheduleSessionDashboardRefresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteSessionHandler = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
if (isStreamingRef.current) {
|
||||||
|
addSystemMessage("正在回复中,暂不能删除会话");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = sessions.find((s) => s.path === path);
|
||||||
|
const title = formatSessionTitle(row?.name || row?.firstMessage || "该会话");
|
||||||
|
if (!confirm(`删除会话「${title}」?此操作不可恢复。`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionsApi.deleteSession(path);
|
||||||
|
sessionCache.current.delete(path);
|
||||||
|
setSessions((prev) => prev.filter((s) => s.path !== path));
|
||||||
|
if (activeSessionPathRef.current === path) {
|
||||||
|
clearChat();
|
||||||
|
setBackendSessionPath(null);
|
||||||
|
sessionActivationPromise.current = null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(`删除会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sessions, addSystemMessage, clearChat],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renameSessionHandler = useCallback(
|
||||||
|
async (path: string, currentTitle: string) => {
|
||||||
|
if (isStreamingRef.current) {
|
||||||
|
addSystemMessage("正在回复中,暂不能重命名会话");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = sessions.find((s) => s.path === path);
|
||||||
|
const fallback = formatSessionTitle(row?.name || row?.firstMessage || currentTitle);
|
||||||
|
const next = window.prompt("重命名会话", fallback);
|
||||||
|
if (next === null) return;
|
||||||
|
const trimmed = next.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
addSystemMessage("会话名称不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionsApi.renameSession(path, trimmed);
|
||||||
|
sessionCache.current.delete(path);
|
||||||
|
setSessions((prev) =>
|
||||||
|
prev.map((s) => (s.path === path ? { ...s, name: trimmed } : s)),
|
||||||
|
);
|
||||||
|
if (activeSessionPathRef.current === path) {
|
||||||
|
setHeaderTitle(formatSessionTitle(trimmed));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(`重命名失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sessions, addSystemMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleSessionPinHandler = useCallback(
|
||||||
|
async (path: string, pinned: boolean) => {
|
||||||
|
if (isStreamingRef.current) {
|
||||||
|
addSystemMessage("正在回复中,暂不能修改置顶");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionsApi.setSessionPinned(path, pinned);
|
||||||
|
await loadSessions();
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(`${pinned ? "置顶" : "取消置顶"}失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addSystemMessage, loadSessions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const newSession = useCallback(() => {
|
||||||
|
if (isStreamingRef.current) {
|
||||||
|
addSystemMessage("正在回复中,暂不能创建新会话");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newSessionPromise.current) return;
|
||||||
|
|
||||||
|
setBackendSessionPath(null);
|
||||||
|
setActiveSessionPath(null);
|
||||||
|
sessionActivationPromise.current = null;
|
||||||
|
setHeaderMeta("");
|
||||||
|
clearChat("新会话");
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
const data = await chatApi.createNewSession();
|
||||||
|
setBackendSessionPath(data.sessionFile || null);
|
||||||
|
await fetchSessionStateFull();
|
||||||
|
})();
|
||||||
|
|
||||||
|
newSessionPromise.current = promise;
|
||||||
|
void promise
|
||||||
|
.catch((err) => {
|
||||||
|
setHeaderMeta("");
|
||||||
|
addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
newSessionPromise.current = null;
|
||||||
|
});
|
||||||
|
}, [addSystemMessage, clearChat, fetchSessionStateFull]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
async (text: string, images?: ImageContent[]) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
const hasImages = Array.isArray(images) && images.length > 0;
|
||||||
|
if ((!trimmed && !hasImages) || isStreamingRef.current) return;
|
||||||
|
|
||||||
|
if (newSessionPromise.current) {
|
||||||
|
try {
|
||||||
|
await newSessionPromise.current;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freshSessionBootRef.current) {
|
||||||
|
try {
|
||||||
|
await freshSessionBootRef.current;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backendSessionPathRef.current && !activeSessionPathRef.current) {
|
||||||
|
try {
|
||||||
|
const data = await chatApi.createNewSession();
|
||||||
|
setBackendSessionPath(data.sessionFile || null);
|
||||||
|
} catch (err) {
|
||||||
|
addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSessionPathRef.current && backendSessionPathRef.current !== activeSessionPathRef.current) {
|
||||||
|
setHeaderMeta("正在切换会话...");
|
||||||
|
if (sessionActivationPromise.current) {
|
||||||
|
try {
|
||||||
|
await sessionActivationPromise.current;
|
||||||
|
} catch {
|
||||||
|
setHeaderMeta("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const activationPromise = activateSessionBackend(activeSessionPathRef.current);
|
||||||
|
sessionActivationPromise.current = activationPromise;
|
||||||
|
await activationPromise;
|
||||||
|
} catch (err) {
|
||||||
|
setHeaderMeta("");
|
||||||
|
addSystemMessage(`切换会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
sessionActivationPromise.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHeaderMeta("");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = nextMessageId();
|
||||||
|
optimisticUserIdRef.current = userId;
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: userId,
|
||||||
|
role: "user",
|
||||||
|
content: trimmed,
|
||||||
|
...(hasImages ? { images } : {}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await chatApi.sendChat(trimmed, hasImages ? images : undefined);
|
||||||
|
} catch (err) {
|
||||||
|
const failedId = optimisticUserIdRef.current;
|
||||||
|
optimisticUserIdRef.current = null;
|
||||||
|
if (failedId) {
|
||||||
|
setMessages((prev) => prev.filter((msg) => msg.id !== failedId));
|
||||||
|
}
|
||||||
|
addSystemMessage(`发送失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activateSessionBackend, addSystemMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const abortStream = useCallback(async () => {
|
||||||
|
if (!isStreamingRef.current) return;
|
||||||
|
try {
|
||||||
|
await chatApi.abortChat();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyModelSelection = useCallback(
|
||||||
|
async (key: string) => {
|
||||||
|
if (!key || isApplyingModelSettings) return;
|
||||||
|
const [provider, ...idParts] = key.split("/");
|
||||||
|
const modelId = idParts.join("/");
|
||||||
|
if (!provider || !modelId) return;
|
||||||
|
|
||||||
|
setIsApplyingModelSettings(true);
|
||||||
|
setHeaderMeta("正在切换模型");
|
||||||
|
try {
|
||||||
|
await chatApi.setModel(provider, modelId);
|
||||||
|
await fetchSessionStateFull();
|
||||||
|
setHeaderMeta("模型已切换");
|
||||||
|
setTimeout(() => setHeaderMeta(""), 1000);
|
||||||
|
} catch (err) {
|
||||||
|
setHeaderMeta("");
|
||||||
|
addSystemMessage(`切换模型失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
await fetchSessionStateFull();
|
||||||
|
} finally {
|
||||||
|
setIsApplyingModelSettings(false);
|
||||||
|
await fetchSessionStateFull();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isApplyingModelSettings, addSystemMessage, fetchSessionStateFull],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyThinkingSelection = useCallback(
|
||||||
|
async (level: string) => {
|
||||||
|
if (isApplyingModelSettings) return;
|
||||||
|
setIsApplyingModelSettings(true);
|
||||||
|
setHeaderMeta("正在设置推理强度");
|
||||||
|
try {
|
||||||
|
await chatApi.setThinkingLevel(level);
|
||||||
|
await fetchSessionStateFull();
|
||||||
|
setHeaderMeta("推理强度已设置");
|
||||||
|
setTimeout(() => setHeaderMeta(""), 1000);
|
||||||
|
} catch (err) {
|
||||||
|
setHeaderMeta("");
|
||||||
|
addSystemMessage(`设置推理强度失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
await fetchSessionStateFull();
|
||||||
|
} finally {
|
||||||
|
setIsApplyingModelSettings(false);
|
||||||
|
await fetchSessionStateFull();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isApplyingModelSettings, addSystemMessage, fetchSessionStateFull],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSseEvent = useCallback(
|
||||||
|
(ev: SseEvent) => {
|
||||||
|
switch (ev.type) {
|
||||||
|
case "connected":
|
||||||
|
setConnected(true);
|
||||||
|
scheduleSessionDashboardRefresh();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "agent_start":
|
||||||
|
setIsStreaming(true);
|
||||||
|
setConnected(true);
|
||||||
|
streamingAssistantId.current = null;
|
||||||
|
scheduleSessionDashboardRefresh();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "agent_end":
|
||||||
|
setIsStreaming(false);
|
||||||
|
setConnected(true);
|
||||||
|
setMessages((prev) => {
|
||||||
|
const streamId =
|
||||||
|
streamingAssistantId.current ?? findStreamingAssistantId(prev);
|
||||||
|
if (!streamId) return prev;
|
||||||
|
return prev.map((msg) =>
|
||||||
|
msg.id === streamId && msg.streaming ? { ...msg, streaming: false } : msg,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (backendSessionPathRef.current) {
|
||||||
|
sessionCache.current.delete(backendSessionPathRef.current);
|
||||||
|
}
|
||||||
|
setTimeout(() => void loadSessions(), 1000);
|
||||||
|
void fetchSessionStateFull();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "message_start": {
|
||||||
|
const m = ev.message;
|
||||||
|
if (m.role === "user") {
|
||||||
|
const userText = extractContent(m.content);
|
||||||
|
const userImages = extractImages(m.content);
|
||||||
|
if (optimisticUserIdRef.current) {
|
||||||
|
const optId = optimisticUserIdRef.current;
|
||||||
|
optimisticUserIdRef.current = null;
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((msg) =>
|
||||||
|
msg.id === optId
|
||||||
|
? {
|
||||||
|
...msg,
|
||||||
|
content: userText || msg.content,
|
||||||
|
images: userImages.length ? userImages : msg.images,
|
||||||
|
}
|
||||||
|
: msg,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: nextMessageId(),
|
||||||
|
role: "user",
|
||||||
|
content: userText,
|
||||||
|
...(userImages.length ? { images: userImages } : {}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message_update": {
|
||||||
|
const m = ev.message;
|
||||||
|
if (m.role === "assistant") {
|
||||||
|
const assistantText = extractContent(m.content);
|
||||||
|
if (!streamingAssistantId.current) {
|
||||||
|
streamingAssistantId.current = nextMessageId();
|
||||||
|
}
|
||||||
|
const streamId = streamingAssistantId.current;
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.some((msg) => msg.id === streamId)) {
|
||||||
|
return prev.map((msg) =>
|
||||||
|
msg.id === streamId
|
||||||
|
? { ...msg, content: assistantText, streaming: true }
|
||||||
|
: msg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{ id: streamId, role: "assistant", content: assistantText, streaming: true },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message_end": {
|
||||||
|
const m = ev.message;
|
||||||
|
if (m.role === "assistant") {
|
||||||
|
const assistantText = extractContent(m.content);
|
||||||
|
const toolCalls = getToolCalls(m.content);
|
||||||
|
setMessages((prev) => {
|
||||||
|
const streamId =
|
||||||
|
streamingAssistantId.current ?? findStreamingAssistantId(prev);
|
||||||
|
let next = prev;
|
||||||
|
|
||||||
|
if (streamId) {
|
||||||
|
next = finalizeAssistantInList(prev, streamId, assistantText);
|
||||||
|
} else if (assistantText.trim()) {
|
||||||
|
let lastAssistantIdx = -1;
|
||||||
|
for (let i = prev.length - 1; i >= 0; i--) {
|
||||||
|
if (prev[i].role === "assistant") {
|
||||||
|
lastAssistantIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastAssistantIdx >= 0) {
|
||||||
|
const last = prev[lastAssistantIdx];
|
||||||
|
if (last.content.trim() === assistantText.trim()) {
|
||||||
|
next = prev;
|
||||||
|
} else {
|
||||||
|
next = prev.map((msg, i) =>
|
||||||
|
i === lastAssistantIdx
|
||||||
|
? { ...msg, content: assistantText, streaming: false }
|
||||||
|
: msg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next = [
|
||||||
|
...prev,
|
||||||
|
{ id: nextMessageId(), role: "assistant", content: assistantText },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
const tid = tc.toolCallId || tc.id;
|
||||||
|
const tidStr = tid ? String(tid) : undefined;
|
||||||
|
const text = formatToolCall(tc);
|
||||||
|
const existingIdx = tidStr
|
||||||
|
? next.findIndex((msg) => msg.toolCallId === tidStr)
|
||||||
|
: -1;
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
const cur = next[existingIdx].content.trimStart();
|
||||||
|
const running =
|
||||||
|
cur.startsWith("🔧") || cur.startsWith("✅") || cur.startsWith("❌");
|
||||||
|
if (!running) {
|
||||||
|
next = next.map((msg, i) =>
|
||||||
|
i === existingIdx ? { ...msg, content: text } : msg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next = [
|
||||||
|
...next,
|
||||||
|
{
|
||||||
|
id: tidStr ? `tool-${tidStr}` : nextMessageId(),
|
||||||
|
role: "tool_call" as const,
|
||||||
|
content: text,
|
||||||
|
toolCallId: tidStr,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
streamingAssistantId.current = null;
|
||||||
|
scheduleSessionDashboardRefresh();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tool_execution_start": {
|
||||||
|
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
|
||||||
|
const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`;
|
||||||
|
setMessages((prev) => {
|
||||||
|
const existingIdx = tid
|
||||||
|
? prev.findIndex((msg) => msg.toolCallId === tid)
|
||||||
|
: -1;
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
return prev.map((msg, i) =>
|
||||||
|
i === existingIdx ? { ...msg, content: label } : msg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||||
|
role: "tool_call" as const,
|
||||||
|
content: label,
|
||||||
|
toolCallId: tid,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tool_execution_update":
|
||||||
|
case "tool_execution_end": {
|
||||||
|
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
|
||||||
|
const isError = ev.type === "tool_execution_end" ? ev.isError : undefined;
|
||||||
|
const full = buildToolCallUpdateText(ev, isError);
|
||||||
|
setMessages((prev) => {
|
||||||
|
let idx = tid ? prev.findIndex((msg) => msg.toolCallId === tid) : -1;
|
||||||
|
if (idx < 0) {
|
||||||
|
const toolCalls = prev.filter((m) => m.role === "tool_call");
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
|
idx = prev.findIndex((m) => m.id === toolCalls[toolCalls.length - 1].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (idx < 0) return prev;
|
||||||
|
return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg));
|
||||||
|
});
|
||||||
|
if (ev.type === "tool_execution_end" && ev.isError) {
|
||||||
|
addSystemMessage(`工具 ${ev.toolName} 执行出错`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scheduleSessionDashboardRefresh, loadSessions, fetchSessionStateFull, addSystemMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncViewportHeight();
|
||||||
|
const onResize = () => {
|
||||||
|
syncViewportHeight();
|
||||||
|
if (window.innerWidth > 768) setSidebarOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
window.visualViewport?.addEventListener("resize", syncViewportHeight);
|
||||||
|
window.visualViewport?.addEventListener("scroll", syncViewportHeight);
|
||||||
|
window.addEventListener("orientationchange", syncViewportHeight);
|
||||||
|
|
||||||
|
const bootstrap = {
|
||||||
|
sessions: false,
|
||||||
|
models: false,
|
||||||
|
sessionState: false,
|
||||||
|
sse: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryMarkBootstrapReady = () => {
|
||||||
|
if (bootstrap.sessions && bootstrap.models && bootstrap.sessionState && bootstrap.sse) {
|
||||||
|
markRouteReady("chat");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSessions()
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
bootstrap.sessions = true;
|
||||||
|
tryMarkBootstrapReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
void chatApi
|
||||||
|
.fetchModels()
|
||||||
|
.then((data) => setAvailableModels(data.models || []))
|
||||||
|
.catch((err) => {
|
||||||
|
addSystemMessage(`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
bootstrap.models = true;
|
||||||
|
tryMarkBootstrapReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (eventSource) eventSource.close();
|
||||||
|
eventSource = new EventSource("/api/events");
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
setConnected(true);
|
||||||
|
if (!bootstrap.sse) {
|
||||||
|
bootstrap.sse = true;
|
||||||
|
tryMarkBootstrapReady();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
eventSource.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
handleSseEvent(JSON.parse(e.data) as SseEvent);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
setConnected(false);
|
||||||
|
setIsStreaming(false);
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
void fetchSessionStateFull()
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
bootstrap.sessionState = true;
|
||||||
|
tryMarkBootstrapReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
void ensureFreshSessionOnBoot()
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
void fetchSessionStateFull();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
window.visualViewport?.removeEventListener("resize", syncViewportHeight);
|
||||||
|
window.visualViewport?.removeEventListener("scroll", syncViewportHeight);
|
||||||
|
window.removeEventListener("orientationchange", syncViewportHeight);
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
eventSource?.close();
|
||||||
|
};
|
||||||
|
}, [loadSessions, fetchSessionStateFull, handleSseEvent, addSystemMessage, markRouteReady, ensureFreshSessionOnBoot]);
|
||||||
|
|
||||||
|
const value: ChatContextValue = {
|
||||||
|
connected,
|
||||||
|
isStreaming,
|
||||||
|
messages,
|
||||||
|
sessions,
|
||||||
|
activeSessionPath,
|
||||||
|
headerTitle,
|
||||||
|
headerMeta,
|
||||||
|
sidebarOpen,
|
||||||
|
sessionState,
|
||||||
|
availableModels,
|
||||||
|
currentModelKey,
|
||||||
|
thinkingLevel,
|
||||||
|
availableThinkingLevels,
|
||||||
|
isApplyingModelSettings,
|
||||||
|
toggleSidebar: () => setSidebarOpen((o) => !o),
|
||||||
|
closeSidebar: () => setSidebarOpen(false),
|
||||||
|
loadSessions,
|
||||||
|
loadSession,
|
||||||
|
deleteSession: deleteSessionHandler,
|
||||||
|
renameSession: renameSessionHandler,
|
||||||
|
toggleSessionPin: toggleSessionPinHandler,
|
||||||
|
newSession,
|
||||||
|
sendMessage,
|
||||||
|
abortStream,
|
||||||
|
applyModelSelection,
|
||||||
|
applyThinkingSelection,
|
||||||
|
addSystemMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatContext(): ChatContextValue {
|
||||||
|
const ctx = useContext(ChatContext);
|
||||||
|
if (!ctx) throw new Error("useChatContext must be used within ChatProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
10
.pi/agent/extensions/webui/frontend/src/main.tsx
Normal file
10
.pi/agent/extensions/webui/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles/global.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
13
.pi/agent/extensions/webui/frontend/src/routes/ChatPage.tsx
Normal file
13
.pi/agent/extensions/webui/frontend/src/routes/ChatPage.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ChatInput } from "../components/chat/ChatInput";
|
||||||
|
import { MessageList } from "../components/chat/MessageList";
|
||||||
|
import { Header } from "../components/layout/Header";
|
||||||
|
|
||||||
|
export function ChatPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<MessageList />
|
||||||
|
<ChatInput />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: var(--app-height);
|
||||||
|
height: var(--app-height);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHeader {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: max(14px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) 14px max(18px, env(safe-area-inset-left));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid #eef0f3;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle p {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8b95a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 220px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
padding-left: max(12px, env(safe-area-inset-left));
|
||||||
|
background: #f4f6f9;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4b5563;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtnActive {
|
||||||
|
background: #fff;
|
||||||
|
color: #2563eb;
|
||||||
|
border-color: #e5e7eb;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtn:focus-visible {
|
||||||
|
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panes {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paneActive {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #eef0f3;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader p {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8b95a8;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryBtn,
|
||||||
|
.secondaryBtn,
|
||||||
|
.linkBtn {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #374151;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkBtn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryBtn {
|
||||||
|
color: #fff;
|
||||||
|
background: #2563eb;
|
||||||
|
border: 1px solid #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryBtn:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryBtn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryBtn {
|
||||||
|
color: #374151;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryBtn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
color: #111827;
|
||||||
|
background: #fff;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea:focus {
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formBody {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #111827;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput:focus {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldHint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8b95a8;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarPreviewRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarPreview {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewHint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 7px 14px 9px;
|
||||||
|
border-top: 1px solid #eef0f3;
|
||||||
|
color: #8b95a8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusOk {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusError {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 20px 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyCompact {
|
||||||
|
padding: 10px 4px 2px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillItem,
|
||||||
|
.mcpServerItem,
|
||||||
|
.extensionItem {
|
||||||
|
padding: 10px 10px 9px;
|
||||||
|
border: 1px solid #edf0f4;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skillItem + .skillItem,
|
||||||
|
.mcpServerItem + .mcpServerItem,
|
||||||
|
.extensionItem + .extensionItem {
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemName {
|
||||||
|
min-width: 0;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemMeta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemDesc {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemPath {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolList {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpServerHead {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpServerMain {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpServerSub {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpServerBadges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 19px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolItem {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border: 1px solid #f1f3f6;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolItem[open] {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolSummary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolSummary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolName {
|
||||||
|
min-width: 0;
|
||||||
|
color: #111827;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpToolDesc {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpParamList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcpParamList code {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #edf0f4;
|
||||||
|
color: #374151;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionCounts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionCounts span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionDetails {
|
||||||
|
margin-top: 8px;
|
||||||
|
border-top: 1px solid #f1f3f6;
|
||||||
|
padding-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionDetails > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 11px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensionDetails > summary:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameList {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8b95a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameList > div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameList code {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #edf0f4;
|
||||||
|
color: #374151;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pageHeader {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: max(12px, env(safe-area-inset-top));
|
||||||
|
padding-bottom: 12px;
|
||||||
|
padding-left: max(12px, env(safe-area-inset-left));
|
||||||
|
padding-right: max(12px, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px max(12px, env(safe-area-inset-left)) 10px max(12px, env(safe-area-inset-right));
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar {
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panes {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px max(10px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
535
.pi/agent/extensions/webui/frontend/src/routes/SettingsPage.tsx
Normal file
535
.pi/agent/extensions/webui/frontend/src/routes/SettingsPage.tsx
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import * as settingsApi from "../api/settings";
|
||||||
|
import { useAvatars } from "../context/AvatarContext";
|
||||||
|
import { useBootstrap } from "../context/BootstrapContext";
|
||||||
|
import type { ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "../types/events";
|
||||||
|
import type { McpToolInfo } from "../types/events";
|
||||||
|
import styles from "./SettingsPage.module.css";
|
||||||
|
|
||||||
|
const PANE_IDS: SettingsPaneId[] = ["prompt", "skills", "mcp", "extensions", "other"];
|
||||||
|
const SETTINGS_PANE_KEY = "sproutclaw-settings-pane";
|
||||||
|
|
||||||
|
function renderNameList(label: string, items: string[] | undefined) {
|
||||||
|
if (!Array.isArray(items) || !items.length) return null;
|
||||||
|
return (
|
||||||
|
<div className={styles.nameList}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<div>
|
||||||
|
{items.map((item) => (
|
||||||
|
<code key={item}>{item}</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function McpToolItem({ tool }: { tool: McpToolInfo }) {
|
||||||
|
const params = Array.isArray(tool.parameters) ? tool.parameters : [];
|
||||||
|
const required = new Set(Array.isArray(tool.required) ? tool.required : []);
|
||||||
|
const description = String(tool.description || "").replace(/\s+/g, " ").trim();
|
||||||
|
const shortDescription =
|
||||||
|
description.length > 86 ? `${description.slice(0, 86).trimEnd()}...` : description;
|
||||||
|
const title = [tool.name, description, params.length ? `参数: ${params.join(", ")}` : ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className={styles.mcpToolItem} title={title}>
|
||||||
|
<summary className={styles.mcpToolSummary}>
|
||||||
|
<span className={styles.mcpToolName}>{tool.name || "未命名工具"}</span>
|
||||||
|
{params.length ? <span className={styles.badge}>{params.length} 参数</span> : null}
|
||||||
|
</summary>
|
||||||
|
{shortDescription ? <div className={styles.mcpToolDesc}>{shortDescription}</div> : null}
|
||||||
|
{params.length ? (
|
||||||
|
<div className={styles.mcpParamList}>
|
||||||
|
{params.map((param) => (
|
||||||
|
<code key={param}>
|
||||||
|
{param}
|
||||||
|
{required.has(param) ? "*" : ""}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const { markRouteReady } = useBootstrap();
|
||||||
|
const { updateAvatars } = useAvatars();
|
||||||
|
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
|
||||||
|
const [systemPrompt, setSystemPrompt] = useState("");
|
||||||
|
const [systemPromptPath, setSystemPromptPath] = useState("");
|
||||||
|
const [userAvatarUrl, setUserAvatarUrl] = useState("");
|
||||||
|
const [agentAvatarUrl, setAgentAvatarUrl] = useState("");
|
||||||
|
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||||
|
const [mcpTools, setMcpTools] = useState<McpServerInfo[]>([]);
|
||||||
|
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
|
||||||
|
const [extensionsPath, setExtensionsPath] = useState("");
|
||||||
|
const [statusText, setStatusText] = useState("");
|
||||||
|
const [statusKind, setStatusKind] = useState<"" | "ok" | "error">("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [savingAvatars, setSavingAvatars] = useState(false);
|
||||||
|
const [reloading, setReloading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let initial: SettingsPaneId = "prompt";
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(SETTINGS_PANE_KEY);
|
||||||
|
if (saved && PANE_IDS.includes(saved as SettingsPaneId)) {
|
||||||
|
initial = saved as SettingsPaneId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
setActivePane(initial);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setStatus = (text: string, kind: "" | "ok" | "error" = "") => {
|
||||||
|
setStatusText(text);
|
||||||
|
setStatusKind(kind);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPane = (paneId: SettingsPaneId) => {
|
||||||
|
setActivePane(paneId);
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(SETTINGS_PANE_KEY, paneId);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setStatus("");
|
||||||
|
try {
|
||||||
|
const data = await settingsApi.fetchSettings();
|
||||||
|
setSystemPrompt(data.systemPrompt || "");
|
||||||
|
setSystemPromptPath(data.systemPromptPath || "");
|
||||||
|
setUserAvatarUrl(data.userAvatarUrl || "");
|
||||||
|
setAgentAvatarUrl(data.agentAvatarUrl || "");
|
||||||
|
updateAvatars({
|
||||||
|
userAvatarUrl: data.userAvatarUrl || "",
|
||||||
|
agentAvatarUrl: data.agentAvatarUrl || "",
|
||||||
|
});
|
||||||
|
setSkills(data.skills || []);
|
||||||
|
setMcpTools(data.mcpTools || []);
|
||||||
|
setExtensions(data.extensions || []);
|
||||||
|
setExtensionsPath(data.extensionsPath || "");
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`加载设置失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
markRouteReady("settings");
|
||||||
|
}
|
||||||
|
}, [markRouteReady, updateAvatars]);
|
||||||
|
|
||||||
|
const reloadSettings = async () => {
|
||||||
|
setReloading(true);
|
||||||
|
setStatus("正在重新加载 Agent 资源...");
|
||||||
|
try {
|
||||||
|
await settingsApi.reloadSettings();
|
||||||
|
await loadSettings();
|
||||||
|
setStatus("已重新加载并刷新设置", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`重新加载失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||||
|
} finally {
|
||||||
|
setReloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSystemPrompt = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setStatus("保存中...");
|
||||||
|
try {
|
||||||
|
const data = await settingsApi.saveSystemPrompt(systemPrompt);
|
||||||
|
if (data.systemPromptPath) setSystemPromptPath(data.systemPromptPath);
|
||||||
|
setStatus("已保存,Agent 已重新加载", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAvatars = async () => {
|
||||||
|
setSavingAvatars(true);
|
||||||
|
setStatus("保存中...");
|
||||||
|
try {
|
||||||
|
const data = await settingsApi.saveAvatars({ userAvatarUrl, agentAvatarUrl });
|
||||||
|
const next = {
|
||||||
|
userAvatarUrl: data.userAvatarUrl || "",
|
||||||
|
agentAvatarUrl: data.agentAvatarUrl || "",
|
||||||
|
};
|
||||||
|
setUserAvatarUrl(next.userAvatarUrl);
|
||||||
|
setAgentAvatarUrl(next.agentAvatarUrl);
|
||||||
|
updateAvatars(next);
|
||||||
|
setStatus("头像设置已保存", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||||
|
} finally {
|
||||||
|
setSavingAvatars(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSettings();
|
||||||
|
}, [loadSettings]);
|
||||||
|
|
||||||
|
const toolTotal = mcpTools.reduce((sum, server) => sum + (server.toolCount || 0), 0);
|
||||||
|
const statusClass =
|
||||||
|
statusKind === "ok" ? styles.statusOk : statusKind === "error" ? styles.statusError : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<main className={styles.main}>
|
||||||
|
<header className={styles.pageHeader}>
|
||||||
|
<div className={styles.pageTitle}>
|
||||||
|
<img className={styles.logo} src="/logo.png" width={34} height={34} alt="" decoding="async" />
|
||||||
|
<div>
|
||||||
|
<h1>设置</h1>
|
||||||
|
<p>萌小芽 Agent</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link to="/" className={`${styles.secondaryBtn} ${styles.linkBtn}`}>
|
||||||
|
返回聊天
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.shell}>
|
||||||
|
<div className={styles.layout}>
|
||||||
|
<nav className={styles.sidebar} role="tablist" aria-label="设置分区">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["prompt", "系统提示词"],
|
||||||
|
["skills", "Skills"],
|
||||||
|
["mcp", "MCP 工具"],
|
||||||
|
["extensions", "插件扩展"],
|
||||||
|
["other", "其他设置"],
|
||||||
|
] as const
|
||||||
|
).map(([id, label]) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activePane === id}
|
||||||
|
className={`${styles.navBtn} ${activePane === id ? styles.navBtnActive : ""}`}
|
||||||
|
onClick={() => showPane(id)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className={styles.panes}>
|
||||||
|
<section
|
||||||
|
className={`${styles.pane} ${styles.panel} ${activePane === "prompt" ? styles.paneActive : ""}`}
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={activePane !== "prompt"}
|
||||||
|
>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<div>
|
||||||
|
<h2>系统提示词</h2>
|
||||||
|
<p>{systemPromptPath}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primaryBtn}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void saveSystemPrompt()}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className={styles.textarea}
|
||||||
|
spellCheck={false}
|
||||||
|
aria-label="系统提示词"
|
||||||
|
value={systemPrompt}
|
||||||
|
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||||
|
/>
|
||||||
|
{statusText && activePane === "prompt" ? (
|
||||||
|
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className={`${styles.pane} ${styles.panel} ${activePane === "skills" ? styles.paneActive : ""}`}
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={activePane !== "skills"}
|
||||||
|
>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<div>
|
||||||
|
<h2>Skills</h2>
|
||||||
|
<p>{skills.length} 个已加载</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.secondaryBtn}
|
||||||
|
disabled={reloading}
|
||||||
|
onClick={() => void reloadSettings()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.list}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.empty}>加载中...</div>
|
||||||
|
) : !skills.length ? (
|
||||||
|
<div className={styles.empty}>暂无已加载 skills</div>
|
||||||
|
) : (
|
||||||
|
skills.map((skill) => {
|
||||||
|
const meta = [skill.scope, skill.source].filter(Boolean).join(" · ");
|
||||||
|
return (
|
||||||
|
<div key={skill.path || skill.name || skill.command} className={styles.skillItem}>
|
||||||
|
<div className={styles.titleRow}>
|
||||||
|
<div className={styles.itemName}>
|
||||||
|
{skill.name || skill.command || "未命名"}
|
||||||
|
</div>
|
||||||
|
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
|
||||||
|
</div>
|
||||||
|
{skill.description ? (
|
||||||
|
<div className={styles.itemDesc}>{skill.description}</div>
|
||||||
|
) : null}
|
||||||
|
{skill.path ? <div className={styles.itemPath}>{skill.path}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className={`${styles.pane} ${styles.panel} ${activePane === "mcp" ? styles.paneActive : ""}`}
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={activePane !== "mcp"}
|
||||||
|
>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<div>
|
||||||
|
<h2>MCP 工具</h2>
|
||||||
|
<p>
|
||||||
|
{mcpTools.length} 个 Server · {toolTotal} 个工具
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.secondaryBtn}
|
||||||
|
disabled={reloading}
|
||||||
|
onClick={() => void reloadSettings()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.list}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.empty}>加载中...</div>
|
||||||
|
) : !mcpTools.length ? (
|
||||||
|
<div className={styles.empty}>暂无 MCP server 配置</div>
|
||||||
|
) : (
|
||||||
|
mcpTools.map((server) => {
|
||||||
|
const tools = Array.isArray(server.tools) ? server.tools : [];
|
||||||
|
const status = server.cached ? "已缓存" : "未连接";
|
||||||
|
const cachedAt = server.cachedAt
|
||||||
|
? new Date(server.cachedAt).toLocaleString()
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<div key={server.name} className={styles.mcpServerItem}>
|
||||||
|
<div className={styles.mcpServerHead}>
|
||||||
|
<div className={styles.mcpServerMain}>
|
||||||
|
<div className={styles.itemName}>{server.name || "未命名 Server"}</div>
|
||||||
|
{cachedAt ? (
|
||||||
|
<div className={styles.mcpServerSub}>更新 {cachedAt}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.mcpServerBadges}>
|
||||||
|
<span className={styles.badge}>{status}</span>
|
||||||
|
<span className={styles.badge}>{server.toolCount || 0} 工具</span>
|
||||||
|
{server.resourceCount ? (
|
||||||
|
<span className={styles.badge}>{server.resourceCount} 资源</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tools.length ? (
|
||||||
|
<div className={styles.mcpToolList}>
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<McpToolItem key={tool.name} tool={tool} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`${styles.empty} ${styles.emptyCompact}`}>
|
||||||
|
暂无已缓存工具,运行 /mcp reconnect 后刷新。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className={`${styles.pane} ${styles.panel} ${activePane === "extensions" ? styles.paneActive : ""}`}
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={activePane !== "extensions"}
|
||||||
|
>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<div>
|
||||||
|
<h2>插件扩展</h2>
|
||||||
|
<p>
|
||||||
|
{extensionsPath
|
||||||
|
? `${extensions.length} 个已加载 · ${extensionsPath}`
|
||||||
|
: `${extensions.length} 个已加载`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.secondaryBtn}
|
||||||
|
disabled={reloading}
|
||||||
|
onClick={() => void reloadSettings()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.list}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.empty}>加载中...</div>
|
||||||
|
) : !extensions.length ? (
|
||||||
|
<div className={styles.empty}>暂无已加载插件扩展</div>
|
||||||
|
) : (
|
||||||
|
extensions.map((extension) => {
|
||||||
|
const meta =
|
||||||
|
extension.kind ||
|
||||||
|
[extension.scope, extension.source].filter(Boolean).join(" · ");
|
||||||
|
const counts = (
|
||||||
|
[
|
||||||
|
["命令", extension.commands?.length || 0],
|
||||||
|
["工具", extension.tools?.length || 0],
|
||||||
|
["事件", extension.handlers?.length || 0],
|
||||||
|
["Flags", extension.flags?.length || 0],
|
||||||
|
["快捷键", extension.shortcuts?.length || 0],
|
||||||
|
] as const
|
||||||
|
).filter(([, count]) => count > 0);
|
||||||
|
const hasDetails = ["commands", "tools", "handlers", "flags", "shortcuts"].some(
|
||||||
|
(key) => (extension[key as keyof ExtensionInfo] as string[] | undefined)?.length,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={extension.location || extension.name || extension.path}
|
||||||
|
className={styles.extensionItem}
|
||||||
|
>
|
||||||
|
<div className={styles.titleRow}>
|
||||||
|
<div className={styles.itemName}>
|
||||||
|
{extension.name || extension.path || "未命名"}
|
||||||
|
</div>
|
||||||
|
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
|
||||||
|
</div>
|
||||||
|
{counts.length ? (
|
||||||
|
<div className={styles.extensionCounts}>
|
||||||
|
{counts.map(([label, count]) => (
|
||||||
|
<span key={label}>
|
||||||
|
{label} {count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{hasDetails ? (
|
||||||
|
<details className={styles.extensionDetails}>
|
||||||
|
<summary>查看注册项</summary>
|
||||||
|
{renderNameList("命令", extension.commands)}
|
||||||
|
{renderNameList("工具", extension.tools)}
|
||||||
|
{renderNameList("事件", extension.handlers)}
|
||||||
|
{renderNameList("Flags", extension.flags)}
|
||||||
|
{renderNameList("快捷键", extension.shortcuts)}
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
{extension.location ? (
|
||||||
|
<div className={styles.itemPath}>{extension.location}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className={`${styles.pane} ${styles.panel} ${activePane === "other" ? styles.paneActive : ""}`}
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={activePane !== "other"}
|
||||||
|
>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<div>
|
||||||
|
<h2>其他设置</h2>
|
||||||
|
<p>聊天头像使用网页图片链接</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primaryBtn}
|
||||||
|
disabled={savingAvatars}
|
||||||
|
onClick={() => void saveAvatars()}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formBody}>
|
||||||
|
<label className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>用户头像链接</span>
|
||||||
|
<input
|
||||||
|
className={styles.textInput}
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
placeholder="https://example.com/user-avatar.png"
|
||||||
|
value={userAvatarUrl}
|
||||||
|
onChange={(e) => setUserAvatarUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
{userAvatarUrl.trim() ? (
|
||||||
|
<div className={styles.avatarPreviewRow}>
|
||||||
|
<img
|
||||||
|
className={styles.avatarPreview}
|
||||||
|
src={userAvatarUrl.trim()}
|
||||||
|
alt=""
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<span className={styles.previewHint}>预览</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
<label className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>Agent 头像链接</span>
|
||||||
|
<input
|
||||||
|
className={styles.textInput}
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
placeholder="https://example.com/agent-avatar.png"
|
||||||
|
value={agentAvatarUrl}
|
||||||
|
onChange={(e) => setAgentAvatarUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
{agentAvatarUrl.trim() ? (
|
||||||
|
<div className={styles.avatarPreviewRow}>
|
||||||
|
<img
|
||||||
|
className={styles.avatarPreview}
|
||||||
|
src={agentAvatarUrl.trim()}
|
||||||
|
alt=""
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<span className={styles.previewHint}>预览</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
<p className={styles.fieldHint}>
|
||||||
|
留空则不显示对应头像。仅支持 http / https 图片链接。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{statusText && activePane === "other" ? (
|
||||||
|
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
.pi/agent/extensions/webui/frontend/src/styles/global.css
Normal file
55
.pi/agent/extensions/webui/frontend/src/styles/global.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
@import "./variables.css";
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: inherit;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(37, 99, 235, 0.06), transparent 28%),
|
||||||
|
linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%);
|
||||||
|
color: #1a1a1a;
|
||||||
|
height: var(--app-height);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
optgroup {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp,
|
||||||
|
pre {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs,
|
||||||
|
pre code.hljs,
|
||||||
|
code.hljs {
|
||||||
|
font-family: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
.pi/agent/extensions/webui/frontend/src/styles/variables.css
Normal file
15
.pi/agent/extensions/webui/frontend/src/styles/variables.css
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
:root {
|
||||||
|
--app-height: 100vh;
|
||||||
|
--font-family: "LXGW WenKai Mono", "霞鹜文楷等宽", ui-monospace, monospace;
|
||||||
|
--font-ui: var(--font-family);
|
||||||
|
--font-mono: var(--font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
122
.pi/agent/extensions/webui/frontend/src/types/events.ts
Normal file
122
.pi/agent/extensions/webui/frontend/src/types/events.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import type { RpcMessage } from "./message";
|
||||||
|
|
||||||
|
export interface ConnectedEvent {
|
||||||
|
type: "connected";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentStartEvent {
|
||||||
|
type: "agent_start";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentEndEvent {
|
||||||
|
type: "agent_end";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageStartEvent {
|
||||||
|
type: "message_start";
|
||||||
|
message: RpcMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageUpdateEvent {
|
||||||
|
type: "message_update";
|
||||||
|
message: RpcMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageEndEvent {
|
||||||
|
type: "message_end";
|
||||||
|
message: RpcMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolExecutionStartEvent {
|
||||||
|
type: "tool_execution_start";
|
||||||
|
toolCallId?: string;
|
||||||
|
toolName: string;
|
||||||
|
args?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolExecutionUpdateEvent {
|
||||||
|
type: "tool_execution_update";
|
||||||
|
toolCallId?: string;
|
||||||
|
toolName: string;
|
||||||
|
partialResult?: { content?: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolExecutionEndEvent {
|
||||||
|
type: "tool_execution_end";
|
||||||
|
toolCallId?: string;
|
||||||
|
toolName: string;
|
||||||
|
isError?: boolean;
|
||||||
|
result?: { content?: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SseEvent =
|
||||||
|
| ConnectedEvent
|
||||||
|
| AgentStartEvent
|
||||||
|
| AgentEndEvent
|
||||||
|
| MessageStartEvent
|
||||||
|
| MessageUpdateEvent
|
||||||
|
| MessageEndEvent
|
||||||
|
| ToolExecutionStartEvent
|
||||||
|
| ToolExecutionUpdateEvent
|
||||||
|
| ToolExecutionEndEvent;
|
||||||
|
|
||||||
|
export interface SkillInfo {
|
||||||
|
name?: string;
|
||||||
|
command?: string;
|
||||||
|
description?: string;
|
||||||
|
path?: string;
|
||||||
|
scope?: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpToolInfo {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
parameters?: string[];
|
||||||
|
required?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServerInfo {
|
||||||
|
name?: string;
|
||||||
|
configured?: boolean;
|
||||||
|
cached?: boolean;
|
||||||
|
toolCount?: number;
|
||||||
|
resourceCount?: number;
|
||||||
|
cachedAt?: string;
|
||||||
|
tools?: McpToolInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionInfo {
|
||||||
|
name?: string;
|
||||||
|
path?: string;
|
||||||
|
kind?: string;
|
||||||
|
scope?: string;
|
||||||
|
source?: string;
|
||||||
|
location?: string;
|
||||||
|
commands?: string[];
|
||||||
|
tools?: string[];
|
||||||
|
handlers?: string[];
|
||||||
|
flags?: string[];
|
||||||
|
shortcuts?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsData {
|
||||||
|
systemPrompt?: string;
|
||||||
|
systemPromptPath?: string;
|
||||||
|
userAvatarUrl?: string;
|
||||||
|
agentAvatarUrl?: string;
|
||||||
|
skills?: SkillInfo[];
|
||||||
|
mcpTools?: McpServerInfo[];
|
||||||
|
mcpConfigPath?: string;
|
||||||
|
mcpCachePath?: string;
|
||||||
|
extensions?: ExtensionInfo[];
|
||||||
|
extensionsPath?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarSettings {
|
||||||
|
userAvatarUrl: string;
|
||||||
|
agentAvatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettingsPaneId = "prompt" | "skills" | "mcp" | "extensions" | "other";
|
||||||
95
.pi/agent/extensions/webui/frontend/src/types/message.ts
Normal file
95
.pi/agent/extensions/webui/frontend/src/types/message.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
export type MessageRole = "user" | "assistant" | "system" | "tool_call";
|
||||||
|
|
||||||
|
export interface ContentBlock {
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
data?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
toolName?: string;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
arguments?: unknown;
|
||||||
|
args?: unknown;
|
||||||
|
input?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageContent {
|
||||||
|
type: "image";
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RpcMessage {
|
||||||
|
role: string;
|
||||||
|
content?: string | ContentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCallBlock {
|
||||||
|
type: "toolCall";
|
||||||
|
toolName?: string;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
arguments?: unknown;
|
||||||
|
args?: unknown;
|
||||||
|
input?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: MessageRole;
|
||||||
|
content: string;
|
||||||
|
images?: ImageContent[];
|
||||||
|
toolCallId?: string;
|
||||||
|
streaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelInfo {
|
||||||
|
provider: string;
|
||||||
|
id: string;
|
||||||
|
reasoning?: boolean;
|
||||||
|
contextWindow?: number;
|
||||||
|
thinkingLevelMap?: Record<string, string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSummary {
|
||||||
|
path: string;
|
||||||
|
name?: string;
|
||||||
|
firstMessage?: string;
|
||||||
|
messageCount?: number;
|
||||||
|
modified?: string;
|
||||||
|
created?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionHistoryPayload {
|
||||||
|
session?: { id?: string | number; name?: string };
|
||||||
|
messages?: RpcMessage[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionState {
|
||||||
|
error?: string;
|
||||||
|
sessionId?: string | number;
|
||||||
|
sessionName?: string;
|
||||||
|
model?: ModelInfo;
|
||||||
|
thinkingLevel?: string;
|
||||||
|
stats?: {
|
||||||
|
tokens?: {
|
||||||
|
input?: number;
|
||||||
|
output?: number;
|
||||||
|
cacheRead?: number;
|
||||||
|
cacheWrite?: number;
|
||||||
|
};
|
||||||
|
cost?: number;
|
||||||
|
contextUsage?: { percent?: number; contextWindow?: number };
|
||||||
|
};
|
||||||
|
autoCompactionEnabled?: boolean;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
isCompacting?: boolean;
|
||||||
|
turnIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||||
|
|
||||||
|
export const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
||||||
22
.pi/agent/extensions/webui/frontend/src/types/session.ts
Normal file
22
.pi/agent/extensions/webui/frontend/src/types/session.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { RpcMessage } from "./message";
|
||||||
|
|
||||||
|
export interface SessionSummary {
|
||||||
|
path: string;
|
||||||
|
name?: string;
|
||||||
|
firstMessage?: string;
|
||||||
|
messageCount?: number;
|
||||||
|
modified?: string;
|
||||||
|
created?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionHistoryPayload {
|
||||||
|
session?: { id?: string | number; name?: string };
|
||||||
|
messages?: RpcMessage[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewSessionResponse {
|
||||||
|
sessionFile?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { ChatMessage } from "../types/message";
|
||||||
|
import { escHtml } from "./format";
|
||||||
|
|
||||||
|
function roleLabel(role: ChatMessage["role"]): string {
|
||||||
|
switch (role) {
|
||||||
|
case "user":
|
||||||
|
return "用户";
|
||||||
|
case "assistant":
|
||||||
|
return "助手";
|
||||||
|
case "tool_call":
|
||||||
|
return "工具";
|
||||||
|
case "system":
|
||||||
|
return "系统";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportableMessages(messages: ChatMessage[]): ChatMessage[] {
|
||||||
|
return messages.filter((m) => !m.streaming && m.content.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messagesToMarkdown(title: string, messages: ChatMessage[]): string {
|
||||||
|
const lines = [`# ${title}`, ""];
|
||||||
|
for (const m of exportableMessages(messages)) {
|
||||||
|
switch (m.role) {
|
||||||
|
case "user":
|
||||||
|
lines.push("## 用户", "", m.content, "");
|
||||||
|
break;
|
||||||
|
case "assistant":
|
||||||
|
lines.push("## 助手", "", m.content, "");
|
||||||
|
break;
|
||||||
|
case "tool_call":
|
||||||
|
lines.push("## 工具", "", "```", m.content, "```", "");
|
||||||
|
break;
|
||||||
|
case "system":
|
||||||
|
lines.push(`> ${m.content}`, "");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${lines.join("\n").trimEnd()}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messagesToMinimalHtml(title: string, messages: ChatMessage[]): string {
|
||||||
|
const sections = exportableMessages(messages)
|
||||||
|
.map((m) => {
|
||||||
|
const label = roleLabel(m.role);
|
||||||
|
return `<section class="block"><h2>${escHtml(label)}</h2><pre>${escHtml(m.content)}</pre></section>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${escHtml(title)}</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:"LXGW WenKai Mono","霞鹜文楷等宽",ui-monospace,monospace;max-width:720px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#111;background:#fff}
|
||||||
|
h1{font-size:1.25rem;font-weight:600;margin:0 0 1.25rem}
|
||||||
|
.block{margin:0 0 1rem}
|
||||||
|
h2{font-size:.75rem;font-weight:600;color:#6b7280;margin:0 0 .35rem;text-transform:uppercase;letter-spacing:.04em}
|
||||||
|
pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:.9375rem;font-family:inherit}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${escHtml(title)}</h1>
|
||||||
|
${sections}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeExportFilename(title: string): string {
|
||||||
|
const cleaned = title.replace(/[\\/:*?"<>|]/g, "_").trim();
|
||||||
|
return cleaned || "conversation";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadTextFile(filename: string, content: string, mime: string): void {
|
||||||
|
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function singleAssistantMarkdown(content: string): string {
|
||||||
|
return content.trimEnd() + (content.endsWith("\n") ? "" : "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageMarkdownFilename(content: string): string {
|
||||||
|
const snippet =
|
||||||
|
content
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 24)
|
||||||
|
.replace(/[\\/:*?"<>|]/g, "_") || "message";
|
||||||
|
return `${snippet}.md`;
|
||||||
|
}
|
||||||
47
.pi/agent/extensions/webui/frontend/src/utils/format.ts
Normal file
47
.pi/agent/extensions/webui/frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export function formatFooterTokens(count: number | undefined): string {
|
||||||
|
const n = Number(count) || 0;
|
||||||
|
if (n <= 0) return "0";
|
||||||
|
if (n < 1000) return String(Math.round(n));
|
||||||
|
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
||||||
|
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
||||||
|
if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
return `${Math.round(n / 1_000_000)}M`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimeAgo(isoStr: string | undefined): string {
|
||||||
|
if (!isoStr) return "";
|
||||||
|
const d = new Date(isoStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - d.getTime();
|
||||||
|
const mins = Math.floor(diffMs / 60000);
|
||||||
|
if (mins < 1) return "刚刚";
|
||||||
|
if (mins < 60) return `${mins} 分钟前`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours} 小时前`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return `${days} 天前`;
|
||||||
|
return d.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncViewportHeight(): void {
|
||||||
|
const viewportHeight = window.visualViewport?.height || window.innerHeight;
|
||||||
|
document.documentElement.style.setProperty("--app-height", `${viewportHeight}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTouchLike(): boolean {
|
||||||
|
return window.matchMedia("(max-width: 768px)").matches || navigator.maxTouchPoints > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageIdCounter = 0;
|
||||||
|
export function nextMessageId(): string {
|
||||||
|
messageIdCounter += 1;
|
||||||
|
return `msg-${Date.now()}-${messageIdCounter}`;
|
||||||
|
}
|
||||||
58
.pi/agent/extensions/webui/frontend/src/utils/images.ts
Normal file
58
.pi/agent/extensions/webui/frontend/src/utils/images.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export interface ImageContent {
|
||||||
|
type: "image";
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_CHAT_IMAGES = 8;
|
||||||
|
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
export const ALLOWED_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||||
|
|
||||||
|
export function imageToDataUrl(image: ImageContent): string {
|
||||||
|
return `data:${image.mimeType};base64,${image.data}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fileToImageContent(file: File): Promise<ImageContent> {
|
||||||
|
if (!ALLOWED_IMAGE_MIME.has(file.type)) {
|
||||||
|
throw new Error(`不支持的图片类型: ${file.type || "unknown"}`);
|
||||||
|
}
|
||||||
|
if (file.size > MAX_IMAGE_BYTES) {
|
||||||
|
throw new Error(`图片过大(最大 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result));
|
||||||
|
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||||
|
if (!match) throw new Error("无效图片数据");
|
||||||
|
return { type: "image", mimeType: match[1], data: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clipboardItemsToImages(items: DataTransferItemList): Promise<ImageContent[]> {
|
||||||
|
const images: ImageContent[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.type.startsWith("image/")) continue;
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (!file) continue;
|
||||||
|
images.push(await fileToImageContent(file));
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateImagePayload(image: ImageContent): ImageContent {
|
||||||
|
if (image.type !== "image") throw new Error("无效图片对象");
|
||||||
|
if (!ALLOWED_IMAGE_MIME.has(image.mimeType)) {
|
||||||
|
throw new Error(`不支持的图片类型: ${image.mimeType}`);
|
||||||
|
}
|
||||||
|
if (!image.data) throw new Error("图片数据为空");
|
||||||
|
const padding = image.data.endsWith("==") ? 2 : image.data.endsWith("=") ? 1 : 0;
|
||||||
|
const size = Math.floor((image.data.length * 3) / 4) - padding;
|
||||||
|
if (size > MAX_IMAGE_BYTES) {
|
||||||
|
throw new Error(`图片过大(最大 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`);
|
||||||
|
}
|
||||||
|
return { type: "image", mimeType: image.mimeType, data: image.data };
|
||||||
|
}
|
||||||
67
.pi/agent/extensions/webui/frontend/src/utils/markdown.ts
Normal file
67
.pi/agent/extensions/webui/frontend/src/utils/markdown.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import hljs from "highlight.js/lib/core";
|
||||||
|
import javascript from "highlight.js/lib/languages/javascript";
|
||||||
|
import typescript from "highlight.js/lib/languages/typescript";
|
||||||
|
import python from "highlight.js/lib/languages/python";
|
||||||
|
import bash from "highlight.js/lib/languages/bash";
|
||||||
|
import json from "highlight.js/lib/languages/json";
|
||||||
|
import markdown from "highlight.js/lib/languages/markdown";
|
||||||
|
import css from "highlight.js/lib/languages/css";
|
||||||
|
import xml from "highlight.js/lib/languages/xml";
|
||||||
|
import { Marked, type Tokens } from "marked";
|
||||||
|
import "highlight.js/styles/github.min.css";
|
||||||
|
|
||||||
|
hljs.registerLanguage("javascript", javascript);
|
||||||
|
hljs.registerLanguage("typescript", typescript);
|
||||||
|
hljs.registerLanguage("python", python);
|
||||||
|
hljs.registerLanguage("bash", bash);
|
||||||
|
hljs.registerLanguage("json", json);
|
||||||
|
hljs.registerLanguage("markdown", markdown);
|
||||||
|
hljs.registerLanguage("css", css);
|
||||||
|
hljs.registerLanguage("xml", xml);
|
||||||
|
hljs.registerLanguage("html", xml);
|
||||||
|
|
||||||
|
function escapeMarkdownCode(raw: string): string {
|
||||||
|
return String(raw)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssistantCodeBlock(token: Tokens.Code): string {
|
||||||
|
const langSlug = (((token.lang || "").match(/^\S+/) || [""])[0]).trim().toLowerCase();
|
||||||
|
const codeRaw = String(token.text ?? "").replace(/\n+$/, "");
|
||||||
|
const skipHl = !!token.escaped;
|
||||||
|
|
||||||
|
let innerHtml: string;
|
||||||
|
if (skipHl) {
|
||||||
|
innerHtml = codeRaw;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (langSlug && hljs.getLanguage(langSlug)) {
|
||||||
|
innerHtml = hljs.highlight(codeRaw, { language: langSlug }).value;
|
||||||
|
} else {
|
||||||
|
innerHtml = hljs.highlightAuto(codeRaw).value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
innerHtml = escapeMarkdownCode(codeRaw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeLangSlug = /^[a-z][a-z0-9_-]*$/i.test(langSlug) ? langSlug : "";
|
||||||
|
const codeClass = safeLangSlug ? `hljs language-${safeLangSlug}` : "hljs";
|
||||||
|
return `<pre class="assistant-pre"><code class="${codeClass}">${innerHtml}</code></pre>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marked = new Marked({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
renderer: {
|
||||||
|
code: renderAssistantCodeBlock,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function renderMarkdown(text: string): string {
|
||||||
|
if (!text) return "";
|
||||||
|
return marked.parse(text) as string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export function formatSessionTitle(title: string | undefined): string {
|
||||||
|
const text = typeof title === "string" ? title.trim() : "";
|
||||||
|
return text || "未命名";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与服务端一致的机器标签(会话 id、纯 hex 段等),不写进界面标题 */
|
||||||
|
export function isMachineSessionLabel(text: string | undefined, headerId: string): boolean {
|
||||||
|
const t = (text ?? "").trim();
|
||||||
|
if (!t) return true;
|
||||||
|
if (headerId && t === headerId) return true;
|
||||||
|
if (/^[0-9a-f]{8,}$/i.test(t)) return true;
|
||||||
|
if (/^[0-9]{10,}$/.test(t)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首句截取(侧边栏兜底与顶栏回填,需与 server.ts 保持一致逻辑) */
|
||||||
|
export function titleFromFirstUserMessage(text: string | undefined, maxChars = 56): string {
|
||||||
|
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
|
||||||
|
if (!cleaned) return "";
|
||||||
|
const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/);
|
||||||
|
let candidate = sentenceMatch?.[1] ? sentenceMatch[1].trim() : cleaned;
|
||||||
|
if (candidate.length > maxChars) {
|
||||||
|
candidate = `${candidate.slice(0, maxChars).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelKey(model: { provider: string; id: string } | undefined): string {
|
||||||
|
return model ? `${model.provider}/${model.id}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelLabel(model: { provider: string; id: string } | undefined): string {
|
||||||
|
if (!model) return "";
|
||||||
|
return `${model.provider}/${model.id}`;
|
||||||
|
}
|
||||||
82
.pi/agent/extensions/webui/frontend/src/utils/toolCall.ts
Normal file
82
.pi/agent/extensions/webui/frontend/src/utils/toolCall.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { ContentBlock, ImageContent, ToolCallBlock } from "../types/message";
|
||||||
|
|
||||||
|
export function extractContent(content: string | ContentBlock[] | undefined): string {
|
||||||
|
if (!content) return "";
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.filter((c) => c.type === "text")
|
||||||
|
.map((c) => c.text ?? "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
return String(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractImages(content: string | ContentBlock[] | undefined): ImageContent[] {
|
||||||
|
if (!Array.isArray(content)) return [];
|
||||||
|
return content
|
||||||
|
.filter((c): c is ImageContent => c.type === "image" && typeof c.data === "string" && !!c.mimeType)
|
||||||
|
.map((c) => ({ type: "image", data: c.data, mimeType: c.mimeType }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToolCalls(content: string | ContentBlock[] | undefined): ToolCallBlock[] {
|
||||||
|
if (!Array.isArray(content)) return [];
|
||||||
|
return content.filter((c): c is ToolCallBlock => c.type === "toolCall");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToolCall(tc: ToolCallBlock): string {
|
||||||
|
const name = tc.toolName || tc.name || "tool";
|
||||||
|
const args = tc.arguments ?? tc.args ?? tc.input ?? {};
|
||||||
|
let text = `${name}(${JSON.stringify(args)})`;
|
||||||
|
const shortId = tc.toolCallId || tc.id;
|
||||||
|
if (shortId) text = `# ${String(shortId).slice(0, 8)}\n${text}`;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToolResult(tr: { content?: unknown }): string {
|
||||||
|
let text = "";
|
||||||
|
const content = tr.content;
|
||||||
|
if (typeof content === "string") text = content;
|
||||||
|
else if (Array.isArray(content)) {
|
||||||
|
text = content.map((c) => (c && typeof c === "object" && "text" in c ? String(c.text) : JSON.stringify(c))).join("\n");
|
||||||
|
} else if (content && typeof content === "object" && "text" in content) {
|
||||||
|
text = String((content as { text: string }).text);
|
||||||
|
} else {
|
||||||
|
text = JSON.stringify(content || tr, null, 2);
|
||||||
|
}
|
||||||
|
return text.length > 800 ? `${text.slice(0, 800)}\n… (已截断)` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveToolCallSummary(fullText: string): string {
|
||||||
|
const raw = String(fullText || "").trim();
|
||||||
|
if (!raw) return "tool";
|
||||||
|
let icon = "";
|
||||||
|
if (raw.startsWith("🔧")) icon = "🔧";
|
||||||
|
else if (raw.startsWith("✅")) icon = "✅";
|
||||||
|
else if (raw.startsWith("❌")) icon = "❌";
|
||||||
|
const rest = icon ? raw.slice(icon.length).trimStart() : raw;
|
||||||
|
const idLine = rest.split("\n").find((l) => l.trimStart().startsWith("# "));
|
||||||
|
const idPart = idLine ? idLine.replace(/^\s*#\s*/, "").trim().split(/\s/)[0] : "";
|
||||||
|
const oneLine = rest.replace(/\s+/g, " ");
|
||||||
|
const nameMatch = oneLine.match(/(\w+)\s*\(/);
|
||||||
|
const name = nameMatch ? nameMatch[1] : "tool";
|
||||||
|
let label = idPart ? `${name} · ${idPart}` : name;
|
||||||
|
if (icon) label = `${icon} ${label}`;
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildToolCallUpdateText(
|
||||||
|
ev: { toolName: string; partialResult?: { content?: unknown }; result?: { content?: unknown } },
|
||||||
|
isError?: boolean,
|
||||||
|
): string {
|
||||||
|
const icon = isError === undefined ? "🔧" : isError ? "❌" : "✅";
|
||||||
|
let detail = "";
|
||||||
|
if (ev.partialResult?.content) {
|
||||||
|
detail = `\n${formatToolResult(ev.partialResult)}`;
|
||||||
|
}
|
||||||
|
if (isError !== undefined) {
|
||||||
|
const resultText = ev.result?.content ? `\n${formatToolResult(ev.result)}` : "";
|
||||||
|
return `${icon} ${ev.toolName}${resultText || detail}`;
|
||||||
|
}
|
||||||
|
return `${icon} ${ev.toolName}${detail}`;
|
||||||
|
}
|
||||||
2
.pi/agent/extensions/webui/frontend/src/vite-env.d.ts
vendored
Normal file
2
.pi/agent/extensions/webui/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pwa/client" />
|
||||||
22
.pi/agent/extensions/webui/frontend/tsconfig.app.json
Normal file
22
.pi/agent/extensions/webui/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
.pi/agent/extensions/webui/frontend/tsconfig.json
Normal file
7
.pi/agent/extensions/webui/frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
20
.pi/agent/extensions/webui/frontend/tsconfig.node.json
Normal file
20
.pi/agent/extensions/webui/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
65
.pi/agent/extensions/webui/frontend/vite.config.ts
Normal file
65
.pi/agent/extensions/webui/frontend/vite.config.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "prompt",
|
||||||
|
includeAssets: ["logo.png", "logo192.png", "logo512.png"],
|
||||||
|
manifest: {
|
||||||
|
name: "萌小芽",
|
||||||
|
short_name: "萌小芽",
|
||||||
|
description: "树萌芽智能 AI 助手 Web 客户端",
|
||||||
|
theme_color: "#f7f8fb",
|
||||||
|
background_color: "#f7f8fb",
|
||||||
|
display: "standalone",
|
||||||
|
orientation: "portrait-primary",
|
||||||
|
scope: "/",
|
||||||
|
start_url: "/",
|
||||||
|
lang: "zh-CN",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "logo192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "logo512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "logo512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
navigateFallback: "index.html",
|
||||||
|
globPatterns: ["**/*.{js,css,html,png,ico,woff2}"],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^\/api\//,
|
||||||
|
handler: "NetworkOnly",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
outDir: "../dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:19133",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
364
.pi/agent/extensions/webui/index.ts
Normal file
364
.pi/agent/extensions/webui/index.ts
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* WebUI 扩展 —— 在浏览器中通过网页与 pi 对话
|
||||||
|
*
|
||||||
|
* 安装:
|
||||||
|
* 将本文件放入 .pi/extensions/webui/index.ts
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* /webui on → 启动网页服务(默认端口 19133)
|
||||||
|
* /webui off → 停止网页服务
|
||||||
|
* /webui on 8080 → 指定端口启动
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, spawnSync, execSync, type ChildProcess } from "node:child_process";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PID_FILE = join(__dirname, ".webui.pid");
|
||||||
|
const DIST_DIR = join(__dirname, "dist");
|
||||||
|
const FRONTEND_DIR = join(__dirname, "frontend");
|
||||||
|
|
||||||
|
let serverProcess: ChildProcess | null = null;
|
||||||
|
let serverPort = 19133;
|
||||||
|
|
||||||
|
function findTsx(): string {
|
||||||
|
// 从扩展所在目录向上找 repo 根目录
|
||||||
|
let dir = __dirname;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const candidate = join(dir, "node_modules", ".bin", "tsx");
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
const parent = dirname(dir);
|
||||||
|
if (parent === dir) break;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
// fallback
|
||||||
|
return join(__dirname, "..", "..", "..", "node_modules", ".bin", "tsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRepoRoot(): string {
|
||||||
|
let dir = __dirname;
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
if (
|
||||||
|
existsSync(join(dir, "package.json")) &&
|
||||||
|
existsSync(join(dir, "packages", "coding-agent", "src", "cli.ts"))
|
||||||
|
) {
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
const parent = dirname(dir);
|
||||||
|
if (parent === dir) break;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
return process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentDir(repoRoot: string): string {
|
||||||
|
return process.env.PI_CODING_AGENT_DIR || join(repoRoot, ".pi", "agent");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPidFile(): number | null {
|
||||||
|
if (!existsSync(PID_FILE)) return null;
|
||||||
|
try {
|
||||||
|
const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
|
||||||
|
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePidFile(pid: number): void {
|
||||||
|
try {
|
||||||
|
writeFileSync(PID_FILE, String(pid), "utf8");
|
||||||
|
} catch {
|
||||||
|
// 忽略:PID 文件只是辅助信息
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPidFile(): void {
|
||||||
|
try {
|
||||||
|
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProcessAlive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForStartup(port: number, child: ChildProcess): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const finish = (err?: Error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
finish(new Error(`webui 子进程已退出,code=${child.exitCode ?? "null"}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const http = require("node:http");
|
||||||
|
const req = http.get(
|
||||||
|
{ hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1500 },
|
||||||
|
(res: any) => {
|
||||||
|
res.resume();
|
||||||
|
if (res.statusCode === 200) finish();
|
||||||
|
else setTimeout(check, 200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", () => setTimeout(check, 200));
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy();
|
||||||
|
setTimeout(check, 200);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout = setTimeout(() => finish(new Error("webui 启动超时")), 10000);
|
||||||
|
setTimeout(check, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function probePort(port: number): Promise<"free" | "running" | "occupied"> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const http = require("node:http");
|
||||||
|
const req = http.get(
|
||||||
|
{ hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1200 },
|
||||||
|
(res: any) => {
|
||||||
|
res.resume();
|
||||||
|
resolve(res.statusCode === 200 ? "running" : "occupied");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", () => resolve("free"));
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve("free");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureBuilt(ctx: { ui: { notify: (msg: string, kind: string) => void } }): Promise<boolean> {
|
||||||
|
const indexHtml = join(DIST_DIR, "index.html");
|
||||||
|
if (existsSync(indexHtml)) return true;
|
||||||
|
|
||||||
|
ctx.ui.notify("正在构建 WebUI 前端...", "info");
|
||||||
|
|
||||||
|
if (!existsSync(join(FRONTEND_DIR, "node_modules"))) {
|
||||||
|
const install = spawnSync("npm", ["install"], { cwd: FRONTEND_DIR, stdio: "inherit" });
|
||||||
|
if (install.status !== 0) {
|
||||||
|
ctx.ui.notify("WebUI 前端依赖安装失败", "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = spawnSync("npm", ["run", "build"], { cwd: FRONTEND_DIR, stdio: "inherit" });
|
||||||
|
if (build.status !== 0 || !existsSync(indexHtml)) {
|
||||||
|
ctx.ui.notify("WebUI 前端构建失败", "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify("WebUI 前端构建完成", "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServer(port: number, ctx: any): Promise<void> {
|
||||||
|
if (serverProcess) {
|
||||||
|
ctx.ui.notify(`网页服务已在端口 ${serverPort} 运行`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldPid = readPidFile();
|
||||||
|
if (oldPid && !isProcessAlive(oldPid)) {
|
||||||
|
clearPidFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await probePort(port);
|
||||||
|
if (state === "running") {
|
||||||
|
serverPort = port;
|
||||||
|
const pids = findPidsByPort(port);
|
||||||
|
if (pids.length) writePidFile(pids[0]);
|
||||||
|
ctx.ui.notify(`网页服务已在端口 ${port} 运行`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state === "occupied") {
|
||||||
|
ctx.ui.notify(`端口 ${port} 已被其他进程占用`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoRoot = findRepoRoot();
|
||||||
|
const tsxBin = findTsx();
|
||||||
|
const serverFile = join(__dirname, "server.ts");
|
||||||
|
|
||||||
|
if (!existsSync(serverFile)) {
|
||||||
|
ctx.ui.notify(`服务器文件未找到: ${serverFile}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await ensureBuilt(ctx))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
serverPort = port;
|
||||||
|
serverProcess = spawn(tsxBin, [serverFile, "--port", String(port)], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: ["ignore", "inherit", "inherit"],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PI_CODING_AGENT_DIR: getAgentDir(repoRoot),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
serverProcess.on("exit", (code) => {
|
||||||
|
serverProcess = null;
|
||||||
|
clearPidFile();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (serverProcess.pid) {
|
||||||
|
writePidFile(serverProcess.pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForStartup(port, serverProcess);
|
||||||
|
} catch (err: any) {
|
||||||
|
clearPidFile();
|
||||||
|
serverProcess = null;
|
||||||
|
ctx.ui.notify(`网页服务启动失败: ${err.message}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(`网页服务已启动 → http://smallmengya:${port}`, "info");
|
||||||
|
|
||||||
|
// 显示局域网地址
|
||||||
|
const { networkInterfaces } = require("node:os");
|
||||||
|
const nets = networkInterfaces();
|
||||||
|
for (const name of Object.keys(nets)) {
|
||||||
|
for (const net of nets[name]) {
|
||||||
|
if (net.family === "IPv4" && !net.internal) {
|
||||||
|
ctx.ui.notify(` 局域网: http://${net.address}:${port}`, "info");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPidsByPort(port: number): number[] {
|
||||||
|
try {
|
||||||
|
const out = execSync(`ss -tlnp 'sport = :${port}'`, { encoding: "utf8" });
|
||||||
|
const pids = new Set<number>();
|
||||||
|
for (const match of out.matchAll(/pid=(\d+)/g)) {
|
||||||
|
const pid = parseInt(match[1], 10);
|
||||||
|
if (Number.isFinite(pid) && pid > 0) pids.add(pid);
|
||||||
|
}
|
||||||
|
return [...pids];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function killProcessTree(pid: number): void {
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPortFree(port: number, timeoutMs = 5000): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if ((await probePort(port)) === "free") return true;
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
return (await probePort(port)) === "free";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopByPort(port: number, ctx: any): Promise<boolean> {
|
||||||
|
const pids = findPidsByPort(port);
|
||||||
|
if (!pids.length) return false;
|
||||||
|
|
||||||
|
for (const pid of pids) {
|
||||||
|
killProcessTree(pid);
|
||||||
|
}
|
||||||
|
await waitForPortFree(port);
|
||||||
|
|
||||||
|
if ((await probePort(port)) === "free") {
|
||||||
|
clearPidFile();
|
||||||
|
ctx.ui.notify(`网页服务已停止(端口 ${port},PID ${pids.join(", ")})`, "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(`端口 ${port} 上的网页服务未能完全停止`, "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopServer(ctx: any, port = serverPort || 19133): Promise<void> {
|
||||||
|
if (serverProcess) {
|
||||||
|
serverProcess.kill("SIGTERM");
|
||||||
|
serverProcess = null;
|
||||||
|
serverPort = 0;
|
||||||
|
clearPidFile();
|
||||||
|
ctx.ui.notify("网页服务已停止", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pid = readPidFile();
|
||||||
|
if (pid && isProcessAlive(pid)) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGTERM");
|
||||||
|
await waitForPortFree(port);
|
||||||
|
clearPidFile();
|
||||||
|
ctx.ui.notify(`网页服务已停止(PID ${pid})`, "info");
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
clearPidFile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await probePort(port);
|
||||||
|
if (state === "running" || state === "occupied") {
|
||||||
|
if (await stopByPort(port, ctx)) return;
|
||||||
|
ctx.ui.notify(`端口 ${port} 仍被占用,请手动检查进程`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPidFile();
|
||||||
|
ctx.ui.notify("网页服务未运行", "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerCommand("webui", {
|
||||||
|
description: "通过 /webui on 启动、/webui off 停止 Web 聊天界面",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const trimmed = args.trim();
|
||||||
|
const [command = "", value = ""] = trimmed.split(/\s+/, 2);
|
||||||
|
|
||||||
|
if (command === "off" || command === "stop" || command === "0") {
|
||||||
|
const stopPort = value ? parseInt(value, 10) : serverPort || 19133;
|
||||||
|
await stopServer(ctx, Number.isFinite(stopPort) ? stopPort : 19133);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portInput = command === "on" ? value : command;
|
||||||
|
const port = portInput ? parseInt(portInput, 10) : 19133;
|
||||||
|
if (isNaN(port) || port < 1 || port > 65535) {
|
||||||
|
ctx.ui.notify(`无效端口: ${trimmed || "(空)"},使用 19133`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await startServer(port, ctx);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
928
.pi/agent/extensions/webui/server.ts
Normal file
928
.pi/agent/extensions/webui/server.ts
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pi-mono WebUI Server
|
||||||
|
*
|
||||||
|
* 被 webui 扩展启动,作为独立子进程运行。
|
||||||
|
* 内嵌 HTTP 服务 + pi RPC 子进程,提供浏览器聊天界面。
|
||||||
|
*
|
||||||
|
* 用法(由扩展自动调用):
|
||||||
|
* tsx server.ts --port 19133
|
||||||
|
*
|
||||||
|
* 前端静态文件位于同目录 dist/ 下(Vite 构建产物)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { appendFileSync, readFileSync, readdirSync, statSync, existsSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { join, dirname, resolve, basename } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
closeWebuiDatabase,
|
||||||
|
deleteWebuiConfig,
|
||||||
|
getAllWebuiConfig,
|
||||||
|
getWebuiConfig,
|
||||||
|
initWebuiDatabase,
|
||||||
|
prunePinnedSessionPaths,
|
||||||
|
readWebuiAvatarSettings,
|
||||||
|
removePinnedSessionPath,
|
||||||
|
setSessionPinned,
|
||||||
|
setWebuiConfig,
|
||||||
|
setWebuiConfigMany,
|
||||||
|
writeWebuiAvatarSettings,
|
||||||
|
} from "./db.js";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PUBLIC_DIR = join(__dirname, "dist");
|
||||||
|
const PID_FILE = join(__dirname, ".webui.pid");
|
||||||
|
|
||||||
|
// 从 CWD 确定 repo 根目录(启动时 CWD 为 repo 根目录)
|
||||||
|
const REPO_ROOT = process.cwd();
|
||||||
|
const AGENT_DIR = resolve(process.env.PI_CODING_AGENT_DIR || join(REPO_ROOT, ".pi", "agent"));
|
||||||
|
const SESSIONS_DIR = join(AGENT_DIR, "sessions");
|
||||||
|
const AGENT_EXTENSIONS_DIR = join(AGENT_DIR, "extensions");
|
||||||
|
const SYSTEM_PROMPT_FILE = join(AGENT_DIR, "AGENTS.md");
|
||||||
|
const MCP_CACHE_FILE = join(AGENT_DIR, "mcp-cache.json");
|
||||||
|
const MCP_CONFIG_FILE = join(AGENT_DIR, "mcp.json");
|
||||||
|
const TSX_BIN = join(REPO_ROOT, "node_modules", ".bin", "tsx");
|
||||||
|
const PI_CLI_DIST = join(REPO_ROOT, "packages", "coding-agent", "dist", "cli.js");
|
||||||
|
const PI_CLI_SRC = join(REPO_ROOT, "packages", "coding-agent", "src", "cli.ts");
|
||||||
|
|
||||||
|
function resolvePiRpcLaunch(): { command: string; args: string[]; mode: "dist" | "src" } {
|
||||||
|
const rpcArgs = ["--mode", "rpc"];
|
||||||
|
if (existsSync(PI_CLI_DIST)) {
|
||||||
|
return { command: process.execPath, args: [PI_CLI_DIST, ...rpcArgs], mode: "dist" };
|
||||||
|
}
|
||||||
|
console.warn(`[webui] 未找到 ${PI_CLI_DIST},回退 tsx 源码启动(较慢)。请运行: npm run build --workspace=@earendil-works/pi-coding-agent`);
|
||||||
|
return { command: TSX_BIN, args: [PI_CLI_SRC, ...rpcArgs], mode: "src" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 解析 CLI 参数 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
function getArg(flag: string): string | undefined {
|
||||||
|
const idx = args.indexOf(flag);
|
||||||
|
return idx !== -1 ? args[idx + 1] : undefined;
|
||||||
|
}
|
||||||
|
const PORT = parseInt(getArg("--port") || "19133", 10);
|
||||||
|
|
||||||
|
function writePidFile(): void {
|
||||||
|
try {
|
||||||
|
writeFileSync(PID_FILE, String(process.pid), "utf8");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPidFile(): void {
|
||||||
|
try {
|
||||||
|
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shutdown(exitCode = 0): never {
|
||||||
|
closeWebuiDatabase();
|
||||||
|
clearPidFile();
|
||||||
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const webuiDb = initWebuiDatabase(__dirname);
|
||||||
|
console.log(`[webui] 配置数据库: ${webuiDb.dbPath}`);
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => shutdown(0));
|
||||||
|
process.on("SIGINT", () => shutdown(0));
|
||||||
|
writePidFile();
|
||||||
|
|
||||||
|
// ─── 启动 pi RPC 子进程 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const piLaunch = resolvePiRpcLaunch();
|
||||||
|
|
||||||
|
console.log(`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${piLaunch.args.join(" ")}`);
|
||||||
|
|
||||||
|
const pi = spawn(piLaunch.command, piLaunch.args, {
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PI_CODING_AGENT_DIR: AGENT_DIR,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.stderr.on("data", (data) => process.stderr.write(`[pi] ${data}`));
|
||||||
|
pi.on("exit", (code) => {
|
||||||
|
console.log(`[webui] pi 退出, code=${code}`);
|
||||||
|
shutdown(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── JSONL 行协议 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let buffer = "";
|
||||||
|
const pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
||||||
|
const sseClients = new Set<any>();
|
||||||
|
let reqId = 0;
|
||||||
|
|
||||||
|
function onLine(line: string) {
|
||||||
|
if (!line.trim()) return;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line);
|
||||||
|
if (msg.type === "response" && msg.id && pending.has(msg.id)) {
|
||||||
|
const p = pending.get(msg.id)!;
|
||||||
|
pending.delete(msg.id);
|
||||||
|
p.resolve(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = `data: ${JSON.stringify(msg)}\n\n`;
|
||||||
|
for (const res of sseClients) res.write(data);
|
||||||
|
} catch {
|
||||||
|
// 忽略非 JSON 行
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() || "";
|
||||||
|
for (const line of lines) onLine(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendCmd(command: Record<string, unknown>): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = `req_${++reqId}`;
|
||||||
|
const line = JSON.stringify({ ...command, id }) + "\n";
|
||||||
|
pending.set(id, { resolve, reject });
|
||||||
|
pi.stdin.write(line);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (pending.has(id)) {
|
||||||
|
pending.delete(id);
|
||||||
|
reject(new Error(`命令超时: ${command.type}`));
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 会话文件读取 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function listSessionFiles(): string[] {
|
||||||
|
if (!existsSync(SESSIONS_DIR)) return [];
|
||||||
|
const files: string[] = [];
|
||||||
|
const visit = (dir: string) => {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const fullPath = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
visit(fullPath);
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(SESSIONS_DIR);
|
||||||
|
return files
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionSummary(filePath: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath, "utf8");
|
||||||
|
const lines = content.trim().split("\n");
|
||||||
|
if (!lines.length) return null;
|
||||||
|
const header = JSON.parse(lines[0]);
|
||||||
|
if (header.type !== "session") return null;
|
||||||
|
|
||||||
|
let nameFromInfo = "";
|
||||||
|
let messageCount = 0;
|
||||||
|
let firstMessage = "";
|
||||||
|
const stats = statSync(filePath);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.type === "session_info" && entry.name) {
|
||||||
|
const n = String(entry.name).trim();
|
||||||
|
if (n && !isMachineSessionLabel(n, header.id)) {
|
||||||
|
nameFromInfo = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.type === "message") {
|
||||||
|
messageCount++;
|
||||||
|
if (!firstMessage && entry.message?.role === "user") {
|
||||||
|
firstMessage = extractPreview(entry.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromFirstUser = titleFromFirstUserMessage(firstMessage);
|
||||||
|
let name = nameFromInfo;
|
||||||
|
if (!name || isMachineSessionLabel(name, header.id)) {
|
||||||
|
name = fromFirstUser || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: filePath,
|
||||||
|
id: header.id,
|
||||||
|
name,
|
||||||
|
created: header.timestamp,
|
||||||
|
modified: stats.mtime.toISOString(),
|
||||||
|
messageCount,
|
||||||
|
firstMessage: firstMessage || "(空)",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMachineSessionLabel(text: string, sessionHeaderId: string): boolean {
|
||||||
|
const t = (text ?? "").trim();
|
||||||
|
if (!t) return true;
|
||||||
|
if (sessionHeaderId && t === sessionHeaderId) return true;
|
||||||
|
if (/^[0-9a-f]{8,}$/i.test(t)) return true;
|
||||||
|
if (/^[0-9]{10,}$/.test(t)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 侧边栏标题:优先用户首句(遇句号等截断),否则整段截取 */
|
||||||
|
function titleFromFirstUserMessage(text: string, maxChars = 56): string {
|
||||||
|
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
|
||||||
|
if (!cleaned) return "";
|
||||||
|
const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/);
|
||||||
|
let candidate =
|
||||||
|
sentenceMatch && sentenceMatch[1] ? sentenceMatch[1].trim() : cleaned;
|
||||||
|
if (candidate.length > maxChars) {
|
||||||
|
candidate = `${candidate.slice(0, maxChars).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionMessages(filePath: string): unknown[] {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath, "utf8");
|
||||||
|
return content
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => { try { const e = JSON.parse(l); return e.type === "message" ? e.message : null; } catch { return null; } })
|
||||||
|
.filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPreview(msg: any): string {
|
||||||
|
const c = msg.content;
|
||||||
|
if (!c) return "";
|
||||||
|
if (typeof c === "string") return c.slice(0, 200);
|
||||||
|
if (Array.isArray(c)) {
|
||||||
|
const text = c.filter((x: any) => x.type === "text").map((x: any) => x.text).join("").slice(0, 200);
|
||||||
|
if (text) return text;
|
||||||
|
const imageCount = c.filter((x: any) => x.type === "image").length;
|
||||||
|
if (imageCount > 0) return `[${imageCount} 张图片]`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_CHAT_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||||
|
const MAX_CHAT_IMAGES = 8;
|
||||||
|
const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
function normalizeChatImages(images: unknown): Array<{ type: "image"; data: string; mimeType: string }> | undefined {
|
||||||
|
if (!Array.isArray(images) || images.length === 0) return undefined;
|
||||||
|
const out: Array<{ type: "image"; data: string; mimeType: string }> = [];
|
||||||
|
for (const raw of images.slice(0, MAX_CHAT_IMAGES)) {
|
||||||
|
if (!raw || typeof raw !== "object") continue;
|
||||||
|
const mimeType = String((raw as any).mimeType || "");
|
||||||
|
const data = String((raw as any).data || "");
|
||||||
|
if ((raw as any).type !== "image" || !ALLOWED_CHAT_IMAGE_MIME.has(mimeType) || !data) {
|
||||||
|
throw new Error(`不支持的图片类型: ${mimeType || "unknown"}`);
|
||||||
|
}
|
||||||
|
const size = Buffer.from(data, "base64").length;
|
||||||
|
if (size > MAX_CHAT_IMAGE_BYTES) {
|
||||||
|
throw new Error(`图片过大(最大 ${Math.round(MAX_CHAT_IMAGE_BYTES / 1024 / 1024)}MB)`);
|
||||||
|
}
|
||||||
|
out.push({ type: "image", mimeType, data });
|
||||||
|
}
|
||||||
|
return out.length ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSessionFile(filePath: string): string {
|
||||||
|
const resolved = resolve(filePath);
|
||||||
|
const sessionsRoot = resolve(SESSIONS_DIR);
|
||||||
|
if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) {
|
||||||
|
throw new Error("无效会话路径");
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSessionName(filePath: string, name: string): string {
|
||||||
|
const sessionPath = resolveSessionFile(filePath);
|
||||||
|
if (!existsSync(sessionPath)) throw new Error("会话不存在");
|
||||||
|
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) throw new Error("会话名称不能为空");
|
||||||
|
|
||||||
|
const lines = readFileSync(sessionPath, "utf8").trim().split("\n");
|
||||||
|
const ids = new Set<string>();
|
||||||
|
let leafId: string | null = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (typeof entry.id === "string") {
|
||||||
|
ids.add(entry.id);
|
||||||
|
leafId = entry.id;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!leafId) throw new Error("无效会话文件");
|
||||||
|
|
||||||
|
let id = randomUUID().slice(0, 8);
|
||||||
|
for (let i = 0; i < 100 && ids.has(id); i++) {
|
||||||
|
id = randomUUID().slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
type: "session_info",
|
||||||
|
id,
|
||||||
|
parentId: leafId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
name: trimmed,
|
||||||
|
};
|
||||||
|
appendFileSync(sessionPath, `\n${JSON.stringify(entry)}`, "utf8");
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSystemPrompt(): string {
|
||||||
|
if (!existsSync(SYSTEM_PROMPT_FILE)) return "";
|
||||||
|
return readFileSync(SYSTEM_PROMPT_FILE, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSystemPrompt(content: string): void {
|
||||||
|
mkdirSync(dirname(SYSTEM_PROMPT_FILE), { recursive: true });
|
||||||
|
writeFileSync(SYSTEM_PROMPT_FILE, content, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAvatarUrl(value: unknown): string {
|
||||||
|
if (typeof value !== "string") return "";
|
||||||
|
const url = value.trim();
|
||||||
|
if (!url) return "";
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`无效头像链接: ${url}`);
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
throw new Error("头像链接仅支持 http 或 https");
|
||||||
|
}
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfigKey(key: unknown): string {
|
||||||
|
if (typeof key !== "string") throw new Error("配置键必须是字符串");
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (!trimmed) throw new Error("配置键不能为空");
|
||||||
|
if (trimmed.length > 128) throw new Error("配置键过长");
|
||||||
|
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(trimmed)) {
|
||||||
|
throw new Error("配置键格式无效");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfigValue(value: unknown): string {
|
||||||
|
if (typeof value !== "string") throw new Error("配置值必须是字符串");
|
||||||
|
if (value.length > 65536) throw new Error("配置值过长");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSkillCommand(command: any): Record<string, unknown> {
|
||||||
|
const rawName = String(command.name || "");
|
||||||
|
const sourceInfo = command.sourceInfo || {};
|
||||||
|
return {
|
||||||
|
name: rawName.replace(/^skill:/, ""),
|
||||||
|
command: rawName,
|
||||||
|
description: command.description || "",
|
||||||
|
scope: sourceInfo.scope || "",
|
||||||
|
source: sourceInfo.source || "",
|
||||||
|
path: sourceInfo.path || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listLoadedSkills(): Promise<Record<string, unknown>[]> {
|
||||||
|
const response = await sendCmd({ type: "get_commands" });
|
||||||
|
if (!response.success) throw new Error(response.error || "读取 skills 失败");
|
||||||
|
const commands = response.data?.commands || [];
|
||||||
|
return commands
|
||||||
|
.filter((command: any) => command?.source === "skill")
|
||||||
|
.map(normalizeSkillCommand)
|
||||||
|
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExtension(extension: any): Record<string, unknown> {
|
||||||
|
const sourceInfo = extension.sourceInfo || {};
|
||||||
|
const pathValue = String(extension.path || "");
|
||||||
|
const source = String(sourceInfo.source || "");
|
||||||
|
const resolvedPath = String(extension.resolvedPath || "");
|
||||||
|
return {
|
||||||
|
name: displayExtensionName(pathValue, source),
|
||||||
|
rawName: extension.name || "",
|
||||||
|
path: pathValue,
|
||||||
|
resolvedPath,
|
||||||
|
scope: sourceInfo.scope || "",
|
||||||
|
source,
|
||||||
|
sourcePath: sourceInfo.path || "",
|
||||||
|
location: displayExtensionLocation(pathValue, resolvedPath),
|
||||||
|
kind: displayExtensionKind(sourceInfo.scope, source),
|
||||||
|
commands: extension.commands || [],
|
||||||
|
tools: extension.tools || [],
|
||||||
|
flags: extension.flags || [],
|
||||||
|
shortcuts: extension.shortcuts || [],
|
||||||
|
handlers: extension.handlers || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProjectExtension(extension: any): boolean {
|
||||||
|
const pathValue = String(extension.path || extension.resolvedPath || "");
|
||||||
|
if (!pathValue) return false;
|
||||||
|
const resolved = resolve(pathValue);
|
||||||
|
const agentExtensionsRoot = resolve(AGENT_EXTENSIONS_DIR);
|
||||||
|
return resolved === agentExtensionsRoot || resolved.startsWith(`${agentExtensionsRoot}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayExtensionName(extensionPath: string, source: string): string {
|
||||||
|
const sourceMatch = source.match(/^npm:(.+)$/);
|
||||||
|
if (sourceMatch?.[1]) return sourceMatch[1];
|
||||||
|
|
||||||
|
const normalized = extensionPath.replace(/\\/g, "/");
|
||||||
|
const parts = normalized.split("/").filter(Boolean);
|
||||||
|
const file = parts[parts.length - 1] || normalized;
|
||||||
|
if (/^index\.[tj]s$/i.test(file) && parts.length >= 2) {
|
||||||
|
return cleanExtensionName(parts[parts.length - 2]);
|
||||||
|
}
|
||||||
|
return cleanExtensionName(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanExtensionName(name: string): string {
|
||||||
|
return basename(name).replace(/\.[cm]?[tj]s$/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayExtensionKind(scope: string, source: string): string {
|
||||||
|
const scopeText = scope === "project" ? "项目" : scope === "user" ? "用户" : scope || "未知";
|
||||||
|
if (source.startsWith("npm:")) return `${scopeText} NPM`;
|
||||||
|
if (source === "auto") return `${scopeText}本地`;
|
||||||
|
return source ? `${scopeText} · ${source}` : scopeText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayExtensionLocation(extensionPath: string, resolvedPath: string): string {
|
||||||
|
const pathValue = extensionPath || resolvedPath;
|
||||||
|
if (!pathValue) return "";
|
||||||
|
return pathValue.replace(REPO_ROOT, ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listLoadedExtensions(): Promise<Record<string, unknown>[]> {
|
||||||
|
const response = await sendCmd({ type: "get_extensions" });
|
||||||
|
if (!response.success) throw new Error(response.error || "读取扩展失败");
|
||||||
|
return (response.data?.extensions || [])
|
||||||
|
.filter(isProjectExtension)
|
||||||
|
.map(normalizeExtension)
|
||||||
|
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonFile(filePath: string): any | null {
|
||||||
|
if (!existsSync(filePath)) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMcpConfig(): any {
|
||||||
|
const projectConfig = readJsonFile(MCP_CONFIG_FILE);
|
||||||
|
if (projectConfig?.mcpServers && typeof projectConfig.mcpServers === "object") {
|
||||||
|
return projectConfig;
|
||||||
|
}
|
||||||
|
return { mcpServers: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMcpTool(serverName: string, tool: any): Record<string, unknown> {
|
||||||
|
const schema = tool?.inputSchema && typeof tool.inputSchema === "object" ? tool.inputSchema : {};
|
||||||
|
const properties = schema && typeof schema.properties === "object" ? Object.keys(schema.properties) : [];
|
||||||
|
const required = Array.isArray(schema.required) ? schema.required.map(String) : [];
|
||||||
|
return {
|
||||||
|
server: serverName,
|
||||||
|
name: String(tool?.name || ""),
|
||||||
|
description: String(tool?.description || ""),
|
||||||
|
parameters: properties,
|
||||||
|
required,
|
||||||
|
hasSchema: Object.keys(schema).length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listMcpTools(): Record<string, unknown>[] {
|
||||||
|
const config = readMcpConfig();
|
||||||
|
const cache = readJsonFile(MCP_CACHE_FILE);
|
||||||
|
const configuredServers = config?.mcpServers && typeof config.mcpServers === "object"
|
||||||
|
? Object.keys(config.mcpServers)
|
||||||
|
: [];
|
||||||
|
const cachedServers = cache?.servers && typeof cache.servers === "object" ? cache.servers : {};
|
||||||
|
const serverNames = Array.from(new Set([...configuredServers, ...Object.keys(cachedServers)])).sort();
|
||||||
|
|
||||||
|
return serverNames.map((serverName) => {
|
||||||
|
const entry = cachedServers[serverName] || {};
|
||||||
|
const tools = Array.isArray(entry.tools)
|
||||||
|
? entry.tools.map((tool: any) => normalizeMcpTool(serverName, tool)).filter((tool: any) => tool.name)
|
||||||
|
: [];
|
||||||
|
const resources = Array.isArray(entry.resources) ? entry.resources : [];
|
||||||
|
return {
|
||||||
|
name: serverName,
|
||||||
|
configured: configuredServers.includes(serverName),
|
||||||
|
cached: Boolean(cachedServers[serverName]),
|
||||||
|
toolCount: tools.length,
|
||||||
|
resourceCount: resources.length,
|
||||||
|
cachedAt: entry.cachedAt ? new Date(entry.cachedAt).toISOString() : "",
|
||||||
|
tools: tools.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name))),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortSessionSummaries(
|
||||||
|
summaries: Array<Record<string, unknown>>,
|
||||||
|
pinnedPaths: string[],
|
||||||
|
): Array<Record<string, unknown>> {
|
||||||
|
const pinnedOrder = new Map(pinnedPaths.map((path, index) => [path, index]));
|
||||||
|
return [...summaries].sort((a, b) => {
|
||||||
|
const aPath = String(a.path);
|
||||||
|
const bPath = String(b.path);
|
||||||
|
const aPin = pinnedOrder.get(aPath);
|
||||||
|
const bPin = pinnedOrder.get(bPath);
|
||||||
|
if (aPin !== undefined && bPin !== undefined) return aPin - bPin;
|
||||||
|
if (aPin !== undefined) return -1;
|
||||||
|
if (bPin !== undefined) return 1;
|
||||||
|
return new Date(String(b.modified || b.created || 0)).getTime() - new Date(String(a.modified || a.created || 0)).getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotatePinnedSessions(
|
||||||
|
summaries: Array<Record<string, unknown>>,
|
||||||
|
pinnedPaths: string[],
|
||||||
|
): Array<Record<string, unknown>> {
|
||||||
|
const pinnedSet = new Set(pinnedPaths);
|
||||||
|
return summaries.map((summary) => ({
|
||||||
|
...summary,
|
||||||
|
pinned: pinnedSet.has(String(summary.path)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionListResponse() {
|
||||||
|
const summaries = listSessionFiles()
|
||||||
|
.map(readSessionSummary)
|
||||||
|
.filter(Boolean) as Array<Record<string, unknown>>;
|
||||||
|
const pinnedPaths = prunePinnedSessionPaths(summaries.map((summary) => String(summary.path)));
|
||||||
|
const sorted = sortSessionSummaries(summaries, pinnedPaths);
|
||||||
|
return {
|
||||||
|
sessions: annotatePinnedSessions(sorted, pinnedPaths),
|
||||||
|
pinnedPaths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTTP 服务 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "application/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".png": "image/png",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".webmanifest": "application/manifest+json",
|
||||||
|
".json": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
function serveStatic(urlPath: string, res: any, spaFallback = false): void {
|
||||||
|
const file = urlPath === "/" ? "/index.html" : urlPath;
|
||||||
|
const full = join(PUBLIC_DIR, file);
|
||||||
|
if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end("Forbidden"); return; }
|
||||||
|
if (!existsSync(full)) {
|
||||||
|
if (spaFallback && !urlPath.includes(".")) {
|
||||||
|
return serveStatic("/index.html", res, false);
|
||||||
|
}
|
||||||
|
res.writeHead(404); res.end("Not found"); return;
|
||||||
|
}
|
||||||
|
const ext = file.match(/\.\w+$/)?.[0] || ".html";
|
||||||
|
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
|
||||||
|
res.end(readFileSync(full));
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(res: any, data: unknown, status = 200): void {
|
||||||
|
res.writeHead(status, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req: any): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (c: Buffer) => (body += c.toString()));
|
||||||
|
req.on("end", () => { try { resolve(JSON.parse(body)); } catch { reject(new Error("无效 JSON")); } });
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const url = new URL(req.url!, `http://localhost:${PORT}`);
|
||||||
|
|
||||||
|
// 静态文件
|
||||||
|
if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
|
||||||
|
return serveStatic(url.pathname, res, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/chat
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/chat") {
|
||||||
|
return readBody(req)
|
||||||
|
.then(({ message, images }) => {
|
||||||
|
const msg = typeof message === "string" ? message : "";
|
||||||
|
const imgs = normalizeChatImages(images);
|
||||||
|
if (!msg.trim() && !imgs?.length) throw new Error("消息不能为空");
|
||||||
|
if (imgs?.length) console.log(`[webui] chat: ${imgs.length} image(s), message=${msg.length} chars`);
|
||||||
|
return sendCmd({ type: "prompt", message: msg, images: imgs }).then(() => json(res, { ok: true }));
|
||||||
|
})
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/events (SSE)
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/events") {
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
});
|
||||||
|
res.write('data: {"type":"connected"}\n\n');
|
||||||
|
sseClients.add(res);
|
||||||
|
req.on("close", () => sseClients.delete(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/messages
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/messages") {
|
||||||
|
return sendCmd({ type: "get_messages" })
|
||||||
|
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/new-session
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/new-session") {
|
||||||
|
return sendCmd({ type: "new_session" })
|
||||||
|
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/abort
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/abort") {
|
||||||
|
return sendCmd({ type: "abort" })
|
||||||
|
.then(() => json(res, { ok: true }))
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/models
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/models") {
|
||||||
|
return sendCmd({ type: "get_available_models" })
|
||||||
|
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/model
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/model") {
|
||||||
|
return readBody(req)
|
||||||
|
.then(({ provider, modelId }) =>
|
||||||
|
sendCmd({ type: "set_model", provider, modelId }).then((r: any) =>
|
||||||
|
r.success ? json(res, { model: r.data }) : json(res, { error: r.error }, 500),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/thinking
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/thinking") {
|
||||||
|
return readBody(req)
|
||||||
|
.then(({ level }) =>
|
||||||
|
sendCmd({ type: "set_thinking_level", level }).then((r: any) =>
|
||||||
|
r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/avatars
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/avatars") {
|
||||||
|
return json(res, readWebuiAvatarSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/webui/config
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/webui/config") {
|
||||||
|
const key = url.searchParams.get("key");
|
||||||
|
if (key) {
|
||||||
|
const normalized = normalizeConfigKey(key);
|
||||||
|
return json(res, { key: normalized, value: getWebuiConfig(normalized) });
|
||||||
|
}
|
||||||
|
return json(res, { config: getAllWebuiConfig(), dbPath: webuiDb.dbPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/webui/config
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/webui/config") {
|
||||||
|
return readBody(req)
|
||||||
|
.then(({ key, value, entries }) => {
|
||||||
|
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
|
||||||
|
const normalized: Record<string, string> = {};
|
||||||
|
for (const [rawKey, rawValue] of Object.entries(entries as Record<string, unknown>)) {
|
||||||
|
normalized[normalizeConfigKey(rawKey)] = normalizeConfigValue(rawValue);
|
||||||
|
}
|
||||||
|
setWebuiConfigMany(normalized);
|
||||||
|
return json(res, { ok: true, config: getAllWebuiConfig() });
|
||||||
|
}
|
||||||
|
if (typeof key !== "string") throw new Error("缺少配置键 key");
|
||||||
|
const normalizedKey = normalizeConfigKey(key);
|
||||||
|
const normalizedValue = normalizeConfigValue(value);
|
||||||
|
setWebuiConfig(normalizedKey, normalizedValue);
|
||||||
|
return json(res, { ok: true, key: normalizedKey, value: normalizedValue });
|
||||||
|
})
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/webui/config/delete
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/webui/config/delete") {
|
||||||
|
return readBody(req)
|
||||||
|
.then(({ key }) => {
|
||||||
|
const normalized = normalizeConfigKey(key);
|
||||||
|
const deleted = deleteWebuiConfig(normalized);
|
||||||
|
json(res, { ok: true, deleted, key: normalized });
|
||||||
|
})
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/settings
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/settings") {
|
||||||
|
return Promise.all([listLoadedSkills(), listLoadedExtensions()])
|
||||||
|
.then(([skills, extensions]) => json(res, {
|
||||||
|
...readWebuiAvatarSettings(),
|
||||||
|
webuiDbPath: webuiDb.dbPath,
|
||||||
|
systemPrompt: readSystemPrompt(),
|
||||||
|
systemPromptPath: SYSTEM_PROMPT_FILE,
|
||||||
|
extensionsPath: AGENT_EXTENSIONS_DIR,
|
||||||
|
mcpCachePath: MCP_CACHE_FILE,
|
||||||
|
mcpConfigPath: MCP_CONFIG_FILE,
|
||||||
|
skills,
|
||||||
|
extensions,
|
||||||
|
mcpTools: listMcpTools(),
|
||||||
|
}))
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/settings/reload
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/settings/reload") {
|
||||||
|
return sendCmd({ type: "reload" })
|
||||||
|
.then((r: any) => (r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500)))
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/settings/system-prompt
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/settings/system-prompt") {
|
||||||
|
return readBody(req)
|
||||||
|
.then(async ({ systemPrompt }) => {
|
||||||
|
if (typeof systemPrompt !== "string") {
|
||||||
|
throw new Error("systemPrompt 必须是字符串");
|
||||||
|
}
|
||||||
|
writeSystemPrompt(systemPrompt);
|
||||||
|
const reload = await sendCmd({ type: "reload" });
|
||||||
|
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
|
||||||
|
json(res, { ok: true, systemPromptPath: SYSTEM_PROMPT_FILE });
|
||||||
|
})
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/settings/avatars
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/settings/avatars") {
|
||||||
|
return readBody(req)
|
||||||
|
.then(({ userAvatarUrl, agentAvatarUrl }) => {
|
||||||
|
const settings = {
|
||||||
|
userAvatarUrl: normalizeAvatarUrl(userAvatarUrl),
|
||||||
|
agentAvatarUrl: normalizeAvatarUrl(agentAvatarUrl),
|
||||||
|
};
|
||||||
|
writeWebuiAvatarSettings(settings);
|
||||||
|
json(res, { ok: true, ...settings });
|
||||||
|
})
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/sessions
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/sessions") {
|
||||||
|
return json(res, buildSessionListResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/sessions/history
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/sessions/history") {
|
||||||
|
return readBody(req)
|
||||||
|
.then(({ path: sp }) => {
|
||||||
|
const messages = readSessionMessages(sp as string);
|
||||||
|
const summary = readSessionSummary(sp as string);
|
||||||
|
json(res, { messages, session: summary });
|
||||||
|
})
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/sessions/delete
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/sessions/delete") {
|
||||||
|
return readBody(req)
|
||||||
|
.then(({ path: sp }) => {
|
||||||
|
const sessionPath = resolveSessionFile(sp as string);
|
||||||
|
if (!existsSync(sessionPath)) throw new Error("会话不存在");
|
||||||
|
unlinkSync(sessionPath);
|
||||||
|
removePinnedSessionPath(sessionPath);
|
||||||
|
json(res, { ok: true });
|
||||||
|
})
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/sessions/pin
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/sessions/pin") {
|
||||||
|
return readBody(req)
|
||||||
|
.then(({ path: sp, pinned }) => {
|
||||||
|
const sessionPath = resolveSessionFile(sp as string);
|
||||||
|
if (!existsSync(sessionPath)) throw new Error("会话不存在");
|
||||||
|
const pinnedPaths = setSessionPinned(sessionPath, Boolean(pinned));
|
||||||
|
json(res, { ok: true, path: sessionPath, pinned: Boolean(pinned), pinnedPaths });
|
||||||
|
})
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/sessions/load
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/sessions/load") {
|
||||||
|
return readBody(req)
|
||||||
|
.then(async ({ path: sp }) => {
|
||||||
|
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
|
||||||
|
if (!sw.success) throw new Error(sw.error);
|
||||||
|
const mr = await sendCmd({ type: "get_messages" });
|
||||||
|
if (!mr.success) throw new Error(mr.error);
|
||||||
|
const summary = readSessionSummary(sp as string);
|
||||||
|
json(res, { messages: mr.data.messages, session: summary });
|
||||||
|
})
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/sessions/activate
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/sessions/activate") {
|
||||||
|
return readBody(req)
|
||||||
|
.then(async ({ path: sp }) => {
|
||||||
|
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
|
||||||
|
if (!sw.success) throw new Error(sw.error);
|
||||||
|
json(res, { ok: true });
|
||||||
|
})
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/sessions/name
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/sessions/name") {
|
||||||
|
return readBody(req)
|
||||||
|
.then(async ({ path: sp, name }) => {
|
||||||
|
if (typeof sp !== "string" || !sp.trim()) throw new Error("无效会话路径");
|
||||||
|
const savedName = appendSessionName(sp, typeof name === "string" ? name : "");
|
||||||
|
const sessionPath = resolveSessionFile(sp);
|
||||||
|
const state = await sendCmd({ type: "get_state" });
|
||||||
|
if (state.success && state.data?.sessionFile === sessionPath) {
|
||||||
|
const rename = await sendCmd({ type: "set_session_name", name: savedName });
|
||||||
|
if (!rename.success) throw new Error(rename.error || "同步当前会话名称失败");
|
||||||
|
}
|
||||||
|
json(res, { ok: true, name: savedName });
|
||||||
|
})
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/session-state
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/session-state") {
|
||||||
|
return sendCmd({ type: "get_state" })
|
||||||
|
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||||
|
.catch((err) => json(res, { error: err.message }, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end("Not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
if (err.code === "EADDRINUSE") {
|
||||||
|
console.error(`[webui] 端口 ${PORT} 已被占用`);
|
||||||
|
} else {
|
||||||
|
console.error(`[webui] HTTP 服务启动失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, "0.0.0.0", () => {
|
||||||
|
console.log(`[webui] HTTP 服务已启动: http://localhost:${PORT}`);
|
||||||
|
console.log(`[webui] 局域网访问: http://smallmengya:${PORT}`);
|
||||||
|
});
|
||||||
14
.pi/agent/settings.example.json
Normal file
14
.pi/agent/settings.example.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"lastChangelogVersion": "0.75.4",
|
||||||
|
"showChangelogOnStartup": false,
|
||||||
|
"defaultProvider": "your-provider",
|
||||||
|
"defaultModel": "your-model",
|
||||||
|
"defaultThinkingLevel": "high",
|
||||||
|
"transport": "sse",
|
||||||
|
"theme": "dark",
|
||||||
|
"packages": [
|
||||||
|
"npm:pi-subagents",
|
||||||
|
"npm:pi-mcp-adapter",
|
||||||
|
"npm:pi-autocontext"
|
||||||
|
]
|
||||||
|
}
|
||||||
173
README.md
173
README.md
@@ -1,6 +1,6 @@
|
|||||||
# sproutclaw
|
# sproutclaw
|
||||||
|
|
||||||
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 pi-mono 改造,重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
|
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 [pi-mono](https://github.com/badlogic/pi-mono) 改造,重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
|
||||||
|
|
||||||
## 定位
|
## 定位
|
||||||
|
|
||||||
@@ -14,16 +14,19 @@
|
|||||||
|
|
||||||
| 路径 | 说明 |
|
| 路径 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体 |
|
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体(含 sproutclaw 定制补丁) |
|
||||||
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
|
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
|
||||||
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
|
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
|
||||||
| `packages/tui` | 终端 UI 渲染库 |
|
| `packages/tui` | 终端 UI 渲染库 |
|
||||||
| `.pi/agent/extensions/webui` | 本地扩展的网页对话入口 |
|
| `.pi/agent/extensions/webui` | 网页对话入口(React 前端 + HTTP 服务) |
|
||||||
|
| `.pi/agent/extensions/*.ts` | sproutclaw 本地扩展(中文启动页、命令安装等) |
|
||||||
|
| `scripts/sync-upstream.sh` | 合并上游 pi-mono 更新 |
|
||||||
|
| `scripts/push-sproutclaw.sh` | 推送到 GitHub / Gitea |
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install --ignore-scripts
|
||||||
npm run build
|
npm run build
|
||||||
npm run check
|
npm run check
|
||||||
./test.sh
|
./test.sh
|
||||||
@@ -48,70 +51,114 @@ npm run check
|
|||||||
/webui off
|
/webui off
|
||||||
```
|
```
|
||||||
|
|
||||||
## 说明
|
WebUI 前端单独构建:
|
||||||
|
|
||||||
这个仓库是自用分支,默认会围绕树萌芽的服务器环境、部署规范和日常开发习惯进行调整。上游能力来自 pi-mono,后续会继续精简、增强并沉淀适合服务器运维开发场景的 Agent 工作流。
|
|
||||||
|
|
||||||
To learn more about pi:
|
|
||||||
|
|
||||||
* [Visit pi.dev](https://pi.dev), the project website with demos
|
|
||||||
* [Read the documentation](https://pi.dev/docs/latest), but you can also ask the agent to explain itself
|
|
||||||
|
|
||||||
## Share your OSS coding agent sessions
|
|
||||||
|
|
||||||
If you use pi or other coding agents for open source work, please share your sessions.
|
|
||||||
|
|
||||||
Public OSS session data helps improve coding agents with real-world tasks, tool use, failures, and fixes instead of toy benchmarks.
|
|
||||||
|
|
||||||
For the full explanation, see [this post on X](https://x.com/badlogicgames/status/2037811643774652911).
|
|
||||||
|
|
||||||
To publish sessions, use [`badlogic/pi-share-hf`](https://github.com/badlogic/pi-share-hf). Read its README.md for setup instructions. All you need is a Hugging Face account, the Hugging Face CLI, and `pi-share-hf`.
|
|
||||||
|
|
||||||
You can also watch [this video](https://x.com/badlogicgames/status/2041151967695634619), where I show how I publish my `pi-mono` sessions.
|
|
||||||
|
|
||||||
I regularly publish my own `pi-mono` work sessions here:
|
|
||||||
|
|
||||||
- [badlogicgames/pi-mono on Hugging Face](https://huggingface.co/datasets/badlogicgames/pi-mono)
|
|
||||||
|
|
||||||
## All Packages
|
|
||||||
|
|
||||||
| Package | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| **[@earendil-works/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) |
|
|
||||||
| **[@earendil-works/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management |
|
|
||||||
| **[@earendil-works/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI |
|
|
||||||
| **[@earendil-works/pi-tui](packages/tui)** | Terminal UI library with differential rendering |
|
|
||||||
|
|
||||||
For Slack/chat automation and workflows see [earendil-works/pi-chat](https://github.com/earendil-works/pi-chat).
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.md](AGENTS.md) for project-specific rules (for both humans and agents).
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install --ignore-scripts # Install all dependencies without running lifecycle scripts
|
cd .pi/agent/extensions/webui/frontend && npm install && npm run build
|
||||||
npm run build # Build all packages
|
|
||||||
npm run check # Lint, format, and type check
|
|
||||||
./test.sh # Run tests (skips LLM-dependent tests without API keys)
|
|
||||||
./pi-test.sh # Run pi from sources (can be run from any directory)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Supply-chain hardening
|
## Git 分支策略
|
||||||
|
|
||||||
We treat npm dependency changes as reviewed code changes.
|
本仓库是 pi-mono 的 fork,采用三层分支,同时维护**上游同步**和 **sproutclaw 定制**。
|
||||||
|
|
||||||
- Direct external dependencies are pinned to exact versions. Internal workspace packages remain version-ranged.
|
| 分支 | 用途 |
|
||||||
- `.npmrc` sets `save-exact=true` and `min-release-age=2` to avoid same-day dependency releases during npm resolution.
|
| --- | --- |
|
||||||
- `package-lock.json` is the dependency ground truth. Pre-commit blocks accidental lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1` is set.
|
| `main` | 稳定可跑版本:已合并的上游 + sproutclaw 定制,推送到 GitHub |
|
||||||
- `npm run check` verifies pinned direct deps, native TypeScript import compatibility, and the generated coding-agent shrinkwrap.
|
| `upstream-sync` | 临时同步分支:专门用来 `merge upstream/main`,解决冲突后再合回 `main` |
|
||||||
- The published CLI package includes `packages/coding-agent/npm-shrinkwrap.json`, generated from the root lockfile, to pin transitive deps for npm users.
|
| `feature/*` | 日常开发:WebUI、扩展、补丁等,完成后 merge 进 `main` |
|
||||||
- Release smoke tests use `npm run release:local` to build, pack, and create isolated npm and Bun installs outside the repo before publishing.
|
|
||||||
- Local release installs, documented npm installs, and `pi update --self` use `--ignore-scripts` where supported.
|
|
||||||
- CI installs with `npm ci --ignore-scripts`, and a scheduled GitHub workflow runs `npm audit --omit=dev` plus `npm audit signatures --omit=dev`.
|
|
||||||
- Shrinkwrap generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed.
|
|
||||||
|
|
||||||
## License
|
### Remote 配置
|
||||||
|
|
||||||
MIT
|
```text
|
||||||
|
origin → https://github.com/shumengya/sproutclaw.git (你的 GitHub)
|
||||||
|
upstream → https://github.com/badlogic/pi-mono.git (上游 pi)
|
||||||
|
gitea → ssh://git@git.shumengya.top:8022/shumengya/sproutclaw.git (内网备份,可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
首次配置 upstream:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote add upstream https://github.com/badlogic/pi-mono.git
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub 网络不稳定时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GIT_HTTP_VERSION=1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日常开发(feature 分支)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/new-feature.sh webui-avatar-fix # 从 main 创建 feature/webui-avatar-fix
|
||||||
|
# ... 开发、测试 ...
|
||||||
|
git add <改动的具体文件> # 不要用 git add -A
|
||||||
|
git commit -m "fix(webui): agent avatar during tool calls"
|
||||||
|
git checkout main
|
||||||
|
git merge feature/webui-avatar-fix
|
||||||
|
./scripts/push-sproutclaw.sh
|
||||||
|
git branch -d feature/webui-avatar-fix # 合并后可删
|
||||||
|
```
|
||||||
|
|
||||||
|
### 推送本地更新到 GitHub
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
./scripts/push-sproutclaw.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 合并上游 pi-mono 更新
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/sync-upstream.sh
|
||||||
|
# 若有冲突,按下方规则解决后:
|
||||||
|
git add <resolved-files>
|
||||||
|
git commit
|
||||||
|
git checkout main && git merge upstream-sync
|
||||||
|
./scripts/push-sproutclaw.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**合并冲突处理惯例:**
|
||||||
|
|
||||||
|
| 文件 | 处理方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| `README.md`、sproutclaw 定制代码 | 保留本仓库版本 |
|
||||||
|
| `package-lock.json` | 采用 upstream,再执行 `npm install --ignore-scripts` |
|
||||||
|
| `*.generated.ts` | 采用 upstream |
|
||||||
|
| `.pi/agent/extensions/` | 保留 sproutclaw 版本 |
|
||||||
|
|
||||||
|
合并后必做:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --ignore-scripts
|
||||||
|
npm run build
|
||||||
|
./pi-test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### sproutclaw 相对 upstream 的 intentional 改动
|
||||||
|
|
||||||
|
合并上游时,以下定制**不要误删**:
|
||||||
|
|
||||||
|
- `showChangelogOnStartup` 设置项(关闭启动 What's New)
|
||||||
|
- `.pi/agent/extensions/webui/` 网页 UI
|
||||||
|
- `.pi/agent/extensions/startup-chinese.ts` 等本地扩展
|
||||||
|
- 本 README(仅 sproutclaw 说明,不含上游文档)
|
||||||
|
|
||||||
|
## 本地配置
|
||||||
|
|
||||||
|
`.pi/agent/` 中以下内容**不进 Git**(含密钥/内网地址):
|
||||||
|
|
||||||
|
- `settings.json` → 参考 `settings.example.json` 本地复制
|
||||||
|
- `models.json`、`auth.json`
|
||||||
|
- `sessions/`
|
||||||
|
|
||||||
|
首次部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .pi/agent/settings.example.json .pi/agent/settings.json
|
||||||
|
# 编辑 settings.json 填入 provider / model
|
||||||
|
```
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
这个仓库是自用 fork,默认围绕树萌芽的服务器环境、部署规范和日常开发习惯调整。上游能力来自 pi-mono,通过 `upstream-sync` 分支定期同步,sproutclaw 定制在 `feature/*` 开发后合入 `main`。
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export interface Settings {
|
|||||||
hideThinkingBlock?: boolean;
|
hideThinkingBlock?: boolean;
|
||||||
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
|
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
|
||||||
quietStartup?: boolean;
|
quietStartup?: boolean;
|
||||||
|
showChangelogOnStartup?: boolean;
|
||||||
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
|
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
|
||||||
npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"])
|
npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"])
|
||||||
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
||||||
@@ -780,6 +781,16 @@ export class SettingsManager {
|
|||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getShowChangelogOnStartup(): boolean {
|
||||||
|
return this.settings.showChangelogOnStartup ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowChangelogOnStartup(show: boolean): void {
|
||||||
|
this.globalSettings.showChangelogOnStartup = show;
|
||||||
|
this.markModified("showChangelogOnStartup");
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
getShellCommandPrefix(): string | undefined {
|
getShellCommandPrefix(): string | undefined {
|
||||||
return this.settings.shellCommandPrefix;
|
return this.settings.shellCommandPrefix;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -858,6 +858,10 @@ export class InteractiveMode {
|
|||||||
* Only shows new entries since last seen version, skips for resumed sessions.
|
* Only shows new entries since last seen version, skips for resumed sessions.
|
||||||
*/
|
*/
|
||||||
private getChangelogForDisplay(): string | undefined {
|
private getChangelogForDisplay(): string | undefined {
|
||||||
|
if (!this.settingsManager.getShowChangelogOnStartup()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip changelog for resumed/continued sessions (already have messages)
|
// Skip changelog for resumed/continued sessions (already have messages)
|
||||||
if (this.session.state.messages.length > 0) {
|
if (this.session.state.messages.length > 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
21
scripts/new-feature.sh
Executable file
21
scripts/new-feature.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create a feature branch from latest main.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
echo "Usage: $0 <feature-name>" >&2
|
||||||
|
echo "Example: $0 webui-avatar-fix" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
NAME="$1"
|
||||||
|
BRANCH="feature/$NAME"
|
||||||
|
|
||||||
|
git checkout main
|
||||||
|
git pull --ff-only origin main 2>/dev/null || true
|
||||||
|
git checkout -b "$BRANCH"
|
||||||
|
|
||||||
|
echo "Created and switched to $BRANCH"
|
||||||
32
scripts/push-sproutclaw.sh
Executable file
32
scripts/push-sproutclaw.sh
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Push main (and upstream-sync if present) to GitHub and Gitea.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
export GIT_HTTP_VERSION="${GIT_HTTP_VERSION:-1.1}"
|
||||||
|
|
||||||
|
BRANCH="$(git branch --show-current)"
|
||||||
|
if [[ "$BRANCH" != "main" ]]; then
|
||||||
|
echo "Checkout main before pushing (current: $BRANCH)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Push main -> origin (GitHub)"
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet refs/heads/upstream-sync; then
|
||||||
|
echo "==> Push upstream-sync -> origin"
|
||||||
|
git push origin upstream-sync
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git remote get-url gitea &>/dev/null; then
|
||||||
|
echo "==> Push main -> gitea"
|
||||||
|
git push gitea main
|
||||||
|
if git show-ref --verify --quiet refs/heads/upstream-sync; then
|
||||||
|
git push gitea upstream-sync
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
46
scripts/sync-upstream.sh
Executable file
46
scripts/sync-upstream.sh
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Merge upstream pi-mono into upstream-sync, then into main.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
export GIT_HTTP_VERSION="${GIT_HTTP_VERSION:-1.1}"
|
||||||
|
|
||||||
|
echo "==> Fetch upstream (badlogic/pi-mono)"
|
||||||
|
git fetch upstream
|
||||||
|
|
||||||
|
CURRENT="$(git branch --show-current)"
|
||||||
|
if [[ "$CURRENT" != "main" && "$CURRENT" != "upstream-sync" ]]; then
|
||||||
|
echo "Switch to main or upstream-sync first (current: $CURRENT)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Update upstream-sync from main"
|
||||||
|
git checkout upstream-sync 2>/dev/null || git checkout -b upstream-sync main
|
||||||
|
git merge main --ff-only
|
||||||
|
|
||||||
|
echo "==> Merge upstream/main into upstream-sync"
|
||||||
|
if ! git merge upstream/main -m "merge: sync upstream pi-mono main"; then
|
||||||
|
echo
|
||||||
|
echo "Conflicts detected. Resolve them, then:"
|
||||||
|
echo " git add <resolved-files>"
|
||||||
|
echo " git commit"
|
||||||
|
echo " git checkout main && git merge upstream-sync"
|
||||||
|
echo " ./scripts/push-sproutclaw.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Merge upstream-sync into main"
|
||||||
|
git checkout main
|
||||||
|
git merge upstream-sync --ff-only
|
||||||
|
|
||||||
|
echo "==> Install deps and build"
|
||||||
|
npm install --ignore-scripts
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Done. Push with: ./scripts/push-sproutclaw.sh"
|
||||||
|
echo "Conflict hints:"
|
||||||
|
echo " README.md, sproutclaw patches -> keep ours"
|
||||||
|
echo " package-lock.json, *.generated.ts -> take upstream, then npm install"
|
||||||
Reference in New Issue
Block a user