Compare commits

404 Commits

Author SHA1 Message Date
a7fb0a4369 fix: 同步 PI_CODING_AGENT_DIR 并更新模型列表
Some checks failed
CI / build-check-test (push) Has been cancelled
- build.sh 导出 PI_CODING_AGENT_DIR 以对齐 pi-mcp-adapter 配置路径
- 更新 models.generated.ts
- 移除 bmi-calculator.html 示例文件
2026-06-26 13:21:14 +08:00
539599dbb8 feat(coding-agent): isolate SproutClaw namespace from upstream pi
Some checks failed
CI / build-check-test (push) Has been cancelled
Use piConfig name sproutclaw and configDir .sproutclaw so commands, env vars,
and project config no longer collide with the stock pi install.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 10:29:28 +08:00
294ce75892 docs: 更新 README,修正过时内容
Some checks failed
CI / build-check-test (push) Has been cancelled
- 移除 .pi/ 目录条目(已 gitignore)
- 更新 WebUI 说明为独立 sproutclaw-web 服务
- 修正 build.sh/test.sh 说明和脚本引用错误
- 更新合并上游流程(直接 merge,不再用临时分支)
- 补充 Gitea remote 配置说明

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:56:31 +08:00
f1c5e7dc1d chore: rename pi-built/pi-test scripts to build/test
Some checks failed
CI / build-check-test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:54:00 +08:00
812bca79a3 docs: 将 CONTRIBUTING.md 和 SECURITY.md 翻译为中文
Some checks failed
CI / build-check-test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:49:18 +08:00
07a918d4cd chore: merge upstream sync branch into main
Some checks failed
CI / build-check-test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:18:34 +08:00
8ad811c977 feat: sync upstream pi-mono (386 commits)
从上游 badlogic/pi-mono 同步 386 个提交,手动解决所有冲突:

保留 SproutClaw 独有功能:
- RPC: reload 命令、bash 流式输出、get_extensions 命令
- settings-manager: showChangelogOnStartup 设置
- agent-session: turnIndex getter
- 移除 pi 版本检查通知(interactive-mode)
- skills 系统、启动脚本、自定义工具脚本

直接采用上游版本:
- packages/ai 模型列表及测试文件
- packages/coding-agent 文档
- 其余未冲突的全部上游代码

升级 @mistralai/mistralai 至 2.2.6(修复 promptCacheKey 类型错误)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:18:02 +08:00
131546b2fe chore: remove npm audit scheduled cron trigger
Some checks failed
CI / build-check-test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 19:50:04 +08:00
9ed9ed91dd chore: remove .pi config from git tracking
.pi 是本地持久化配置目录,不应上传到仓库。
从追踪中移除所有 .pi 文件,并在 .gitignore 顶部添加整体排除规则。
同时修复 pre-commit hook 在 gitignore 文件重 add 时不报错。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 19:45:14 +08:00
0d8ea01b43 chore: sync latest workspace updates
- 更新 AI 模型列表(models.generated.ts / image-models.generated.ts)
- 新增 bmi-calculator.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 19:41:28 +08:00
Armin Ronacher
d93b92baca fix(coding-agent): show changelog URL in update notice 2026-06-20 22:23:25 +02:00
Armin Ronacher
b4f31408b6 Add [Unreleased] section for next cycle 2026-06-20 22:15:40 +02:00
Armin Ronacher
615bf2f874 Release v0.79.9 2026-06-20 22:15:37 +02:00
Armin Ronacher
1aa79b9b29 docs: update unreleased changelog audit 2026-06-20 22:00:00 +02:00
Armin Ronacher
350ac3f344 fix: remove inprogress from auto-closed issues 2026-06-20 21:48:37 +02:00
Armin Ronacher
500b568b04 fix(ai): use OpenAI endpoint for Fireworks GLM-5.2
closes #5923
2026-06-20 21:32:02 +02:00
Armin Ronacher
5505316ea2 fix(coding-agent): cache extension imports for session switches
Closes #5905.
2026-06-20 21:07:45 +02:00
Armin Ronacher
a1da88aed4 fix(coding-agent): make session path traversal linear
Avoid quadratic path construction when walking deep session branches. On a 600k-entry pathological session, path construction improved from about 20.3s with Array.unshift() to about 35ms with push() plus reverse().

