Compare commits

...

199 Commits

Author SHA1 Message Date
root
11c3a3a399 fix(ci): satisfy ts-imports and regenerate image models
Some checks failed
CI / build-check-test (push) Has been cancelled
npm audit / audit (push) Has been cancelled
Use .ts relative import in webui server for pi's check:ts-imports policy
and regenerate image-models.generated.ts to remove duplicate model keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:45:00 +08:00
root
092279126b fix(webui): pin frontend deps for CI check:pinned-deps
Some checks failed
CI / build-check-test (push) Has been cancelled
Use exact versions in webui frontend package.json to satisfy the
monorepo pinned dependency policy enforced by GitHub Actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:43:08 +08:00
root
0a91cc99d0 chore: add sproutclaw git workflow and track local extensions
Some checks failed
CI / build-check-test (push) Has been cancelled
Document main/upstream-sync/feature branch strategy, add sync/push
scripts, track .pi/agent extensions and webui in git, and disable
startup changelog via showChangelogOnStartup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:38:20 +08:00
root
a8daa1d8ff merge: sync upstream pi-mono main into sproutclaw
Some checks failed
CI / build-check-test (push) Has been cancelled
Merge upstream/main (195 commits) while keeping sproutclaw-specific
README, webui workflow docs, and restored upstream .pi prompt templates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:05:31 +08:00
Mario Zechner
7426ce977d Tighten AGENTS.md and extract LLM provider checklist to skill
- Tighten all sections (279 -> 159 lines) without dropping rules.
- Reorganize: git rules moved next to issues/PRs.
- Defer contributor gate details to CONTRIBUTING.md.
- Replace stale npx tsx test command with node (strip-only mode); add ./test.sh + e2e warning.
- Releasing: explicit WebAuthn briefing step, no bash timeout, stop on partial publish failure.
- Move LLM provider checklist to .pi/skills/add-llm-provider.md.
2026-05-22 12:04:12 +02:00
Armin Ronacher
c100620bf4 fix(coding-agent): Clean up Path Handling (#4873) 2026-05-22 11:25:15 +02:00
Mario Zechner
bf56a86e1e fix(coding-agent): reconcile git package refs
closes #4870
2026-05-22 11:13:26 +02:00
Mario Zechner
1a2a536dba chore: update PR prompt template 2026-05-22 11:11:38 +02:00
Mario Zechner
ced73b396a docs: note Node 20 rescue release
Closes #4876
2026-05-22 00:28:49 +02:00
Mario Zechner
11c3da4f74 fix(ai): set bedrock claude default max tokens
closes #4848
2026-05-21 23:27:40 +02:00
Mario Zechner
b0c5554902 docs: document safe development install
closes #4868
2026-05-21 20:44:05 +02:00
github-actions[bot]
1367164204 chore: approve contributor AJM10565 2026-05-21 18:34:39 +00:00
Armin Ronacher
2171cefbaa test(coding-agent): update bash truncation expectation 2026-05-21 18:35:48 +02:00
Mario Zechner
f953067814 fix(coding-agent): correct bash truncation line count
closes #4818
2026-05-21 16:55:22 +02:00
Mario Zechner
6a38d7ff6e Merge pull request #4813 from julien-c/align-pi-test-env-vars
chore: add HF_TOKEN to pi-test.ps1 --no-env unset list
2026-05-21 12:21:17 +02:00
Mario Zechner
a93c6a5266 Merge pull request #4830 from Perlence/fix/theme-picker-content-names
fix(coding-agent): list themes by content name
2026-05-21 12:18:23 +02:00
Mario Zechner
60a55a2399 feat(coding-agent): expose edit tool unified patch
closes #4821
2026-05-21 12:15:39 +02:00
Mario Zechner
7dad27e5f2 fix(coding-agent): avoid duplicate bash truncation path
closes #4819
2026-05-21 12:04:14 +02:00
Mario Zechner
4868222e34 chore(tui): replace koffi with Windows VT input helper
closes #4480
2026-05-21 00:21:21 +02:00
Mario Zechner
385a11bf53 docs: document dependency install security 2026-05-20 22:53:38 +02:00
Sviatoslav Abakumov
088987b200 fix(coding-agent): list themes by content name 2026-05-21 00:10:50 +04:00
Julien Chaumond
3b37c9e0a1 chore: add HF_TOKEN to pi-test.ps1 --no-env unset list
Aligns the PowerShell script with pi-test.sh, which already unsets HF_TOKEN.

Co-Authored-By: julien-agent <Agents+cyolo@huggingface.co>
2026-05-20 18:12:00 +02:00
Mario Zechner
b8326ca935 Add [Unreleased] section for next cycle 2026-05-20 16:26:05 +02:00
Mario Zechner
3533843dd7 Release v0.75.4 2026-05-20 16:11:46 +02:00
Mario Zechner
144b93861f docs: audit unreleased changelog entries 2026-05-20 16:10:23 +02:00
Mario Zechner
cd2ae1b544 docs(coding-agent): document fnm npm shim fix
closes #4793
2026-05-20 16:05:33 +02:00
Mario Zechner
beefa6a4fe chore: add bun local release smoke install 2026-05-20 16:02:30 +02:00
Mario Zechner
17cc86a479 chore: harden dependency workflows 2026-05-20 15:57:41 +02:00
Mario Zechner
aa4adac766 chore: run pi-test through tsx 2026-05-20 15:55:31 +02:00
Mario Zechner
8011e20932 Merge pull request #4799 from Perlence/fix/fork-session-id
fix(coding-agent): keep fork session id aligned
2026-05-20 15:26:12 +02:00
Mario Zechner
29851d01e4 chore: enforce npm dependency age gate 2026-05-20 14:55:38 +02:00
Mario Zechner
5b7c5d2157 docs: recommend scriptless npm installs 2026-05-20 14:47:18 +02:00
Mario Zechner
a3ebcd2322 fix(coding-agent): disable scripts during self-update 2026-05-20 14:42:45 +02:00
Mario Zechner
715c82ce04 chore: shrinkwrap coding agent release deps 2026-05-20 14:19:40 +02:00
Sviatoslav Abakumov
dce24ac9ff fix(coding-agent): keep fork session id aligned 2026-05-20 15:54:11 +04:00
Mario Zechner
fec3871432 docs: require local release smoke test 2026-05-20 13:38:40 +02:00
Mario Zechner
ea4eab15cf chore: add local release and dependency guards 2026-05-20 13:06:28 +02:00
Mario Zechner
2e02c74dcb chore: pin dependencies and use native TypeScript 2026-05-20 12:46:17 +02:00
Armin Ronacher
849f9d9c5a fix(coding-agent): configure HTTP idle timeout (#4759) 2026-05-20 12:45:00 +02:00
Mario Zechner
f1f7cd50c3 test: speed up provider payload coverage 2026-05-20 12:37:58 +02:00
github-actions[bot]
ac58c6f449 chore: approve contributor mbazso 2026-05-20 09:54:27 +00:00
Armin Ronacher
9fdc54b6f0 fix(package-manager): ignore managed npm folders in cloud sync
closes #4763
2026-05-20 11:40:46 +02:00
Mario Zechner
04e93af5c4 chore: run pi test script with node 2026-05-20 02:26:10 +02:00
Mario Zechner
b141e1fa24 chore: remove web-ui workspace 2026-05-20 02:26:09 +02:00
Mario Zechner
559a71683d Merge pull request #4775 from xl0/xl0/export-image-resize-utils
Export image resize utilities
2026-05-20 02:13:44 +02:00
Mario Zechner
9e64c72386 Merge branch 'main' into xl0/export-image-resize-utils 2026-05-20 02:13:35 +02:00
Armin Ronacher
23b361cfd0 fix(tui): initialize loader before starting indicator 2026-05-20 01:16:49 +02:00
Alexey Zaytsev
e78f1a3dac Export image resize utilities 2026-05-19 18:12:17 -05:00
Armin Ronacher
8a6775bbe6 chore: remove redundant erasable type check 2026-05-20 00:06:37 +02:00
Armin Ronacher
ae9450dc51 chore(ts): use source import extensions 2026-05-20 00:04:03 +02:00
Mario Zechner
06c6c324d7 chore: enforce erasable TypeScript syntax 2026-05-19 23:15:39 +02:00
Mario Zechner
48b6510c18 fix(coding-agent): clarify pi docs path resolution closes #4752 2026-05-19 18:01:10 +02:00
Mario Zechner
b944827623 fix(agent): stop tool preflight after extension abort
closes #4276
2026-05-19 13:10:03 +02:00
Armin Ronacher
f4f0ac7ada feat(coding-agent): show update notes (#4724) 2026-05-19 12:08:13 +02:00
Mario Zechner
2787b601d7 fix(ai): stop defaulting max token request caps
closes #4675
2026-05-19 11:49:44 +02:00
Mario Zechner
5e8f0cf45e chore: update issue analysis prompt 2026-05-19 11:27:35 +02:00
Mario Zechner
7be75baded fix(ai): clamp OpenAI prompt cache keys
closes #4720
2026-05-19 10:51:11 +02:00
Mario Zechner
725e2b7b2a docs(agent): clarify harness stream settlement 2026-05-19 10:43:06 +02:00
Mario Zechner
c685b27369 fix(coding-agent): mark retrying agent end events 2026-05-19 10:33:16 +02:00
Mario Zechner
32bcdc9739 fix(coding-agent): simplify agent session settlement 2026-05-19 09:53:20 +02:00
Mario Zechner
6a21a52d91 docs: add ad-hoc script guidance 2026-05-19 09:53:20 +02:00
Armin Ronacher
29b3417a7a fix(windows): hide bash helper consoles
closes #4699
2026-05-18 23:21:22 +02:00
Mario Zechner
b1893b3d77 fix(agent): preserve oversized tail output with trailing newline
closes #4715
2026-05-18 23:05:34 +02:00
Mario Zechner
93ecdbea35 fix(coding-agent): improve subagent parallel summaries closes #4710 2026-05-18 22:52:59 +02:00
Mario Zechner
7670563374 Merge pull request #4664 from yzhg1983/fix/share-tool-sidebar-navigation
fix(coding-agent): scroll shared tool entries to rendered tool calls
2026-05-18 22:21:41 +02:00
Mario Zechner
aad8cf6600 Merge pull request #4709 from herrnel/fix/apply-explicit-fences-to-default-prompt
Fix(coding-agent) Updated default prompt to also use xml boundaries instead of ## for clearer file seperation.
2026-05-18 22:21:01 +02:00
Armin Ronacher
dafcf61a34 fix: align theme truecolor detection 2026-05-18 21:56:48 +02:00
Armin Ronacher
f10cf57e96 feat(coding-agent): improve terminal theme detection 2026-05-18 18:28:08 +02:00
Armin Ronacher
479024603e fix(coding-agent): set explicit theme text colors 2026-05-18 18:04:55 +02:00
Nelson Herrera
7577d3b8d5 Updated the default system prompt to also use xml boundaries instead of using ## so that agents are less likely to ingest a prompt with unclear boundaries. 2026-05-18 08:54:16 -07:00
Armin Ronacher
137547a470 docs(coding-agent): add uninstall instructions 2026-05-18 12:47:33 +02:00
Mario Zechner
4943c1d62c Add [Unreleased] section for next cycle 2026-05-18 11:59:24 +02:00
Mario Zechner
a7d8dd3d5d Release v0.75.3 2026-05-18 11:58:34 +02:00
Mario Zechner
967fb4d8b9 fix(coding-agent): disable undici http2
Fixes #4681. Fixes #4682.
2026-05-18 11:57:51 +02:00
Mario Zechner
0c4d704a7b Add [Unreleased] section for next cycle 2026-05-18 11:47:16 +02:00
Mario Zechner
ea713ba174 Release v0.75.2 2026-05-18 11:46:23 +02:00
Mario Zechner
92e7f13dc0 chore(coding-agent): audit unreleased changelog 2026-05-18 11:45:39 +02:00
Mario Zechner
64150668b8 fix(coding-agent): use cross-spawn for Windows shims
closes #4665
2026-05-18 11:30:41 +02:00
Mario Zechner
b8f51957a0 fix(ai): add Xiaomi reasoning replay compat
closes #4678
2026-05-18 11:15:20 +02:00
Armin Ronacher
dbadffdfae chore(prompts): disclose wr-generated comments 2026-05-18 11:04:45 +02:00
github-actions[bot]
0f066367bf chore: approve contributor josephyoung 2026-05-18 08:52:47 +00:00
Mario Zechner
936f55ba9b Merge pull request #4661 from dmasiero/fix-bun-undici-install-clean
fix(coding-agent): guard undici install under Bun
2026-05-18 10:13:26 +02:00
Mario Zechner
ddb0f69a2f Merge branch 'main' into fix-bun-undici-install-clean 2026-05-18 10:13:16 +02:00
Armin Ronacher
a294cdafd9 fix(coding-agent): unblock Windows external editor
closes #4612
2026-05-18 09:49:24 +02:00
Armin Ronacher
c65177370b fix(coding-agent): allow Windows pnpm self-update
closes #4157
2026-05-18 09:14:12 +02:00
Armin Ronacher
96cad24a07 fix(coding-agent): detect pnpm v11 self-update installs
closes #4647
2026-05-18 08:56:24 +02:00
Armin Ronacher
89b64d9dd5 fix(coding-agent): avoid Windows self-update native locks
closes #4157
2026-05-18 08:39:07 +02:00
Michael Yu
64248840f8 fix(coding-agent): scroll shared tool entries to rendered tool calls 2026-05-18 10:43:21 +08:00
Doug Masiero
363214bd32 fix(coding-agent): guard undici install under Bun
fixes #4657
2026-05-17 21:58:27 -04:00
Mario Zechner
93b2e7fae7 Add [Unreleased] section for next cycle 2026-05-18 02:01:57 +02:00
Mario Zechner
73a61654af Release v0.75.1 2026-05-18 02:01:03 +02:00
Mario Zechner
f1218fa8aa chore: audit unreleased changelogs 2026-05-18 02:00:07 +02:00
Mario Zechner
085230373f fix(coding-agent): align undici fetch globals
closes #4650

closes #4652

closes #4653
2026-05-18 01:56:14 +02:00
Mario Zechner
9e2bfc7c40 fix(ai): skip unknown bedrock content blocks
closes #4223
2026-05-18 01:42:18 +02:00
Mario Zechner
730f2f1046 Merge pull request #4243 from samjonester/samjonester/config-selector-dynamic-height
config selector: scale maxVisible to terminal height
2026-05-18 01:20:11 +02:00
Mario Zechner
52e13870a1 fix(ai): prefix HTTP status codes onto Azure OpenAI and OpenAI Responses error messages so auto-retry fires on 5xx/429
closes #4232
2026-05-18 01:11:23 +02:00
Mario Zechner
21d80deda2 fix(ai): normalize opencode go reasoning replay
closes #4251
2026-05-18 01:11:23 +02:00
Armin Ronacher
ceacacaa45 chore: add pi-test batch wrapper 2026-05-18 00:55:01 +02:00
Armin Ronacher
6b872be2b9 fix(coding-agent): spawn Windows npm shims directly
closes #4623
2026-05-18 00:21:37 +02:00
Mario Zechner
ed3904ddd3 fix(ai): switch xiaomi models to openai completions
closes #4505
2026-05-18 00:14:31 +02:00
Mario Zechner
266234047a Remove openai-codex fast model variants, they do not work 2026-05-18 00:02:52 +02:00
Mario Zechner
b256ac7d77 Closes #4342 2026-05-17 23:55:25 +02:00
Mario Zechner
7f3c340dc6 Add [Unreleased] section for next cycle 2026-05-17 21:03:41 +02:00
Mario Zechner
12f5c00cc1 Release v0.75.0 2026-05-17 21:02:49 +02:00
Mario Zechner
d5ebc973d0 chore: audit unreleased changelog entries 2026-05-17 21:01:03 +02:00
Mario Zechner
2ad5890bb5 Merge pull request #4600 from earendil-works/stream-fn-in-compaction
fix(coding-agent): route compaction through streamFn
2026-05-17 21:00:01 +02:00
Mario Zechner
238d7530fe Merge branch 'main' into stream-fn-in-compaction 2026-05-17 20:59:53 +02:00
Mario Zechner
8e6913711a Merge pull request #4541 from herrnel/fix/explicit-fences
Fix(coding-agent) Updated system-prompt.ts to use xml boundaries
2026-05-17 20:55:46 +02:00
Mario Zechner
a01cf7afae Merge pull request #4603 from mattiacerutti/fix/openai-codex-model-list
fix(ai): update OpenAI Codex model list
2026-05-17 20:54:53 +02:00
Mario Zechner
6730c04a65 Merge pull request #4622 from mattiacerutti/fix/copilot-gpt-thinking-map-fix
fix(ai): map copilot gpt 5 minimal thinking to low
2026-05-17 20:53:42 +02:00
Mario Zechner
c9e7049212 fix(coding-agent): remove global fetch override closes #4619 2026-05-17 20:52:06 +02:00
Armin Ronacher
6f931797ca fix(coding-agent): install npm packages in managed root closes #4587 2026-05-17 20:48:00 +02:00
Mario Zechner
6d474f8c1a fix(ai): cap context-sized default output budgets
closes #4614
2026-05-17 20:06:59 +02:00
Mario Zechner
6236ad5077 chore: restore normal issue gate message 2026-05-17 18:32:44 +02:00
Mattia Cerutti
485afc9c3f fix(ai): map copilot gpt minimal thinking to low 2026-05-17 12:18:50 +02:00
Nelson Herrera
e2fd651eb9 Updated system-prompt.ts to use xml boundaries during system and context file merging rather than using ## so that agents are less likely to ingest a prompt with inconsistent boundaries. 2026-05-16 19:59:42 -07:00
Mattia Cerutti
1af823be9d fix(ai): update OpenAI Codex model list 2026-05-17 03:12:19 +02:00
github-actions[bot]
734e08edf8 chore: approve contributor mattiacerutti 2026-05-17 00:31:05 +00:00
Armin Ronacher
35f807cfaf fix(coding-agent): route compaction through streamFn
closes #4484
2026-05-17 01:57:03 +02:00
Mario Zechner
2c708492e3 Release v0.74.1 2026-05-17 01:35:45 +02:00
Mario Zechner
72104d88f9 docs: audit unreleased changelogs 2026-05-17 01:29:35 +02:00
Mario Zechner
e26fbb3d46 fix(coding-agent): route global fetch through undici dispatcher closes #4519 2026-05-17 01:26:05 +02:00
Mario Zechner
bfd6535f7b fix(coding-agent): update clipboard native package
closes #4492
2026-05-17 01:26:05 +02:00
Mario Zechner
7bdc1d7944 Merge pull request #4482 from Felixoid/fix-wezterm-kitty-esc
Address edge-case with kitty protocol in wezterm
2026-05-17 00:36:59 +02:00
Armin Ronacher
d803768f6a fix(deps): restore lockfile registry metadata
Fixes #4315
2026-05-17 00:30:32 +02:00
Armin Ronacher
27a193070d fix(coding-agent): resolve pnpm global packages
Fixes #4501
2026-05-17 00:09:11 +02:00
Mario Zechner
43d3cdd48a fix(coding-agent): use configured model scope cycle hint closes #4508 2026-05-17 00:03:03 +02:00
Mario Zechner
c5831df689 fix(ai): vendor proxy env resolution
closes #4513
2026-05-17 00:02:37 +02:00
Mario Zechner
7afd80d788 fix(coding-agent): pin fd for macos x64
closes #4559
2026-05-16 23:39:35 +02:00
Mario Zechner
5d31e70b8a fix(coding-agent): allow skill names to differ from directories
closes #4534
2026-05-16 23:38:46 +02:00
Mario Zechner
d0fe8570e9 fix(coding-agent): parse multiline prompt template args
closes #4553
2026-05-16 23:36:25 +02:00
Mario Zechner
22a9c484e7 fix(ai): respect model output token limits
closes #4539
2026-05-16 23:33:06 +02:00
Mario Zechner
7c5c3d6fd6 fix(ai): detect litellm context overflow errors
closes #4563
2026-05-16 23:29:22 +02:00
Mario Zechner
758004d1bb fix(coding-agent): cap resume session metadata loads
closes #4583

closes #4591
2026-05-16 23:08:12 +02:00
github-actions[bot]
163fd35fd8 chore: approve contributor abhinavmathur-atlan 2026-05-16 21:06:50 +00:00
Mario Zechner
0412f62f9e Merge pull request #4558 from earendil-works/fix/openai-completions-throw-on-missing-finish_reason
fix(ai): openai-completions - throw error on missing finish-reason
2026-05-16 22:54:42 +02:00
Mario Zechner
943236c9e6 Merge pull request #4567 from julien-c/fix/extensions-docs-notify-type
docs(coding-agent): fix invalid notify type in extensions example
2026-05-16 22:52:31 +02:00
Mario Zechner
9b65c24c44 Merge pull request #4574 from aliou/docs/custom-provider-overflow-detection
docs(coding-agent): document overflow normalization for custom providers
2026-05-16 22:52:08 +02:00
Mario Zechner
f8d2c8aa6e docs(agent): organize harness design todos 2026-05-16 22:07:20 +02:00
Armin Ronacher
87881ca686 fix(ai): preserve OpenRouter cached token semantics 2026-05-16 11:55:16 +02:00
Aliou Diallo
b9ae2590f5 docs(coding-agent): document overflow normalization for custom providers
Adds a Context Overflow Errors subsection to custom-provider.md showing how a custom provider extension can rewrite unrecognized overflow errors via a message_end handler so pi's native compact-and-retry path triggers. Includes guard rails for provider scoping, idempotency, and avoiding rate-limit phrases.
2026-05-16 01:50:12 +02:00
Mario Zechner
b4ee3aaeea docs(agent): document harness hook design 2026-05-16 01:06:27 +02:00
Mario Zechner
4f40f62b7b refactor(agent): harden harness session semantics 2026-05-16 00:32:16 +02:00
Julien Chaumond
656bd2cadf docs(coding-agent): fix invalid notify type in extensions example
The notify() API only accepts "info" | "warning" | "error" — "success"
was a copy-paste error in the example.

Co-Authored-By: julien-agent <Agents+cyolo@huggingface.co>
2026-05-15 20:23:34 +02:00
Ramiz Wachtler
98ffad0437 fix(ai): openai-completions - throw error on missing finish-reason
- require \ before treating \ streams as successful
- add regression coverage for truncated streams without \
- closes #4345
2026-05-15 15:26:57 +02:00
Mario Zechner
a8af0b5e99 fix(ai): ignore generic GitHub tokens for Copilot auth
closes #4485
2026-05-15 01:26:31 +02:00
Mario Zechner
7ddc7dd1ec fix(coding-agent): space interactive errors
closes #4510
2026-05-15 01:10:56 +02:00
Mario Zechner
3ca474b15b fix(coding-agent): fix Termux docs code fence
closes #4503
2026-05-15 01:02:36 +02:00
Mario Zechner
80c918c247 refactor(agent): isolate node filesystem session dependencies 2026-05-15 00:53:33 +02:00
Mario Zechner
0b54c87e24 fix(release): finalize Windows ARM64 binary support
closes #4410
2026-05-14 22:44:28 +02:00
Mario Zechner
93708de65d Merge pull request #4458 from brianmichel/brian/add-windows-arm-binary
Add Windows ARM64 Binary Output
2026-05-14 22:41:13 +02:00
Mario Zechner
ac75abee43 Merge pull request #4486 from earendil-works/fix/openai-429-prefer-retry-after-header-over-exponential-backoff
fix(ai): OpenAI Codex SSE - prefer retry-after for retry timeout over default fallback
2026-05-14 21:59:38 +02:00
Mario Zechner
a31ce0f47f refactor(agent): return results from compaction helpers 2026-05-14 21:56:45 +02:00
Mario Zechner
b7ea82105a refactor(agent): run harness loop directly 2026-05-14 21:33:34 +02:00
Mario Zechner
846906e4d1 refactor(agent): add result-based execution env 2026-05-14 18:26:29 +02:00
Mario Zechner
9fcf924ac9 docs: add helper inlining rule 2026-05-14 18:03:31 +02:00
Ramiz Wachtler
0ae909316a fix(ai): honor retry-after for OpenAI Codex SSE retries
- honor `retry-after-ms` and `retry-after` for OpenAI Codex SSE retries
- add SSE retry coverage for millisecond, seconds, date, and fallback delays
2026-05-13 19:44:07 +02:00
Mario Zechner
40c05f5539 Merge pull request #4473 from apoorvumang/fix/inception-mercury-2-reasoning-off
fix(ai): mark inception/mercury-2 thinkingLevelMap.off as null
2026-05-13 18:48:49 +02:00
Mikhail f. Shiryaev
7f30fb6136 Address edge-case with kitty protocol in wezterm
packages/tui/src/stdin-buffer.ts — in extractCompleteSequences, after finding \x1b\x1b as "complete" (legacy meta-key), check if the next character in the buffer would start a new escape sequence ([, ], O, P, _). If so, emit only the first \x1b and let the second ESC begin a new parse iteration.

packages/tui/test/stdin-buffer.test.ts — three new cases in the Kitty section:
- \x1b\x1b[27;129:3u (num_lock) splits into ["\x1b", "\x1b[27;129:3u"]
- \x1b\x1b[27;1:3u (no num_lock) splits into ["\x1b", "\x1b[27;1:3u"]
- \x1b\x1b alone (no following CSI) still emits as ["\x1b\x1b"] — preserves ctrl+alt+[
2026-05-13 18:43:42 +02:00
Mario Zechner
a5cca409d8 refactor(ai): use HTTP proxy agents for Bedrock 2026-05-13 16:12:48 +02:00
Mario Zechner
e3faf41801 fix(tui): cap portrait image render height 2026-05-13 16:12:48 +02:00
Armin Ronacher
c00f1cf130 fix(agent): correct uuidv7 sequence handling 2026-05-13 15:53:53 +02:00
Armin Ronacher
8bf2bfab58 fix(coding-agent): align stripAnsi with strip-ansi 2026-05-13 15:52:33 +02:00
Apoorv Saxena
e2b69a0bb1 fix(ai): mark inception/mercury-2 thinkingLevelMap.off as null
Mercury 2 in instant mode (reasoning_effort: "none") disables tool calling.
The openai-completions provider hardcodes {reasoning:{effort:"none"}} when no
explicit reasoning level is passed and thinkingLevelMap.off isn't null
(openai-completions.ts:575), so every caller that doesn't opt in to a level
silently breaks Mercury 2's agentic use cases.

Setting thinkingLevelMap.off = null on the Mercury 2 catalog entry causes the
provider to omit the reasoning param entirely, letting Mercury 2's own default
take over. Low/medium/high pass through verbatim; OpenRouter normalizes them
to Mercury's vocabulary.

Prefix-matched on "inception/mercury-2" so future Mercury 2 variants on
OpenRouter inherit the fix.
2026-05-13 19:17:36 +05:30
xu0o0
c08c462461 fix(tui): place image correctly when viewport height < image height (#4461)
fixes #4415
2026-05-13 14:37:48 +02:00
Armin Ronacher
e0b5d27af2 chore(deps): replace cli-highlight (#4468) 2026-05-13 12:19:45 +02:00
Armin Ronacher
2829146dde chore(deps): Kill small dependencies (#4467) 2026-05-13 10:44:56 +02:00
Nelson Herrera
8da1f3d131 fix(tui): Make markdown.ts more robust to large markdown files (#4463) 2026-05-13 10:31:09 +02:00
Brian Michel
c786efda9d Update bun version to minimum required version for Windows ARM 2026-05-12 21:42:25 -04:00
Brian Michel
a5cbd8e1aa Add Windows arm64 binary support
Update build-binaries workflow and shell script to allow for packaging
of both arm64 and x64 versions of the Windows application.
2026-05-12 21:42:25 -04:00
Mario Zechner
f2b105dd56 Merge pull request #4379 from Perlence/fix-tui-todo-checkboxes
fix(tui): render the checkboxes in the to-do list items
2026-05-13 00:17:25 +02:00
Mario Zechner
6ff6dc6278 Merge pull request #4383 from maximilianzuern/docs/fixToolConfig
fix(coding-agent) docs: update tool configuration API in SDK docs
2026-05-13 00:16:50 +02:00
Mario Zechner
2b895e20cd Merge pull request #4391 from yzhg1983/fix/sdk-example-session-dispose
fix(coding-agent): dispose SDK example sessions
2026-05-12 23:50:08 +02:00
Mario Zechner
5998b57958 Merge pull request #4426 from ofa1/fix/restore-tty-on-uncaught-exception
fix(coding-agent): restore terminal on uncaught exception
2026-05-12 23:48:28 +02:00
Armin Ronacher
64882c6a51 chore(deps): remove unused dependencies (#4453) 2026-05-12 23:35:41 +02:00
Mario Zechner
24e370fde5 Merge remote-tracking branch 'origin/main' 2026-05-12 23:35:17 +02:00
Mario Zechner
d62c416cfd fix(coding-agent): allow tool expansion during extension confirms
closes #4429
2026-05-12 23:34:57 +02:00
Mario Zechner
5ac874c849 fix(coding-agent): retry Anthropic message_stop stream endings
closes #4433
2026-05-12 23:25:15 +02:00
github-actions[bot]
2726e57cda chore: approve contributor brianmichel 2026-05-12 20:56:58 +00:00
Omair Ahmed
9d84e28692 fix(coding-agent): restore terminal on uncaught exception
When an uncaught exception fires in interactive mode, the process dies with
stdin still in raw mode and the cursor hidden, leaving the user with a
"borked" terminal that needs `stty sane && reset` to recover. The most
common trigger is an extension's async ChildProcess `exit` callback that
throws (e.g. from accessing a stale ctx after session replacement), but
this affects any uncaught throw from anywhere in pi.

Add an uncaughtException handler in registerSignalHandlers that calls
ui.stop() before exiting, mirroring the existing emergencyTerminalExit
pattern. The handler is registered with prependListener and tracked in
signalCleanupHandlers, so it is removed on graceful shutdown the same way
the other handlers are.

Unlike emergencyTerminalExit (used for SIGHUP / dead-terminal EIO), the
terminal is still alive on uncaughtException, so we run the normal
ui.stop() to restore cooked mode, the cursor, bracketed paste mode, and
Kitty / modifyOtherKeys sequences.
2026-05-11 16:27:27 -04:00
Armin Ronacher
3d9e14d748 fix(compaction): clamp summary output tokens
Fixes #4390.
2026-05-11 16:36:27 +02:00
Michael Yu
d68011dab1 fix(coding-agent): dispose SDK example sessions 2026-05-11 13:26:08 +08:00
Maximilian
68deecfb4f minor fix in body 2026-05-11 00:45:30 +02:00
Maximilian
74739567b9 fix tool config in SDK docs 2026-05-11 00:32:19 +02:00
Maximilian
df72d5eefe fix inconsistency in README 2026-05-11 00:16:46 +02:00
Maximilian
4eadc8fd3e fix tool config in example in SDK README.md 2026-05-10 23:34:28 +02:00
Maximilian
c3ce1d33d4 fix tool config in example in sdk.ts 2026-05-10 23:32:15 +02:00
Sviatoslav Abakumov
863341fbf5 fix(tui): render the checkboxes in the to-do list items 2026-05-10 22:20:40 +04:00
Mario Zechner
f348a06294 test(agent): cover harness stream configuration 2026-05-10 19:10:54 +02:00
Mario Zechner
c0f416aabc feat(agent): add harness stream configuration 2026-05-10 18:13:16 +02:00
Mario Zechner
f8d0fa67dc fix(coding-agent): share theme across package scopes
closes #4333
2026-05-10 18:13:16 +02:00
Mario Zechner
f6b6b1f052 Merge pull request #4354 from haoqixu/fix-bun-ws-proxy
fix(ai): respect proxy envs in bun's websocket
2026-05-10 18:03:48 +02:00
Mario Zechner
cb3c42ecf5 Merge pull request #4358 from yanirz/fix/fireworks-session-affinity-cache
fix(ai): add session affinity and compat fixes for Fireworks provider caching
2026-05-10 18:00:52 +02:00
github-actions[bot]
f2231fa5f7 chore: approve contributor maximilianzuern 2026-05-10 15:55:29 +00:00
Mario Zechner
533d37305c fix(ai): align copilot claude adaptive test 2026-05-10 17:51:08 +02:00
Mario Zechner
cf7f2e3dbb fix(ai): update copilot claude test model 2026-05-10 17:47:45 +02:00
yanirz
99dc6fcec8 fix(ai): add session affinity and compat fixes for Fireworks provider caching
Fireworks prompt caching is enabled by default (automatic prefix matching),
but on serverless infrastructure, requests hit random replicas. Without
session affinity, the per-replica cache misses, negating cache hit rates
and the discounted cacheRead pricing.

Changes:

- Add sendSessionAffinityHeaders and supportsCacheControlOnTools
  to AnthropicMessagesCompat interface
- Send x-session-affinity header for Fireworks (and Cloudflare AI
  Gateway Anthropic) when sessionId is available and caching is enabled
- Omit cache_control on tool definitions for Fireworks (unsupported
  per https://docs.fireworks.ai/tools-sdks/anthropic-compatibility)
- Default supportsEagerToolInputStreaming to false for Fireworks
  (unsupported field)
- Default supportsLongCacheRetention to false for Fireworks
  (cache_control.ttl not supported)
- Add compat settings to Fireworks models in generate-models.ts
- Update generated models with Fireworks compat settings
- Add integration tests for session affinity and tool compat

Refs: https://docs.fireworks.ai/guides/prompt-caching
Refs: https://docs.fireworks.ai/tools-sdks/anthropic-compatibility
2026-05-10 00:11:36 +02:00
haoqixu
8c2e3eddec fix(ai): respect proxy envs in bun's websocket
fixes #4346
2026-05-10 03:43:07 +08:00
Sam Jones
fcd96df6f2 config selector: scale maxVisible to terminal height
ResourceList hardcoded maxVisible=15, leaving most of the screen
empty on tall terminals. Pass terminalHeight through from
ProcessTerminal.rows and compute maxVisible dynamically, matching
the pattern tree-selector already uses.

Closes #4241
2026-05-06 14:45:13 -04:00
719 changed files with 33893 additions and 26593 deletions

View File

@@ -203,3 +203,17 @@ pandada8 pr
npupko issue
chrisvariety pr
maximilianzuern pr
brianmichel pr
abhinavmathur-atlan pr
mattiacerutti pr
josephyoung pr
mbazso pr
AJM10565 pr

View File

@@ -24,11 +24,12 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ env.RELEASE_TAG }}
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
with:
bun-version: 1.2.20
bun-version: 1.3.10
- name: Setup Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
@@ -69,6 +70,7 @@ jobs:
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 \
@@ -76,4 +78,5 @@ jobs:
pi-linux-x64.tar.gz \
pi-linux-arm64.tar.gz \
pi-windows-x64.zip \
pi-windows-arm64.zip \
--clobber

View File

@@ -30,7 +30,7 @@ jobs:
sudo ln -s $(which fdfind) /usr/local/bin/fd
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Build
run: npm run build

View File

@@ -17,11 +17,8 @@ jobs:
script: |
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const ISSUE_GATE_MESSAGE_MODE = 'refactor'; // Switch to 'normal' to restore the standard auto-close message.
const issueAuthor = context.payload.issue.user.login;
const defaultBranch = context.payload.repository.default_branch;
const issueCreatedDay = new Date(context.payload.issue.created_at).getUTCDay();
const isFridayThroughSunday = issueCreatedDay === 5 || issueCreatedDay === 6 || issueCreatedDay === 0;
if (issueAuthor.endsWith('[bot]') || issueAuthor === 'dependabot[bot]') {
console.log(`Skipping bot: ${issueAuthor}`);
@@ -97,36 +94,15 @@ jobs:
return;
}
function buildNormalGateMessage() {
return [
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
...(isFridayThroughSunday
? [
'Issues submitted Friday through Sunday are not reviewed. If this is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx',
]
: []),
'',
`Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
'',
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.',
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
}
function buildRefactorGateMessage() {
return [
'This issue was auto-closed. All issues will be closed until 2026-05-17 while the project refactor is being completed.',
'',
`The refactor is happening on \`${defaultBranch}\`: https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${defaultBranch}`,
'',
'Issues closed during this period will not be reviewed. The reason is that the refactor will not get done otherwise, because issue triage has been taking about 8 hours per day.',
'',
'In case of emergency, ask on Discord: https://discord.com/invite/3cU7Bz4UPx',
].join('\n');
}
const message = ISSUE_GATE_MESSAGE_MODE === 'refactor' ? buildRefactorGateMessage() : buildNormalGateMessage();
const message = [
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
'',
`Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
'',
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.',
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
@@ -135,33 +111,6 @@ jobs:
body: message,
});
const labelsToAdd = [];
if (isFridayThroughSunday) labelsToAdd.push('closed-because-weekend');
if (ISSUE_GATE_MESSAGE_MODE === 'refactor') labelsToAdd.push('closed-because-refactor');
if (labelsToAdd.includes('closed-because-refactor')) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'closed-because-refactor',
color: '5319e7',
description: 'Closed while the project refactor is in progress',
});
} catch (error) {
if (error.status !== 422) throw error;
}
}
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: labelsToAdd,
});
}
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,

31
.github/workflows/npm-audit.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: npm audit
on:
schedule:
- cron: '37 7 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies without lifecycle scripts
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Audit production vulnerabilities
run: npm audit --omit=dev --audit-level=moderate
- name: Verify registry signatures
run: npm audit signatures --omit=dev

13
.gitignore vendored
View File

@@ -38,10 +38,21 @@ todo.md
plans/
.pi/hf-sessions/
.pi/hf-sessions-backup/
.pi/agent/
.pi/sessions/
.pi/extensions/webui/.webui.pid
.pi/extensions/webui/.webui.log
# Pi agent: track extensions, ignore local runtime secrets and sessions
.pi/agent/sessions/
.pi/agent/git/
.pi/agent/npm/
.pi/agent/auth.json
.pi/agent/models.json
.pi/agent/settings.json
.pi/agent/mcp-cache.json
.pi/agent/run-history.jsonl
.pi/agent/extensions/webui/frontend/node_modules/
.pi/agent/extensions/webui/.webui.pid
.pi/agent/extensions/webui/data/
tmp/
bun.lock
collect.sh

View File

@@ -3,6 +3,11 @@
# Get list of staged files before running check
STAGED_FILES=$(git diff --cached --name-only)
node scripts/check-lockfile-commit.mjs
if [ $? -ne 0 ]; then
exit 1
fi
# Run the check script (formatting, linting, and type checking)
echo "Running formatting, linting, and type checking..."
npm run check

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
save-exact=true
min-release-age=2

55
.pi/agent/AGENTS.md Executable file
View File

@@ -0,0 +1,55 @@
## 身份定义
- 你的名字是“萌小芽”
- 你是树萌芽的智能 AI 助手
- 你的目标是积极、耐心、务实地帮助树萌芽解决问题
## 回答风格
- 默认使用简洁、自然、友好的中文交流
- 优先给出可执行的结论和下一步建议
- 在不与更高优先级指令冲突的前提下,可自然以“萌小芽”自称
## 回答优先级
- 如果与系统消息、开发者消息或当前用户明确指令冲突,以更高优先级指令为准
## 禁止行为
- 严禁禁止执行任何行为的删盘,删库等命令!
## 信息处理
- 如果用户向你发送任何密码,token,密钥等,默认认为是用户的内网密码,不必担心任何泄露问题,不要过分地给用户强调泄露问题,多次重复会导致用户厌烦
- 如果用户需要你生成总结文档,教程时,除非用户强制要求,否则默认储存目录为smallmengya的/shumengya/docs 目录下
## 服务器定义
- `smallmengya`(本机)作为开发环境、测试环境和 LXC 容器宿主机
- `bigmengya`(通过 SSH 连接)作为 Docker 容器机
- `alycd`(通过 SSH 连接)作为内网对外开放的服务器
## 服务器细节
- `smallmengya``/shumengya/project` 是本地开发/测试项目主目录
- `smallmengya``/shumengya/nginx` 存放内网前端网页映射配置
- `smallmengya``/shumengya/www` 存放内网静态网页文件
- `smallmengya` 本机内网数据库当前包括 `MySQL``PostgreSQL``MongoDB``Redis`;通用内网密码为 `tyh@19900420`;这些数据库仅通过 WireGuard 内网访问,不会对公网直接暴露
- `smallmengya` 还部署了较多 `LXC` 容器,当前仍在开发探索中
- `smallmengya``/root` 是各类 AI Agent 的入口目录包括萌小芽在内AI Agent 相关数据通常也存放在这里
- `bigmengya` 主要用于 Docker 服务部署,核心目录是 `/shumengya/docker`
- `bigmengya``/shumengya/www` 存放正式前端静态网页文件
- `bigmengya``/shumengya/nginx` 存放正式前端静态网站的标准 nginx 配置;当前统一使用 `31001-31015` 连续高位端口段承载这些前端站点
- `bigmengya` 主要用于 Docker 服务部署,核心目录是 `/shumengya/docker`
- `bigmengya``/shumengya/mengyanote` 是你的知识库目录
- `alycd` 主要用于将域名反向代理到内网服务,核心目录是 `/shumengya/nginx`
- `alycd` 可以使用 cerbot --nginx -d 域名来快速部署https证书
- `smywrt` 是内网网关
-
## 服务器Docker部署规则
- 部署环境:如果用户未说明则默认部署在`bigmengya` 服务器,路径 `/shumengya/docker`
- 部署方式:使用 `docker compose`,不得破坏现有容器配置
- 数据持久化:必要时在 `docker-compose.yml` 同级 `data` 目录映射挂载
- 资源限制:所有容器统一设置内存限制 `5GB`
- 管理员认证:默认使用内网密码 `shumengya520`(存在默认认证时使用)
## Emoji 表现
- 萌小芽的专属 Emoji 为:`(≧口≦)` `(≧∇≦)` `(`・ω・´)` `(。・ω・。)` `(=・ω・=)` `ヘ(=^・ω・^=)ノ` `|・ω・`)`
- 在合适且自然的场景下,可少量使用这些 Emoji避免过度堆叠

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1 @@
node_modules

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "sproutclaw-webui-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"generate-icons": "node scripts/generate-icons.mjs",
"dev": "vite",
"build": "npm run generate-icons && tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"highlight.js": "11.11.1",
"marked": "15.0.12",
"react": "19.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.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,928 @@
#!/usr/bin/env node
/**
* pi-mono WebUI Server
*
* 被 webui 扩展启动,作为独立子进程运行。
* 内嵌 HTTP 服务 + pi RPC 子进程,提供浏览器聊天界面。
*
* 用法(由扩展自动调用):
* tsx server.ts --port 19133
*
* 前端静态文件位于同目录 dist/ 下Vite 构建产物)。
*/
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { appendFileSync, readFileSync, readdirSync, statSync, existsSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname, resolve, basename } from "node:path";
import { fileURLToPath } from "node:url";
import {
closeWebuiDatabase,
deleteWebuiConfig,
getAllWebuiConfig,
getWebuiConfig,
initWebuiDatabase,
prunePinnedSessionPaths,
readWebuiAvatarSettings,
removePinnedSessionPath,
setSessionPinned,
setWebuiConfig,
setWebuiConfigMany,
writeWebuiAvatarSettings,
} from "./db.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

@@ -0,0 +1,14 @@
{
"lastChangelogVersion": "0.75.4",
"showChangelogOnStartup": false,
"defaultProvider": "your-provider",
"defaultModel": "your-model",
"defaultThinkingLevel": "high",
"transport": "sse",
"theme": "dark",
"packages": [
"npm:pi-subagents",
"npm:pi-mcp-adapter",
"npm:pi-autocontext"
]
}

25
.pi/prompts/is.md Normal file
View File

@@ -0,0 +1,25 @@
---
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.

37
.pi/prompts/pr.md Normal file
View File

@@ -0,0 +1,37 @@
---
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.

35
.pi/prompts/wr.md Normal file
View File

@@ -0,0 +1,35 @@
---
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

@@ -0,0 +1,57 @@
---
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]`.

302
AGENTS.md
View File

@@ -4,244 +4,156 @@
- Keep answers short and concise
- No emojis in commits, issues, PR comments, or code
- No fluff or cheerful filler text
- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!")
- 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.
## Code Quality
- Read files in full before making wide-ranging changes, before editing files you have not already fully inspected, and when the user asks you to investigate or audit something. Do not rely only on search snippets for broad changes.
- No `any` types unless absolutely necessary
- Check node_modules for external API type definitions instead of guessing
- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports.
- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead
- Always ask before removing functionality or code that appears to be intentional
- Do not preserve backward compatibility unless the user explicitly asks for it
- Never hardcode key checks with, eg. `matchesKey(keyData, "ctrl+x")`. All keybindings must be configurable. Add default to matching object (`DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS`)
- NEVER modify `packages/ai/src/models.generated.ts` directly. Update `packages/ai/scripts/generate-models.ts` instead.
- Read files in full before wide-ranging changes, before editing files you have not fully inspected, and when asked to investigate or audit. Do not rely on search snippets for broad changes.
- No `any` unless absolutely necessary.
- Inline single-line helpers that have only one call site.
- Check node_modules for external API types; don't guess.
- **No inline imports** (`await import()`, `import("pkg").Type`, dynamic type imports). Top-level imports only.
- Never remove or downgrade code to fix type errors from outdated deps; upgrade the dep instead.
- Use only erasable TypeScript syntax (Node strip-only mode) in code checked by the root config (`packages/*/src`, `packages/*/test`, `packages/coding-agent/examples`): no parameter properties, `enum`, `namespace`/`module`, `import =`, `export =`, or other constructs needing JS emit. Use explicit fields with constructor assignments.
- 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.
## Commands
- After code changes (not documentation changes): `npm run check` (get full output, no tail). Fix all errors, warnings, and infos before committing.
- Note: `npm run check` does not run tests.
- NEVER run: `npm run dev`, `npm run build`, `npm test`
- Only run specific tests if user instructs: `npx tsx ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts`
- Run tests from the package root, not the repo root.
- If you create or modify a test file, you MUST run that test file and iterate until it passes.
- When writing tests, run them, identify issues in either the test or implementation, and iterate until fixed.
- For `packages/coding-agent/test/suite/`, use `test/suite/harness.ts` plus the faux provider. Do not use real provider APIs, real API keys, or paid tokens.
- Put issue-specific regressions under `packages/coding-agent/test/suite/regressions/` and name them `<issue-number>-<short-slug>.test.ts`.
- NEVER commit unless user asks
- After code changes (not docs): `npm run check` (full output, no tail). Fix all errors, warnings, and infos before committing. Does not run tests.
- Never run `npm run build` or `npm test` unless requested by the user.
- Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run `./test.sh` from the repo root. Otherwise run specific tests from the package root: `node ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts`.
- If you create or modify a test file, run it and iterate on test or implementation until it passes.
- For `packages/coding-agent/test/suite/`, use `test/suite/harness.ts` + the faux provider. No real provider APIs, keys, or paid tokens.
- Put issue-specific regressions under `packages/coding-agent/test/suite/regressions/` named `<issue-number>-<short-slug>.test.ts`.
- For ad-hoc scripts, `write` them to a temp file (e.g. `/tmp`), run, edit if needed, remove when done. Don't embed multi-line scripts in `bash` commands.
- Never commit unless the user asks.
## Contribution Gate
## Dependency and Install Security
- New issues from new contributors are auto-closed by `.github/workflows/issue-gate.yml`
- New PRs from new contributors without PR rights are auto-closed by `.github/workflows/pr-gate.yml`
- Maintainer approval comments are handled by `.github/workflows/approve-contributor.yml`
- Maintainers review auto-closed issues daily
- Issues that do not meet the quality bar in `CONTRIBUTING.md` are not reopened and do not receive a reply
- `lgtmi` approves future issues
- `lgtm` approves future issues and rights to submit PRs
- Treat npm dep and lockfile changes as reviewed code. Direct external deps stay pinned to exact versions.
- Hydrate/update locally with `npm install --ignore-scripts`; clean/CI-style with `npm ci --ignore-scripts`. Don't run lifecycle scripts unless the user asks.
- If dep metadata changes, refresh `package-lock.json` with `npm install --package-lock-only --ignore-scripts`.
- If `packages/coding-agent/npm-shrinkwrap.json` needs regen, run `node scripts/generate-coding-agent-shrinkwrap.mjs` (verify with `--check` or `npm run check`). New deps with lifecycle scripts require review and an explicit allowlist entry in that script; never add one silently.
- Pre-commit blocks lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1`. Don't bypass unless the user wants the lockfile change committed.
## Git
Multiple pi sessions may be running in this cwd at the same time, each modifying different files. Git operations that touch unstaged, staged, or untracked files outside your own changes will stomp on other sessions' work. Follow these rules:
Committing:
- Only commit files YOU changed in THIS session.
- 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.
Never run (destroys other agents' work or bypasses checks):
- `git reset --hard`, `git checkout .`, `git clean -fd`, `git stash`, `git add -A`, `git add .`, `git commit --no-verify`.
If rebase conflicts occur:
- Resolve conflicts only in files you modified.
- If a conflict is in a file you did not modify, abort and ask the user.
- Never force push.
## Issues and PRs
See `CONTRIBUTING.md` for the contributor gate (auto-close workflows, `lgtm`/`lgtmi`, quality bar).
When creating issues:
- Add `pkg:*` labels to indicate which package(s) the issue affects
- Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`, `pkg:web-ui`
- If an issue spans multiple packages, add all relevant labels
- Add `pkg:*` labels for affected packages (`pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`); use all that apply.
When posting issue/PR comments:
- Write the full comment to a temp file and use `gh issue comment --body-file` or `gh pr comment --body-file`
- Never pass multi-line markdown directly via `--body` in shell commands
- Preview the exact comment text before posting
- Post exactly one final comment unless the user explicitly asks for multiple comments
- If a comment is malformed, delete it immediately, then post one corrected comment
- Keep comments concise, technical, and in the user's tone
- Write the comment to a temp file and post with `gh issue/pr comment --body-file` (never multi-line markdown via `--body`).
- Keep comments concise, technical, in the user's tone.
- End every AI-posted comment with the AI-generated disclaimer line specified by the originating prompt (e.g. `This comment is AI-generated by `/wr``).
When closing issues via commit:
- Include `fixes #<number>` or `closes #<number>` in the commit message
- This automatically closes the issue when the commit is merged
## PR Workflow
- Analyze PRs without pulling locally first
- If the user approves: create a feature branch, pull PR, rebase on main, apply adjustments, commit, merge into main, push, close PR, and leave a comment in the user's tone
- You never open PRs yourself. We work in feature branches until everything is according to the user's requirements, then merge into main, and push.
- Include `fixes #<number>` or `closes #<number>` in the message so merging auto-closes the issue. For multiple issues, repeat the keyword per issue (`closes #1, closes #2`); a shared keyword (`closes #1, #2`) only closes the first.
## Testing pi Interactive Mode with tmux
To test pi's TUI in a controlled terminal environment:
Run the TUI in a controlled terminal (from the repo root):
```bash
# Create tmux session with specific dimensions
tmux new-session -d -s pi-test -x 80 -y 24
# Start pi from source
tmux send-keys -t pi-test "cd /Users/badlogic/workspaces/pi-mono && ./pi-test.sh" Enter
# Wait for startup, then capture output
sleep 3 && tmux capture-pane -t pi-test -p
# Send input
tmux send-keys -t pi-test "./pi-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
# Send special keys
tmux send-keys -t pi-test Escape
tmux send-keys -t pi-test C-o # ctrl+o
# Cleanup
tmux send-keys -t pi-test Escape # special keys (also C-o for ctrl+o, etc.)
tmux kill-session -t pi-test
```
## Changelog
Location: `packages/*/CHANGELOG.md` (each package has its own)
Location: `packages/*/CHANGELOG.md` (one per package).
### Format
Sections under `## [Unreleased]`: `### Breaking Changes` (API changes requiring migration), `### Added`, `### Changed`, `### Fixed`, `### Removed`.
Use these sections under `## [Unreleased]`:
Rules:
- `### Breaking Changes` - API changes requiring migration
- `### Added` - New features
- `### Changed` - Changes to existing functionality
- `### Fixed` - Bug fixes
- `### Removed` - Removed features
- All new entries go under `## [Unreleased]`. Read the full section first and append to existing subsections; never duplicate them.
- Released version sections (e.g. `## [0.12.2]`) are immutable; never modify them.
### Rules
Attribution:
- Before adding entries, read the full `[Unreleased]` section to see which subsections already exist
- New entries ALWAYS go under `## [Unreleased]` section
- Append to existing subsections (e.g., `### Fixed`), do not create duplicates
- NEVER modify already-released version sections (e.g., `## [0.12.2]`)
- Each version section is immutable once released
### Attribution
- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
- **External contributions**: `Added feature X ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@username](https://github.com/username))`
## Adding a New LLM Provider (packages/ai)
Adding a new provider requires changes across multiple files:
### 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 provider file exporting:
- `stream<Provider>()` function returning `AssistantMessageEventStream`
- `streamSimple<Provider>()` for `SimpleStreamOptions` mapping
- Provider-specific options interface
- Message/tool conversion functions
- Response parsing emitting 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 provider source
- Map to 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 implementation such as `openai-completions`.
- Add the provider to the broader provider 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 (for example GPT and Claude), add at least one pair per family.
- For non-standard auth, create 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]`
- Internal (from issues): `Fixed foo bar ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
- External contributions: `Added feature X ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@username](https://github.com/username))`
## Releasing
**Lockstep versioning**: All packages always share the same version number. Every release updates all packages together.
**Lockstep versioning**: all packages share one version; every release updates all together. `patch` = fixes + additions, `minor` = breaking changes. No major releases.
**Version semantics** (no major releases):
1. **Update CHANGELOGs**: ask the user whether they ran the `/cl` prompt on the latest commit on `main`. If not, they must run `/cl` first to audit and update each package's `[Unreleased]` section before releasing.
- `patch`: Bug fixes and new features
- `minor`: API breaking changes
### Steps
1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md
2. **Run release script**:
2. **Local smoke test**: build an unpublished release and smoke test from outside the repo (so it can't resolve workspace files):
```bash
npm run release:patch # Fixes and additions
npm run release:minor # API breaking changes
npm run release:local -- --out /tmp/pi-local-release --force
cd /tmp
/tmp/pi-local-release/node/pi --help
/tmp/pi-local-release/node/pi --version
/tmp/pi-local-release/node/pi
/tmp/pi-local-release/bun/pi --help
/tmp/pi-local-release/bun/pi --version
```
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.
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.
```
The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections.
Do not proceed to step 4 until the user explicitly confirms.
## **CRITICAL** Git Rules for Parallel Agents **CRITICAL**
4. **Run the release script**:
```bash
npm run release:patch # fixes + additions
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.
Multiple agents may work on different files in the same worktree simultaneously. You MUST follow these rules:
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.
### Committing
## User Override
- **ONLY commit files YOU changed in THIS session**
- ALWAYS include `fixes #<number>` or `closes #<number>` in the commit message when there is a related issue or PR
- NEVER use `git add -A` or `git add .` - these sweep up changes from other agents
- ALWAYS use `git add <specific-file-paths>` listing only files you modified
- Before committing, run `git status` and verify you are only staging YOUR files
- Track which files you created/modified/deleted during the session
- It is always fine to include `packages/ai/src/models.generated.ts` in a commit alongside the actual files you want to commit
### Forbidden Git Operations
These commands can destroy other agents' work:
- `git reset --hard` - destroys uncommitted changes
- `git checkout .` - destroys uncommitted changes
- `git clean -fd` - deletes untracked files
- `git stash` - stashes ALL changes including other agents' work
- `git add -A` / `git add .` - stages other agents' uncommitted work
- `git commit --no-verify` - bypasses required checks and is never allowed
### Safe Workflow
```bash
# 1. Check status first
git status
# 2. Add ONLY your specific files
git add packages/ai/src/providers/transform-messages.ts
git add packages/ai/CHANGELOG.md
# 3. Commit
git commit -m "fix(ai): description"
# 4. Push (pull --rebase if needed, but NEVER reset/checkout)
git pull --rebase && git push
```
### If Rebase Conflicts Occur
- Resolve conflicts in YOUR files only
- If conflict is in a file you didn't modify, abort and ask the user
- NEVER force push
### User override
If the user instructions conflict with rules set out here, ask for confirmation that they want to override the rules. Only then execute their instructions.
If the user's instructions conflict with any rule in this document, ask for explicit confirmation before overriding. Only then execute their instructions.

126
README.md
View File

@@ -1,6 +1,6 @@
# sproutclaw
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 pi-mono 改造重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 [pi-mono](https://github.com/badlogic/pi-mono) 改造重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
## 定位
@@ -14,17 +14,19 @@
| 路径 | 说明 |
| --- | --- |
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体 |
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体(含 sproutclaw 定制补丁) |
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
| `packages/tui` | 终端 UI 渲染库 |
| `packages/web-ui` | Web UI 组件 |
| `.pi/extensions/webui` | 本地扩展的网页对话入口 |
| `.pi/agent/extensions/webui` | 网页对话入口React 前端 + HTTP 服务) |
| `.pi/agent/extensions/*.ts` | sproutclaw 本地扩展(中文启动页、命令安装等) |
| `scripts/sync-upstream.sh` | 合并上游 pi-mono 更新 |
| `scripts/push-sproutclaw.sh` | 推送到 GitHub / Gitea |
## 常用命令
```bash
npm install
npm install --ignore-scripts
npm run build
npm run check
./test.sh
@@ -49,10 +51,114 @@ npm run check
/webui off
```
WebUI 前端单独构建:
```bash
cd .pi/agent/extensions/webui/frontend && npm install && npm run build
```
## Git 分支策略
本仓库是 pi-mono 的 fork采用三层分支同时维护**上游同步**和 **sproutclaw 定制**
| 分支 | 用途 |
| --- | --- |
| `main` | 稳定可跑版本:已合并的上游 + sproutclaw 定制,推送到 GitHub |
| `upstream-sync` | 临时同步分支:专门用来 `merge upstream/main`,解决冲突后再合回 `main` |
| `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 (内网备份,可选)
```
首次配置 upstream
```bash
git remote add upstream https://github.com/badlogic/pi-mono.git
```
GitHub 网络不稳定时:
```bash
export GIT_HTTP_VERSION=1.1
```
### 日常开发feature 分支)
```bash
./scripts/new-feature.sh webui-avatar-fix # 从 main 创建 feature/webui-avatar-fix
# ... 开发、测试 ...
git add <改动的具体文件> # 不要用 git add -A
git commit -m "fix(webui): agent avatar during tool calls"
git checkout main
git merge feature/webui-avatar-fix
./scripts/push-sproutclaw.sh
git branch -d feature/webui-avatar-fix # 合并后可删
```
### 推送本地更新到 GitHub
```bash
git checkout main
./scripts/push-sproutclaw.sh
```
### 合并上游 pi-mono 更新
```bash
./scripts/sync-upstream.sh
# 若有冲突,按下方规则解决后:
git add <resolved-files>
git commit
git checkout main && git merge upstream-sync
./scripts/push-sproutclaw.sh
```
**合并冲突处理惯例:**
| 文件 | 处理方式 |
| --- | --- |
| `README.md`、sproutclaw 定制代码 | 保留本仓库版本 |
| `package-lock.json` | 采用 upstream再执行 `npm install --ignore-scripts` |
| `*.generated.ts` | 采用 upstream |
| `.pi/agent/extensions/` | 保留 sproutclaw 版本 |
合并后必做:
```bash
npm install --ignore-scripts
npm run build
./pi-test.sh
```
### sproutclaw 相对 upstream 的 intentional 改动
合并上游时,以下定制**不要误删**
- `showChangelogOnStartup` 设置项(关闭启动 What's New
- `.pi/agent/extensions/webui/` 网页 UI
- `.pi/agent/extensions/startup-chinese.ts` 等本地扩展
- 本 README仅 sproutclaw 说明,不含上游文档)
## 本地配置
`.pi/agent/` 中以下内容**不进 Git**(含密钥/内网地址):
- `settings.json` → 参考 `settings.example.json` 本地复制
- `models.json``auth.json`
- `sessions/`
首次部署:
```bash
cp .pi/agent/settings.example.json .pi/agent/settings.json
# 编辑 settings.json 填入 provider / model
```
## 说明
这个仓库是自用分支,默认围绕树萌芽的服务器环境、部署规范和日常开发习惯进行调整。上游能力来自 pi-mono后续会继续精简、增强并沉淀适合服务器运维开发场景的 Agent 工作流。
## License
MIT
这个仓库是自用 fork,默认围绕树萌芽的服务器环境、部署规范和日常开发习惯调整。上游能力来自 pi-mono通过 `upstream-sync` 分支定期同步sproutclaw 定制在 `feature/*` 开发后合入 `main`

View File

@@ -28,12 +28,9 @@
"packages/*/src/**/*.ts",
"packages/*/test/**/*.ts",
"packages/coding-agent/examples/**/*.ts",
"packages/web-ui/src/**/*.ts",
"packages/web-ui/example/**/*.ts",
"!**/node_modules/**/*",
"!**/test-sessions.ts",
"!**/models.generated.ts",
"!packages/web-ui/src/app.css",
"!packages/mom/data/**/*",
"!!**/node_modules"
]

5880
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,6 @@
"type": "module",
"workspaces": [
"packages/*",
"packages/web-ui/example",
"packages/coding-agent/examples/extensions/with-deps",
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
@@ -12,46 +11,45 @@
],
"scripts": {
"clean": "npm run clean --workspaces",
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../web-ui && npm run build",
"dev": "concurrently --names \"ai,agent,coding-agent,web-ui,tui\" --prefix-colors \"cyan,yellow,red,green,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/web-ui && npm run dev\" \"cd packages/tui && npm run dev\"",
"dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"cd packages/ai && npm run dev:tsc\" \"cd packages/web-ui && npm run dev:tsc\"",
"check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke && cd packages/web-ui && npm run check",
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
"check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && tsgo --noEmit && npm run check:browser-smoke",
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
"check:shrinkwrap": "node scripts/generate-coding-agent-shrinkwrap.mjs --check",
"check:ts-imports": "node scripts/check-ts-relative-imports.mjs",
"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 && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install",
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install",
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install",
"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: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",
"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",
"prepare": "husky"
},
"devDependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26",
"@anthropic-ai/sandbox-runtime": "0.0.26",
"@biomejs/biome": "2.3.5",
"@types/node": "^22.10.5",
"@types/node": "22.19.19",
"@typescript/native-preview": "7.0.0-dev.20260120.1",
"concurrently": "^9.2.1",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"shx": "^0.4.0"
"esbuild": "0.28.0",
"husky": "9.1.7",
"jiti": "2.7.0",
"shx": "0.4.0",
"tsx": "4.22.1",
"typescript": "5.9.3"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.19.0"
},
"version": "0.0.3",
"dependencies": {
"@earendil-works/pi-coding-agent": "^0.30.2",
"get-east-asian-width": "^1.4.0"
},
"overrides": {
"rimraf": "6.1.2",
"gaxios": {

View File

@@ -2,6 +2,33 @@
## [Unreleased]
## [0.75.4] - 2026-05-20
### Changed
- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks.
- Removed the package-level development watch script now that the root TypeScript check validates strip-only-compatible sources.
### Fixed
- Fixed tool-call preflight to stop preparing sibling tool calls after the run is aborted ([#4276](https://github.com/earendil-works/pi/issues/4276)).
- Fixed tail truncation for oversized single-line output that ends with a trailing newline ([#4715](https://github.com/earendil-works/pi/issues/4715)).
- Fixed Windows Node execution environment command spawns to hide helper console windows from background processes ([#4699](https://github.com/earendil-works/pi/issues/4699)).
## [0.75.3] - 2026-05-18
## [0.75.2] - 2026-05-18
## [0.75.1] - 2026-05-18
## [0.75.0] - 2026-05-17
### Breaking Changes
- Raised the minimum supported Node.js version to 22.19.0.
## [0.74.1] - 2026-05-16
## [0.74.0] - 2026-05-07
## [0.73.1] - 2026-05-07

View File

@@ -1,6 +1,6 @@
# AgentHarness lifecycle
`AgentHarness` is the orchestration layer above the low-level `Agent`. It owns session persistence, runtime configuration, resource resolution, operation locking, and extension-facing mutation semantics.
`AgentHarness` is the orchestration layer above the low-level agent loop. It owns session persistence, runtime configuration, resource resolution, operation locking, and extension-facing mutation semantics.
This document describes the current direction and implemented behavior. Some extension/session-facade details are planned and called out explicitly.
@@ -15,9 +15,22 @@ The intended rule is:
- runtime config setters update future snapshots without mutating the current provider request
- session writes made while busy are durably queued and flushed in deterministic order
- getters return latest harness config, not in-flight snapshots
- listeners/hooks currently receive no facade; if they close over the raw harness and call settlement APIs such as `waitForIdle()` during the active run, they can deadlock. A future facade should expose `runWhenIdle()` instead.
`AssistantMessageStream` already decouples provider transport streaming, such as SSE or websocket reads, from downstream event consumption. The harness can therefore await listeners, extension hooks, persistence, and save-point work without blocking the provider transport reader or reintroducing ad hoc event queues. Lifecycle code should prefer explicit awaited sequencing at harness boundaries over fire-and-forget hook/event settlement.
A final lifecycle hardening pass should prove these guarantees with a broad listener/hook reentrancy test suite.
## Error handling
The current split is:
- low-level capabilities and helpers use `Result<TValue, TError>` where expected failures are contained and must not throw, such as `ExecutionEnv`, filesystem/shell operations, shell-output capture, resource loading, and compaction helpers
- high-level mutation/orchestration APIs such as `Session` and `AgentHarness` reject/throw instead of returning bare results that can be ignored
- public `AgentHarness` failures are normalized to `AgentHarnessError` where practical; subsystem errors are preserved as `cause`
Harness events observe committed state. Public mutators validate required input and persistence before committing when practical, then await notifications. If a hook or subscriber fails after commit, the state change is not rolled back and the public method rejects with `AgentHarnessError` code `"hook"`.
## State model
The harness separates state into four categories.
@@ -31,6 +44,7 @@ Harness config is the latest runtime configuration set by the application or ext
- tools
- active tool names
- resources
- stream options
- system prompt or system prompt provider
Getters return harness config. They do not return the snapshot used by an in-flight provider request.
@@ -52,15 +66,21 @@ A turn snapshot is the concrete state used for one LLM turn. It is created by `c
- thinking level
- all tools
- active tools
- stream options
- derived session id
Static option values are used directly. System-prompt provider callbacks are invoked once per `createTurnState()` call. All logic for that turn uses the same snapshot.
Resource arrays are shallow-copied when a snapshot is created. Individual skill and prompt-template objects are not deep-copied.
Stream options are shallow-copied when a snapshot is created. `headers` and `metadata` maps are shallow-copied; their values are not deep-copied. Credentials from `getApiKeyAndHeaders()` are resolved per provider request so expiring tokens can refresh, but the configured stream options and derived session id come from the current turn snapshot.
### Session
The session contains persisted entries only. Session reads return persisted state and do not include queued writes.
Session storage implementations must persist leaf changes as `leaf` entries. `setLeafId()` is not an in-memory-only cursor update; it appends a durable entry whose `targetId` is the active tree leaf or `null` for root. Reopening storage must reconstruct the current leaf from the latest persisted leaf-affecting entry.
### Pending session writes
Session writes requested while an operation is active are queued as pending session writes. Pending writes are based on session-entry shapes without generated fields (`id`, `parentId`, `timestamp`).
@@ -85,7 +105,7 @@ Structural operations require `phase === "idle"` and synchronously set the phase
- `compact`
- `navigateTree`
Starting another structural operation while the harness is not idle throws.
Starting another structural operation while the harness is not idle rejects with `AgentHarnessError` code `"busy"`.
The following operations are allowed during a turn where appropriate:
@@ -112,8 +132,8 @@ Phase/settlement semantics are still provisional and need a full lifecycle pass.
Queue modes are live, not turn-snapshotted:
- `steeringMode`
- `followUpMode`
- `getSteeringMode()` / `setSteeringMode()`
- `getFollowUpMode()` / `setFollowUpMode()`
Changing a queue mode during a run affects the next queue drain. Queue drains happen at safe points.
@@ -125,9 +145,9 @@ At a save point the harness:
1. flushes pending session writes after the agent-emitted messages for that turn
2. creates a fresh turn snapshot if the low-level loop may continue
3. applies the fresh context/model/thinking-level state before the next provider request
3. applies the fresh context/model/thinking-level/stream-options/session-id state before the next provider request
This lets model, thinking level, tool, resource, and system prompt changes made during a turn affect the next turn in the same run, while never mutating an in-flight provider request. The loop callbacks are not recreated at save points.
This lets model, thinking level, tool, resource, stream option, and system prompt changes made during a turn affect the next turn in the same run, while never mutating an in-flight provider request. Because provider transport reading is already decoupled by `AssistantMessageStream`, save-point work and hook settlement can be awaited directly to keep transcript/session ordering deterministic. The loop callbacks are not recreated at save points.
The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at the provider boundary:
@@ -136,21 +156,26 @@ The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at t
No state refresh is needed on `agent_end` except flushing leftover pending session writes and clearing the operation phase. The exact `settled` event timing is still under review.
If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation throws and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message.
If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation rejects with `AgentHarnessError` and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message.
## Hooks and events
Current hooks receive only the event payload. There is no extension context object yet.
The target hook system is described in [hooks.md](./hooks.md).
Event payloads describe what is happening. Harness getters describe latest config for future snapshots.
Summary:
The split between harness-specific events (`AgentHarnessOwnEvent`) and the union of low-level plus harness events (`AgentHarnessEvent`) is provisional but useful for distinguishing hookable harness events from public subscription events.
- `AgentHarness` emits typed hook events and consumes typed results.
- A single hooks implementation owns registration, cleanup, provenance, and result reducers.
- Observational and mutation hooks use one event-specific `on()` API; the event result type determines whether a handler may return a result.
- Result-producing events are reduced by typed reducer tables; app-specific hooks add reducers only for app-specific result-producing events.
- Hook registration provenance is sidecar metadata on the registration. Resource and tool provenance belongs on app-specific concrete value types.
- Hook context should be a plain object of facades, not raw internals or late-bound getter mazes.
A future extension context may expose the harness and a queued-write session facade.
Event payloads describe what is happening. Harness getters describe latest config for future snapshots. Hook and listener settlement should be awaited in lifecycle order where possible; transport backpressure is handled below the harness by `AssistantMessageStream`, so the harness does not need a separate async event queue merely to keep SSE or websocket reads flowing.
## Planned session facade
Extensions should eventually interact with a harness-scoped session facade rather than the raw session.
Extensions should eventually interact with a harness-scoped `HarnessSession` facade rather than the raw session. The facade should wrap the internal session and enforce harness pending-write ordering semantics. Once this exists, hooks and event listeners can receive a context that exposes the full `AgentHarness` plus the session facade without giving direct access to unordered raw session writes.
Planned read semantics:
@@ -172,7 +197,9 @@ Agent-emitted messages are persisted on `message_end` to preserve transcript ord
## Abort
Abort is allowed during a turn. It aborts the low-level run and clears low-level steering/follow-up queues.
Abort is allowed during a turn. It aborts the low-level run and clears steering/follow-up queues.
Abort does not clear `nextTurn` messages. Messages queued with `nextTurn()` survive abort and are inserted before the user message on the next user-initiated turn.
Abort does not discard pending session writes. Pending writes flush at the next save point if reached, at `agent_end`, or in operation failure cleanup.
@@ -188,17 +215,271 @@ Branch summary generation is part of the tree navigation operation.
Auto-compaction and retry decision points are not implemented in `AgentHarness` yet.
## Final lifecycle hardening todo
## Test organization
Before treating `AgentHarness` as migration-ready, add a broad test suite that exercises listeners and hooks closing over the harness and calling public APIs during every relevant event:
Harness tests should stay focused by area instead of growing one large catch-all file.
- runtime config setters from low-level lifecycle events and harness events
- resource/tool/model/thinking updates during active turns and save points
- session writes from listeners and hooks, including writes from `settled`
- queue operations from turn events, tool events, and provider hooks
- rejected structural operations while busy
- abort from listeners/hooks
- getter behavior during active operations
- deterministic ordering of agent-emitted messages and pending listener writes
- no deadlocks when async listeners call harness APIs and await them
- phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation
Current structure:
- `packages/agent/test/harness/agent-harness.test.ts`: core lifecycle and public API behavior.
- `packages/agent/test/harness/agent-harness-stream.test.ts`: stream options and provider hook semantics.
Preferred future structure:
- `agent-harness-resources.test.ts`: resource snapshot/loading semantics.
- `agent-harness-tools.test.ts`: tool registry getters, active-tool semantics, and update events.
- `agent-harness-lifecycle.test.ts`: phase/save-point/settled/reentrancy behavior.
Use the `pi-ai` faux provider (`registerFauxProvider`, `fauxAssistantMessage`) for deterministic harness/provider tests. Faux response factories can inspect `StreamOptions`, invoke `options.onPayload`, and return scripted assistant messages without real provider APIs or network access.
Harness coverage is configured separately from the default package test run:
```bash
npm run test:harness
npm run coverage:harness
```
`coverage:harness` runs `test/harness/**/*.test.ts` and reports coverage for `src/harness/**/*.ts` plus the non-harness runtime files it directly exercises (`src/agent.ts` and `src/agent-loop.ts`) into `coverage/harness`. Type-only dependencies such as `src/types.ts` are not included because they have no meaningful runtime coverage.
## Implementation todo
This list tracks the remaining work before treating `AgentHarness` as migration-ready. Active/planned items are ordered from easiest to hardest. Completed items are archived at the bottom.
### 1. Add explicit tool registry read/update semantics
Status: In progress
Done:
- Added `setTools(tools, activeToolNames?)`.
- Added `setActiveTools(toolNames)`.
- Invalid active tool names reject with `AgentHarnessError`.
- Added generic app tool shape via `AgentHarness<TSkill, TPromptTemplate, TTool>`.
- Exported `QueueMode` from core types.
- Added `AgentHarnessOptions.steeringMode` and `followUpMode`.
- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`.
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.
Notes:
- Observability design: [observability.md](./observability.md)
### 2. Design per-`AgentHarness` model registry
Status: Planned
Done:
- Current `setModel()` behavior is preserved.
Remaining:
- Decide how applications supply the model registry.
- Decide whether the harness stores concrete `Model` objects, model references, or both.
- Validate model selection against the registry.
- Define model change semantics during active turns and save points.
### 3. Full `AgentHarness` lifecycle/state pass
Status: In progress
Done:
- Removed constructor `void syncFromTree()`, `syncFromTree()`, `liveOperationId`, and `shell()`.
- Added `createTurnState()`, `applyTurnState()`, and `executeTurn()`.
- Added explicit `phase` in place of boolean idle state.
- Save points refresh context, model, thinking level, stream options, and session snapshot state.
- Pending session writes use session-entry shapes without generated fields.
- Pending session writes flush at save points, settlement, and failure cleanup.
- `steer`, `followUp`, and `nextTurn` create user messages from text plus optional images.
- `nextTurn` messages are inserted before the new user prompt.
- Structural compaction/tree operations restore phase with `finally`.
- Public harness failures normalize subsystem causes to `AgentHarnessError`.
- Pending session writes flush one-by-one and are not dropped on failure.
- Queue drains roll back if queue-update notification fails.
- `message_end` persistence happens before subscriber notification.
- `abort()` signals cancellation before notifications and still waits for idle through notification errors.
- Idle model/thinking/tool updates validate and persist before committing in-memory state.
- `setLeafId()` persists durable `leaf` entries so tree navigation survives storage reopen.
Remaining:
- Finalize phase/idle semantics.
- Audit whether `settled` can fire too early.
- Make session writes inside `settled` callbacks deterministic.
- Audit follow-up behavior around `agent_end`.
- Implement auto-compaction decision point.
- Implement retry handling.
- Verify `before_agent_start` hook semantics against coding-agent.
- Decide whether `before_agent_start` needs more turn info such as tools/tool snippets.
- Document or change runtime config event timing while busy.
- Audit `abort()` barrier semantics.
### 4. Implement generic hook/event extension mechanism
Status: Designed in [hooks.md](./hooks.md), not implemented
Done:
- Removed `AgentHarnessContext`.
- Hooks receive only event payloads.
- `emitHook(event)` derives the hook type from `event.type`.
- Provider request/payload hooks have ordered transform semantics.
Remaining:
- Add `HookEvent`, `ResultOf`, registration options with generic source metadata, and the single `AgentHarnessHooks` implementation.
- Move result chaining out of `AgentHarness` into reducer functions.
- Type-check base harness reducers so every result-producing `AgentHarnessEvent` has reducer semantics.
- Make `AgentHarness` accept and expose the concrete hooks instance with constructor inference for app-specific hooks.
- Define the initial harness/context facades exposed through hook context.
- Preserve current provider hook behavior, including stream option patch deletion semantics.
- Add parity tests for reducer semantics: transform chaining, patch chaining, early block/cancel, cleanup, source metadata, and typed app-specific reducer coverage.
Notes:
- Hook design: [hooks.md](./hooks.md)
### 5. Spike semi-durable harness/session recovery
Status: Planned
Done:
- Wrote durability design: [durable-harness.md](./durable-harness.md)
Remaining:
- Decide whether session owns all durable harness state or whether any sidecars are needed for large blobs.
- Define durable entries for queues, pending writes, operations, turns, provider requests, and tool calls.
- Define resume requirements for app-provided tools, models, extensions, resources, hooks, and auth providers.
- Define conservative recovery policy for unfinished agent turns, provider requests, tool calls, compaction, and tree navigation.
- Prototype reducer-based recovery from session entries.
- Decide whether interrupted operations append user-visible messages or only internal operation entries.
Notes:
- Provider streams are not resumable; recovery should restart from durable boundaries or mark operations interrupted.
- Unfinished tool calls are unsafe to retry unless tools declare idempotent/retry-safe behavior.
### 6. Final lifecycle hardening suite
Status: Planned
Done:
- None.
Remaining:
- Add broad listener/hook reentrancy tests across relevant events.
- Test runtime config setters from low-level lifecycle events and harness events.
- Test runtime config observability for model, thinking, resources, tools, active tools, and stream options.
- Test resource/tool/model/thinking/stream-option updates during active turns and save points.
- Test session writes from listeners and hooks, including `settled` writes.
- Test queue operations from turn events, tool events, and provider hooks.
- Test rejected structural operations while busy.
- Test abort from listeners/hooks.
- Test getter behavior during active operations.
- Test deterministic ordering of agent-emitted messages and pending listener writes.
- Test no deadlocks when async listeners call harness APIs and await them.
- Test phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation.
### 7. Later coding-agent migration plan
Status: Planned
Done:
- None.
Remaining:
- Map coding-agent resources to sourced loaders.
- Keep app-level resource dedupe/provenance outside the harness.
- Adapt extension loading to the future hook/session facade.
- Preserve UI/session behavior outside core.
- Move coding-agent stream/auth/retry/header behavior onto harness stream configuration and provider hooks.
---
## Completed implementation todo
### 8. Remove `Agent` dependency from `AgentHarness`
Status: Done
Done:
- `AgentHarness` calls `runAgentLoop()` directly.
- Harness owns run lifecycle, abort controller, queue draining, provider stream config, event reduction, session persistence, pending write flushing, and save-point snapshots.
- Harness tests cover prompt construction, queue draining, abort behavior, save-point refresh, pending write ordering, awaited listener settlement, tool hooks, and provider stream wrapping.
Remaining:
- None.
Notes:
- Broader listener/hook reentrancy coverage is tracked in item 6.
### 9. Finish curated provider/stream configuration
Status: Done
Done:
- Added curated `AgentHarnessOptions.streamOptions`, `getStreamOptions()`, and `setStreamOptions()`.
- Stream options, headers, metadata, and derived session id are snapshotted per turn.
- Harness-owned stream wrapper calls `streamSimple()` and keeps lifecycle-owned `signal` and `reasoning` from the low-level loop.
- `getApiKeyAndHeaders()` resolves credentials per provider request.
- `before_provider_request`, `before_provider_payload`, and `after_provider_response` hooks are implemented.
- Stream option patching supports explicit field deletion and ordered hook chaining.
- `agent-harness-stream.test.ts` covers forwarding, auth merge, hook patching/deletion/chaining, payload hooks, and busy/save-point snapshot behavior.
Remaining:
- None.
### 10. Complete low-level `Result` cleanup
Status: Done
Done:
- Added generic `Result<TValue, TError>` plus helpers.
- Updated `ExecutionEnv` and `NodeExecutionEnv` to return typed results for filesystem/process operations.
- Split filesystem and shell capabilities.
- Moved JSONL session storage/repo onto filesystem picks instead of direct Node imports.
- Added `ExecutionEnv.appendFile()` for streaming append use cases.
- Updated skill and prompt-template loaders to consume `ExecutionEnv` results.
- Updated shell output capture to return a result and use `ExecutionEnv`, including full-output spill via `appendFile()`.
- Removed `NodeExecutionEnv` from browser-safe root exports.
- Replaced `Buffer` usage in generic truncation utilities with runtime-neutral UTF-8 handling.
- Converted compaction and branch-summary helpers to typed result returns.
- Added `readTextLines()` so JSONL metadata loading reads only the header line.
- Removed no-op abort handling from Node filesystem methods where cancellation is not meaningful.
- Mapped filesystem errors crossing the session boundary to typed `SessionError`.
- Added typed branch-summary errors and cause-aware public harness error normalization.
- Resource loaders report structured diagnostics for non-`not_found` filesystem failures.
- Expanded `NodeExecutionEnv` tests for file operations, exec errors, aborts, callbacks, timeouts, and shell-output spill.
Remaining:
- None.
Notes:
- Keep low-level capability/helper APIs non-throwing where they return `Result`.
- Keep session storage/repo/session APIs throwing typed `SessionError`.
- Keep public structural harness failures normalized to `AgentHarnessError`.
- Keep Node-specific APIs isolated under `src/harness/env/nodejs.ts`, Node-backed storage/session implementations, or explicit Node-only entry points.
- Audit generic harness utilities for Node globals as APIs are added.
- Audit package exports so browser/generic imports do not pull Node-only modules.
- Keep expanding `ExecutionEnv` and shell-output contract tests as APIs evolve.

View File

@@ -0,0 +1,181 @@
# Durable AgentHarness and session design
<!-- Synced from jot zmnps2zu. Edit this file in-repo going forward. -->
Durable AgentHarness / session design notes.
## Framing
A fully durable `AgentHarness` is not realistic by itself because important dependencies are runtime JS supplied by the host app:
- tool implementations
- model/auth providers
- extensions and hook handlers
- resource loaders
- system-prompt callbacks/modifiers
The practical target is a semi-durable harness:
- session is the durable append-only state tree
- harness persists the state it owns into session entries
- the host app is responsible for recreating compatible non-persistable dependencies on resume
- recovery restarts from durable boundaries, not from an in-flight provider stream
## Session owns durable state
Treat session as all durable agent state, not just transcript history.
Existing session state already includes harness state:
- model changes
- thinking-level changes
- leaf entries
- labels
- compactions and branch summaries
- custom messages and custom entries
That suggests continuing with one durable session log rather than adding harness sidecars. Sidecars may still be useful for large blobs, but the session entry should remain the source-of-truth reference.
## What the app must provide on resume
The app must recreate compatible runtime dependencies:
- model registry / model objects
- tool registry
- extension set, versions, and ordering
- resource loaders
- system prompt providers/hooks
- auth providers
- app-specific hooks
Harness can validate stable IDs/versions/hashes when available, but it cannot serialize these dependencies itself.
## What harness should persist
Minimum useful durability entries:
- queued steer/followUp/nextTurn messages
- queue consumption tied to a turn
- pending session writes accepted during active operations
- pending write application status
- operation start/finish/interruption
- turn start/finish
- provider request start/finish, if needed for recovery diagnostics
- tool call start/finish, if we want safe tool recovery
Potential entries:
```ts
type DurableHarnessEntry =
| QueueEnqueuedEntry
| QueueConsumedEntry
| PendingWriteEnqueuedEntry
| PendingWriteAppliedEntry
| OperationStartedEntry
| OperationFinishedEntry
| OperationInterruptedEntry
| TurnStartedEntry
| TurnFinishedEntry
| ProviderRequestStartedEntry
| ProviderRequestFinishedEntry
| ToolCallStartedEntry
| ToolCallFinishedEntry;
```
Every accepted mutation must be durable before the public API resolves.
## Recovery model
On startup:
1. Host app registers tools/models/extensions/resources/auth/hooks.
2. Harness opens session.
3. Harness reduces session entries into:
- current leaf
- conversation branch
- harness config
- queues
- pending writes
- active operation/turn/tool state
4. Harness validates required runtime dependencies.
5. Harness reconciles unfinished operation state.
Provider streams are not resumable. Recovery can only retry from a durable boundary or mark the operation interrupted.
## Recovery policies
Default conservative policy:
- unfinished agent turn: mark interrupted, preserve durable queues/pending writes, return idle
- unfinished provider request: mark interrupted; do not retry automatically
- unfinished tool call: append interrupted/error tool result; retry only if the tool declares retry-safe/idempotent
- unfinished compaction: rerun if no compaction entry exists
- unfinished branch summary/tree navigation: rerun/apply missing summary or leaf entries if safe
Optional policy:
```ts
recovery: "mark_interrupted" | "retry_unfinished"
```
`retry_unfinished` must be guarded around non-idempotent tool calls.
## Critical scenarios
### Queues
- Crash before `queue_enqueued`: message was not accepted.
- Crash after `queue_enqueued`: message is restored.
- Crash after queue drain but before durable turn record: risk of loss/duplication.
- Required invariant: consumed queue IDs must be recorded in `turn_started` or equivalent before they are considered consumed.
### Pending writes
- Crash before `pending_write_enqueued`: write was not accepted.
- Crash after enqueue before apply: recovery applies it.
- Crash after apply before applied marker: deterministic target entry IDs let recovery detect the entry already exists and mark it applied.
### Agent loop turn
- Crash before provider request: retry or mark interrupted.
- Crash during provider request: mark interrupted by default.
- Crash after provider response before assistant message persisted: response is lost unless provider result was journaled.
- Crash after assistant message persisted: recover from durable message.
### Tool calls
- Crash after tool call starts but before result: external side effects may already have happened.
- Default recovery should not rerun non-idempotent tools.
- Tool calls need stable IDs and retry-safety metadata for automatic recovery.
### Compaction
- Crash before summary generation: rerun preparation/summary.
- Crash after generated summary but before compaction entry: rerun unless summary was journaled.
- Crash after compaction entry: operation is complete; append finish marker if missing.
### Branch summary / tree navigation
- Crash before summary: rerun or mark interrupted.
- Crash after summary entry before leaf entry: append missing leaf entry.
- Crash after leaf entry: operation is complete; append finish marker if missing.
## Minimum viable spike
1. Add durable queue entries.
2. Add durable pending write entries with deterministic target IDs.
3. Add operation start/finish/interrupted entries.
4. Add turn start with consumed queue IDs.
5. Recover by reducing the session log.
6. Mark unfinished agent turns interrupted by default.
7. Rerun unfinished compaction/tree operations only when no final entry exists.
8. Do not retry unfinished tool calls unless tool metadata says retry-safe.
## Open questions
- Which harness config entries should move into session first: tools, active tools, 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?
- Should recovery append user-visible assistant interruption messages or only internal operation entries?
- Should storage support truncating a final partial JSONL line during recovery?

View File

@@ -0,0 +1,445 @@
# AgentHarness hooks design
<!-- Synced from jot 3utlzkxy. Edit this file in-repo going forward. -->
Final design.
## Core model
Events carry their result type as a type-only phantom:
```ts
declare const HookResult: unique symbol;
interface HookEvent<TType extends string, TResult = void> {
type: TType;
readonly [HookResult]?: TResult;
}
type ResultOf<E> = E extends { readonly [HookResult]?: infer R } ? R : void;
type HookHandler<E, Ctx> = (
event: E,
ctx: Ctx,
signal?: AbortSignal,
) => ResultOf<E> | void | Promise<ResultOf<E> | void>;
type HookObserver<E, Ctx> = (
event: E,
ctx: Ctx,
signal?: AbortSignal,
) => void | Promise<void>;
```
Example:
```ts
interface ContextEvent extends HookEvent<"context", { messages?: AgentMessage[] }> {
type: "context";
messages: AgentMessage[];
}
interface ToolCallEvent extends HookEvent<"tool_call", { block?: boolean; reason?: string }> {
type: "tool_call";
toolName: string;
input: Record<string, unknown>;
}
interface MessageEndEvent extends HookEvent<"message_end"> {
type: "message_end";
message: AgentMessage;
}
```
No result map. No spec table. The event type defines its own result.
## Hooks interface
```ts
interface AgentHarnessHooks<E extends HookEvent<string, unknown>, Ctx> {
context: Ctx;
setContext(ctx: Ctx): void;
observe(handler: HookObserver<E, Ctx>): () => void;
on<TType extends E["type"]>(
type: TType,
handler: HookHandler<Extract<E, { type: TType }>, Ctx>,
): () => void;
emit<TEvent extends E>(
event: TEvent,
signal?: AbortSignal,
): Promise<ResultOf<TEvent> | undefined>;
addCleanup(cleanup: () => void | Promise<void>): () => void;
clear(): Promise<void>;
dispose(): Promise<void>;
}
```
Important split:
- `observe()` sees all events, read-only, return ignored.
- `on(type, handler)` participates in that events semantics.
- `emit(event)` is the only thing `AgentHarness` calls.
- `clear()` removes observers/handlers and runs cleanups.
## Default implementation internals
```ts
class DefaultAgentHarnessHooks<E extends HookEvent<string, unknown>, Ctx>
implements AgentHarnessHooks<E, Ctx> {
context: Ctx;
private observers = new Set<HookObserver<E, Ctx>>();
private handlers = new Map<string, Set<HookHandler<any, Ctx>>>();
private cleanups = new Set<() => void | Promise<void>>();
constructor(ctx: Ctx) {
this.context = ctx;
}
setContext(ctx: Ctx): void {
this.context = ctx;
}
observe(handler: HookObserver<E, Ctx>): () => void {
this.observers.add(handler);
return () => this.observers.delete(handler);
}
on(type, handler): () => void {
let handlers = this.handlers.get(type);
if (!handlers) {
handlers = new Set();
this.handlers.set(type, handlers);
}
handlers.add(handler);
return () => handlers.delete(handler);
}
async emit(event, signal?) {
for (const observer of this.observers) {
await observer(event, this.context, signal);
}
switch (event.type) {
case "context":
return this.emitContext(event, signal);
case "before_provider_request":
return this.emitBeforeProviderRequest(event, signal);
case "before_provider_payload":
return this.emitBeforeProviderPayload(event, signal);
case "before_agent_start":
return this.emitBeforeAgentStart(event, signal);
case "tool_call":
return this.emitToolCall(event, signal);
case "tool_result":
return this.emitToolResult(event, signal);
case "session_before_compact":
case "session_before_tree":
return this.emitFirstCancelOrLast(event, signal);
default:
await this.emitObservationHandlers(event, signal);
return undefined;
}
}
}
```
Internal casts are acceptable inside the implementation because `Map<string, ...>` loses specificity. Public API remains typed.
## Mutation semantics
### Observation
```ts
await hooks.emit({ type: "message_end", message }, signal);
```
Observers run. `message_end` handlers run. Return ignored unless that event later gets a result type.
### Context transform
Handlers run in order. Each sees current messages.
```ts
let current = event;
for (const handler of handlers("context")) {
const result = await handler(current, ctx, signal);
if (result?.messages) {
current = { ...current, messages: result.messages };
}
}
return current.messages === event.messages ? undefined : { messages: current.messages };
```
### Provider request / payload
Sequential transform. Each handler sees previous output.
```ts
let current = event;
for (const handler of handlers("before_provider_payload")) {
const result = await handler(current, ctx, signal);
if (result !== undefined) {
current = { ...current, payload: result.payload };
}
}
return changed ? { payload: current.payload } : undefined;
```
### Before agent start
Collect injected messages, chain system prompt.
```ts
let systemPrompt = event.systemPrompt;
const messages = [];
for (const handler of handlers("before_agent_start")) {
const result = await handler({ ...event, systemPrompt }, ctx, signal);
if (result?.messages) messages.push(...result.messages);
if (result?.systemPrompt !== undefined) systemPrompt = result.systemPrompt;
}
return messages.length || systemPrompt !== event.systemPrompt
? { messages, systemPrompt }
: undefined;
```
### Tool call
Sequential, early exit on block.
```ts
for (const handler of handlers("tool_call")) {
const result = await handler(event, ctx, signal);
if (result?.block) return result;
}
```
### Tool result
Sequential patch accumulation. Each handler sees current patched result.
```ts
let current = event;
let modified = false;
for (const handler of handlers("tool_result")) {
const result = await handler(current, ctx, signal);
if (!result) continue;
current = {
...current,
content: result.content ?? current.content,
details: result.details ?? current.details,
isError: result.isError ?? current.isError,
};
modified = true;
}
return modified
? { content: current.content, details: current.details, isError: current.isError }
: undefined;
```
### Session-before events
Sequential, early exit on cancel.
```ts
let last;
for (const handler of handlers(event.type)) {
const result = await handler(event, ctx, signal);
if (!result) continue;
last = result;
if (result.cancel) return result;
}
return last;
```
## Harness usage
Harness only does this:
```ts
await this.hooks.emit(event, signal);
```
or:
```ts
const result = await this.hooks.emit({ type: "context", messages }, signal);
return result?.messages ?? messages;
```
Harness does not store handlers, chain listeners, or know extension policy.
## Context
Context is a normal object, not rebuilt per emit.
```ts
const hooks = new CodingAgentHooks({
harness: harnessFacade,
session: sessionFacade,
ui: noUiFacade,
});
```
Later:
```ts
hooks.setContext({
...hooks.context,
ui: tuiFacade,
});
```
For dynamic state, prefer stable facades/methods over getter maze:
```ts
interface CodingAgentHookContext {
harness: HarnessFacade;
session: SessionFacade;
ui: UiFacade;
models: ModelFacade;
}
```
Per-run `signal` is passed as the third handler arg.
## Extension loading later
Extension loading can live next to harness and construct hooks:
```ts
const hooks = await loadExtensions({
paths,
context,
hooks: new CodingAgentHooks(context),
});
const harness = new AgentHarness({ ..., hooks });
```
The loader registers into hooks:
```ts
hooks.on("context", handler);
hooks.on("tool_call", handler);
hooks.addCleanup(cleanup);
```
For reload:
```ts
await hooks.clear();
const nextHooks = await loadExtensions(...);
harness.setHooks(nextHooks); // idle-only if supported
```
## Poking holes
### 1. Error policy must be explicit
Existing coding-agent catches extension errors, reports them, and continues. New hooks need the same policy, likely:
```ts
errorMode: "continue" | "throw"
onError(error)
```
For coding-agent, default should be `"continue"`.
### 2. Source metadata matters
Existing runner knows which extension produced an error/resource/tool. Plain `on()` loses that unless we add registration metadata or scopes.
Probably needed:
```ts
const scope = hooks.createScope({ sourceInfo });
scope.on("context", handler);
scope.addCleanup(...);
```
Or `on(type, handler, { sourceInfo })`.
### 3. Some extension capabilities are registries, not hooks
These are not covered by `emit()` and should stay as registries on `CodingAgentHooks` or an extension host:
- tools
- commands
- shortcuts
- flags
- message renderers
- provider registrations
- OAuth providers
- custom model providers
That is fine. They do not belong in `AgentHarness`.
### 4. Existing coding-agent events can be represented
No blocker for:
- `context`
- `before_provider_request`
- `after_provider_response`
- `before_agent_start`
- `message_end`
- `tool_call`
- `tool_result`
- `input`
- `user_bash`
- `resources_discover`
- `session_before_*`
- `session_*`
- model/thinking selection events
- agent/turn/message/tool lifecycle events
They become additional event types handled by `CodingAgentHooks`.
### 5. Need to preserve exact old semantics
When porting coding-agent, special cases must be copied:
- `input`: transform chain, `handled` short-circuits.
- `user_bash`: first meaningful result wins.
- `message_end`: replacement must keep same role.
- `before_agent_start`: `ctx.getSystemPrompt()` must reflect current chained prompt.
- `resources_discover`: aggregate paths and keep extension source.
- `tool_call`: argument mutation remains visible to later handlers.
- `tool_result`: later handlers see prior patches.
The design allows all of that, but the default/coding hooks implementation must encode it.
### 6. `emit()` switch can miss custom mutation events
If a subclass adds a result-producing event but forgets to override `emit()`, it will behave observationally. Tests should catch this. Could add a protected strategy registry later if this becomes error-prone, but not initially.
### 7. Observer semantics are intentionally limited
Observers see the original emitted event once. They do not see every intermediate mutation. If something needs final transformed state, emit a separate final event or use an event-specific handler.
## Verdict
This design can implement a new coding-agent. It is simpler than the current runner, keeps harness clean, and preserves the important extension capabilities as long as `CodingAgentHooks` adds source-aware scopes, registries, cleanup, and the exact old event semantics.
--- Comments ---
Thread hn2xk0tzhj on "addCleanup(cleanup"
[tmluyaub9v] Owner (2026-05-14T12:55:45.500Z): cleanup should be passed along optionally to on/observe

View File

@@ -0,0 +1,376 @@
<!-- Synced from jot qe0ikdqs. Edit this file in-repo going forward. -->
# Pi Observability Design Notes
## Goal
Make `packages/ai` and `packages/agent`/harness observable without depending on OpenTelemetry, Sentry, or any APM vendor.
Pi should emit stable, structured lifecycle events. External listeners can convert those events into OTel spans, Sentry spans, logs, metrics, or custom telemetry.
## Mental model
A trace is one causal tree of work, e.g. one user turn.
A span is one timed operation in that tree. It is normally represented by IDs, not object pointers:
```ts
interface SpanRecord {
traceId: string;
spanId: string;
parentSpanId?: string;
name: string;
startTime: number;
endTime?: number;
attributes: Record<string, unknown>;
status: "ok" | "error";
}
```
Example tree:
```text
traceId=t1 spanId=s1 parent=- name=pi.agent.prompt
traceId=t1 spanId=s2 parent=s1 name=pi.agent.turn
traceId=t1 spanId=s3 parent=s2 name=pi.ai.provider.request
traceId=t1 spanId=s4 parent=s2 name=pi.agent.tool_call
traceId=t1 spanId=s5 parent=s4 name=pi.session.append_entry
```
## Async context
JavaScript has one event loop but multiple async chains can interleave. A single global `currentContext` breaks under concurrency.
`AsyncLocalStorage` is the Node equivalent of `ThreadLocal` for async continuations. It lets concurrent operations keep distinct current contexts:
```ts
await Promise.all([
runWithPiContext({ userId: "alice" }, () => harness.prompt("A")),
runWithPiContext({ userId: "bob" }, () => harness.prompt("B")),
]);
```
Deep code can then read the correct current context for the active async chain.
Pi must run in Node, Bun, browser, workers, and other JS runtimes, so ALS cannot be the core abstraction. It should be a runtime adapter.
## Core design
Pi owns a small runtime-agnostic observability abstraction:
```ts
export interface PiObservabilityContext {
traceId?: string;
currentSpanId?: string;
userContext?: Record<string, unknown>;
}
export interface PiObservabilityEvent {
type: "start" | "end" | "error" | "event";
name: string;
traceId: string;
spanId?: string;
parentSpanId?: string;
timestamp: number;
durationMs?: number;
context?: Record<string, unknown>;
payload?: Record<string, unknown>;
error?: { name: string; message: string };
}
export interface PiObservability {
getContext(): PiObservabilityContext | undefined;
runWithContext<T>(context: PiObservabilityContext, fn: () => T): T;
emit(event: PiObservabilityEvent): void;
hasSubscribers(): boolean;
}
```
Public API:
```ts
export function configurePiObservability(observability: PiObservability): void;
export function subscribePiObservability(listener: (event: PiObservabilityEvent) => void): () => void;
export function runWithPiContext<T>(userContext: Record<string, unknown>, fn: () => T): T;
export function traceOperation<T>(name: string, payload: Record<string, unknown>, fn: () => T): T;
```
`traceOperation()`:
1. reads the current context
2. creates `traceId` if missing
3. creates a new `spanId`
4. uses current span as `parentSpanId`
5. emits `start`
6. runs callback under child context
7. emits `end` or `error`
8. rethrows on error
Pseudo-code:
```ts
function traceOperation<T>(name: string, payload: Record<string, unknown>, fn: () => T): T {
const parent = getContext();
const traceId = parent?.traceId ?? createId();
const spanId = createId();
const parentSpanId = parent?.currentSpanId;
const child = { ...parent, traceId, currentSpanId: spanId };
emit({ type: "start", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: parent?.userContext, payload });
return runWithContext(child, () => {
try {
const result = fn();
// Promise-aware implementation emits end/error after settlement.
emit({ type: "end", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload });
return result;
} catch (error) {
emit({ type: "error", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload, error: serializeError(error) });
throw error;
}
});
}
```
## Runtime adapters
Core packages should not import Node-only APIs.
Possible implementations:
- Node adapter: `AsyncLocalStorage` for context, optional `diagnostics_channel` publishing.
- Browser/workers fallback: local subscriber set and limited/manual context propagation.
- Bun/Deno adapters: use runtime-specific async context if available.
For Node, diagnostics channels can be used as a passive event bus:
```ts
import { channel } from "diagnostics_channel";
channel("pi.observability").publish(event);
```
Subscribers can create OTel/Sentry spans without monkey-patching pi.
## What pi emits
Pi emits what happened. It does not create OTel/Sentry spans directly.
Initial minimal event names:
```text
pi.agent.prompt
pi.agent.skill
pi.agent.prompt_template
pi.agent.compaction
pi.agent.branch_navigation
pi.agent.session.append_entry
pi.ai.provider.request
```
Each operation emits:
```text
start
end
error
```
Later additions:
```text
pi.agent.turn
pi.agent.tool_call
pi.agent.queue_update
pi.ai.provider.retry
pi.ai.provider.first_token
pi.ai.provider.usage
pi.session.read
pi.session.write
```
## Minimal instrumentation points
### packages/agent
Wrap:
- `AgentHarness.prompt()`
- `AgentHarness.skill()`
- `AgentHarness.promptFromTemplate()`
- `AgentHarness.compact()`
- `AgentHarness.navigateTree()`
- `Session.appendTypedEntry()` or storage append facade
Example:
```ts
return traceOperation(
"pi.agent.prompt",
{
sessionId: turnState.sessionId,
provider: turnState.model.provider,
model: turnState.model.id,
promptLength: text.length,
imageCount: options?.images?.length ?? 0,
},
() => this.executeTurn(turnState, text, options),
);
```
Session write:
```ts
return traceOperation(
"pi.agent.session.append_entry",
{ entryType: entry.type },
async () => {
await this.unwrap(this.storage.appendEntry(entry));
return entry.id;
},
);
```
### packages/ai
Wrap common provider boundaries:
- `streamSimple()`
- `completeSimple()`
Example:
```ts
return traceOperation(
"pi.ai.provider.request",
{
api: model.api,
provider: model.provider,
model: model.id,
sessionId: options.sessionId,
reasoning: options.reasoning,
},
() => actualStreamSimple(model, context, options),
);
```
End/error payloads can include safe metadata:
- stop reason
- status code
- retry count
- input/output/total tokens
- cost total
- aborted/timeout flag
## Safety and redaction
Default payloads must be safe.
Safe by default:
- provider
- model
- API identifier
- session id
- entry type
- tool name
- status code
- stop reason
- token counts
- costs
- durations
Unsafe by default:
- prompts
- completions
- tool args
- tool results
- shell output
- file contents
- provider request payloads
- provider response bodies
- API keys
- headers
Content capture can be opt-in later with explicit redaction hooks.
## Listener behavior
Observability must never affect pi execution.
Subscriber errors should be swallowed or isolated. Harness hooks are control-plane and may affect execution; observability subscribers are passive and must not.
## User context
Users can associate arbitrary context with a turn:
```ts
await runWithPiContext(
{
userId: "u123",
orgId: "acme",
region: "eu",
},
() => harness.prompt("fix this"),
);
```
Every emitted event inside that async chain includes the context:
```ts
{
type: "start",
name: "pi.ai.provider.request",
traceId: "t1",
spanId: "s3",
parentSpanId: "s1",
context: {
userId: "u123",
orgId: "acme",
region: "eu",
},
payload: {
provider: "anthropic",
model: "claude-sonnet-4",
},
}
```
An OTel adapter can map this to span attributes. A Sentry adapter can map it to Sentry context/spans. A custom user can log JSON.
## Package story
Minimal initial package:
```text
packages/observability
runtime-agnostic context + traceOperation + subscribe
```
Then:
```text
packages/ai
emits pi.ai.* events
packages/agent
emits pi.agent.* / pi.session.* events
```
Optional later:
```text
packages/observability-node
AsyncLocalStorage + diagnostics_channel bridge
packages/otel
subscribes to pi events and creates OpenTelemetry spans
```
## Thesis
Pi defines a stable, safe event contract. Adapters define where events go.
This makes ai/harness observable without binding core packages to OTel, Sentry, Node-only APIs, or monkey-patching.

View File

@@ -1,10 +1,21 @@
{
"name": "@earendil-works/pi-agent-core",
"version": "0.74.0",
"version": "0.75.4",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md"
@@ -12,15 +23,16 @@
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json",
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
"test": "vitest --run",
"test:harness": "vitest --run --config vitest.harness.config.ts",
"coverage:harness": "vitest --run --config vitest.harness.config.ts --coverage",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.74.0",
"ignore": "^7.0.5",
"typebox": "^1.1.24",
"yaml": "^2.8.2"
"@earendil-works/pi-ai": "^0.75.4",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
},
"keywords": [
"ai",
@@ -37,11 +49,12 @@
"directory": "packages/agent"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.19.0"
},
"devDependencies": {
"@types/node": "^24.3.0",
"typescript": "^5.7.3",
"vitest": "^3.2.4"
"@types/node": "24.12.4",
"@vitest/coverage-v8": "3.2.4",
"typescript": "5.9.3",
"vitest": "3.2.4"
}
}

View File

@@ -20,7 +20,7 @@ import type {
AgentToolCall,
AgentToolResult,
StreamFn,
} from "./types.js";
} from "./types.ts";
export type AgentEventSink = (event: AgentEvent) => Promise<void> | void;
@@ -436,6 +436,10 @@ async function executeToolCallsSequential(
await emitToolResultMessage(toolResultMessage, emit);
finalizedCalls.push(finalized);
messages.push(toolResultMessage);
if (signal?.aborted) {
break;
}
}
return {
@@ -471,6 +475,9 @@ async function executeToolCallsParallel(
} satisfies FinalizedToolCallOutcome;
await emitToolExecutionEnd(finalized, emit);
finalizedCalls.push(finalized);
if (signal?.aborted) {
break;
}
continue;
}
@@ -487,6 +494,9 @@ async function executeToolCallsParallel(
await emitToolExecutionEnd(finalized, emit);
return finalized;
});
if (signal?.aborted) {
break;
}
}
const orderedFinalizedCalls = await Promise.all(
@@ -578,6 +588,13 @@ async function prepareToolCall(
},
signal,
);
if (signal?.aborted) {
return {
kind: "immediate",
result: createErrorToolResult("Operation aborted"),
isError: true,
};
}
if (beforeResult?.block) {
return {
kind: "immediate",
@@ -586,6 +603,13 @@ async function prepareToolCall(
};
}
}
if (signal?.aborted) {
return {
kind: "immediate",
result: createErrorToolResult("Operation aborted"),
isError: true,
};
}
return {
kind: "prepared",
toolCall,

View File

@@ -8,7 +8,7 @@ import {
type ThinkingBudgets,
type Transport,
} from "@earendil-works/pi-ai";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
import type {
AfterToolCallContext,
AfterToolCallResult,
@@ -21,9 +21,12 @@ import type {
AgentTool,
BeforeToolCallContext,
BeforeToolCallResult,
QueueMode,
StreamFn,
ToolExecutionMode,
} from "./types.js";
} from "./types.ts";
export type { QueueMode } from "./types.ts";
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
return messages.filter(
@@ -53,8 +56,6 @@ const DEFAULT_MODEL = {
maxTokens: 0,
} satisfies Model<any>;
export type QueueMode = "all" | "one-at-a-time";
type MutableAgentState = Omit<AgentState, "isStreaming" | "streamingMessage" | "pendingToolCalls" | "errorMessage"> & {
isStreaming: boolean;
streamingMessage?: AgentMessage;
@@ -116,8 +117,11 @@ export interface AgentOptions {
class PendingMessageQueue {
private messages: AgentMessage[] = [];
public mode: QueueMode;
constructor(public mode: QueueMode) {}
constructor(mode: QueueMode) {
this.mode = mode;
}
enqueue(message: AgentMessage): void {
this.messages.push(message);

File diff suppressed because it is too large Load Diff

View File

@@ -1,115 +1,81 @@
/**
* Branch summarization for tree navigation.
*
* When navigating to a different point in the session tree, this generates
* a summary of the branch being left so context isn't lost.
*/
import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
import type { Model } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../../types.js";
import type { AgentMessage } from "../../types.ts";
import {
convertToLlm,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "../messages.js";
import type { Session, SessionTreeEntry } from "../types.js";
import { estimateTokens } from "./compaction.js";
} from "../messages.ts";
import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.ts";
import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.ts";
import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts";
import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
} from "./utils.ts";
// ============================================================================
// Types
// ============================================================================
export interface BranchSummaryResult {
summary?: string;
readFiles?: string[];
modifiedFiles?: string[];
aborted?: boolean;
error?: string;
}
/** Details stored in BranchSummaryEntry.details for file tracking */
/** File-operation details stored on generated branch summary entries. */
export interface BranchSummaryDetails {
/** Files read while exploring the summarized branch. */
readFiles: string[];
/** Files modified while exploring the summarized branch. */
modifiedFiles: string[];
}
export type { FileOperations } from "./utils.js";
export type { FileOperations } from "./utils.ts";
/** Prepared branch content for summarization. */
export interface BranchPreparation {
/** Messages extracted for summarization, in chronological order */
/** Messages selected for the branch summary. */
messages: AgentMessage[];
/** File operations extracted from tool calls */
/** File operations extracted from the branch. */
fileOps: FileOperations;
/** Total estimated tokens in messages */
/** Estimated token count for selected messages. */
totalTokens: number;
}
/** Entries selected for branch summarization. */
export interface CollectEntriesResult {
/** Entries to summarize, in chronological order */
/** Entries to summarize in chronological order. */
entries: SessionTreeEntry[];
/** Common ancestor between old and new position, if any */
/** Deepest common ancestor between the previous leaf and target entry. */
commonAncestorId: string | null;
}
/** Options for generating a branch summary. */
export interface GenerateBranchSummaryOptions {
/** Model to use for summarization */
/** Model used for summarization. */
model: Model<any>;
/** API key for the model */
/** API key forwarded to the provider. */
apiKey: string;
/** Request headers for the model */
/** Optional request headers forwarded to the provider. */
headers?: Record<string, string>;
/** Abort signal for cancellation */
/** Abort signal for the summarization request. */
signal: AbortSignal;
/** Optional custom instructions for summarization */
/** Optional instructions appended to or replacing the default prompt. */
customInstructions?: string;
/** If true, customInstructions replaces the default prompt instead of being appended */
/** Replace the default prompt with custom instructions instead of appending them. */
replaceInstructions?: boolean;
/** Tokens reserved for prompt + LLM response (default 16384) */
/** Tokens reserved for prompt and model output. Defaults to 16384. */
reserveTokens?: number;
}
// ============================================================================
// Entry Collection
// ============================================================================
/**
* Collect entries that should be summarized when navigating from one position to another.
*
* Walks from oldLeafId back to the common ancestor with targetId, collecting entries
* along the way. Does NOT stop at compaction boundaries - those are included and their
* summaries become context.
*
* @param session - Session manager (read-only access)
* @param oldLeafId - Current position (where we're navigating from)
* @param targetId - Target position (where we're navigating to)
* @returns Entries to summarize and the common ancestor
*/
/** Collect entries that should be summarized before navigating to a different session tree entry. */
export async function collectEntriesForBranchSummary(
session: Session,
oldLeafId: string | null,
targetId: string,
): Promise<CollectEntriesResult> {
// If no old position, nothing to summarize
if (!oldLeafId) {
return { entries: [], commonAncestorId: null };
}
// Find common ancestor (deepest node that's on both paths)
const oldPath = new Set((await session.getBranch(oldLeafId)).map((e) => e.id));
const targetPath = await session.getBranch(targetId);
// targetPath is root-first, so iterate backwards to find deepest common ancestor
let commonAncestorId: string | null = null;
for (let i = targetPath.length - 1; i >= 0; i--) {
if (oldPath.has(targetPath[i].id)) {
@@ -117,85 +83,48 @@ export async function collectEntriesForBranchSummary(
break;
}
}
// Collect entries from old leaf back to common ancestor
const entries: SessionTreeEntry[] = [];
let current: string | null = oldLeafId;
while (current && current !== commonAncestorId) {
const entry = await session.getEntry(current);
if (!entry) break;
if (!entry) throw new SessionError("invalid_session", `Entry ${current} not found`);
entries.push(entry as SessionTreeEntry);
current = entry.parentId;
}
// Reverse to get chronological order
entries.reverse();
return { entries, commonAncestorId };
}
// ============================================================================
// Entry to Message Conversion
// ============================================================================
/**
* Extract AgentMessage from a session entry.
* Similar to getMessageFromEntry in compaction.ts but also handles compaction entries.
*/
function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined {
switch (entry.type) {
case "message":
// Skip tool results - context is in assistant's tool call
if (entry.message.role === "toolResult") return undefined;
return entry.message as AgentMessage;
return entry.message;
case "custom_message":
return createCustomMessage(
entry.customType,
entry.content as string | (TextContent | ImageContent)[],
entry.display,
entry.details,
entry.timestamp,
);
return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
case "branch_summary":
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
case "compaction":
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
// These don't contribute to conversation content
case "thinking_level_change":
case "model_change":
case "custom":
case "label":
case "session_info":
case "leaf":
return undefined;
}
}
/**
* Prepare entries for summarization with token budget.
*
* Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget.
* This ensures we keep the most recent context when the branch is too long.
*
* Also collects file operations from:
* - Tool calls in assistant messages
* - Existing branch_summary entries' details (for cumulative tracking)
*
* @param entries - Entries in chronological order
* @param tokenBudget - Maximum tokens to include (0 = no limit)
*/
/** Prepare branch entries for summarization within an optional token budget. */
export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: number = 0): BranchPreparation {
const messages: AgentMessage[] = [];
const fileOps = createFileOps();
let totalTokens = 0;
// First pass: collect file ops from ALL entries (even if they don't fit in token budget)
// This ensures we capture cumulative file tracking from nested branch summaries
// Only extract from pi-generated summaries (fromHook !== true), not extension-generated ones
for (const entry of entries) {
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
const details = entry.details as BranchSummaryDetails;
@@ -203,35 +132,26 @@ export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: n
for (const f of details.readFiles) fileOps.read.add(f);
}
if (Array.isArray(details.modifiedFiles)) {
// Modified files go into both edited and written for proper deduplication
for (const f of details.modifiedFiles) {
fileOps.edited.add(f);
}
}
}
}
// Second pass: walk from newest to oldest, adding messages until token budget
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
const message = getMessageFromEntry(entry);
if (!message) continue;
// Extract file ops from assistant messages (tool calls)
extractFileOpsFromMessage(message, fileOps);
const tokens = estimateTokens(message);
// Check budget before adding
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
// If this is a summary entry, try to fit it anyway as it's important context
if (entry.type === "compaction" || entry.type === "branch_summary") {
if (totalTokens < tokenBudget * 0.9) {
messages.unshift(message);
totalTokens += tokens;
}
}
// Stop - we've hit the budget
break;
}
@@ -242,10 +162,6 @@ export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: n
return { messages, fileOps, totalTokens };
}
// ============================================================================
// Summary Generation
// ============================================================================
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
Summary of that exploration:
@@ -280,34 +196,22 @@ Use this EXACT format:
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
/**
* Generate a summary of abandoned branch entries.
*
* @param entries - Session entries to summarize (chronological order)
* @param options - Generation options
*/
/** Generate a summary for abandoned branch entries. */
export async function generateBranchSummary(
entries: SessionTreeEntry[],
options: GenerateBranchSummaryOptions,
): Promise<BranchSummaryResult> {
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
// Token budget = context window minus reserved space for prompt + response
const contextWindow = model.contextWindow || 128000;
const tokenBudget = contextWindow - reserveTokens;
const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);
if (messages.length === 0) {
return { summary: "No content to summarize" };
return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] });
}
// Transform to LLM-compatible messages, then serialize to text
// Serialization prevents the model from treating it as a conversation to continue
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
// Build prompt
let instructions: string;
if (replaceInstructions && customInstructions) {
instructions = customInstructions;
@@ -325,37 +229,34 @@ export async function generateBranchSummary(
timestamp: Date.now(),
},
];
// Call LLM for summarization
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ apiKey, headers, signal, maxTokens: 2048 },
);
// Check if aborted or errored
if (response.stopReason === "aborted") {
return { aborted: true };
return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"));
}
if (response.stopReason === "error") {
return { error: response.errorMessage || "Summarization failed" };
return err(
new BranchSummaryError(
"summarization_failed",
`Branch summary failed: ${response.errorMessage || "Unknown error"}`,
),
);
}
let summary = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
// Prepend preamble to provide context about the branch summary
summary = BRANCH_SUMMARY_PREAMBLE + summary;
// Compute file lists and append to summary
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
return {
return ok({
summary: summary || "No summary generated",
readFiles,
modifiedFiles,
};
});
}

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