Refs #5804.
2026-06-20 20:29:15 +02:00
Armin Ronacher
8597ebafd9 fix(ai): expose OpenRouter GLM-5.2 xhigh effort
closes #5770
2026-06-20 18:47:42 +02:00
Armin Ronacher
416c673dfe fix: skip no-action for to-discuss issues 2026-06-20 16:13:13 +02:00
Alexey Zaytsev
3095977d13 fix(tui): stabilize streaming code fence rendering (#5846) 2026-06-20 14:58:30 +02:00
Armin Ronacher
8b97e75c6b feat(ai): add chat-template thinking compat
closes #5673
2026-06-19 23:34:17 +02:00
Armin Ronacher
128330e36f fix(coding-agent): preserve untouched lines in fuzzy edit
closes #5899
2026-06-19 20:16:47 +02:00
Vegard Stikbakke
1287b69fe0 fix(coding-agent): run legacy WSL bash commands via stdin
closes #5893
2026-06-19 15:08:25 +02:00
Vegard Stikbakke
6e6ce70caf fix(ai): filter Copilot models by account availability
closes #5897
2026-06-19 13:17:13 +02:00
Armin Ronacher
226a316824 fix: mark auto-closed issues not planned 2026-06-19 12:46:56 +02:00
Vegard Stikbakke
373cd6ae52 fix(coding-agent): prioritize provider matches in model selector
closes #5892
2026-06-19 12:25:02 +02:00
Armin Ronacher
47d1d90a8e fix: close no-action issues as not planned 2026-06-19 11:08:16 +02:00
Armin Ronacher
783571a615 feat: track auto-closed issue triage 2026-06-19 11:03:11 +02:00
Armin Ronacher
56b22768db Add [Unreleased] section for next cycle 2026-06-19 09:53:45 +02:00
Armin Ronacher
8eb9704b3e Release v0.79.8 2026-06-19 09:53:43 +02:00
Armin Ronacher
74677bbfbf docs: audit unreleased changelogs 2026-06-19 09:45:03 +02:00
Armin Ronacher
a2f70e5f28 fix(coding-agent): reset tool test mocks 2026-06-19 09:22:13 +02:00
Armin Ronacher
ea65a51a15 fix: update vulnerable dependencies 2026-06-19 00:43:44 +02:00
Fred K. Schott
0d89a33373 feat(packages): Add selective pi-ai base entrypoints (#5348) 2026-06-19 00:41:49 +02:00
Armin Ronacher
1a418ad223 chore: remove inprogress label on close 2026-06-19 00:26:30 +02:00
Armin Ronacher
9179734cc2 docs(coding-agent): audit unreleased changelog 2026-06-18 23:58:34 +02:00
Armin Ronacher
651d10d90b feat(ai): enable Mistral prompt caching 2026-06-18 23:47:25 +02:00
Armin Ronacher
8025fdd01f meta: Update readmes slightly 2026-06-18 23:36:08 +02:00
Armin Ronacher
fd1ba2c7fe test(coding-agent): seed auto-compaction queue fixture 2026-06-18 23:15:18 +02:00
Armin Ronacher
cab89d1435 docs: audit unreleased changelogs 2026-06-18 23:09:41 +02:00
Armin Ronacher
c60f6a8ab3 feat(coding-agent): expose post-compaction token estimates
closes #5877
2026-06-18 23:01:10 +02:00
Danila Poyarkov
b09fbde00e feat(ai): add OpenRouter Fusion alias (#5866) 2026-06-18 22:39:02 +02:00
Armin Ronacher
7d08c81a09 fix(coding-agent): avoid empty compaction summaries
closes #4811
2026-06-18 22:17:57 +02:00
Armin Ronacher
6b9f3f492f fix(coding-agent): avoid retrying successful overflow compaction
closes #5720
2026-06-18 19:30:14 +02:00
Armin Ronacher
788a044483 Add [Unreleased] section for next cycle 2026-06-18 18:22:10 +02:00
Armin Ronacher
c4ab61dcbf Release v0.79.7 2026-06-18 18:22:07 +02:00
Armin Ronacher
717494223d docs: audit unreleased changelogs 2026-06-18 18:16:48 +02:00
Armin Ronacher
aae62dfa87 feat(coding-agent): make bare update self-only 2026-06-18 18:09:03 +02:00
Alexey Zaytsev
2b46f38863 feat(coding-agent): Expose edit-diff for extensions (#5756) 2026-06-18 17:14:39 +02:00
Armin Ronacher
d0b46764b1 feat(coding-agent): add automatic theme mode (#5874) 2026-06-18 17:13:47 +02:00
Armin Ronacher
908be616f2 ref: Remove some options from package reporting 2026-06-18 17:12:03 +02:00
Armin Ronacher
bc93655e1c meta: Added report template 2026-06-18 16:26:01 +02:00
Vegard Stikbakke
20da9bc140 fix attribution for 008c76f9 2026-06-18 10:59:25 +02:00
Diego de Oliveira
7a14325b24 feat(tui): detect Warp terminal and enable Kitty image protocol (#5841)
Warp supports the Kitty graphics protocol and OSC 8 hyperlinks,
but pi's terminal-image capability detection did not recognize it.
Add detection via TERM_PROGRAM=WarpTerminal, WARP_SESSION_ID,
and WARP_TERMINAL_SESSION_UUID so images render inline without
requiring the TERM_PROGRAM=kitty workaround.

Fixes #5827
2026-06-18 10:45:51 +02:00
Vegard Stikbakke
51f7523587 fix(coding-agent): include RPC request id on unknown commands
closes #5868
2026-06-18 10:44:59 +02:00
Vegard Stikbakke
008c76f955 feat(coding-agent): export project config dir name 2026-06-18 10:27:11 +02:00
Vegard Stikbakke
58dd2f5996 feat(ai): add GLM-5.2 to OpenCode Go model catalog
closes #5860
2026-06-18 10:06:23 +02:00
Armin Ronacher
6d5ede31c8 fix(coding-agent): match provider-first model searches 2026-06-17 17:16:39 +02:00
Vegard Stikbakke
ae89286d07 docs: update changelogs for tree panning
closes #5830
2026-06-17 14:48:35 +02:00
Vegard Stikbakke
068ab5d19b fix(coding-agent): horizontally pan tree selector
Fixes #5830
2026-06-17 14:41:39 +02:00
github-actions[bot]
29c1504cc1 chore: approve contributor dodiego 2026-06-17 07:24:29 +00:00
Armin Ronacher
12bb8dd2c9 Add [Unreleased] section for next cycle 2026-06-16 23:59:17 +02:00
Armin Ronacher
31bfb2f16f Release v0.79.6 2026-06-16 23:59:14 +02:00
Armin Ronacher
34b6aea1e6 docs(coding-agent): add changelog entries for fetch override and DeepSeek V4 thinking-off fixes 2026-06-16 23:59:00 +02:00
Armin Ronacher
7da475db66 fix(ai): regenerate model catalog 2026-06-16 23:58:43 +02:00
Armin Ronacher
bd9f8773ad fix(ai): restore OpenCode Go DeepSeek thinking controls 2026-06-16 23:14:56 +02:00
Armin Ronacher
a93f066607 fix(coding-agent): preserve fetch overrides 2026-06-16 20:04:53 +02:00
Armin Ronacher
a78cd7ccb1 fix(coding-agent): stabilize self-update tests 2026-06-16 20:02:20 +02:00
Armin Ronacher
0b0b9eae62 Add [Unreleased] section for next cycle 2026-06-16 19:54:26 +02:00
Armin Ronacher
6561cb293b Release v0.79.5 2026-06-16 19:54:23 +02:00
Armin Ronacher
b6b5bed9ae docs: update unreleased changelogs 2026-06-16 19:46:15 +02:00
Armin Ronacher
2431491c92 fix(ai): avoid duplicate OpenCode DeepSeek reasoning controls
closes #5818
2026-06-16 19:05:44 +02:00
Armin Ronacher
2d597f0212 fix(ai): tolerate null Responses message content
closes #5819
2026-06-16 19:00:12 +02:00
Armin Ronacher
910508595a feat(coding-agent): add settings http proxy
closes #5790
2026-06-16 18:53:19 +02:00
Armin Ronacher
0680726ac4 fix: upgrade marked to 18.0.5 2026-06-16 17:41:59 +02:00
Armin Ronacher
8f0e9251b9 fix(coding-agent): do not open browser for device code login 2026-06-16 17:35:50 +02:00
Armin Ronacher
7f29e7a369 feat: add provider-scoped environment overrides (#5807) 2026-06-16 17:19:08 +02:00
4h9fbZ
3039f3e17d fix(tui): restore cursorUp start-of-line jump when input is non-empty (#5789) 2026-06-16 15:14:17 +02:00
Armin Ronacher
06d8c54de2 fix(coding-agent): avoid Windows pi update exit assertion
closes #5805
2026-06-16 15:10:23 +02:00
Armin Ronacher
75b0d723c0 fix(ai): support Z.AI GLM-5.2 effort levels
closes #5770
2026-06-16 14:29:27 +02:00
Ramiz Wachtler
f8a77f47f2 feat(coding-agent): add Vercel AI Gateway attribution (#5798) 2026-06-16 09:37:37 +02:00
Armin Ronacher
b0c8f65ffa fix(ai): update Google Vertex Gemini models
closes #5761
2026-06-16 01:03:12 +02:00
Armin Ronacher
7cfd1af395 fix(coding-agent): keep empty session selector open
closes #5747
2026-06-16 00:39:44 +02:00
Armin Ronacher
bee8e9c805 feat(coding-agent): mark experimental sessions in footer 2026-06-15 23:22:23 +02:00
Armin Ronacher
431d88f4fc meta: Link to rfcs 2026-06-15 17:36:39 +02:00
Armin Ronacher
0369bdb8f4 fix(ai): add Moonshot CN Kimi K2.7 metadata
closes #5760
2026-06-15 17:08:59 +02:00
root
a2a5539e78 Merge branch 'main' of ssh://git.shumengya.top:8022/shumengya/sproutclaw 2026-06-15 17:08:54 +08:00
root
072398bddc chore: ignore .pi/agent/skills directory 2026-06-15 17:08:22 +08:00
Armin Ronacher
1aa3c02d56 Add [Unreleased] section for next cycle 2026-06-15 10:28:24 +02:00
Armin Ronacher
bba6af2c71 Release v0.79.4 2026-06-15 10:28:21 +02:00
Armin Ronacher
b1ad469be4 docs: audit changelog entries 2026-06-15 10:23:19 +02:00
Armin Ronacher
8a7ad60f03 feat(coding-agent): add binary release checksums
closes #5739
2026-06-15 10:11:44 +02:00
Joseph Mearman
3fa4095629 fix: drain stdout before resolving when a child holds the pipe past exit (#5753) 2026-06-15 09:55:45 +02:00
Armin Ronacher
408ac103ec fix(ai): update Copilot Claude thinking metadata
closes #4637
2026-06-15 09:50:25 +02:00
github-actions[bot]
28b3af5d0f chore: approve contributor Mearman 2026-06-15 07:25:09 +00:00
Bucky
0be5bb6c96 fix(ai): price anthropic 1h cache writes at 2x input (#5738)
anthropic 1h cache writes were billed at the 5m rate; split the bucket and price the 1h portion correctly
2026-06-15 09:21:14 +02:00
Vegard Stikbakke
a851968170 docs(coding-agent): reorder containerization patterns 2026-06-15 09:16:13 +02:00
731ba23515 feat(ui): 美化启动界面并重构 /is 工具/插件展示
Some checks failed
npm audit / audit (push) Has been cancelled
CI / build-check-test (push) Has been cancelled
- 缩小标题加 ❯ 引导符、彩色版本号、中文说明
- 去除启动头部段落间空行
- /is 本地扩展和 npm 扩展合并为 [插件] 分组展示
- /is 新增 [工具] 区块显示所有注册工具及激活状态

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:32:16 +08:00
root
675dd18182 chore: sync latest workspace updates
Some checks failed
CI / build-check-test (push) Has been cancelled
2026-06-15 13:38:22 +08:00
Armin Ronacher
bb959aae01 fix(coding-agent): wrap tree help on narrow terminals
closes #5055
2026-06-15 01:25:46 +02:00
Armin Ronacher
24053eab66 fix(tui): update tab overlay boundary expectation 2026-06-15 01:12:40 +02:00
Armin Ronacher
5b6058c3a7 fix(tui): align overlays over CJK wide cells
closes #5297
2026-06-15 01:06:15 +02:00
Armin Ronacher
ba0ec61563 fix(coding-agent): restore terminal on SIGTERM
closes #5724
2026-06-15 00:47:35 +02:00
Armin Ronacher
b5e13bcd8a docs(coding-agent): clarify active tools docs
closes #5729
2026-06-15 00:02:31 +02:00
Armin Ronacher
93b3b7c1fe fix(tui): preserve WezTerm Kitty images on full redraw
Reserve visible Kitty image rows before drawing placements in full redraws, while preserving the first-row path for taller-than-viewport images so #4415 stays fixed.

Closes #5618.
2026-06-14 23:16:59 +02:00
Armin Ronacher
d683a581b7 meta: Update CONTRIBUTING.md for clearer language 2026-06-14 22:31:42 +02:00
Alexey Zaytsev
6b40c99a59 feat(examples): Wrap question extension text instead of truncating (#5708) 2026-06-14 15:02:58 +02:00
ad62015fe3 chore: commit pending workspace updates
Some checks failed
CI / build-check-test (push) Has been cancelled
2026-06-14 20:36:01 +08:00
Armin Ronacher
11b5403fad fix(coding-agent): exit after package commands
closes #5687
2026-06-14 10:53:00 +02:00
Vegard Stikbakke
f0989800cb feat: detect first-run terminal theme (#5385) 2026-06-14 07:14:42 +02:00
Armin Ronacher
3fcfb7abf7 docs(coding-agent): document extension resource lifecycle 2026-06-14 02:05:11 +02:00
Armin Ronacher
c48f656f88 fix(coding-agent): handle npm package semver ranges
closes #5695
2026-06-14 01:38:51 +02:00
Armin Ronacher
2fbdff9dab fix(coding-agent): fix pnpm self-update bin-dir
closes #5689
2026-06-14 01:31:50 +02:00
Armin Ronacher
5be8c31fa7 meta: add extension disclaimer to bug reporting 2026-06-14 00:57:56 +02:00
Armin Ronacher
21a904f4b7 fix(ai): disable OpenCode long cache retention for rejecting routes
closes #5702
2026-06-13 23:59:45 +02:00
Armin Ronacher
9e9fc79478 fix(coding-agent): treat uppercase config values as literals
closes #5661
2026-06-13 21:24:22 +02:00
Armin Ronacher
f315d81491 meta: Update weekend policy in contributing to be less confusing 2026-06-13 15:43:00 +02:00
root
62e2866228 chore: commit pending changes before gitea push
Some checks failed
CI / build-check-test (push) Has been cancelled
npm audit / audit (push) Has been cancelled
2026-06-13 19:47:30 +08:00
Armin Ronacher
6f29450e13 fix(ai): update adaptive thinking model expectations 2026-06-13 11:17:38 +02:00
Armin Ronacher
b15148fe37 Add [Unreleased] section for next cycle 2026-06-13 11:11:14 +02:00
Armin Ronacher
f2585c4c82 Release v0.79.3 2026-06-13 11:11:11 +02:00
Armin Ronacher
57b6bdce71 docs(coding-agent): update Codex context limit changelog 2026-06-13 11:07:31 +02:00
Armin Ronacher
aa3a5233ad fix(ai): restore Codex context limits 2026-06-13 11:02:38 +02:00
Armin Ronacher
032c01c1e1 Add [Unreleased] section for next cycle 2026-06-13 00:25:18 +02:00
Armin Ronacher
f21f3c4bbd Release v0.79.2 2026-06-13 00:25:15 +02:00
Armin Ronacher
e320f09618 docs: update unreleased changelogs 2026-06-13 00:20:01 +02:00
Armin Ronacher
121f0edbf4 fix(ai): detect parenthesized context overflow errors
closes #5677
2026-06-13 00:01:37 +02:00
Michael Yu
7a3cb6312e fix(ai): normalize generated model costs (#5634) 2026-06-13 00:01:04 +02:00
Armin Ronacher
b4bff7f0d0 fix(coding-agent): avoid project trust prompt for update (#5674) 2026-06-12 23:37:16 +02:00
Armin Ronacher
a7cdc679e7 fix(ai): correct GPT-5 context window metadata
closes #5644
2026-06-12 23:03:10 +02:00
Armin Ronacher
17721d5e01 fix(tui): preserve unordered user list markers (closes #5657) 2026-06-12 20:22:53 +02:00
Armin Ronacher
daab056ac1 fix(agent): ignore late tool progress updates
closes #5573
2026-06-12 18:59:32 +02:00
Armin Ronacher
adf567c1c6 fix(coding-agent): rechain fork paths without labels
closes #5669
2026-06-12 18:50:31 +02:00
Armin Ronacher
1b2c32c653 fix(coding-agent): resolve authenticated slash model ids
closes #5643
2026-06-12 18:32:42 +02:00
Armin Ronacher
0caca6cf3f fix(tui): support slash-separated fuzzy filter tokens 2026-06-12 18:24:55 +02:00
Armin Ronacher
6102dd2084 fix(coding-agent): handle missing export themes
closes #5596
2026-06-12 17:56:43 +02:00
Armin Ronacher
1fc80f4f6c fix(coding-agent): preserve custom fallback thinking
closes #5552
2026-06-12 17:26:42 +02:00
Armin Ronacher
be7d5cf585 fix(ai): relax Codex SSE header timeout 2026-06-12 17:08:30 +02:00
Ramiz Wachtler
a455f62f72 fix(ai): preserve Anthropic refusal details (#5666)
Propagate Anthropic refusal stop_details explanation to errorMessage
2026-06-12 14:52:46 +02:00
Armin Ronacher
1c24336560 fix(tui): keep WezTerm Kitty images visible
Closes #5618.
2026-06-12 12:59:32 +02:00
Vegard Stikbakke
3f44d3e2eb fix(ai): remove stale OpenRouter Kimi free model assertion (#5650) 2026-06-12 09:00:15 +02:00
Vegard Stikbakke
1da903983a fix(coding-agent): skip first-time setup for forks (#5627) 2026-06-11 21:36:26 +02:00
Vegard Stikbakke
406a2214aa fix(coding-agent): refine setup copy 2026-06-10 19:55:19 +02:00
d51a055d78 feat: update webui extensions, models, and agent config
Some checks failed
CI / build-check-test (push) Has been cancelled
npm audit / audit (push) Has been cancelled
2026-06-10 21:45:53 +08:00
Vegard Stikbakke
0ab2aa86af feat(coding-agent): add experimental first-time setup flow (#5587)
Behind PI_EXPERIMENTAL=1, show a first-time setup dialog on interactive startup when the default agent directory is used and settings.json does not exist. The dialog preselects the detected terminal appearance with an explicit dark/light choice (live preview) and asks for opt-in analytics data sharing. Submitting writes settings.json, which serves as the completion marker; opting in stores a generated trackingId. Escape at any point skips out of setup.
2026-06-10 12:11:49 +02:00
Mario Zechner
a3cd03e7ff Merge pull request #5585 from haoqixu/fix-editor-cjk-wrap
fix(tui): wrap CJK text at character boundaries in editor
2026-06-10 11:43:42 +02:00
Mario Zechner
2f5066d7a0 Merge pull request #5562 from Perlence/fix-tui-render-loose-lists
fix(tui): separate list items with blank lines in loose lists
2026-06-10 11:42:12 +02:00
Mario Zechner
e537dba3de Merge pull request #5561 from unexge/push-lpxyxwstnswr
feat(ai): link AWS data retention docs in Bedrock validation errors
2026-06-10 11:41:29 +02:00
Mario Zechner
9fd75b8acb Merge pull request #5560 from haoqixu/fix-5552
fix(coding-agent): parse :thinking suffix from custom model IDs in fallback path
2026-06-10 11:39:52 +02:00
Mario Zechner
a7f9fe681d fix: bump shell-quote to 1.8.4 in lockfile (GHSA-w7jw-789q-3m8p) 2026-06-10 11:01:30 +02:00
root
cf5edd6394 feat(sproutclaw): modularize webui, extensions, and agent config layout
Some checks failed
CI / build-check-test (push) Has been cancelled
Restructure local extensions into per-feature directories, split WebUI
into backend modules with slash commands and systemd support, and track
prompts/skills under .pi/agent for portable Gitea deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 16:57:08 +08:00
Burak Varli
0b6c95dda0 feat(ai): link AWS data retention docs in Bedrock validation errors
When Bedrock rejects a request with "data retention mode '<mode>' is not
available for this model", append a pointer to the AWS data retention
documentation so users can configure a supported mode.
2026-06-10 07:45:50 +00:00
haoqixu
eb01f59dc9 fix(tui): wrap CJK text at character boundaries in editor 2026-06-10 15:39:44 +08:00
Armin Ronacher
9ccfcd7cfc fix(ai): omit disabled thinking for Claude Fable 5 2026-06-10 00:27:11 +02:00
Armin Ronacher
dacb367e9e fix(ai): expect Claude Fable 5 in adaptive thinking model test 2026-06-09 23:20:20 +02:00
Armin Ronacher
82f2b1e908 Add [Unreleased] section for next cycle 2026-06-09 23:14:55 +02:00
Armin Ronacher
28df940f0d Release v0.79.1 2026-06-09 23:14:52 +02:00
Armin Ronacher
4d9f9f455d fix(ai): regenerate image models for upstream Riverflow rename 2026-06-09 23:14:38 +02:00
Armin Ronacher
66f432cae4 fix(ai): regenerate models for Claude Fable 5 and Azure metadata overrides
Also add inherited coding-agent changelog entries for Fable 5 and the
Azure gpt-5.4/5.5 context window and gpt-5-pro maxTokens fixes.
2026-06-09 23:08:05 +02:00
Armin Ronacher
6b5923f107 fix(ai): correct Azure gpt-5.4/5.5 context window and gpt-5-pro maxTokens
Azure Foundry deploys gpt-5.4 and gpt-5.5 with a 1,050,000 context
window, but the Azure provider cloned OpenAI-direct's 272k API limit.
Override the context window in the Azure derivation.

Also fix gpt-5-pro maxTokens, which upstream metadata set to 272000 (a
duplicate of the input sub-limit) instead of the actual 128000 max
output; corrected at the source so OpenAI-direct and Azure both match.

closes #5559
2026-06-09 22:57:20 +02:00
Armin Ronacher
5a9d72ea02 feat(ai): add Claude Fable 5 metadata 2026-06-09 22:42:48 +02:00
Armin Ronacher
a0c2465d47 docs: audit unreleased changelogs 2026-06-09 22:33:02 +02:00
Sviatoslav Abakumov
dfd4571cb0 fix(tui): separate list items with blank lines in loose lists 2026-06-09 23:53:18 +04:00
haoqixu
aa03982169 fix(coding-agent): parse :thinking suffix from custom model IDs in fallback path
Fixes #5552
2026-06-10 02:01:35 +08:00
Mario Zechner
c5582102f5 Merge pull request #5553 from dannote/prompt-template-defaults
Add prompt template argument defaults
2026-06-09 14:23:08 +02:00
Mario Zechner
143b251e77 Merge branch 'main' into prompt-template-defaults 2026-06-09 14:23:00 +02:00
Mario Zechner
ae7a885da2 Closes #5045, /new should not persist if original session was ephemeral 2026-06-09 14:17:33 +02:00
Mario Zechner
b7e721cb38 feat(tui): support autocomplete trigger characters
closes #4703
2026-06-09 14:15:39 +02:00
Mario Zechner
69ea1a6310 docs(coding-agent): clarify model name display docs
closes #4841
2026-06-09 14:14:57 +02:00
Danila Poyarkov
d81ac20920 feat(coding-agent): add prompt template argument defaults 2026-06-09 15:12:07 +03:00
Mario Zechner
d041b5cc35 Merge pull request #5549 from earendil-works/approval-settings
feat(ui): Improved project approval settings
2026-06-09 13:47:29 +02:00
Armin Ronacher
c20ea06d4a fix: --help and --version redirect 2026-06-09 13:43:54 +02:00
Mario Zechner
3d02d1da11 fix(ai): map OpenCode max tokens
closes #5331
2026-06-09 13:41:08 +02:00
Mario Zechner
9632bddd38 fix(coding-agent): stabilize OAuth login prompt rows
closes #5433
2026-06-09 13:41:08 +02:00
Mario Zechner
64b51efb6e fix(ai): use z.ai thinking payload
closes #5330
2026-06-09 13:37:58 +02:00
Armin Ronacher
359a0769f1 fix: simplify help 2026-06-09 13:36:05 +02:00
Armin Ronacher
5cb4f597f7 feat(ui): Improved project approval settings 2026-06-09 13:25:54 +02:00
Vegard Stikbakke
66335d3a49 feat(coding-agent): add experimental feature guard (#5547) 2026-06-09 13:23:07 +02:00
Mario Zechner
28c83e8385 fix(coding-agent): sync queue modes on reload
closes #5377
2026-06-09 13:21:59 +02:00
Mario Zechner
1906074369 fix(coding-agent): handle invalid models json during migration
closes #5418
2026-06-09 13:14:24 +02:00
Mario Zechner
e4907b3b09 fix(tui): restore prompt draft after history browsing
closes #5494
2026-06-09 13:06:13 +02:00
Mario Zechner
db3f9953ee feat(coding-agent): expose project trust to extensions
closes #5523
2026-06-09 13:03:59 +02:00
github-actions[bot]
081a0a2bef chore: approve contributor dangooddd 2026-06-09 11:02:46 +00:00
Mario Zechner
84cdd02400 fix(ai): disable Azure OpenAI response storage
closes #5530
2026-06-09 12:53:17 +02:00
Mario Zechner
8da077bcca fix(tui): wrap CJK text at grapheme boundaries
closes #5495
2026-06-09 12:52:52 +02:00
github-actions[bot]
def99d395e chore: approve contributor vdxz 2026-06-09 10:51:30 +00:00
Mario Zechner
22e4549275 Merge pull request #5283 from smoosex/main
fix(tui): keep hardware cursor marker during slash-command autocomplete
2026-06-09 12:33:15 +02:00
Mario Zechner
2326d5cb4a fix(ai): disable Moonshot thinking when requested
closes #5531
2026-06-09 12:23:41 +02:00
github-actions[bot]
c6bdfa1971 chore: approve contributor davidlifschitz 2026-06-09 09:40:40 +00:00
Mario Zechner
4c4863654e Merge pull request #5505 from awakenalive/patch-1
Update models for together.ai
2026-06-09 10:52:03 +02:00
Mario Zechner
44e33798fd Merge pull request #5527 from AJM10565/fix/bedrock-arn-region-parsing
fix(amazon-bedrock): extract region from inference profile ARNs
2026-06-09 09:50:01 +02:00
ajm_ensighten
8cef3c8d7f fix(amazon-bedrock): extract region from inference profile ARNs
Application inference profile ARNs encode the region
(arn:aws:bedrock:<region>:<account>:...) but the provider ignored it,
falling through to AWS_REGION which may point to a different region.
Extract the region from the ARN when present, taking priority over
environment variables.

Fixes #4860
2026-06-08 17:01:51 -05:00
Armin Ronacher
20b78eafb4 fix(coding-agent): fix changelog links
Fixes #5516
2026-06-08 20:31:20 +02:00
Armin Ronacher
2edd6b432a Add [Unreleased] section for next cycle 2026-06-08 17:15:54 +02:00
Armin Ronacher
c10fb95fd9 Release v0.79.0 2026-06-08 17:15:51 +02:00
Armin Ronacher
35120d7e48 docs: audit unreleased changelogs 2026-06-08 16:01:13 +02:00
Armin Ronacher
ce3a72444e docs(coding-agent): document security model 2026-06-08 15:56:29 +02:00
Eric Henry
2527441b40 Update generate-models.ts
Removed together.ai MiniMaxAI/MiniMax-M2.5 due to it not being supported for serverless inference.

 Error: 400 Unable to access non-serverless model MiniMaxAI/MiniMax-M2.5. Please visit
 https://api.together.ai/models/MiniMaxAI/MiniMax-M2.5 to create and start a new dedicated endpoint for the model.
2026-06-08 08:32:16 -05:00
Mario Zechner
d8aef0feff feat(coding-agent): allow project trust extensions to defer 2026-06-08 13:38:29 +02:00
Mario Zechner
085a08582f fix(coding-agent): remove stale hooks export 2026-06-08 13:27:16 +02:00
Mario Zechner
21917fed37 Merge pull request #5499 from Roman-Galeev/fix/editor-cursor-move-refresh-autocomplete
fix(tui): re-query autocomplete picker on cursor movement
2026-06-08 13:13:05 +02:00
Mario Zechner
718215bd95 feat(coding-agent): add extension project trust decisions 2026-06-08 12:59:08 +02:00
Armin Ronacher
dce3e28516 fix: show security advisories in prompt widget 2026-06-08 12:35:18 +02:00
Armin Ronacher
f4f72d4ed5 docs(agent): add security advisory prompt 2026-06-08 12:35:18 +02:00
Roman Galeev
9d007fe642 fix(tui): re-query autocomplete picker on cursor movement
moveCursor() repositioned the cursor but never called updateAutocomplete()
or cancelAutocomplete(), unlike insertCharacter()/handleBackspace(). So an
open autocomplete picker went stale when the cursor moved: e.g. typing
`/cmd ` (argument menu open) then arrowing Left back into the command name
left the argument list showing against a `/cmd` prefix, and a Tab there
concatenated the stale suggestion onto the partial command name
(e.g. `/swarepo`).

Sync the picker at the end of moveCursor(): when a picker is open, re-query
via updateAutocomplete(), which refreshes the list for the new cursor
position or closes it when nothing matches.

Adds a regression test in test/editor.test.ts (verified to fail without the
fix). Fixes earendil-works/pi#5496.
2026-06-08 10:57:50 +02:00
Mario Zechner
38f18be447 fix(coding-agent): persist implicit project trust on reload 2026-06-08 10:08:01 +02:00
Mario Zechner
130ae577ac fix(tui,coding-agent): make keyboard protocol fallback response-driven
closes #5188
2026-06-07 17:04:19 +02:00
Mario Zechner
72fd911356 fix(coding-agent): neutralize compaction summarization prompt
closes #5401
2026-06-07 11:28:27 +02:00
Mario Zechner
eb43bd4493 fix(coding-agent): export package asset path helpers
closes #5415
2026-06-07 11:25:15 +02:00
Mario Zechner
8c6c8a4ef3 fix(agent): neutralize compaction summarization prompt
closes #5401
2026-06-07 11:19:10 +02:00
Mario Zechner
b8f6f660e7 fix(ai): honor OpenAI Responses developer role compat
closes #5456
2026-06-07 11:07:36 +02:00
Mario Zechner
59d7025668 fix(tui): position cursor for prompt history navigation
closes #5454
2026-06-07 10:56:09 +02:00
Mario Zechner
22ac2713a3 feat(coding-agent): export RPC extension UI types
closes #5455
2026-06-07 10:56:01 +02:00
Mario Zechner
e34b3b3803 docs(coding-agent): update tmux requirement docs
closes #5432
2026-06-07 10:34:57 +02:00
github-actions[bot]
98697d2d7a chore: approve contributor ItsumoSeito 2026-06-07 08:29:37 +00:00
Mario Zechner
ff3e9df5f5 fix(coding-agent): make trust input traversal portable 2026-06-06 22:32:35 +02:00
Mario Zechner
89a92207f1 feat(coding-agent): add project trust gating 2026-06-05 10:51:43 +02:00
Mario Zechner
db594d3a59 feat(coding-agent): show cache hit rate in footer 2026-06-05 10:51:43 +02:00
Mario Zechner
3e73204242 Merge pull request #5371 from Perlence/fix/skill-user-message-spacing
fix(coding-agent): add a space between the skill and user messages
2026-06-04 23:52:17 +02:00
Mario Zechner
b9bfa7ed46 Fix OpenRouter routing compat for custom providers
closes #5347
2026-06-04 23:50:01 +02:00
Mario Zechner
2808fc03f4 Merge pull request #5383 from xl0/instruct-pi-commit
docs: document commit message format
2026-06-04 23:48:19 +02:00
Mario Zechner
c52c22b39e Document issue view gh fields 2026-06-04 23:47:06 +02:00
Mario Zechner
c45787411e Fix tool expand hint punctuation styling
closes #5359
2026-06-04 23:44:25 +02:00
Mario Zechner
ca66adfe60 Add [Unreleased] section for next cycle 2026-06-04 18:31:35 +02:00
Mario Zechner
592c34c056 Release v0.78.1 2026-06-04 18:31:33 +02:00
Mario Zechner
1d33a8eb7a Update generated image model list 2026-06-04 18:31:11 +02:00
Mario Zechner
4f7d756df8 Exclude optional Gondolin example from root check 2026-06-04 18:27:18 +02:00
Mario Zechner
e0c2813a2a Audit unreleased changelog entries 2026-06-04 18:22:58 +02:00
Mario Zechner
f9ce0bf0e7 Update generated model lists 2026-06-04 18:22:58 +02:00
Alexey Zaytsev
e4d6f45efe docs: document commit message format 2026-06-03 23:15:15 -05:00
Sviatoslav Abakumov
e9a9322199 fix(coding-agent): add a space between the skill and user messages 2026-06-03 22:42:38 +04:00
Vegard Stikbakke
dc7b547f62 docs(coding-agent): remove containerization platform note 2026-06-03 15:55:33 +02:00
Vegard Stikbakke
86314bf38d docs: add containerization guide and Gondolin example (#5356) 2026-06-03 15:53:16 +02:00
Mario Zechner
564ad70fb8 Merge pull request #5333 from vastxie/zai-coding-cn-provider
feat(ai): add ZAI Coding Plan China provider
2026-06-03 01:07:01 +02:00
Mario Zechner
4ac20511be Merge pull request #5346 from mattiacerutti/fix/remove-codex-models
fix(ai): remove stale codex models
2026-06-03 00:54:27 +02:00
Armin Ronacher
ea3465a8e3 fix(coding-agent): move temporary extension cache (#5345) 2026-06-03 00:44:21 +02:00
Armin Ronacher
135fb545f9 fix(coding-agent): set auth file mode on creation 2026-06-03 00:32:54 +02:00
Mattia Cerutti
83afcdc24f fix(ai): remove stale codex models 2026-06-03 00:09:30 +02:00
Armin Ronacher
ba6e5298df fix(oauth): harden browser launch handling 2026-06-02 23:15:46 +02:00
Armin Ronacher
a98e087e5d fix(coding-agent): harden git package install paths 2026-06-02 18:20:35 +02:00
弎水
51df39b9b9 feat(ai): add ZAI Coding Plan China provider 2026-06-02 23:39:56 +08:00
Armin Ronacher
0462d44f56 doc: clarify wording in SECURITY.md 2026-06-02 17:24:41 +02:00
Armin Ronacher
e30b1b18d0 Added security file 2026-06-02 17:03:40 +02:00
Mario Zechner
25a4a8ed1e Add Ant Ling provider 2026-06-02 16:37:44 +02:00
Mario Zechner
6cb23f9b5d Fix HTML export URL sanitization 2026-06-02 16:32:18 +02:00
Mario Zechner
7e72ca47c8 Add MiniMax-M3 to direct minimax providers
closes #5313
2026-06-02 16:00:29 +02:00
Mario Zechner
276cb1bb89 Fix stale SDK stream options test after httpIdleTimeoutMs universalization 2026-06-02 15:53:37 +02:00
Mario Zechner
2125221bfc Fix OpenRouter Kimi reasoning compat
closes #5309
2026-06-02 15:36:52 +02:00
Mario Zechner
0d38e17b68 Fix empty self-rendered tool rows
closes #5299
2026-06-02 15:25:27 +02:00
Mario Zechner
6e269edfd0 Restore NVIDIA Qwen 3.5 122B model 2026-06-02 15:10:36 +02:00
Mario Zechner
6014801229 Add NVIDIA NIM provider 2026-06-02 15:09:36 +02:00
Mario Zechner
13898f048f Fix TUI tab width accounting
closes #5218
2026-06-02 13:54:47 +02:00
Mario Zechner
4c6c4482b4 Fix SDK embed ENOENT when package.json missing (closes #5226)
- Wrap package.json read in try/catch to handle missing file gracefully
- Use defaults (APP_NAME=pi, CONFIG_DIR_NAME=.pi, etc.) when package.json not found
- Preserves rebranding via piConfig when package.json exists
- Only ignores ENOENT; other errors still throw
2026-06-02 12:12:10 +02:00
Mario Zechner
7c08227019 Fix stale model references in AI tests after model regeneration
- Update GitHub Copilot tests to use available models (claude-haiku-4.5, gemini-2.5-pro)
- Update OpenRouter tests to use available models (google/gemini-2.5-flash, deepseek/deepseek-chat)
- Fix over-broad sed that incorrectly renamed OpenAI Responses gpt-4o test
- Remove nvidia.md from repo root
- Update packages/ai/CHANGELOG.md
2026-06-02 11:37:32 +02:00
Mario Zechner
7c531d0518 fix: apply httpIdleTimeoutMs to all providers, not just Codex Responses
The HTTP timeout setting (httpIdleTimeoutMs) was only used as a fallback for the
openai-codex-responses API. For other providers like openai-completions
(llama.cpp), the SDK default timeout (10 min) was used instead, ignoring the
user's disabled timeout setting.

Now httpIdleTimeoutMs applies universally as the default SDK request timeout
for all providers that support timeoutMs. Setting HTTP timeout = false (0)
correctly disables SDK timeouts across the board by sending max int32
(effectively infinite) instead of 0, since SDKs treat timeout=0 as immediate
timeout.

closes #5294
2026-06-02 11:26:36 +02:00
Mario Zechner
f58c1702cf fix: apply httpIdleTimeoutMs to all providers, not just Codex Responses
The HTTP timeout setting (httpIdleTimeoutMs) was only used as a fallback for the
openai-codex-responses API. For other providers like openai-completions
(llama.cpp), the SDK default timeout (10 min) was used instead, ignoring the
user's disabled timeout setting.

Now httpIdleTimeoutMs applies universally as the default SDK request timeout
for all providers that support timeoutMs. Setting HTTP timeout = false (0)
correctly disables SDK timeouts across the board.

closes #5294
2026-06-02 11:20:38 +02:00
Mario Zechner
afcf9ff0dc Merge pull request #5306 from xl0/system_prompt_options
Add system prompt options to extension commands
2026-06-02 11:09:30 +02:00
Alexey Zaytsev
8216cca5a7 Add system prompt options to extension commands 2026-06-01 15:42:56 -05:00
Mario Zechner
e56521e323 Add extension mode context 2026-06-01 18:32:03 +02:00
Mario Zechner
335e09ba0d Merge pull request #5264 from psoukie/fix/wsl-mntc-footer-branch-refresh
fix(coding-agent): refresh branch in footer on WSL /mnt repos
2026-06-01 18:07:52 +02:00
Mario Zechner
34719d3f4f Fix Bedrock blank text blocks
closes #4975
2026-06-01 18:04:04 +02:00
Mario Zechner
893ba99466 Merge pull request #5235 from nicobailon/feat/issue-5129-tui-overlay-focus
Feat/issue 5129 tui overlay focus
2026-06-01 17:58:01 +02:00
github-actions[bot]
ce065efa7e chore: approve contributor vastxie 2026-06-01 15:03:47 +00:00
Nico Bailon
91a2f86600 fix(tui): harden overlay focus restoration 2026-05-31 21:20:33 -07:00
Pavel Soukenik
654e4529d5 fix: refresh branch in footer on WSL /mnt repos 2026-05-31 17:12:11 -07:00
Mario Zechner
f429ddbffd Fix OpenAI GPT-5.5 thinking metadata
closes #5243
2026-06-01 00:39:48 +02:00
github-actions[bot]
d4fa41ae3c chore: approve contributor psoukie 2026-05-31 22:22:22 +00:00
Mario Zechner
b177333026 Merge pull request #5221 from PriNova/fix/openrouter-system-role
Fix OpenRouter reasoning instruction role
2026-06-01 00:09:58 +02:00
Mario Zechner
ad9081b7fa Merge pull request #5251 from yzhg1983/fix/anthropic-opus-temperature
fix(ai): suppress deprecated temperature param for Claude Opus 4.7+
2026-06-01 00:03:07 +02:00
Michael Yu
40832d1957 fix(ai): suppress deprecated temperature param for Claude Opus 4.7+ 2026-05-31 16:20:22 +08:00
Nico Bailon
82f29ea442 docs(coding-agent): expand overlay focus demo
Update the overlay QA focus demo to exercise three visible overlays with text input, focus cycling, per-panel dismissal, and visual focus ordering.
2026-05-30 13:29:36 -07:00
Nico Bailon
735ccbd00f fix(tui): release overlay focus to explicit targets
Add an explicit overlay unfocus target so callers can move input to the editor or another component while overlays remain visible. Align fallback overlay focus with visual focus order and cover blocked replacement release, null targets, and multi-overlay cycling.
2026-05-30 13:29:36 -07:00
Nico Bailon
5d9b28ee43 fix(tui): keep focused overlays interactive after UI
Preserve a focused visible overlay as the input owner across extension custom UI replacement, while letting the replacement receive and close its own input before focus is restored. Fixes #5129.
2026-05-30 13:29:36 -07:00
Mario Zechner
3911d6f5cd fix(coding-agent): stream large session files
closes #5231
2026-05-30 21:21:42 +02:00
PriNova
74d81bd363 Fix OpenAI completions developer role test 2026-05-30 12:24:19 +02:00
PriNova
b55fe1bbd4 Fix OpenRouter reasoning instruction role 2026-05-30 11:15:30 +02:00
Mario Zechner
dbb9911a54 Add [Unreleased] section for next cycle 2026-05-30 01:47:40 +02:00
Mario Zechner
0897f175e4 Release v0.78.0 2026-05-30 01:47:37 +02:00
Mario Zechner
36515a3793 Document release npm age gate override 2026-05-30 01:47:26 +02:00
Mario Zechner
886fa6cd11 Audit unreleased changelog entries 2026-05-30 01:27:28 +02:00
Mario Zechner
c1633e609d Clarify hardware cursor docs
closes #5200
2026-05-30 01:20:14 +02:00
Mario Zechner
cfefc30cf1 Merge pull request #5195 from yzhg1983/private/fix-startup-input-queue
fix(coding-agent): buffer early input before prompt loop
2026-05-30 01:15:45 +02:00
Mario Zechner
0ffa590a33 Fix GitLab Duo thinking metadata
closes #5201
2026-05-30 01:08:18 +02:00
Mario Zechner
9c4a3f3518 Fix ANSI wrapping stack overflow
closes #5185
2026-05-30 01:02:48 +02:00
Mario Zechner
31b961f2ac fix(coding-agent): sync clipboard binary archive deps
closes #5184
2026-05-30 00:52:03 +02:00
Mario Zechner
ed1952c5ca Merge pull request #5189 from mpazik/feat/tool-title-hyperlinks
OSC 8 hyperlinks file paths in tool titles
2026-05-30 00:05:22 +02:00
Mario Zechner
5992a62baa Merge pull request #5202 from xl0/export-args-parse
feat(coding-agent): Export CLI argument parser
2026-05-29 23:45:22 +02:00
Mario Zechner
a213abb97e Fix OpenRouter Kimi K2.6 developer role
closes #5159
2026-05-29 23:41:14 +02:00
Mario Zechner
778f519b23 Remove leading spaces from resume session hint 2026-05-29 23:30:02 +02:00
Mario Zechner
ba2d313858 fix(ai): handle OpenCode Kimi reasoning params 2026-05-29 23:27:48 +02:00
Mario Zechner
a36a132c70 fix(ai): abort Codex SSE body reads 2026-05-29 23:20:18 +02:00
Mario Zechner
7921ae499a Require explicit provider API keys 2026-05-29 22:36:05 +02:00
Mario Zechner
01a8c2d62c Merge pull request #5196 from earendil-works/fix/opencode-thinking-requests
fix(ai): handle OpenCode reasoning params
2026-05-29 22:32:13 +02:00
Alexey Zaytsev
dbcfc16741 feat(coding-agent): Export CLI argument parser 2026-05-29 13:22:46 -05:00
Michael Yu
edd1212200 fix(coding-agent): buffer early input before prompt loop 2026-05-29 23:03:03 +08:00
Marek Pazik
9d2bceba5d fix(tui): forward OSC 8 hyperlinks under tmux when the client supports them
detectCapabilities previously disabled hyperlinks under tmux unconditionally. tmux re-emits OSC 8 to the outer terminal only when the attached client advertises the 'hyperlinks' feature in client_termfeatures, and strips them otherwise.

Probe the running server with 'tmux display-message -p #{client_termfeatures}' and enable hyperlinks only when the feature is listed. The probe fails closed: any error (no tmux/server, timeout, old tmux) yields false. images stays null; only the hyperlink decision changed. The probe is injected into detectCapabilities so it stays testable without spawning a process.
2026-05-29 22:02:25 +10:00
Marek Pazik
42ce989a62 feat(coding-agent): hyperlink file paths in tool titles
Wrap the path shown in read/write/edit/ls tool titles in an OSC 8 file:// hyperlink when the terminal advertises hyperlink support.
The link target is always the absolute path, while the visible text stays relative/home-shortened and line annotations (e.g. :120-140) are kept out of the target.
2026-05-29 22:02:25 +10:00
Armin Ronacher
4faac05419 fix(ai): handle OpenCode reasoning params 2026-05-29 12:21:37 +02:00
Mario Zechner
ce554ad3de Add startup session name flag
closes #5153
2026-05-29 11:40:58 +02:00
Mario Zechner
0c448e12e8 Merge pull request #5176 from yzhg1983/myu003/resume-hint-on-exit
feat(coding-agent): print resume hint on interactive exit
2026-05-29 11:16:31 +02:00
Mario Zechner
3d1d18fa96 Merge pull request #5178 from stephanmck/bedrock-custom-headers-pr
ai: add custom-header support to Bedrock provider
2026-05-29 11:15:18 +02:00
Mario Zechner
c122bcde44 Merge pull request #5167 from xl0/expose-convert-png
feat(coding-agent): Export convertToPng for extensions
2026-05-29 11:13:42 +02:00
Stephan Schneider
7619aaefa9 ai: add custom-header support to Bedrock provider
Honour StreamOptions.headers in the Bedrock provider by attaching a
Smithy build-step middleware that merges caller headers into the
request before SigV4 signing. Reserved headers (x-amz-*, authorization,
host) are silently skipped to preserve signing and the bearer-token
auth path. No-op when no headers are supplied. Updates the
StreamOptions.headers JSDoc to drop the Bedrock caveat.
2026-05-29 10:21:44 +02:00
Michael Yu
17e9e87576 feat(coding-agent): print resume hint on interactive exit 2026-05-29 15:56:42 +08:00
Alexey Zaytsev
7a5dc0d0db feat(coding-agent): Export convertToPng for extensions 2026-05-29 00:44:51 -05:00
smoose
c8df99a795 fix(tui): keep hardware cursor marker during slash-command autocomplete
Remove the !autocompleteState guard so CURSOR_MARKER is still emitted
while the slash-command menu is visible. This lets the TUI position the
hardware cursor correctly, which fixes IME candidate-window placement
for CJK input methods.
2026-05-29 10:00:59 +08:00
github-actions[bot]
7be8a10d23 chore: approve contributor rolfvreijdenberger 2026-05-28 23:26:43 +00:00
Armin Ronacher
b6b0f6923f feat(ci): Actually bump setup-bun to 2.2.0 2026-05-29 01:03:12 +02:00
Armin Ronacher
20bcab26e0 feat(ci): Update setup-bun to 2.2.0 2026-05-29 00:51:11 +02:00
Armin Ronacher
93600d8980 fix(release): align package repository metadata 2026-05-29 00:44:58 +02:00
Armin Ronacher
f3b4e1285c fix(release): upgrade npm for trusted publishing 2026-05-29 00:36:43 +02:00
Armin Ronacher
abf07d0c4d Add [Unreleased] section for next cycle 2026-05-29 00:19:23 +02:00
Armin Ronacher
8322745e28 Release v0.77.0 2026-05-29 00:19:06 +02:00
Mario Zechner
f29472c387 Audit unreleased changelog entries 2026-05-29 00:06:06 +02:00
Mario Zechner
0127caea7b Fix OpenRouter DeepSeek V4 xhigh reasoning
closes #4801
2026-05-28 23:56:59 +02:00
Mario Zechner
f9fa077bbb Fix startup timing attribution
closes #4829
2026-05-28 23:53:20 +02:00
Mario Zechner
9380d5f2e4 feat(coding-agent): add exclude tools option closes #5109 2026-05-28 23:49:18 +02:00
Mario Zechner
1ab2899800 Remove unavailable tool preference guideline
Closes #5132
2026-05-28 23:37:13 +02:00
Armin Ronacher
4b4641c6b0 fix(coding-agent): scope custom session dir lookups 2026-05-28 23:25:33 +02:00
Mario Zechner
b64f3f5eae fix(coding-agent): run extension cleanup and restore terminal on signal exits
Signal-triggered shutdown (SIGTERM/SIGHUP) now emits session_shutdown
before any terminal writes, and SIGHUP no longer hard-exits, so extension
resources (e.g. sockets) are released and the terminal is restored even
when the terminal is gone. A genuinely dead terminal still exits without
hot-spinning via the EIO error path.

closes #5080
2026-05-28 23:19:27 +02:00
Mario Zechner
d1fb34bc8d fix(ai): use valid synthetic Responses message ids closes #5148 2026-05-28 23:13:15 +02:00
Mario Zechner
3f1ce9b6e9 fix(ai): avoid duplicate Codex replay message ids closes #5148 2026-05-28 23:11:05 +02:00
github-actions[bot]
f9832ccb82 chore: approve contributor stephanmck 2026-05-28 20:54:28 +00:00
Mario Zechner
97ef317389 Fix Kimi and Xiaomi model metadata
Closes #5075, closes #5078. Refs #5087.
2026-05-28 22:19:47 +02:00
github-actions[bot]
7e70886652 chore: approve contributor MichaelYochpaz 2026-05-28 20:05:12 +00:00
Mario Zechner
bfa3d1fa60 Update Claude Opus and GPT thinking metadata 2026-05-28 21:51:55 +02:00
Armin Ronacher
ae50dec121 chore(release): publish packages from CI 2026-05-28 17:43:05 +02:00
Mario Zechner
53ca936adb Update clipboard native addon
closes #5028
2026-05-28 16:59:44 +02:00
Mario Zechner
b63d26332f Finish harness tool registry semantics 2026-05-28 12:34:02 +02:00
Mario Zechner
458a7bc27c Fix Anthropic empty thinking signature replay
closes #4464
2026-05-28 12:04:33 +02:00
Armin Ronacher
3e9f717445 fix(coding-agent): make config env references explicit
closes #5095
2026-05-28 11:58:38 +02:00
Mario Zechner
5b31ffd744 Abort session work during dispose 2026-05-28 11:35:19 +02:00
Vegard Stikbakke
9d5fb70b7e feat(ai): add Codex device code login (#4911) 2026-05-28 11:09:33 +02:00
Mario Zechner
edd26444af Expose tool prompt guidelines to extensions
closes #4879
2026-05-28 11:03:25 +02:00
Mario Zechner
17d39ccfd4 Add changelog entry for PR 5115 2026-05-28 10:48:11 +02:00
Mario Zechner
8e77f8797b Merge pull request #5115 from DanielThomas/dannyt/sendUserMessage-agent-end
fix(coding-agent): drain follow-ups queued during agent_end
2026-05-28 10:47:31 +02:00
Mario Zechner
e43f2c3d0a Clarify PR review worktree rules 2026-05-28 10:31:03 +02:00
Danny Thomas
a29a7902e9 fix(coding-agent): drain follow-ups queued during agent_end
When an extension queues a follow-up during `agent_end` it
gets stuck on the follow-up queue until after the next user
message.

Add a hasQueuedMessages() check to _handlePostAgentRun
so the existing while/continue loop drains them.

- One-line fix in _handlePostAgentRun
- Integration test in agent-session-queue
- git-merge-and-resolve example extension with tests
2026-05-28 18:27:39 +10:00
Mario Zechner
5f00f3f50f Merge pull request #5091 from earendil-works/fix/kitty-da-negotiation
fix(tui): harden keyboard protocol negotiation
2026-05-28 10:23:41 +02:00
Mario Zechner
cbe8625528 Fix input event streaming behavior semantics 2026-05-28 10:13:06 +02:00
Mario Zechner
1fc57a5d5b Merge pull request #5107 from DanielThomas/dannyt/inputevent-streamingbehavior
feat(coding-agent): expose streamingBehavior on InputEvent
2026-05-28 10:11:30 +02:00
Danny Thomas
bcea4b2e27 feat(coding-agent): expose streamingBehavior on InputEvent
Add streamingBehavior to InputEvent so extensions can distinguish
idle prompts from mid-stream steers and queued follow-ups.

- Add streamingBehavior field to InputEvent type
- Thread it through ExtensionRunner.emitInput() and AgentSession.prompt()
- Add streaming-aware input gate example with tests
- Document in extensions.md
2026-05-28 15:52:38 +10:00
Armin Ronacher
b85bf65678 fix(coding-agent): restore diff code block highlighting
closes #5092
2026-05-28 00:49:27 +02:00
Armin Ronacher
99aec8e02b test(tui): reduce keyboard negotiation coverage 2026-05-27 23:03:13 +02:00
Armin Ronacher
1e168a89c5 docs(coding-agent): fix development AGENTS link
Closes #5041
2026-05-27 22:51:30 +02:00
Armin Ronacher
6ab62a06d7 fix(tui): harden keyboard protocol negotiation 2026-05-27 22:23:39 +02:00
Armin Ronacher
39a26c84f8 Add [Unreleased] section for next cycle 2026-05-27 22:04:01 +02:00
Armin Ronacher
706f8720f8 Release v0.76.0 2026-05-27 22:03:21 +02:00
Armin Ronacher
16dc525e7b fix(build): resolve internal packages from workspace dist 2026-05-27 21:56:55 +02:00
Armin Ronacher
41d28a9232 docs(changelog): audit unreleased entries 2026-05-27 21:40:07 +02:00
Armin Ronacher
52dc08c1f7 feat(session): Explicit session id naming (#5076) 2026-05-27 21:32:10 +02:00
Armin Ronacher
7c02a55632 fix(ai): timeout Codex SSE header stalls 2026-05-27 21:24:51 +02:00
Armin Ronacher
91b46c2ba9 fix(ai): avoid stale Cerebras test model 2026-05-27 20:57:00 +02:00
xu0o0
701801dec2 fix(tui): align input word segmentation with editor (#5068) 2026-05-27 20:50:04 +02:00
Armin Ronacher
61babc24cf feat(rpc): add excludeFromContext flag to bash command (closes #5039) 2026-05-27 18:34:42 +02:00
Sviatoslav Abakumov
4bbe2959bd fix(tui): provide the JetBrains terminal capabilities (#5037) 2026-05-27 12:20:44 +02:00
xu0o0
b62776e4a2 fix(tui): preserve ASCII punctuation word boundaries with Intl.Segmenter (#5067) 2026-05-27 12:12:10 +02:00
Armin Ronacher
493efd422d fix(codex): timeouts for websockets (#4979) 2026-05-27 12:11:29 +02:00
Armin Ronacher
26f1e00f71 fix(ai): use hyphenated Codex session header
closes #4967
2026-05-27 01:11:40 +02:00
Armin Ronacher
2531fc130d fix(ui): preserve user ordered-list markers (closes #5013) 2026-05-27 01:03:33 +02:00
Armin Ronacher
59ec8003db fix(coding-agent): bypass age gates for self-update
closes #4929
2026-05-27 00:59:09 +02:00
xu0o0
4402100830 fix(tui): leverage Intl.Segmenter for proper Unicode word boundaries (#5022) 2026-05-27 00:28:47 +02:00
Armin Ronacher
8fb1e877cd fix(ai): disable hidden provider 429 retries (#4991) 2026-05-26 23:41:58 +02:00
Mario Zechner
96f0edd02b Count user image tokens in context estimates
closes #4983
2026-05-26 18:46:54 +02:00
github-actions[bot]
7c2775f6f6 chore: approve contributor DanielThomas 2026-05-26 07:39:20 +00:00
Armin Ronacher
71446c6c2b fix(ai): correct Codex Spark context window
closes #4969
2026-05-26 09:08:40 +02:00
Armin Ronacher
fa1180b6b7 fix(ai): detect Poolside context overflow
Closes #4943
2026-05-26 08:58:21 +02:00
Armin Ronacher
fc8a155901 fix(ai): honor Codex Responses maxRetries 2026-05-25 12:30:04 +02:00
Armin Ronacher
3eb002766f fix(tui): enable OSC 8 for Windows Terminal (closes #4923) 2026-05-25 00:55:39 +02:00
Armin Ronacher
e007fcd0d2 fix(rpc): reject pending requests on child process exit
closes #4764
2026-05-25 00:40:07 +02:00
Armin Ronacher
ce0e801d8e fix(coding-agent): retry RPC stdout backpressure
closes #4897
2026-05-24 22:59:35 +02:00
Armin Ronacher
9600ded922 revert: fix rpc stdout backpressure
This reverts commit d0d1d8edca.
2026-05-24 11:50:53 +02:00
Armin Ronacher
d0d1d8edca fix(rpc): respect stdout backpressure
closes #4897
2026-05-24 11:28:02 +02:00
Mario Zechner
fc51a40d02 Merge pull request #4922 from earendil-works/horrifying-terminal-hack
fix(tui): detect Apple Terminal Shift+Enter
2026-05-23 20:19:57 +02:00
Armin Ronacher
30b3ab3532 fix(tui): remove native modifier escape hatch 2026-05-23 15:32:38 +02:00
Armin Ronacher
c5181a266e fix(tui): detect Apple Terminal Shift+Enter 2026-05-23 15:09:01 +02:00
Armin Ronacher
4a98f748bb chore(tui): remove unused xterm dependency 2026-05-23 14:18:49 +02:00
Armin Ronacher
15f1dea8df fix(coding-agent): disable managed extension peer resolution
closes #4907
2026-05-23 14:15:00 +02:00
Mario Zechner
30f48feae7 Add [Unreleased] section for next cycle 2026-05-23 12:08:14 +02:00
Mario Zechner
89ba72c35d Update release instructions and image models 2026-05-23 12:07:52 +02:00
Mario Zechner
83a227aed3 Update release instructions and generated models 2026-05-23 12:02:35 +02:00
Mario Zechner
ea2b70ddd9 Release v0.75.5 2026-05-23 11:58:31 +02:00
Mario Zechner
98477f2ee7 Clarify release smoke test instructions 2026-05-23 11:54:36 +02:00
Mario Zechner
b9566fc111 Audit unreleased changelog entries 2026-05-23 11:48:27 +02:00
Mario Zechner
373bd1282e Collapse read output by default
closes #4916
2026-05-23 11:40:54 +02:00
Mario Zechner
8100046cb8 Finish async tool cleanup 2026-05-23 11:36:41 +02:00
Mario Zechner
c4f86e3992 Merge remote-tracking branch 'origin/main' into pr-4756-squash-cleanup
# Conflicts:
#	scripts/build-binaries.sh
2026-05-23 10:52:28 +02:00
Mario Zechner
3f89350cef fix(coding-agent): ship clipboard sidecar in bun binaries
closes #4307
2026-05-23 10:47:35 +02:00
Mario Zechner
ba09f1c9e0 Refine async tool control flow 2026-05-23 10:30:00 +02:00
Mario Zechner
2e1f07bac2 fix(coding-agent): avoid invalid footer home abbreviation
closes #4878
2026-05-23 09:53:36 +02:00
Mario Zechner
e9146a5ff7 fix(coding-agent): use async operations in tools 2026-05-23 09:50:03 +02:00
Armin Ronacher
c85dbb1620 fix(coding-agent): reconcile pinned git update refs
closes #4869
2026-05-23 00:16:07 +02:00
Armin Ronacher
d80bcc367e test(ai): avoid hardcoded Fireworks router id 2026-05-22 22:59:10 +02:00
Armin Ronacher
42379a37bd fix(coding-agent): add OpenCode session headers
closes #4847
2026-05-22 22:22:55 +02:00
Mario Zechner
9b62f1f87c Fix Anthropic eager tool input compat test 2026-05-22 18:40:31 +02:00
Mario Zechner
d801d88a11 Support adaptive thinking for Anthropic-compatible aliases
closes #4790
2026-05-22 18:30:51 +02:00
Armin Ronacher
7002c68f8b fix(ai): declare Bedrock Smithy HTTP handler dependency
closes #4842
2026-05-22 16:50:19 +02:00
Mario Zechner
c841a6c78f Clean up OAuth device-code callbacks 2026-05-22 15:51:36 +02:00
Armin Ronacher
b3ed545938 fix(export-html): escape quotes in exported attributes
closes #4832
2026-05-22 15:48:21 +02:00
Mario Zechner
60e2e4332d Merge pull request #4898 from haoqixu/fix-4761
fix(coding-agent): use the right basedir for patterns
2026-05-22 15:22:45 +02:00
Mario Zechner
11e868b790 Merge pull request #4788 from earendil-works/refactor-device-code-login
feat(ai): refactor device code login for copilot
2026-05-22 15:21:42 +02:00
haoqixu
baf4028fb9 fix(coding-agent): use the right basedir for patterns 2026-05-22 18:33:26 +08:00
Vegard Stikbakke
c554364c2a feat(ai): refactor device code login for copilot 2026-05-22 08:54:18 +02:00
505 changed files with 30175 additions and 23390 deletions

View File

@@ -217,3 +217,27 @@ josephyoung pr
mbazso pr
AJM10565 pr
DanielThomas pr
MichaelYochpaz pr
stephanmck pr
rolfvreijdenberger pr
psoukie pr
vastxie pr
ItsumoSeito pr
davidlifschitz pr
vdxz pr
dangooddd pr
Mearman pr
dodiego pr

View File

@@ -11,6 +11,8 @@ body:
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
**Important:** before reporting an issue in core, please validate first with `pi -ne` that this is not caused by an extension you loaded.
- type: textarea
id: description
attributes:

View File

@@ -0,0 +1,49 @@
name: Package Report
description: Report a problematic Pi package listed on pi.dev
labels: ["package-report"]
body:
- type: markdown
attributes:
value: |
Use this form to report a package listed on pi.dev. For Pi core bugs, use the bug report template instead.
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi-mono/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
- type: input
id: package-name
attributes:
label: Package name
description: The npm package name from pi.dev.
placeholder: "@scope/package"
validations:
required: true
- type: input
id: package-version
attributes:
label: Version
description: The package version shown on pi.dev.
placeholder: "0.1.0"
validations:
required: false
- type: dropdown
id: report-type
attributes:
label: What are you reporting?
options:
- Malicious or unsafe behavior
- Impersonation
- Trademark / TOS Violations
validations:
required: true
- type: textarea
id: details
attributes:
label: Details
description: Describe the concern and include links, logs, or screenshots if helpful.
validations:
required: true

View File

@@ -10,24 +10,30 @@ on:
description: 'Tag to build (e.g., v0.12.0)'
required: true
type: string
source_ref:
description: 'Source ref to build/publish (defaults to tag; use only for release recovery)'
required: false
type: string
permissions:
contents: write
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
env:
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ env.RELEASE_TAG }}
ref: ${{ env.SOURCE_REF }}
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.10
@@ -45,38 +51,86 @@ jobs:
run: |
VERSION="${RELEASE_TAG}"
VERSION="${VERSION#v}" # Remove 'v' prefix
# Extract changelog section for this version
cd packages/coding-agent
awk "/^## \[${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md > /tmp/release-notes.md
# If empty, use a default message
if [ ! -s /tmp/release-notes.md ]; then
echo "Release ${VERSION}" > /tmp/release-notes.md
fi
node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md
- name: Create GitHub Release and upload binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd packages/coding-agent/binaries
# Create release with changelog notes (or update if exists)
gh release create "${RELEASE_TAG}" \
--title "${RELEASE_TAG}" \
--notes-file /tmp/release-notes.md \
pi-darwin-arm64.tar.gz \
pi-darwin-x64.tar.gz \
pi-linux-x64.tar.gz \
pi-linux-arm64.tar.gz \
pi-windows-x64.zip \
pi-windows-arm64.zip \
2>/dev/null || \
gh release upload "${RELEASE_TAG}" \
pi-darwin-arm64.tar.gz \
pi-darwin-x64.tar.gz \
pi-linux-x64.tar.gz \
pi-linux-arm64.tar.gz \
pi-windows-x64.zip \
pi-windows-arm64.zip \
--clobber
release_assets=(
pi-darwin-arm64.tar.gz
pi-darwin-x64.tar.gz
pi-linux-x64.tar.gz
pi-linux-arm64.tar.gz
pi-windows-x64.zip
pi-windows-arm64.zip
)
sha256sum "${release_assets[@]}" > SHA256SUMS
release_assets+=(SHA256SUMS)
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" \
--title "${RELEASE_TAG}" \
--notes-file /tmp/release-notes.md
gh release upload "${RELEASE_TAG}" "${release_assets[@]}" --clobber
else
gh release create "${RELEASE_TAG}" \
--title "${RELEASE_TAG}" \
--notes-file /tmp/release-notes.md \
"${release_assets[@]}"
fi
publish-npm:
runs-on: ubuntu-latest
needs: build
environment: npm-publish
permissions:
contents: read
id-token: write
env:
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ env.SOURCE_REF }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: npm
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep
sudo ln -s $(which fdfind) /usr/local/bin/fd
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Build
run: npm run build
- name: Check
run: npm run check
- name: Test
run: npm test
- name: Verify release artifacts are committed
run: git diff --exit-code
- name: Upgrade npm for trusted publishing
run: |
npm install -g npm@11.16.0 --ignore-scripts
npm --version
- name: Publish npm packages
run: node scripts/publish.mjs

View File

@@ -111,9 +111,17 @@ jobs:
body: message,
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['untriaged'],
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
});

View File

@@ -0,0 +1,137 @@
name: Issue Triage Labels
on:
issues:
types: [reopened, labeled]
jobs:
update-labels:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Update triage labels
uses: actions/github-script@v7
with:
script: |
const UNTRIAGED_LABEL = 'untriaged';
const NO_ACTION_LABEL = 'no-action';
const LAST_READ_LABEL = 'last-read';
const TO_DISCUSS_LABEL = 'to-discuss';
const INPROGRESS_LABEL = 'inprogress';
function issueHasLabel(issue, labelName) {
return (issue.labels ?? []).some((label) => label.name === labelName);
}
async function removeLabelIfPresent(issueNumber, issue, labelName) {
if (!issueHasLabel(issue, labelName)) {
console.log(`Issue #${issueNumber} does not have ${labelName}`);
return;
}
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: labelName,
});
console.log(`Removed ${labelName} from #${issueNumber}`);
} catch (error) {
if (error.status === 404) {
console.log(`Label ${labelName} was already absent from #${issueNumber}`);
return;
}
throw error;
}
}
if (context.payload.action === 'reopened') {
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL);
return;
}
if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) {
console.log('Not a last-read label event');
return;
}
const currentIssueNumber = context.issue.number;
const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all',
labels: LAST_READ_LABEL,
per_page: 100,
});
const previousIssueNumbers = lastReadIssues
.filter((issue) => !issue.pull_request)
.map((issue) => issue.number)
.filter((issueNumber) => issueNumber !== currentIssueNumber);
if (previousIssueNumbers.length === 0) {
console.log('No previous last-read issue found');
return;
}
const previousIssueNumber = Math.max(...previousIssueNumbers);
if (currentIssueNumber <= previousIssueNumber) {
console.log(
`Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`,
);
return;
}
const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all',
labels: UNTRIAGED_LABEL,
per_page: 100,
});
const issuesToMark = untriagedIssues
.filter((issue) => !issue.pull_request)
.filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber)
.sort((a, b) => a.number - b.number);
if (issuesToMark.length === 0) {
console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`);
return;
}
for (const issue of issuesToMark) {
if (issueHasLabel(issue, TO_DISCUSS_LABEL)) {
console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`);
} else {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [NO_ACTION_LABEL],
});
console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`);
}
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
console.log(`Closed #${issue.number} as not planned`);
await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL);
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: UNTRIAGED_LABEL,
});
console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`);
}

View File

@@ -1,8 +1,6 @@
name: npm audit
on:
schedule:
- cron: '37 7 * * *'
workflow_dispatch:
permissions:

View File

@@ -0,0 +1,31 @@
name: Remove In Progress Label On Close
on:
issues:
types: [closed]
jobs:
remove-label:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Remove inprogress label
uses: actions/github-script@v7
with:
script: |
const labelName = 'inprogress';
const labels = context.payload.issue.labels ?? [];
const hasLabel = labels.some((label) => label.name === labelName);
if (!hasLabel) {
console.log(`Issue does not have ${labelName} label`);
return;
}
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: labelName,
});

57
.gitignore vendored
View File

@@ -1,5 +1,8 @@
node_modules/
dist/
# SproutClaw persistent config — never commit
.sproutclaw/
*.log
.DS_Store
*.tsbuildinfo
@@ -25,7 +28,7 @@ packages/*/dist-firefox/
.npm/
coverage/
.nyc_output/
.pi_config/
.sproutclaw_config/
tui-debug.log
compaction-results/
.opencode/
@@ -36,23 +39,41 @@ out.html
packages/coding-agent/binaries/
todo.md
plans/
.pi/hf-sessions/
.pi/hf-sessions-backup/
.pi/sessions/
.pi/extensions/webui/.webui.pid
.pi/extensions/webui/.webui.log
# Pi agent: track extensions, ignore local runtime secrets and sessions
.pi/agent/sessions/
.pi/agent/git/
.pi/agent/npm/
.pi/agent/auth.json
.pi/agent/models.json
.pi/agent/settings.json
.pi/agent/mcp-cache.json
.pi/agent/run-history.jsonl
.pi/agent/extensions/webui/frontend/node_modules/
.pi/agent/extensions/webui/.webui.pid
.pi/agent/extensions/webui/data/
.sproutclaw/hf-sessions/
.sproutclaw/hf-sessions-backup/
.sproutclaw/sessions/
.sproutclaw/extensions/webui/.webui.pid
.sproutclaw/extensions/webui/.webui.log
# Local runtime / secrets (do not commit)
.local/
progress.md
# SproutClaw agent: track extensions, ignore local runtime secrets and sessions
.sproutclaw/agent/sessions/
.sproutclaw/agent/git/
.sproutclaw/agent/npm/
.sproutclaw/agent/bin/
.sproutclaw/agent/data/
.sproutclaw/agent/auth.json
.sproutclaw/agent/models.json
.sproutclaw/agent/settings.json
.sproutclaw/agent/mcp.json
.sproutclaw/agent/mcp-cache.json
.sproutclaw/agent/mcp-npx-cache.json
.sproutclaw/agent/run-history.jsonl
.sproutclaw/agent/taskplane/
.sproutclaw/agent/skills/
.sproutclaw/agent/skills-disabled/
.sproutclaw/agent/extensions/webui/frontend/node_modules/
.sproutclaw/agent/extensions/webui/.webui.pid
.sproutclaw/agent/extensions/webui/data/
tmp/
bun.lock
collect.sh
# Root-level binary/build artifacts (do not commit)
/*.wasm
/llvm-*.js
/rustc-*.js
/util_cmd-*.js
/wasm32-*.tar.br

View File

@@ -38,7 +38,7 @@ fi
# Restage files that were previously staged and may have been modified by formatting
for file in $STAGED_FILES; do
if [ -f "$file" ]; then
git add "$file"
git add "$file" 2>/dev/null || true
fi
done

View File

@@ -1,55 +0,0 @@
## 身份定义
- 你的名字是“萌小芽”
- 你是树萌芽的智能 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 为:`(≧口≦)` `(≧∇≦)` `(`・ω・´)` `(。・ω・。)` `(=・ω・=)` `ヘ(=^・ω・^=)ノ` `|・ω・`)`
- 在合适且自然的场景下,可少量使用这些 Emoji避免过度堆叠

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,47 +0,0 @@
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");
});
}

View File

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

View File

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

View File

@@ -1 +0,0 @@
node_modules

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
{
"name": "sproutclaw-webui-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"generate-icons": "node scripts/generate-icons.mjs",
"dev": "vite",
"build": "npm run generate-icons && tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"highlight.js": "11.11.1",
"marked": "15.0.12",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-router-dom": "7.15.1"
},
"devDependencies": {
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "4.7.0",
"sharp": "0.34.5",
"typescript": "5.9.3",
"vite": "6.4.2",
"vite-plugin-pwa": "1.3.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
{
"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"
]
}

View File

@@ -1,25 +0,0 @@
---
description: Analyze GitHub issues (bugs or feature requests)
argument-hint: "<issue>"
---
Analyze GitHub issue(s): $ARGUMENTS
For each issue:
1. Add the `inprogress` label to the issue via GitHub CLI and assign the issue to the local `gh` user before analysis starts. If either action fails, report that explicitly and continue.
2. Read the issue in full, including all comments and linked issues/PRs.
3. Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path.
4. **For bugs**:
- Ignore any root cause analysis in the issue (likely wrong)
- Read all related code files in full (no truncation)
- Trace the code path and identify the actual root cause
- Propose a fix
5. **For feature requests**:
- Do not trust implementation proposals in the issue without verification
- Read all related code files in full (no truncation)
- Propose the most concise implementation approach
- List affected files and changes needed
Do NOT implement unless explicitly asked. Analyze and propose only.

View File

@@ -1,37 +0,0 @@
---
description: Review PRs from URLs with structured issue and code analysis
argument-hint: "<PR-URL>"
---
You are given one or more GitHub PR URLs: $@
For each PR URL, do the following in order:
1. Add the `inprogress` label to the PR via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue.
2. Read the PR page in full. Include description, all comments, all commits, and all changed files.
3. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments.
4. Analyze the PR diff. Read all relevant code files in full with no truncation and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior.
5. Do not check for a changelog entry. Per CONTRIBUTING.md, contributor PRs must not edit `CHANGELOG.md` — the maintainer adds the entry when merging.
6. Check if packages/coding-agent/README.md, packages/coding-agent/docs/*.md, packages/coding-agent/examples/**/*.md require modification. This is usually the case when existing features have been changed, or new features have been added.
7. Provide a structured review with these sections:
- What it does: one short paragraph describing the change and its intent.
- Good: solid choices or improvements.
- Bad: concrete issues, regressions, missing tests, or risks.
- Ugly: subtle or high impact problems.
- Tests: what is covered, what is missing, and whether existing tests are adequate.
- Open questions for you: only things blocking a merge decision that need the user's input. Omit the section entirely if there are none.
Output format per PR:
PR: <url>
What it does:
- ...
Good:
- ...
Bad:
- ...
Ugly:
- ...
Tests:
- ...
Open questions for you:
- ...
If no issues are found, say so under Bad and Ugly.

View File

@@ -1,35 +0,0 @@
---
description: Finish the current task end-to-end with changelog, commit, and push
argument-hint: "[instructions]"
---
Wrap it.
Additional instructions: $ARGUMENTS
Determine context from the conversation history first.
Rules for context detection:
- If the conversation already mentions a GitHub issue or PR, use that existing context.
- If the work came from `/is` or `/pr`, assume the issue or PR context is already known from the conversation and from the analysis work already done.
- If there is no GitHub issue or PR in the conversation history, treat this as non-GitHub work.
Unless I explicitly override something in this request, do the following in order:
1. Add or update the relevant package changelog entry under `## [Unreleased]` using the repo changelog rules.
2. If this task is tied to a GitHub issue or PR and a final issue or PR comment has not already been posted in this session, draft it in my tone, preview it, and post exactly one final comment. The comment must end with this exact standalone disclaimer line, with no variations:
```text
This comment is AI-generated by `/wr`
```
3. Commit only files you changed in this session.
4. If this task is tied to exactly one GitHub issue, include `closes #<issue>` in the commit message. If it is tied to multiple issues, stop and ask which one to use. If it is not tied to any issue, do not include `closes #` or `fixes #` in the commit message.
5. Check the current git branch. If it is not `main`, stop and ask what to do. Do not push from another branch unless I explicitly say so.
6. Push the current branch.
Constraints:
- Never stage unrelated files.
- Never use `git add .` or `git add -A`.
- Run required checks before committing if code changed.
- Do not open a PR unless I explicitly ask.
- If this is not GitHub issue or PR work, do not post a GitHub comment.
- If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask.

View File

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

View File

@@ -7,6 +7,7 @@
- No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!")
- Technical prose only, be direct
- When the user asks a question, answer it first before making edits or running implementation commands.
- When responding to user feedback or an analysis, explicitly say whether you agree or disagree before saying what you changed.
## Code Quality
@@ -20,7 +21,7 @@
- Always ask before removing functionality or code that appears intentional.
- Do not preserve backward compatibility unless the user asks for it.
- Never hardcode key checks (e.g. `matchesKey(keyData, "ctrl+x")`). Add defaults to `DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS` so they stay configurable.
- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead.
- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead, then regenerate. Including the resulting `models.generated.ts` diff is always OK, even if regeneration includes unrelated upstream model metadata changes.
## Commands
@@ -51,6 +52,7 @@ Committing:
- Stage explicit paths (`git add <path1> <path2>`); never `git add -A` / `git add .`.
- Before committing, run `git status` and verify you are only staging your files.
- `packages/ai/src/models.generated.ts` may always be included alongside your files.
- Message format: `{feat,fix,docs}[(ai,tui,agent,coding-agent)]: <commit message> (optionally multiple lines)`. Message is informative and concise.
Never run (destroys other agents' work or bypasses checks):
@@ -66,6 +68,12 @@ If rebase conflicts occur:
See `CONTRIBUTING.md` for the contributor gate (auto-close workflows, `lgtm`/`lgtmi`, quality bar).
When reviewing PRs:
- Do not run `gh pr checkout`, `git switch`, or otherwise move the worktree to the PR branch unless the user explicitly asks.
- Use `gh pr view`, `gh pr diff`, `gh api`, and local `git show`/`git diff` against fetched refs to inspect PR metadata, commits, and patches without changing branches.
- If you need PR file contents, fetch/read them into temporary files or use `git show <ref>:<path>` without switching branches.
When creating issues:
- Add `pkg:*` labels for affected packages (`pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`); use all that apply.
@@ -86,7 +94,7 @@ Run the TUI in a controlled terminal (from the repo root):
```bash
tmux new-session -d -s pi-test -x 80 -y 24
tmux send-keys -t pi-test "./pi-test.sh" Enter
tmux send-keys -t pi-test "./test.sh" Enter
sleep 3 && tmux capture-pane -t pi-test -p # capture after startup
tmux send-keys -t pi-test "your prompt here" Enter
tmux send-keys -t pi-test Escape # special keys (also C-o for ctrl+o, etc.)
@@ -119,40 +127,35 @@ Attribution:
```bash
npm run release:local -- --out /tmp/pi-local-release --force
cd /tmp
# Node package install smoke tests
/tmp/pi-local-release/node/pi --help
/tmp/pi-local-release/node/pi --version
/tmp/pi-local-release/node/pi --list-models
/tmp/pi-local-release/node/pi -p "Say exactly: ok"
/tmp/pi-local-release/node/pi
# Bun binary smoke tests
/tmp/pi-local-release/bun/pi --help
/tmp/pi-local-release/bun/pi --version
/tmp/pi-local-release/bun/pi --list-models
/tmp/pi-local-release/bun/pi -p "Say exactly: ok"
/tmp/pi-local-release/bun/pi
```
Verify startup, model/account listing, and at least one real prompt with the intended default provider. Failures are release blockers unless the user explicitly accepts the risk.
Verify both Node and Bun startup, model/account listing, interactive startup, and at least one real prompt with the intended default provider. The bare commands `/tmp/pi-local-release/node/pi` and `/tmp/pi-local-release/bun/pi` start interactive mode; run each in tmux, submit a prompt, and wait for the model reply before considering the interactive smoke test passed. Failures are release blockers unless the user explicitly accepts the risk.
3. **Brief the user on the WebAuthn flow before running anything**. Print exactly the following message and then stop and wait for the user to confirm in their next message:
```
Before I run the release script, read this carefully:
- `npm publish` uses WebAuthn 2FA.
- A login URL will appear in the live bash output in this TUI. I will NOT see it until the command exits.
- You must watch the bash output, cmd/ctrl-click the URL, log in in the browser, and select the "don't ask again for N minutes" option so publish can continue.
- This may happen more than once during the release.
Reply "ready" once you have read this and are watching the bash output. I will not run the release script until you do.
```
Do not proceed to step 4 until the user explicitly confirms.
4. **Run the release script**:
3. **Run the release script**:
```bash
npm run release:patch # fixes + additions
npm run release:minor # breaking changes
PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:patch # fixes + additions
PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:minor # breaking changes
```
Do not pass a `timeout` to the bash tool for this call. If publish fails partway, stop and report to the user what happened (which package failed, the error output) along with possible solutions. Never rerun the version bump on your own.
Use `npm_config_min_release_age=0` only for the release command. The repo's normal npm age gate can otherwise block the release lockfile refresh when the current workspace package version was published recently. Review any lockfile or shrinkwrap diffs the release creates before push.
5. **After publish succeeds**:
- Add fresh `## [Unreleased]` sections to package changelogs.
- Commit with `Add [Unreleased] section for next cycle`.
- Push `main` and the release tag.
The release script bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed.
4. **CI publishes npm packages**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required.
5. **If CI publish fails**: inspect the failed `publish-npm` job. The publish helper is idempotent and skips package versions already present on npm, so rerun the tag workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version.
## User Override

View File

@@ -1,93 +1,97 @@
# Contributing to pi
# 贡献指南
This guide exists to save both sides time.
本指南旨在为双方节省时间。
## The One Rule
## 设计理念
**You must understand your code.** If you cannot explain what your changes do and how they interact with the rest of the system, your PR will be closed.
首先明确一点:**SproutClaw 的核心保持精简**。
Using AI to write code is fine. Submitting AI-generated slop without understanding it is not.
如果你的功能不属于核心,它应该以扩展的形式实现。会让核心臃肿的 PR 大概率会被拒绝。
If you use an agent, run it from the `pi-mono` root directory so it picks up `AGENTS.md` automatically. Your agent must follow the rules and guidelines in that file.
SproutClaw 的核心存在的意义在于精简且可扩展,以便扩展能够影响和定制其行为。即便是扩展的 hook 点也需要经过充分考量和讨论,避免引入难以维护的冗余代码和复杂交互。
## Contribution Gate
## 唯一准则
All issues and PRs from new contributors are auto-closed by default.
**你必须理解自己提交的代码。** 如果你无法解释你的改动做了什么、以及它与系统其他部分如何交互,你的 PR 将被关闭。
Issues submitted Friday through Sunday are not reviewed. If something is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx
使用 AI 辅助编写代码没有问题,但在不理解代码的情况下直接提交 AI 生成的内容是不被接受的。
Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar below will not be reopened or receive a reply.
如果你使用 Agent请在 `sproutclaw` 根目录运行,这样它会自动读取 `AGENTS.md`。你的 Agent 必须遵循该文件中的规则和指南。
Approval happens through maintainer replies on issues:
## 贡献门槛
- `lgtmi`: your future issues will not be auto-closed
- `lgtm`: your future issues and PRs will not be auto-closed
来自新贡献者的 issue 和 PR 默认会被自动关闭。
`lgtmi` does not grant rights to submit PRs. Only `lgtm` grants rights to submit PRs.
周五至周日提交的 issue 不保证会被审查。如有紧急问题,请在内部联系维护者。
## Quality Bar For Issues
维护者会每日审查被自动关闭的 issue并重新开放值得处理的内容。不符合以下质量标准的 issue 将不会被重新开放或收到回复。
If you open an issue, you must use one of the two GitHub issue templates.
审批通过由维护者在 issue 中回复:
If you open an issue, keep it short, concrete, and worth reading.
- `lgtmi`:你未来提交的 issue 将不再被自动关闭
- `lgtm`:你未来提交的 issue 和 PR 将不再被自动关闭
- Keep it concise. If it does not fit on one screen, it is too long.
- Write in your own voice.
- State the bug or request clearly.
- Explain why it matters.
- If you want to implement the change yourself, say so.
`lgtmi` 不授予提交 PR 的权限,只有 `lgtm` 才授予该权限。
If the issue is real and written well, a maintainer may reopen it, reply `lgtmi`, or reply `lgtm`.
## Issue 质量标准
## Blocking
提交 issue 时,必须使用两个 GitHub issue 模板之一。
If you ignore this document twice, or if you spam the tracker with agent-generated issues, your GitHub account will be permanently blocked.
提交的 issue 应简洁、具体、有阅读价值:
If you send a large volume of issues through automation, your GitHub account will be permanently blocked. No taksies backsies.
- 保持简短。如果超过一屏,就太长了。
- 用自己的语言表达(不要用 LLM 生成文本;如果必须,请在后续评论中明确标注为 AI 生成)。
- 清晰说明 bug 或需求。
- 解释其重要性。
- 如果你希望自己来实现,请说明。
## Before Submitting a PR
如果 issue 真实且表述良好,维护者可能会重新开放、回复 `lgtmi` 或回复 `lgtm`
Do not open a PR unless you have already been approved with `lgtm`.
## 封禁规则
Before submitting a PR:
如果你两次无视本文档,或通过 Agent 批量刷 issue你的账号将被永久封禁。
如果你通过自动化手段大量提交 issue你的账号将被永久封禁且不可申诉。
## 提交 PR 前
未获得 `lgtm` 批准前,请勿提交 PR。
提交 PR 前,请先执行:
```bash
npm run check
./test.sh
```
Both must pass.
两项均须通过。
Do not edit `CHANGELOG.md`. Changelog entries are added by maintainers.
不要修改 `CHANGELOG.md`,更新日志条目由维护者添加。
If you are adding a new provider to `packages/ai`, see `AGENTS.md` for required tests.
如果你要在 `packages/ai` 中添加新的 provider请参阅 `AGENTS.md` 中关于必要测试的说明。
## Philosophy
## 有疑问?
pi's core is minimal. If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected.
请联系项目维护者。
## Questions?
## 常见问题
Ask on [Discord](https://discord.com/invite/nKXTsAcmbT).
### 为什么新 issue 和 PR 会被自动关闭?
## FAQ
SproutClaw 收到的 issue 数量超过维护者能够实时处理的上限。许多报告不符合本指南的质量标准,或者是通过 Agent 随意提交而未经本人审查和整理的。自动关闭机制让维护者可以按自己的节奏审查,并重新开放符合质量要求的 issue。
### Why are new issues and PRs auto-closed?
### 为什么周末的 issue 优先级较低?
pi receives more issues than the maintainers can responsibly review in real time. Many reports do not meet the quality bar in this guide or do not follow CONTRIBUTING.md. Some are slung at the repository mindlessly via an agent instead of being reviewed and shaped by the person submitting them. Auto-closing creates a buffer so maintainers can review the tracker on their own schedule and reopen the issues that meet the quality bar.
维护者在工作时间处理 issue。周末可能积累更多未处理内容周五至周日提交的 issue 可能在周一审查时被遗漏或优先级降低。如有紧急问题,请直接联系维护者并附上简要描述、复现步骤和相关日志。
### Why are weekend issues not reviewed?
### 为什么有些 issue 没有收到回复?
Maintainers need uninterrupted time away from the issue tracker. Issues submitted Friday through Sunday are auto-closed and are not part of the Monday review queue. If a problem is urgent, ask on Discord and include the short version, a repro, and the relevant logs.
回复本身也是一种维护成本。低质量 issue、表述不清的报告、重复 issue 以及不遵循本指南的 issue 可能被直接关闭而不作说明。这样可以将时间留给可复现的 bug、深思熟虑的需求以及真正用心整理报告的贡献者。
### Why do some issues get no reply?
### 为什么不用 AI 来做所有分类工作?
A reply is maintenance work too. Low-signal issues, unclear reports, duplicates, and issues that do not follow this guide may be closed without discussion. This keeps time available for reproducible bugs, thoughtful requests, and contributors who have done the work to make their report actionable.
AI 可以帮助归纳重复内容、汇总报告、发现缺失信息,但不被信任来做最终的维护决策。经过润色的 AI 生成 issue 仍然可能是错误的、有误导性的,或需要大量时间排查。人工审查始终是最终把关环节。
### Why not let AI triage everything?
### 这对贡献者是否过于苛刻?
AI can help group duplicates, summarize reports, and spot missing information. It is not trusted to make final maintainer decisions. Polished AI-generated issues can still be wrong, misleading, or expensive to investigate. Human review remains the final gate.
### Is this hostile to contributors?
No. It is a guardrail against burnout and tracker spam. Short, concrete, reproducible issues are welcome. Thoughtful contributions are welcome. Automated slop, entitlement, and large volumes of low-effort reports are not.
不是。这是防止维护者精力耗尽和 issue 刷屏的保护机制。简洁、具体、可复现的 issue 是受欢迎的,有深度的贡献同样受欢迎。自动化生成的低质量内容、无理取闹以及大量低价值报告则不被接受。

155
README.md
View File

@@ -8,7 +8,7 @@
- 开发协作助手:理解本地项目结构,执行代码修改、测试验证和 Git 工作流。
- Docker 部署助手:按项目目录组织 `docker compose` 服务,关注数据持久化、端口规划和资源限制。
- 内网服务助手:适配 smallmengya、bigmengya、alycd 等内网服务器使用习惯。
- WebUI/TUI 双入口:保留控制台 TUI同时扩展网页前端,方便在浏览器里管理会话和模型
- WebUI/TUI 双入口:保留控制台 TUI同时提供独立的网页前端(`sproutclaw-web`
## 项目结构
@@ -18,147 +18,182 @@
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
| `packages/tui` | 终端 UI 渲染库 |
| `.pi/agent/extensions/webui` | 网页对话入口React 前端 + HTTP 服务 |
| `.pi/agent/extensions/*.ts` | sproutclaw 本地扩展(中文启动页、命令安装等) |
| `sproutclaw` / `sproutclaw.bat` | 启动入口(从已构建 `dist/cli.js` 启动 |
| `build.sh` / `build.bat` | 同上,使用已构建版启动 |
| `test.sh` / `test.bat` | 从源码tsx启动用于开发调试 |
| `scripts/sync-upstream.sh` | 合并上游 pi-mono 更新 |
| `scripts/push-sproutclaw.sh` | 推送到 GitHub / Gitea |
> `.sproutclaw/agent/` 为本地持久化配置目录settings、auth、sessions、extensions、skills 等),已整体加入 `.gitignore`,不进入版本控制。与原版 pi 的 `~/.pi/agent/` 完全隔离。
## 常用命令
安装依赖并构建:
```bash
npm install --ignore-scripts
npm run build
npm run check
```
从源码运行(开发调试,无需 build
```bash
./test.sh
./pi-test.sh
```
运行本地源码版 Agent
从已构建版运行(日常更快)
```bash
./pi-test.sh
./build.sh
# 或
./sproutclaw
```
启动 WebUI 时,在 TUI 中执行
```text
/webui on 19133
```
关闭 WebUI
```text
/webui off
```
WebUI 前端单独构建:
检查代码
```bash
cd .pi/agent/extensions/webui/frontend && npm install && npm run build
npm run check
```
## WebUIsproutclaw-web
WebUI 是独立项目,位于 `../sproutclaw-web/`,通过 systemd 以服务形式运行。
构建后端:
```bash
cd ../sproutclaw-web
bash build.sh
```
构建前端:
```bash
cd ../sproutclaw-web/frontend
npm install && npm run build
```
管理服务:
```bash
systemctl start sproutclaw-web
systemctl stop sproutclaw-web
systemctl restart sproutclaw-web
systemctl status sproutclaw-web
```
## Git 分支策略
本仓库是 pi-mono 的 fork采用三层分支,同时维护**上游同步**和 **sproutclaw 定制**
本仓库是 pi-mono 的 fork同时维护**上游同步**和 **sproutclaw 定制**
| 分支 | 用途 |
| --- | --- |
| `main` | 稳定可跑版本:已合并的上游 + sproutclaw 定制,推送到 GitHub |
| `upstream-sync` | 临时同步分支:专门用来 `merge upstream/main`,解决冲突后再合回 `main` |
| `main` | 稳定可跑版本:已合并的上游 + sproutclaw 定制,推送到 GitHub / Gitea |
| `feature/*` | 日常开发WebUI、扩展、补丁等完成后 merge 进 `main` |
### Remote 配置
```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 (内网备份,可选)
origin → https://github.com/shumengya/sproutclaw.git
upstream → https://github.com/badlogic/pi-mono.git
gitea → ssh://git@git.shumengya.top:8022/shumengya/sproutclaw.git
```
首次配置 upstream
```bash
git remote add upstream https://github.com/badlogic/pi-mono.git
git remote add gitea ssh://git@git.shumengya.top:8022/shumengya/sproutclaw.git
```
GitHub 网络不稳定时:
### 日常开发
```bash
export GIT_HTTP_VERSION=1.1
```
### 日常开发feature 分支)
```bash
./scripts/new-feature.sh webui-avatar-fix # 从 main 创建 feature/webui-avatar-fix
git checkout -b feature/my-fix
# ... 开发、测试 ...
git add <改动的具体文件> # 不要用 git add -A
git commit -m "fix(webui): agent avatar during tool calls"
git add <改动的具体文件>
git commit -m "fix: ..."
git checkout main
git merge feature/webui-avatar-fix
git merge feature/my-fix
./scripts/push-sproutclaw.sh
git branch -d feature/webui-avatar-fix # 合并后可删
git branch -d feature/my-fix
```
### 推送本地更新到 GitHub
### 推送到 GitHub / Gitea
```bash
git checkout main
./scripts/push-sproutclaw.sh
# 或手动
git push origin main && git push gitea main
```
### 合并上游 pi-mono 更新
```bash
./scripts/sync-upstream.sh
# 若有冲突,按下方规则解决后:
git fetch upstream
git merge upstream/main --no-commit --no-ff
# 解决冲突(见下方惯例),然后:
git add <resolved-files>
git commit
git checkout main && git merge upstream-sync
PI_ALLOW_LOCKFILE_CHANGE=1 git commit
./scripts/push-sproutclaw.sh
```
**合并冲突处理惯例:**
| 文件 | 处理方式 |
| 文件类型 | 处理方式 |
| --- | --- |
| `README.md`、sproutclaw 定制代码 | 保留本仓库版本 |
| `package-lock.json` | 采用 upstream再执行 `npm install --ignore-scripts` |
| `*.generated.ts` | 采用 upstream |
| `.pi/agent/extensions/` | 保留 sproutclaw 版本 |
| `package-lock.json` | 采用 upstream`npm install --ignore-scripts` |
| 测试/文档/新增上游功能 | 采用 upstream |
合并后必做:
```bash
npm install --ignore-scripts
npm run build
./pi-test.sh
./test.sh
```
### sproutclaw 相对 upstream 的 intentional 改动
### sproutclaw 相对 upstream 的定制改动
合并上游时,以下定制**不要误删**
合并上游时,以下内容**不要误删**
- `packages/coding-agent/package.json``piConfig.name = sproutclaw``configDir = .sproutclaw`(命名空间隔离)
- `showChangelogOnStartup` 设置项(关闭启动 What's New
- `.pi/agent/extensions/webui/` 网页 UI
- `.pi/agent/extensions/startup-chinese.ts` 等本地扩展
- 本 README仅 sproutclaw 说明,不含上游文档)
- RPC 扩展:`reload` 命令、bash 流式输出(`bash_update`)、`get_extensions` 接口
- `sproutclaw` / `build.sh` / `test.sh` 等启动脚本
- `scripts/sync-upstream.sh``scripts/push-sproutclaw.sh` 等工具脚本
- 本 README 及中文版 CONTRIBUTING.md / SECURITY.md
## 本地配置
`.pi/agent/` 中以下内容**不进 Git**(含密钥/内网地址)
`.sproutclaw/agent/` 中以下内容不进 Git
- `settings.json` → 参考 `settings.example.json` 本地复制
- `models.json``auth.json`
- `sessions/`
- `models.json``auth.json``mcp.json`
- `sessions/``skills/``extensions/``prompts/`
环境变量(可选):
- `SPROUTCLAW_CODING_AGENT_DIR` — 覆盖 agent 配置目录,默认 `<repo>/.sproutclaw/agent`
首次部署:
```bash
cp .pi/agent/settings.example.json .pi/agent/settings.json
# 编辑 settings.json 填入 provider / model
cp .sproutclaw/agent/settings.example.json .sproutclaw/agent/settings.json
# 编辑 settings.json 填入 provider / model / auth 信息
```
### 与原版 pi 并存
| | 原版 pi | SproutClaw |
|---|---|---|
| 命令 | `pi` | `sproutclaw` / `mengya` |
| 全局配置 | `~/.pi/agent/` | `~/.sproutclaw/agent/` |
| 项目配置 | `.pi/` | `.sproutclaw/` |
| 环境变量 | `PI_CODING_AGENT_DIR` | `SPROUTCLAW_CODING_AGENT_DIR` |
## 说明
这个仓库是自用 fork默认围绕树萌芽的服务器环境、部署规范和日常开发习惯调整。上游能力来自 pi-mono通过 `upstream-sync` 分支定期同步sproutclaw 定制在 `feature/*` 开发后合入 `main`
这个仓库是自用 fork默认围绕树萌芽的服务器环境、部署规范和日常开发习惯调整。上游能力来自 pi-mono通过 `git fetch upstream && git merge upstream/main` 定期同步sproutclaw 定制在 `feature/*` 开发后合入 `main`

58
SECURITY.md Normal file
View File

@@ -0,0 +1,58 @@
# 安全政策
本文档旨在帮助你了解 SproutClaw 的安全设计理念及其边界范围。
## 安全模型概述
SproutClaw 是一个在本地运行的 Coding Agent运行于使用者账号的安全边界之内。监控其操作或将其限制在容器、虚拟机或其他沙箱环境中是使用者自身的责任。
SproutClaw 将本地用户账号及该账号可写的文件视为与 SproutClaw 进程处于同一信任边界内。如果攻击者能够修改用户主目录、工作区、Shell 启动文件、环境变量或 SproutClaw 配置文件,通常即可影响 SproutClaw 或其他本地开发工具的行为。依赖此类本地写入权限的报告不属于安全漏洞,除非其能证明 SproutClaw 本身授予了该写入权限,或越过了操作系统的权限边界。
SproutClaw 依赖使用者仅安装可信扩展、加载可信技能,并仅在可信仓库中使用。这是因为 `AGENTS.md` 等文件中的内容或代码注释可被用于对 Coding Agent 进行提示词注入,且此类攻击无法从根本上防御。
## 报告漏洞
如果你认为在 SproutClaw 中发现了安全漏洞,请通过以下方式私下报告:
- 直接联系项目维护者
报告时请包含:
- 问题描述及其影响
- 复现步骤、概念验证或相关日志
- 受影响的包、版本、提交哈希或配置信息
- 已知的缓解措施(如有)
请勿为安全敏感问题创建公开 issue。
## 范围内In Scope
以下属于安全问题的受理范围:
- 分发包、命令行工具、API 及仓库代码中的安全问题
## 范围外Out of Scope
以下内容不在安全问题受理范围之内:
- 本地代码执行或沙箱行为SproutClaw Coding Agent 有意不提供沙箱)
- 用户自行安装的扩展或技能的行为
- 在不可信仓库中工作所带来的风险
- 安装不可信扩展、技能、包或工具所带来的风险
- 由不可信中间人代理MITM Proxy导致的问题
- 将 SproutClaw 暴露在公网上的风险
- 提示词注入攻击
- 第三方或用户自行控制的凭据泄露
- 需要在目标机器上具备创建、修改、删除或替换文件、目录、符号链接、环境变量、Shell 配置或其他用户可控本地状态的能力才能复现的报告(包括 `~/.pi``~/.pi/agent/models.json`、工作区文件、`AGENTS.md`、技能、扩展、扩展配置、dotfiles 及通过 NFS/漫游配置/dotfile 管理器同步的文件),除非报告能证明 SproutClaw 本身授予了该访问权限
- 由用户有意弱化配置导致的问题
- 针对 SproutClaw Coding Agent 使用可信本地输入/配置的资源耗尽/DoS 声明
- 关于恶意模型输出的报告
- 用户批准或用户主动发起的本地操作被当作漏洞提交的报告
## 报告者须知
最有价值的报告应展示当前可复现的安全边界绕过,并附有已证明的实际影响。仅展示预期本地 Agent 行为、提示词注入或恶意可信扩展/技能的报告,在本安全模型下不构成安全漏洞。
例如,若报告显示向可信 SproutClaw 配置文件写入恶意内容会导致 SproutClaw 执行命令、加载攻击者控制的工具、向攻击者控制的端点发送凭据或改变其他行为,此类报告属于范围之外。
提交报告时,请尽量包含以下信息:受影响的确切路径、包版本或提交 SHA、配置信息以及针对最新版本或最新 `main` 分支的概念验证。对于依赖项报告,请提供证据说明已分发的依赖项确实受影响,且该问题可通过 SproutClaw 触达。

77
build.bat Normal file
View File

@@ -0,0 +1,77 @@
@echo off
setlocal EnableDelayedExpansion
set "SCRIPT_DIR=%~dp0"
if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
set "PI_CLI_DIST=%SCRIPT_DIR%\packages\coding-agent\dist\cli.js"
if not defined SPROUTCLAW_CODING_AGENT_DIR (
set "SPROUTCLAW_CODING_AGENT_DIR=%SCRIPT_DIR%\.sproutclaw\agent"
)
set "NO_ENV=false"
set "ARGS="
:loop
if "%~1"=="" goto endloop
if "%~1"=="--no-env" (
set "NO_ENV=true"
) else (
if "%ARGS%"=="" (
set "ARGS=%1"
) else (
set "ARGS=%ARGS% %1"
)
)
shift
goto loop
:endloop
if "%NO_ENV%"=="true" (
set "ANTHROPIC_API_KEY="
set "ANTHROPIC_OAUTH_TOKEN="
set "OPENAI_API_KEY="
set "GEMINI_API_KEY="
set "GROQ_API_KEY="
set "CEREBRAS_API_KEY="
set "XAI_API_KEY="
set "OPENROUTER_API_KEY="
set "ZAI_API_KEY="
set "MISTRAL_API_KEY="
set "MINIMAX_API_KEY="
set "MINIMAX_CN_API_KEY="
set "AI_GATEWAY_API_KEY="
set "OPENCODE_API_KEY="
set "COPILOT_GITHUB_TOKEN="
set "GH_TOKEN="
set "GITHUB_TOKEN="
set "HF_TOKEN="
set "GOOGLE_APPLICATION_CREDENTIALS="
set "GOOGLE_CLOUD_PROJECT="
set "GCLOUD_PROJECT="
set "GOOGLE_CLOUD_LOCATION="
set "AWS_PROFILE="
set "AWS_ACCESS_KEY_ID="
set "AWS_SECRET_ACCESS_KEY="
set "AWS_SESSION_TOKEN="
set "AWS_REGION="
set "AWS_DEFAULT_REGION="
set "AWS_BEARER_TOKEN_BEDROCK="
set "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI="
set "AWS_CONTAINER_CREDENTIALS_FULL_URI="
set "AWS_WEB_IDENTITY_TOKEN_FILE="
set "AZURE_OPENAI_API_KEY="
set "AZURE_OPENAI_BASE_URL="
set "AZURE_OPENAI_RESOURCE_NAME="
echo Running without API keys...
)
if not exist "%PI_CLI_DIST%" (
echo Build output not found: %PI_CLI_DIST% >&2
echo Run from repo root: npm run build >&2
exit /b 1
)
node "%PI_CLI_DIST%" %ARGS%

View File

@@ -2,12 +2,13 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PI_CLI_DIST="$SCRIPT_DIR/packages/coding-agent/dist/cli.js"
# Default to the repository-local pi config so this source checkout uses the
# copied learning config instead of ~/.pi.
export PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$SCRIPT_DIR/.pi/agent}"
export SPROUTCLAW_CODING_AGENT_DIR="${SPROUTCLAW_CODING_AGENT_DIR:-$SCRIPT_DIR/.sproutclaw/agent}"
# Align pi-mcp-adapter (and other third-party packages that only read PI_CODING_AGENT_DIR)
# with SproutClaw's agent dir, so MCP configs in .sproutclaw/agent/mcp.json are loaded.
export PI_CODING_AGENT_DIR="$SPROUTCLAW_CODING_AGENT_DIR"
# Check for --no-env flag
NO_ENV=false
ARGS=()
for arg in "$@"; do
@@ -19,7 +20,6 @@ for arg in "$@"; do
done
if [[ "$NO_ENV" == "true" ]]; then
# Unset API keys (see packages/ai/src/env-api-keys.ts)
unset ANTHROPIC_API_KEY
unset ANTHROPIC_OAUTH_TOKEN
unset OPENAI_API_KEY
@@ -58,4 +58,10 @@ if [[ "$NO_ENV" == "true" ]]; then
echo "Running without API keys..."
fi
"$SCRIPT_DIR/node_modules/.bin/tsx" --tsconfig "$SCRIPT_DIR/tsconfig.json" "$SCRIPT_DIR/packages/coding-agent/src/cli.ts" ${ARGS[@]+"${ARGS[@]}"}
if [[ ! -f "$PI_CLI_DIST" ]]; then
echo "Build output not found: $PI_CLI_DIST" >&2
echo "Run from repo root: npm run build" >&2
exit 1
fi
exec node "$PI_CLI_DIST" ${ARGS[@]+"${ARGS[@]}"}

View File

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

3670
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"packages/coding-agent/examples/extensions/with-deps",
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
"packages/coding-agent/examples/extensions/sandbox"
"packages/coding-agent/examples/extensions/sandbox",
"packages/coding-agent/examples/extensions/gondolin"
],
"scripts": {
"clean": "npm run clean --workspaces",
@@ -20,18 +21,19 @@
"profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui",
"profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc",
"test": "npm run test --workspaces --if-present",
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only",
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only",
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only",
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:set": "npm version -ws",
"prepublishOnly": "npm run clean && npm run build && npm run check",
"publish": "npm run prepublishOnly && npm publish -ws --access public",
"publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run",
"publish": "npm run prepublishOnly && node scripts/publish.mjs",
"publish:dry": "npm run prepublishOnly && node scripts/publish.mjs --dry-run",
"release:local": "node scripts/local-release.mjs",
"shrinkwrap:coding-agent": "node scripts/generate-coding-agent-shrinkwrap.mjs",
"release:patch": "node scripts/release.mjs patch",
"release:minor": "node scripts/release.mjs minor",
"release:major": "node scripts/release.mjs major",
"release:fix-links": "node scripts/release-notes.mjs fix-github-releases",
"prepare": "husky"
},
"devDependencies": {
@@ -39,7 +41,7 @@
"@biomejs/biome": "2.3.5",
"@types/node": "22.19.19",
"@typescript/native-preview": "7.0.0-dev.20260120.1",
"esbuild": "0.28.0",
"esbuild": "0.28.1",
"husky": "9.1.7",
"jiti": "2.7.0",
"shx": "0.4.0",
@@ -55,5 +57,8 @@
"gaxios": {
"rimraf": "6.1.2"
}
},
"dependencies": {
"@mistralai/mistralai": "2.2.6"
}
}

View File

@@ -2,6 +2,64 @@
## [Unreleased]
## [0.79.9] - 2026-06-20
### Fixed
- Fixed Node execution environment commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)).
## [0.79.8] - 2026-06-19
### Added
- Added `@earendil-works/pi-agent-core/base` for bundlers that want to pair the agent core with selective `@earendil-works/pi-ai/base` provider registration ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)).
## [0.79.7] - 2026-06-18
## [0.79.6] - 2026-06-16
## [0.79.5] - 2026-06-16
## [0.79.4] - 2026-06-15
## [0.79.3] - 2026-06-13
## [0.79.2] - 2026-06-12
### Fixed
- Fixed late tool progress callbacks after tool settlement to be ignored instead of emitting stale `tool_execution_update` events ([#5573](https://github.com/earendil-works/pi/issues/5573)).
## [0.79.1] - 2026-06-09
## [0.79.0] - 2026-06-08
### Fixed
- Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)).
## [0.78.1] - 2026-06-04
## [0.78.0] - 2026-05-29
## [0.77.0] - 2026-05-28
### Breaking Changes
- Renamed agent harness `model_select` and `thinking_level_select` events to `model_update` and `thinking_level_update`.
### Added
- Added agent harness tool registry APIs, `tools_update` events, branch-scoped active-tool persistence, and duplicate tool validation.
## [0.76.0] - 2026-05-27
### Fixed
- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)).
## [0.75.5] - 2026-05-23
## [0.75.4] - 2026-05-20
### Changed

View File

@@ -31,6 +31,24 @@ agent.subscribe((event) => {
await agent.prompt("Hello!");
```
## Base Entry Point
Use `@earendil-works/pi-agent-core/base` with `@earendil-works/pi-ai/base` when bundling applications that should include only selected provider transports:
```typescript
import { Agent } from "@earendil-works/pi-agent-core/base";
import { getModel } from "@earendil-works/pi-ai/base";
import { register } from "@earendil-works/pi-ai/anthropic";
register();
const agent = new Agent({
initialState: { model: getModel("anthropic", "claude-sonnet-4-6") },
});
```
The default `@earendil-works/pi-agent-core` entry point remains batteries-included and registers pi-ai's lazy built-in transports for backward compatibility.
## Core Concepts
### AgentMessage vs LLM Message

View File

@@ -258,13 +258,14 @@ Done:
- Exported `QueueMode` from core types.
- Added `AgentHarnessOptions.steeringMode` and `followUpMode`.
- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`.
- Added `getTools()` and `getActiveTools()`.
- Added `tools_update` observability events, including active-tool-only updates.
- Active tool changes are persisted as branch-scoped `active_tools_change` entries.
- Duplicate tool names and duplicate active tool names reject.
Remaining:
- Add `getTools()` semantics.
- Add `getActiveTools()` semantics.
- Decide and implement tool update observability events.
- Include active-tool-only updates in the runtime config observability plan.
- None.
Notes:

View File

@@ -14,6 +14,8 @@ A fully durable `AgentHarness` is not realistic by itself because important depe
- resource loaders
- system-prompt callbacks/modifiers
Tool registries are runtime dependencies. The harness should persist serializable tool configuration, such as active tool names, but not concrete tool implementations.
The practical target is a semi-durable harness:
- session is the durable append-only state tree
@@ -29,6 +31,7 @@ Existing session state already includes harness state:
- model changes
- thinking-level changes
- active-tool changes
- leaf entries
- labels
- compactions and branch summaries
@@ -50,10 +53,38 @@ The app must recreate compatible runtime dependencies:
Harness can validate stable IDs/versions/hashes when available, but it cannot serialize these dependencies itself.
## Runtime configuration and restore
Constructor options remain explicit runtime configuration and do not read session state. Hidden async restore in a constructor would make failure handling ambiguous.
A future async builder/factory should own durable restore:
```ts
const harness = await AgentHarness.builder()
.env(env)
.session(session)
.model(defaultModel)
.tools(runtimeTools)
.defaultActiveTools(["read", "edit"])
.restore({ missingActiveTools: "fail" });
```
`restore()` should read the active branch, reduce durable harness configuration, apply defaults for missing entries, validate against app-supplied runtime dependencies, construct the harness, and optionally emit `source: "restore"` update events after construction.
For active tools:
- `active_tools_change` entries are branch-scoped durable config.
- If no `active_tools_change` exists on the branch, restore uses builder defaults, or all registered tools if no default active names were supplied.
- Active tool names must be unique.
- Tool registry names must be unique.
- Missing restored active tool names should fail restore by default; permissive drop/disable policies can be added explicitly later.
- Concrete tools are never restored from session; the host app must provide compatible tools.
## What harness should persist
Minimum useful durability entries:
- branch-scoped active tool names
- queued steer/followUp/nextTurn messages
- queue consumption tied to a turn
- pending session writes accepted during active operations
@@ -93,11 +124,11 @@ On startup:
3. Harness reduces session entries into:
- current leaf
- conversation branch
- harness config
- harness config, including active tool names
- queues
- pending writes
- active operation/turn/tool state
4. Harness validates required runtime dependencies.
4. Harness validates required runtime dependencies, including restored active tool names against the app-provided tool registry.
5. Harness reconciles unfinished operation state.
Provider streams are not resumable. Recovery can only retry from a durable boundary or mark the operation interrupted.
@@ -173,7 +204,7 @@ recovery: "mark_interrupted" | "retry_unfinished"
## Open questions
- Which harness config entries should move into session first: tools, active tools, resources, stream options, system prompt refs?
- Which remaining harness config entries should move into session first: resources, stream options, system prompt refs?
- Should resolved system prompt text be snapshotted per turn for audit/debug?
- Do we require strict dependency ID/version matching on resume?
- How much provider request data should be journaled?

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