Compare commits

..

2 Commits

Author SHA1 Message Date
YuTengjing d47848c7a1 feat(runtime): refine runtime request types 2026-05-15 11:37:25 +08:00
YuTengjing 8af46e7511 feat(runtime): define request trigger metadata 2026-05-15 11:37:24 +08:00
367 changed files with 2205 additions and 32547 deletions
+8 -9
View File
@@ -85,12 +85,11 @@ errorElement: <ErrorBoundary />;
## Common Mistakes
| Mistake | Fix |
| ----------------------------------------------------------------- | ----------------------------------------------------------------- |
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
| `createStyles` for static styles | Use `createStaticStyles` + `cssVar` |
| Editing only `desktopRouter.config.tsx` | Must edit both `.tsx` and `.desktop.tsx` |
| Using `margin` for flex spacing | Use `gap` prop on Flexbox |
| Accessing zustand store without selector | Use selectors to access store data (see zustand skill) |
| Text or icon-text actions built with `Flexbox`/`Text` + `onClick` | Use `Button type={'text'} size={'small'}` with `icon` when needed |
| Mistake | Fix |
| ---------------------------------------- | ------------------------------------------------------ |
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
| `createStyles` for static styles | Use `createStaticStyles` + `cssVar` |
| Editing only `desktopRouter.config.tsx` | Must edit both `.tsx` and `.desktop.tsx` |
| Using `margin` for flex spacing | Use `gap` prop on Flexbox |
| Accessing zustand store without selector | Use selectors to access store data (see zustand skill) |
-40
View File
@@ -21,46 +21,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
# Remind contributors when a non-release PR targets `main`.
# Day-to-day PRs should target `canary`; `main` is reserved for releases
# (see .agents/skills/version-release/SKILL.md). Allowed exceptions:
# - PR title matches `🚀 release: v{x.y.z}` (minor release)
# - head branch matches `hotfix/*` or `release/*` (patch release)
- name: Remind contributor if base branch is not canary
if: github.event.action == 'opened' && github.event.pull_request.base.ref == 'main'
env:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
if [[ "$HEAD_REF" == hotfix/* ]] || [[ "$HEAD_REF" == release/* ]]; then
echo "✅ Release/hotfix branch ($HEAD_REF) -> main is allowed"
exit 0
fi
if [[ "$PR_TITLE" =~ ^🚀[[:space:]]+release: ]]; then
echo "✅ Release-titled PR -> main is allowed"
exit 0
fi
echo "⚠️ Non-release PR targets main; posting reminder comment."
gh pr comment "$PR_NUMBER" --body "$(cat <<'EOF'
👋 Thanks for your contribution!
This PR currently targets the **`main`** branch, but `main` is reserved for release PRs only. Day-to-day development (features, fixes, refactors, docs, etc.) should target the **`canary`** branch.
### How to fix
On the PR page, click **Edit** next to the title, then change the base branch from `main` to `canary`.
### When targeting `main` is allowed
- PR title starts with `🚀 release: v{x.y.z}` (minor release)
- Head branch matches `hotfix/*` or `release/*` (patch release)
If your PR fits one of these cases, please ignore this message.
EOF
)"
- name: Check if author is a team member
id: check-team
run: |
-236
View File
@@ -2,242 +2,6 @@
# Changelog
### [Version 2.2.0](https://github.com/lobehub/lobe-chat/compare/v2.1.59-canary.27...v2.2.0)
<sup>Released on **2026-05-18**</sup>
#### 💄 Styles
- **pricing**: restore DeepSeek models to official pricing.
#### 🐛 Bug Fixes
- **conversation**: animate only the last markdown block + drop clearMessages hotkey.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **pricing**: restore DeepSeek models to official pricing, closes [#14911](https://github.com/lobehub/lobe-chat/issues/14911) ([e566688](https://github.com/lobehub/lobe-chat/commit/e566688))
#### What's fixed
- **conversation**: animate only the last markdown block + drop clearMessages hotkey, closes [#14906](https://github.com/lobehub/lobe-chat/issues/14906) ([469a8e6](https://github.com/lobehub/lobe-chat/commit/469a8e6))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.1.58](https://github.com/lobehub/lobe-chat/compare/v2.1.57...v2.1.58)
<sup>Released on **2026-05-13**</sup>
#### ✨ Features
- **agent-runtime**: persist agent operations to `agent_operations` table.
- **misc**: support slack mpim and fix discord dm problem.
- **database**: add `agent_operations` table.
- **markdown**: user_feedback card + task card polish + Run now context menu.
- **documents**: add optimistic create/delete and inline rename for document tree.
- **devtools**: add dev-only feature flag override panel.
- **misc**: add service model assignments settings.
- **misc**: inline skill auth in recommended task templates.
- **activator**: require activation reason.
- **agent-signal,server,prompts**: consolidate in self-review implemented.
- **hetero-agent**: support AskUserQuestion tools for claude code.
- **bot**: gate device tools by sender identity.
- **misc**: add user activity business hook.
- **misc**: add Gemini 3.1 Flash-Lite provider cards.
- **misc**: home daily brief with linkable welcome + paired input hint.
- **agent-signal,prompts,database**: self-review now proposal actions to briefs, and automatically execute actions.
- **misc**: add signOperationJwt with 4h expiry for hetero-agent operations.
- **misc**: migrate Notion to LobeHub Market.
- **misc**: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context.
#### 🐛 Bug Fixes
- **hetero-agent**: wire AskUserBridge response events to renderer.
- **home**: blank user bubble when sending the placeholder hint.
- **conversation**: prevent synthetic scroll from shrinking spacer.
- **task-card**: localize task card date independent of dayjs global locale.
- **web-crawler**: cap response body size to prevent serverless OOM.
- **desktop**: focus onboarding auth success state.
- **misc**: Docs image.
- **desktop**: detect Windows npm .cmd shims for CLI agents (claude/codex/…).
- **misc**: update Task page placeholder copy.
- **builtin-tool-task**: expose `lobe-task` and add `setTaskSchedule`.
- **desktop**: reset pendingLoginMethod on auth failure/cancel paths.
- **utils**: cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit.
- **tasks**: scheduler, hotkey, comment & TodoList polish.
- **cli**: remove stale cron entry from generated man page.
- **misc**: sidebar add agent.
- **misc**: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop.
- **heteroFinish**: trigger task lifecycle on cloud sandbox agent completion.
- **hotkey**: remove redundant onClear to prevent double updateHotkey calls.
- **misc**: reject inactive OIDC access.
- **misc**: drop unreachable aihubmix empty-apiKey test.
- **aihubmix**: use full models endpoint to return complete model list.
- **onboarding**: skip marketplace on early exit, drop CJK in prompts.
- **model-runtime**: enrich stream parse errors with provider/model context.
- **home**: strip markdown links from daily-brief input placeholder.
- **misc**: consume visual content parts in server runtime.
- **misc**: store onboarding interests as keys.
- **hetero-agent**: sync new-step assistant across replicas.
- **misc**: remove the old cron job from lobehub.
- **misc**: refresh content baseline from DB on every ingest call.
- **hetero-agent**: disable Claude Code AskUserQuestion to avoid auto-decline.
- **local-system**: guard readFile against binary blobs and oversized output.
- **database,utils,userMemories**: should perfer to use `paradedb.match(...)` instead of hardcoded normalizer.
- **database**: attach error listeners to Neon/Node pools to prevent Lambda crash.
- **misc**: gateway client-tool pluginState + drop redundant `Exit code: 0` tail.
- **gemini**: handle zero cachedContentTokenCount in usage conversion.
- **misc**: first inject the cloudecc runtime session should use the existingStatus.
- **misc**: slack connect error & slash commands.
- **misc**: polish task agent manager.
- **agent-runtime**: recover malformed tool_call names instead of finishing silently.
- **misc**: remove signin captcha flow.
- **misc**: add temporary email auth error locale.
- **misc**: add bot callback service.
- **misc**: sanitize sensitive comments and examples from production JS bundle.
- **misc**: multiple account link.
#### 💄 Styles
- **misc**: use @lobehub/ui built-in HtmlPreview instead of custom component.
- **misc**: polish desktop header icons, sidebar density, and task menus.
- **review-panel**: hover revert button to discard per-file working-tree changes.
- **misc**: standardize header action icon sizes.
- **tool**: add word wrap toggle to tool arguments display.
- **nav**: unify ActionIcon sizing and improve TodoList encapsulation.
- **web-onboarding**: add Render for saveUserQuestion & showAgentMarketplace.
- **misc**: add `reasoning_effort` support for Grok 4.3.
- **misc**: increase chat topic title length.
- **hetero-agent**: read-only SubAgent threads with breadcrumb header and thread switcher.
- **chat-input**: show skeleton in action bar while config is loading.
- **home**: add Recommendations module with hetero agent action library.
- **copyable-label**: wrap long tool-call params instead of truncating.
- **misc**: format tool execution time as Xmin Ys instead of X.Y min.
- **misc**: Add new DeepSeek-V4 models.
- **topic**: add copy session ID to topic dropdown menu.
- **misc**: use visible divider between queued messages.
- **intervention**: polish confirmation bar layout.
- **settings**: remove image avatar from lab input markdown rendering item.
- **task**: activity card stop run + register /tasks in SPA proxy.
- **misc**: update auth captcha retry copy.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **agent-runtime**: persist agent operations to `agent_operations` table, closes [#14736](https://github.com/lobehub/lobe-chat/issues/14736) ([a772341](https://github.com/lobehub/lobe-chat/commit/a772341))
- **misc**: support slack mpim and fix discord dm problem, closes [#14733](https://github.com/lobehub/lobe-chat/issues/14733) ([729265a](https://github.com/lobehub/lobe-chat/commit/729265a))
- **database**: add `agent_operations` table, closes [#14416](https://github.com/lobehub/lobe-chat/issues/14416) ([cb8b616](https://github.com/lobehub/lobe-chat/commit/cb8b616))
- **markdown**: user_feedback card + task card polish + Run now context menu, closes [#14727](https://github.com/lobehub/lobe-chat/issues/14727) ([79152fa](https://github.com/lobehub/lobe-chat/commit/79152fa))
- **documents**: add optimistic create/delete and inline rename for document tree, closes [#14714](https://github.com/lobehub/lobe-chat/issues/14714) ([0007984](https://github.com/lobehub/lobe-chat/commit/0007984))
- **devtools**: add dev-only feature flag override panel, closes [#14565](https://github.com/lobehub/lobe-chat/issues/14565) ([18b1c25](https://github.com/lobehub/lobe-chat/commit/18b1c25))
- **misc**: add service model assignments settings, closes [#14712](https://github.com/lobehub/lobe-chat/issues/14712) ([eb924ec](https://github.com/lobehub/lobe-chat/commit/eb924ec))
- **misc**: inline skill auth in recommended task templates, closes [#14676](https://github.com/lobehub/lobe-chat/issues/14676) ([4490e3e](https://github.com/lobehub/lobe-chat/commit/4490e3e))
- **activator**: require activation reason, closes [#14597](https://github.com/lobehub/lobe-chat/issues/14597) ([5f14b7e](https://github.com/lobehub/lobe-chat/commit/5f14b7e))
- **agent-signal,server,prompts**: consolidate in self-review implemented, closes [#14657](https://github.com/lobehub/lobe-chat/issues/14657) ([1374fd2](https://github.com/lobehub/lobe-chat/commit/1374fd2))
- **hetero-agent**: support AskUserQuestion tools for claude code, closes [#14639](https://github.com/lobehub/lobe-chat/issues/14639) ([49c3d7e](https://github.com/lobehub/lobe-chat/commit/49c3d7e))
- **bot**: gate device tools by sender identity, closes [#14634](https://github.com/lobehub/lobe-chat/issues/14634) ([3c81011](https://github.com/lobehub/lobe-chat/commit/3c81011))
- **misc**: add user activity business hook, closes [#14601](https://github.com/lobehub/lobe-chat/issues/14601) ([521566b](https://github.com/lobehub/lobe-chat/commit/521566b))
- **misc**: add Gemini 3.1 Flash-Lite provider cards, closes [#14604](https://github.com/lobehub/lobe-chat/issues/14604) ([9b032f0](https://github.com/lobehub/lobe-chat/commit/9b032f0))
- **misc**: home daily brief with linkable welcome + paired input hint, closes [#14589](https://github.com/lobehub/lobe-chat/issues/14589) ([12e37f1](https://github.com/lobehub/lobe-chat/commit/12e37f1))
- **agent-signal,prompts,database**: self-review now proposal actions to briefs, and automatically execute actions, closes [#14583](https://github.com/lobehub/lobe-chat/issues/14583) ([b7a5020](https://github.com/lobehub/lobe-chat/commit/b7a5020))
- **misc**: add signOperationJwt with 4h expiry for hetero-agent operations, closes [#14586](https://github.com/lobehub/lobe-chat/issues/14586) ([d2c379c](https://github.com/lobehub/lobe-chat/commit/d2c379c))
- **misc**: migrate Notion to LobeHub Market, closes [#14578](https://github.com/lobehub/lobe-chat/issues/14578) ([f1f2e58](https://github.com/lobehub/lobe-chat/commit/f1f2e58))
- **misc**: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context, closes [#14568](https://github.com/lobehub/lobe-chat/issues/14568) ([7792f63](https://github.com/lobehub/lobe-chat/commit/7792f63))
#### What's fixed
- **hetero-agent**: wire AskUserBridge response events to renderer, closes [#14732](https://github.com/lobehub/lobe-chat/issues/14732) ([5174c13](https://github.com/lobehub/lobe-chat/commit/5174c13))
- **home**: blank user bubble when sending the placeholder hint, closes [#14678](https://github.com/lobehub/lobe-chat/issues/14678) ([fc275ca](https://github.com/lobehub/lobe-chat/commit/fc275ca))
- **conversation**: prevent synthetic scroll from shrinking spacer, closes [#14584](https://github.com/lobehub/lobe-chat/issues/14584) ([217afcf](https://github.com/lobehub/lobe-chat/commit/217afcf))
- **task-card**: localize task card date independent of dayjs global locale, closes [#14730](https://github.com/lobehub/lobe-chat/issues/14730) ([df0e635](https://github.com/lobehub/lobe-chat/commit/df0e635))
- **web-crawler**: cap response body size to prevent serverless OOM, closes [#14660](https://github.com/lobehub/lobe-chat/issues/14660) ([2202189](https://github.com/lobehub/lobe-chat/commit/2202189))
- **desktop**: focus onboarding auth success state, closes [#14694](https://github.com/lobehub/lobe-chat/issues/14694) ([4e4294f](https://github.com/lobehub/lobe-chat/commit/4e4294f))
- **misc**: Docs image, closes [#14726](https://github.com/lobehub/lobe-chat/issues/14726) ([3a4bd4a](https://github.com/lobehub/lobe-chat/commit/3a4bd4a))
- **desktop**: detect Windows npm .cmd shims for CLI agents (claude/codex/…), closes [#14720](https://github.com/lobehub/lobe-chat/issues/14720) ([a40fe91](https://github.com/lobehub/lobe-chat/commit/a40fe91))
- **misc**: update Task page placeholder copy, closes [#14704](https://github.com/lobehub/lobe-chat/issues/14704) ([eea742f](https://github.com/lobehub/lobe-chat/commit/eea742f))
- **builtin-tool-task**: expose `lobe-task` and add `setTaskSchedule`, closes [#14713](https://github.com/lobehub/lobe-chat/issues/14713) ([5ff4590](https://github.com/lobehub/lobe-chat/commit/5ff4590))
- **desktop**: reset pendingLoginMethod on auth failure/cancel paths, closes [#14695](https://github.com/lobehub/lobe-chat/issues/14695) ([51cefe0](https://github.com/lobehub/lobe-chat/commit/51cefe0))
- **utils**: cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit, closes [#14711](https://github.com/lobehub/lobe-chat/issues/14711) ([948e48b](https://github.com/lobehub/lobe-chat/commit/948e48b))
- **tasks**: scheduler, hotkey, comment & TodoList polish, closes [#14707](https://github.com/lobehub/lobe-chat/issues/14707) ([1ae774d](https://github.com/lobehub/lobe-chat/commit/1ae774d))
- **cli**: remove stale cron entry from generated man page, closes [#14709](https://github.com/lobehub/lobe-chat/issues/14709) ([94e4ea6](https://github.com/lobehub/lobe-chat/commit/94e4ea6))
- **misc**: sidebar add agent, closes [#14693](https://github.com/lobehub/lobe-chat/issues/14693) ([fdedc96](https://github.com/lobehub/lobe-chat/commit/fdedc96))
- **misc**: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop, closes [#185](https://github.com/lobehub/lobe-chat/issues/185), closes [#14689](https://github.com/lobehub/lobe-chat/issues/14689) ([7349ad0](https://github.com/lobehub/lobe-chat/commit/7349ad0))
- **heteroFinish**: trigger task lifecycle on cloud sandbox agent completion, closes [#14681](https://github.com/lobehub/lobe-chat/issues/14681) ([744059c](https://github.com/lobehub/lobe-chat/commit/744059c))
- **hotkey**: remove redundant onClear to prevent double updateHotkey calls, closes [#14663](https://github.com/lobehub/lobe-chat/issues/14663) ([dfe1932](https://github.com/lobehub/lobe-chat/commit/dfe1932))
- **misc**: reject inactive OIDC access, closes [#14674](https://github.com/lobehub/lobe-chat/issues/14674) ([b79c5d8](https://github.com/lobehub/lobe-chat/commit/b79c5d8))
- **misc**: drop unreachable aihubmix empty-apiKey test, closes [#14669](https://github.com/lobehub/lobe-chat/issues/14669) ([b0ee35d](https://github.com/lobehub/lobe-chat/commit/b0ee35d))
- **aihubmix**: use full models endpoint to return complete model list, closes [#14511](https://github.com/lobehub/lobe-chat/issues/14511) ([f4de472](https://github.com/lobehub/lobe-chat/commit/f4de472))
- **onboarding**: skip marketplace on early exit, drop CJK in prompts, closes [#14598](https://github.com/lobehub/lobe-chat/issues/14598) ([a9eb904](https://github.com/lobehub/lobe-chat/commit/a9eb904))
- **model-runtime**: enrich stream parse errors with provider/model context, closes [#14636](https://github.com/lobehub/lobe-chat/issues/14636) ([7daed90](https://github.com/lobehub/lobe-chat/commit/7daed90))
- **home**: strip markdown links from daily-brief input placeholder, closes [#14635](https://github.com/lobehub/lobe-chat/issues/14635) ([0babdcf](https://github.com/lobehub/lobe-chat/commit/0babdcf))
- **misc**: consume visual content parts in server runtime, closes [#14637](https://github.com/lobehub/lobe-chat/issues/14637) ([d445a89](https://github.com/lobehub/lobe-chat/commit/d445a89))
- **misc**: store onboarding interests as keys, closes [#14624](https://github.com/lobehub/lobe-chat/issues/14624) ([9982de3](https://github.com/lobehub/lobe-chat/commit/9982de3))
- **hetero-agent**: sync new-step assistant across replicas, closes [#14631](https://github.com/lobehub/lobe-chat/issues/14631) ([7675bd9](https://github.com/lobehub/lobe-chat/commit/7675bd9))
- **misc**: remove the old cron job from lobehub, closes [#14630](https://github.com/lobehub/lobe-chat/issues/14630) ([457d112](https://github.com/lobehub/lobe-chat/commit/457d112))
- **misc**: refresh content baseline from DB on every ingest call, closes [#14603](https://github.com/lobehub/lobe-chat/issues/14603) ([6595961](https://github.com/lobehub/lobe-chat/commit/6595961))
- **hetero-agent**: disable Claude Code AskUserQuestion to avoid auto-decline, closes [#14629](https://github.com/lobehub/lobe-chat/issues/14629) ([ae8f9cf](https://github.com/lobehub/lobe-chat/commit/ae8f9cf))
- **local-system**: guard readFile against binary blobs and oversized output, closes [#14602](https://github.com/lobehub/lobe-chat/issues/14602) ([96165e4](https://github.com/lobehub/lobe-chat/commit/96165e4))
- **database,utils,userMemories**: should perfer to use `paradedb.match(...)` instead of hardcoded normalizer, closes [#14590](https://github.com/lobehub/lobe-chat/issues/14590) ([38b793f](https://github.com/lobehub/lobe-chat/commit/38b793f))
- **database**: attach error listeners to Neon/Node pools to prevent Lambda crash, closes [#14606](https://github.com/lobehub/lobe-chat/issues/14606) ([11ec59b](https://github.com/lobehub/lobe-chat/commit/11ec59b))
- **misc**: gateway client-tool pluginState + drop redundant `Exit code: 0` tail, closes [#14596](https://github.com/lobehub/lobe-chat/issues/14596) ([4bfd434](https://github.com/lobehub/lobe-chat/commit/4bfd434))
- **gemini**: handle zero cachedContentTokenCount in usage conversion, closes [#14567](https://github.com/lobehub/lobe-chat/issues/14567) ([307cd8e](https://github.com/lobehub/lobe-chat/commit/307cd8e))
- **misc**: first inject the cloudecc runtime session should use the existingStatus, closes [#14592](https://github.com/lobehub/lobe-chat/issues/14592) ([09c66ff](https://github.com/lobehub/lobe-chat/commit/09c66ff))
- **misc**: slack connect error & slash commands, closes [#14591](https://github.com/lobehub/lobe-chat/issues/14591) ([8274be0](https://github.com/lobehub/lobe-chat/commit/8274be0))
- **misc**: polish task agent manager, closes [#14569](https://github.com/lobehub/lobe-chat/issues/14569) ([a02ecbc](https://github.com/lobehub/lobe-chat/commit/a02ecbc))
- **agent-runtime**: recover malformed tool_call names instead of finishing silently, closes [#14577](https://github.com/lobehub/lobe-chat/issues/14577) ([5f8ec8b](https://github.com/lobehub/lobe-chat/commit/5f8ec8b))
- **misc**: remove signin captcha flow, closes [#14573](https://github.com/lobehub/lobe-chat/issues/14573) ([181b7eb](https://github.com/lobehub/lobe-chat/commit/181b7eb))
- **misc**: add temporary email auth error locale, closes [#14564](https://github.com/lobehub/lobe-chat/issues/14564) ([2bdd901](https://github.com/lobehub/lobe-chat/commit/2bdd901))
- **misc**: add bot callback service, closes [#14570](https://github.com/lobehub/lobe-chat/issues/14570) ([e4b5e52](https://github.com/lobehub/lobe-chat/commit/e4b5e52))
- **misc**: sanitize sensitive comments and examples from production JS bundle, closes [#14557](https://github.com/lobehub/lobe-chat/issues/14557) ([1a6e07b](https://github.com/lobehub/lobe-chat/commit/1a6e07b))
- **misc**: multiple account link, closes [#14562](https://github.com/lobehub/lobe-chat/issues/14562) ([760a342](https://github.com/lobehub/lobe-chat/commit/760a342))
#### Styles
- **misc**: use @lobehub/ui built-in HtmlPreview instead of custom component, closes [#14703](https://github.com/lobehub/lobe-chat/issues/14703) ([266d102](https://github.com/lobehub/lobe-chat/commit/266d102))
- **misc**: polish desktop header icons, sidebar density, and task menus, closes [#14724](https://github.com/lobehub/lobe-chat/issues/14724) ([e56edab](https://github.com/lobehub/lobe-chat/commit/e56edab))
- **review-panel**: hover revert button to discard per-file working-tree changes, closes [#14716](https://github.com/lobehub/lobe-chat/issues/14716) ([846e648](https://github.com/lobehub/lobe-chat/commit/846e648))
- **misc**: standardize header action icon sizes, closes [#14717](https://github.com/lobehub/lobe-chat/issues/14717) ([ca9a781](https://github.com/lobehub/lobe-chat/commit/ca9a781))
- **tool**: add word wrap toggle to tool arguments display, closes [#14706](https://github.com/lobehub/lobe-chat/issues/14706) ([bfa2850](https://github.com/lobehub/lobe-chat/commit/bfa2850))
- **nav**: unify ActionIcon sizing and improve TodoList encapsulation, closes [#14692](https://github.com/lobehub/lobe-chat/issues/14692) ([877052f](https://github.com/lobehub/lobe-chat/commit/877052f))
- **web-onboarding**: add Render for saveUserQuestion & showAgentMarketplace, closes [#14667](https://github.com/lobehub/lobe-chat/issues/14667) ([f591f7a](https://github.com/lobehub/lobe-chat/commit/f591f7a))
- **misc**: add `reasoning_effort` support for Grok 4.3, closes [#14642](https://github.com/lobehub/lobe-chat/issues/14642) ([a1fac45](https://github.com/lobehub/lobe-chat/commit/a1fac45))
- **misc**: increase chat topic title length, closes [#14659](https://github.com/lobehub/lobe-chat/issues/14659) ([e0ead0c](https://github.com/lobehub/lobe-chat/commit/e0ead0c))
- **hetero-agent**: read-only SubAgent threads with breadcrumb header and thread switcher, closes [#14658](https://github.com/lobehub/lobe-chat/issues/14658) ([31e9130](https://github.com/lobehub/lobe-chat/commit/31e9130))
- **chat-input**: show skeleton in action bar while config is loading, closes [#14656](https://github.com/lobehub/lobe-chat/issues/14656) ([84b802c](https://github.com/lobehub/lobe-chat/commit/84b802c))
- **home**: add Recommendations module with hetero agent action library, closes [#14645](https://github.com/lobehub/lobe-chat/issues/14645) ([e261a6f](https://github.com/lobehub/lobe-chat/commit/e261a6f))
- **copyable-label**: wrap long tool-call params instead of truncating, closes [#14640](https://github.com/lobehub/lobe-chat/issues/14640) ([60a127b](https://github.com/lobehub/lobe-chat/commit/60a127b))
- **misc**: format tool execution time as Xmin Ys instead of X.Y min, closes [#14641](https://github.com/lobehub/lobe-chat/issues/14641) ([b85a1ad](https://github.com/lobehub/lobe-chat/commit/b85a1ad))
- **misc**: Add new DeepSeek-V4 models, closes [#14110](https://github.com/lobehub/lobe-chat/issues/14110) ([867e22a](https://github.com/lobehub/lobe-chat/commit/867e22a))
- **topic**: add copy session ID to topic dropdown menu, closes [#14595](https://github.com/lobehub/lobe-chat/issues/14595) ([a275009](https://github.com/lobehub/lobe-chat/commit/a275009))
- **misc**: use visible divider between queued messages, closes [#14593](https://github.com/lobehub/lobe-chat/issues/14593) ([909b1ec](https://github.com/lobehub/lobe-chat/commit/909b1ec))
- **intervention**: polish confirmation bar layout, closes [#14587](https://github.com/lobehub/lobe-chat/issues/14587) ([5c11130](https://github.com/lobehub/lobe-chat/commit/5c11130))
- **settings**: remove image avatar from lab input markdown rendering item, closes [#14582](https://github.com/lobehub/lobe-chat/issues/14582) ([d73de25](https://github.com/lobehub/lobe-chat/commit/d73de25))
- **task**: activity card stop run + register /tasks in SPA proxy, closes [#14559](https://github.com/lobehub/lobe-chat/issues/14559) ([a7cc553](https://github.com/lobehub/lobe-chat/commit/a7cc553))
- **misc**: update auth captcha retry copy, closes [#14561](https://github.com/lobehub/lobe-chat/issues/14561) ([c208723](https://github.com/lobehub/lobe-chat/commit/c208723))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.1.57](https://github.com/lobehub/lobe-chat/compare/v2.1.57-canary.33...v2.1.57)
<sup>Released on **2026-05-09**</sup>
+1 -29
View File
@@ -123,40 +123,12 @@ export default defineConfig({
'utf-8-validate',
],
output: {
// Prevent shared deps from being bundled into index.js to avoid side-effect pollution.
// Pattern: when a module is imported by both the main bundle (statically) and a
// dynamic-import chunk (lazy loader), rolldown places it in main and makes the
// chunk back-reference `require("./index.js")`. Electron's main entry isn't in
// Node's CJS cache, so that require recompiles `index.js` from scratch — which
// re-runs `new App()` at top-level and triggers `protocol.registerSchemesAsPrivileged`
// *after* the app is ready → throw.
//
// Same root cause as the original `debug` regression fixed in #11827. Isolate
// each shared module into its own vendor chunk so both ends reference the vendor
// chunk instead of back-referencing main.
// Prevent debug package from being bundled into index.js to avoid side-effect pollution
manualChunks(id) {
if (id.includes('node_modules/debug')) {
return 'vendor-debug';
}
// Small text/binary detection utilities in file-loaders/utils. Imported by
// main (via `sniffBinaryFile`) and potentially by lazy loader chunks.
// Explicitly enumerated to avoid catching `parser-utils.ts`, which pulls in
// xmldom / yauzl / concat-stream — those belong in docx/pptx loader chunks.
if (
/packages\/file-loaders\/src\/utils\/(?:detectUtf16|isBinaryContent|isTextReadableFile)\.ts$/.test(
id,
)
) {
return 'vendor-file-loaders-utils';
}
// jszip — imported by main (via some static path) AND by the docx loader chunk.
// Without this, reading a .docx file throws the protocol re-init error.
if (id.includes('node_modules/jszip')) {
return 'vendor-jszip';
}
// Split i18n json resources by namespace (ns), not by locale.
// Example: ".../resources/locales/zh-CN/common.json?import" -> "locales-common"
const normalizedId = id.replaceAll('\\', '/').split('?')[0];
@@ -24,15 +24,11 @@ import {
buildAgentInput,
materializeImageToPath,
normalizeImage,
resolveCliSpawnPlan,
} from '@lobechat/heterogeneous-agents/spawn';
import { app as electronApp, BrowserWindow } from 'electron';
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
import type {
HeterogeneousAgentBuildPlan,
HeterogeneousAgentImageAttachment,
} from '@/modules/heterogeneousAgent/types';
import type { HeterogeneousAgentImageAttachment } from '@/modules/heterogeneousAgent/types';
import { buildProxyEnv } from '@/modules/networkProxy/envBuilder';
import { detectHeterogeneousCliCommand } from '@/modules/toolDetectors';
import { createLogger } from '@/utils/logger';
@@ -872,210 +868,169 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
const useStdin = spawnPlan.stdinPayload !== undefined;
const cliArgs = spawnPlan.args;
const resolvedCliSpawnPlan = await resolveCliSpawnPlan(session.command, cliArgs);
logger.info(
'Spawning agent:',
resolvedCliSpawnPlan.command,
resolvedCliSpawnPlan.args.join(' '),
`(cwd: ${cwd})`,
);
// `detached: true` on Unix puts the child in a new process group so we
// can SIGINT/SIGKILL the whole tree (claude + any tool subprocesses)
// via `process.kill(-pid, sig)` on cancel. Without this, SIGINT to just
// the claude binary can leave bash/grep/etc. tool children running and
// the CLI hung waiting on them. Windows has different semantics — use
// taskkill /T /F there; no detached flag needed.
// Forward the user's proxy settings to the CLI. The main-process undici
// dispatcher doesn't reach child processes — they need env vars.
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
const spawnOptions = {
cwd,
detached: process.platform !== 'win32',
env: { ...process.env, ...proxyEnv, ...session.env },
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] as ['pipe' | 'ignore', 'pipe', 'pipe'],
};
return new Promise<void>((resolve, reject) => {
const proc = spawn(resolvedCliSpawnPlan.command, resolvedCliSpawnPlan.args, spawnOptions);
this.handleSpawnedAgentProcess({
intervention,
params,
proc,
reject,
resolve,
session,
traceSession,
useStdin,
spawnPlan,
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
// `detached: true` on Unix puts the child in a new process group so we
// can SIGINT/SIGKILL the whole tree (claude + any tool subprocesses)
// via `process.kill(-pid, sig)` on cancel. Without this, SIGINT to just
// the claude binary can leave bash/grep/etc. tool children running and
// the CLI hung waiting on them. Windows has different semantics — use
// taskkill /T /F there; no detached flag needed.
// Forward the user's proxy settings to the CLI. The main-process undici
// dispatcher doesn't reach child processes — they need env vars.
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
const proc = spawn(session.command, cliArgs, {
cwd,
detached: process.platform !== 'win32',
env: { ...process.env, ...proxyEnv, ...session.env },
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
});
});
}
private handleSpawnedAgentProcess({
intervention,
params,
proc,
reject,
resolve,
session,
spawnPlan,
traceSession,
useStdin,
}: {
intervention?: Awaited<ReturnType<HeterogeneousAgentCtr['setupInterventionForOp']>>;
params: SendPromptParams;
proc: ChildProcess;
reject: (reason?: unknown) => void;
resolve: () => void;
session: AgentSession;
spawnPlan: HeterogeneousAgentBuildPlan;
traceSession: CliTraceSession | undefined;
useStdin: boolean;
}) {
proc.on('error', (err) => {
logger.error('Agent process error:', err);
void this.writeCliTraceJson(traceSession, 'process-error.json', {
message: err.message,
name: err.name,
});
void this.flushCliTrace(traceSession);
const sessionError = this.getSessionErrorPayload(err, session);
this.broadcast('heteroAgentSessionError', {
error: sessionError,
sessionId: session.sessionId,
});
reject(new Error(typeof sessionError === 'string' ? sessionError : sessionError.message));
});
// In stdin mode, write the prepared payload and close stdin.
if (useStdin && spawnPlan.stdinPayload !== undefined && proc.stdin) {
void this.writeCliTraceFile(traceSession, 'stdin.txt', spawnPlan.stdinPayload);
const stdin = proc.stdin as Writable;
stdin.write(spawnPlan.stdinPayload, () => {
stdin.end();
});
}
session.process = proc;
// Producer-side conversion (V3 contract): JSONL framing + adapter +
// toStreamEvent all run inside the shared pipeline, so renderer + future
// server `heteroIngest` see the same `AgentStreamEvent` wire shape with
// no per-consumer adapter. The pipeline auto-wires the Codex
// file-change line-stat tracker when `agentType === 'codex'`, so this
// controller stays agent-agnostic.
const pipeline = new AgentStreamPipeline({
agentType: session.agentType,
operationId: params.operationId,
});
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
const broadcastPipelineBatch = (produce: () => ReturnType<AgentStreamPipeline['push']>) => {
stdoutBroadcastQueue = stdoutBroadcastQueue
.then(async () => {
const events = await produce();
// Adapter-extracted CC/Codex session id powers `--resume` on the
// next prompt; surface it through the existing `getSessionInfo`
// IPC by mirroring the freshest value onto the session record.
if (pipeline.sessionId && pipeline.sessionId !== session.agentSessionId) {
session.agentSessionId = pipeline.sessionId;
}
for (const event of events) {
this.broadcast('heteroAgentEvent', {
event,
sessionId: session.sessionId,
});
}
})
.catch((error) => {
logger.error('Failed to broadcast agent stream batch:', error);
// In stdin mode, write the prepared payload and close stdin.
if (useStdin && spawnPlan.stdinPayload !== undefined && proc.stdin) {
void this.writeCliTraceFile(traceSession, 'stdin.txt', spawnPlan.stdinPayload);
const stdin = proc.stdin as Writable;
stdin.write(spawnPlan.stdinPayload, () => {
stdin.end();
});
};
}
// Stream stdout events through the producer pipeline.
const stdout = proc.stdout as Readable;
stdout.on('data', (chunk: Buffer) => {
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
broadcastPipelineBatch(() => pipeline.push(chunk));
});
stdout.on('end', () => {
broadcastPipelineBatch(() => pipeline.flush());
});
session.process = proc;
// Capture stderr
const stderrChunks: string[] = [];
const stderr = proc.stderr as Readable;
stderr.on('data', (chunk: Buffer) => {
void this.appendCliTraceFile(traceSession, 'stderr.log', chunk);
stderrChunks.push(chunk.toString('utf8'));
});
proc.on('exit', (code, signal) => {
// Node may emit `'exit'` BEFORE stdio finishes draining (documented:
// child_process docs note "stdio streams might still be open" at exit
// time). Wait for stdout to fully end/close so the `stdout.on('end')`
// handler has scheduled `pipeline.flush()` onto `stdoutBroadcastQueue`,
// THEN wait for the queue itself to settle. Without this two-step
// gate, trailing flushed events (final synthesized tool_end /
// tool_result) would race against — and lose to — the
// `heteroAgentSessionComplete` broadcast, leaving renderer-side
// persistence to finalize on incomplete state.
const stdoutDrained = streamFinished(stdout, { writable: false }).catch(() => {
/* end / close / error are all "done"; we still want to settle. */
// Producer-side conversion (V3 contract): JSONL framing + adapter +
// toStreamEvent all run inside the shared pipeline, so renderer + future
// server `heteroIngest` see the same `AgentStreamEvent` wire shape with
// no per-consumer adapter. The pipeline auto-wires the Codex
// file-change line-stat tracker when `agentType === 'codex'`, so this
// controller stays agent-agnostic.
const pipeline = new AgentStreamPipeline({
agentType: session.agentType,
operationId: params.operationId,
});
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
void stdoutDrained
.then(() => stdoutBroadcastQueue)
.finally(async () => {
// Tear down the AskUserQuestion bridge / temp `mcp.json` for this
// op. Pending MCP handlers get a `session_ended` cancellation so
// they return cleanly even if CC was killed mid-tool-call.
if (intervention) {
await intervention.cleanup().catch((err) => {
logger.warn('AskUserQuestion cleanup error:', err);
});
}
void this.writeCliTraceJson(traceSession, 'exit.json', {
code,
finishedAt: new Date().toISOString(),
signal,
const broadcastPipelineBatch = (produce: () => ReturnType<AgentStreamPipeline['push']>) => {
stdoutBroadcastQueue = stdoutBroadcastQueue
.then(async () => {
const events = await produce();
// Adapter-extracted CC/Codex session id powers `--resume` on the
// next prompt; surface it through the existing `getSessionInfo`
// IPC by mirroring the freshest value onto the session record.
if (pipeline.sessionId && pipeline.sessionId !== session.agentSessionId) {
session.agentSessionId = pipeline.sessionId;
}
for (const event of events) {
this.broadcast('heteroAgentEvent', {
event,
sessionId: session.sessionId,
});
}
})
.catch((error) => {
logger.error('Failed to broadcast agent stream batch:', error);
});
await this.flushCliTrace(traceSession);
};
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
session.process = undefined;
// Stream stdout events through the producer pipeline.
const stdout = proc.stdout as Readable;
stdout.on('data', (chunk: Buffer) => {
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
broadcastPipelineBatch(() => pipeline.push(chunk));
});
stdout.on('end', () => {
broadcastPipelineBatch(() => pipeline.flush());
});
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
// exit as a clean shutdown — surfacing it as an error would make a
// user-initiated cancel look like an agent failure, and an Electron
// shutdown affecting OTHER running CC sessions would pollute their
// topics with a misleading "Agent exited with code 143" message.
if (session.cancelledByUs) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
return;
}
// Capture stderr
const stderrChunks: string[] = [];
const stderr = proc.stderr as Readable;
stderr.on('data', (chunk: Buffer) => {
void this.appendCliTraceFile(traceSession, 'stderr.log', chunk);
stderrChunks.push(chunk.toString('utf8'));
});
if (code === 0) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
} else {
const stderrOutput = stderrChunks.join('').trim();
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
const sessionError = this.getSessionErrorPayload(errorMsg, session);
this.broadcast('heteroAgentSessionError', {
error: sessionError,
sessionId: session.sessionId,
});
reject(
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
);
}
proc.on('error', (err) => {
logger.error('Agent process error:', err);
void this.writeCliTraceJson(traceSession, 'process-error.json', {
message: err.message,
name: err.name,
});
void this.flushCliTrace(traceSession);
const sessionError = this.getSessionErrorPayload(err, session);
this.broadcast('heteroAgentSessionError', {
error: sessionError,
sessionId: session.sessionId,
});
reject(new Error(typeof sessionError === 'string' ? sessionError : sessionError.message));
});
proc.on('exit', (code, signal) => {
// Node may emit `'exit'` BEFORE stdio finishes draining (documented:
// child_process docs note "stdio streams might still be open" at exit
// time). Wait for stdout to fully end/close so the `stdout.on('end')`
// handler has scheduled `pipeline.flush()` onto `stdoutBroadcastQueue`,
// THEN wait for the queue itself to settle. Without this two-step
// gate, trailing flushed events (final synthesized tool_end /
// tool_result) would race against — and lose to — the
// `heteroAgentSessionComplete` broadcast, leaving renderer-side
// persistence to finalize on incomplete state.
const stdoutDrained = streamFinished(stdout, { writable: false }).catch(() => {
/* end / close / error are all "done"; we still want to settle. */
});
void stdoutDrained
.then(() => stdoutBroadcastQueue)
.finally(async () => {
// Tear down the AskUserQuestion bridge / temp `mcp.json` for this
// op. Pending MCP handlers get a `session_ended` cancellation so
// they return cleanly even if CC was killed mid-tool-call.
if (intervention) {
await intervention.cleanup().catch((err) => {
logger.warn('AskUserQuestion cleanup error:', err);
});
}
void this.writeCliTraceJson(traceSession, 'exit.json', {
code,
finishedAt: new Date().toISOString(),
signal,
});
await this.flushCliTrace(traceSession);
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
session.process = undefined;
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
// exit as a clean shutdown — surfacing it as an error would make a
// user-initiated cancel look like an agent failure, and an Electron
// shutdown affecting OTHER running CC sessions would pollute their
// topics with a misleading "Agent exited with code 143" message.
if (session.cancelledByUs) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
return;
}
if (code === 0) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
} else {
const stderrOutput = stderrChunks.join('').trim();
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
const sessionError = this.getSessionErrorPayload(errorMsg, session);
this.broadcast('heteroAgentSessionError', {
error: sessionError,
sessionId: session.sessionId,
});
reject(
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
);
}
});
});
});
}
@@ -1,5 +1,5 @@
import { constants } from 'node:fs';
import { access, mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { access, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
@@ -12,8 +12,6 @@ import {
type GrepContentParams,
type GrepContentResult,
type ListLocalFileParams,
type ListProjectSkillsParams,
type ListProjectSkillsResult,
type LocalFilePreviewUrlParams,
type LocalFilePreviewUrlResult,
type LocalMoveFilesResultItem,
@@ -122,62 +120,6 @@ const collectProjectDirectories = (files: string[], root: string): ProjectFileIn
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
};
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
// Cap recursion to guard against pathological directory trees.
const MAX_SKILL_FILE_COUNT = 1000;
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
const results: string[] = [];
const stack: string[] = [dir];
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
const current = stack.pop()!;
let entries;
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile()) {
results.push(toPosixRelativePath(path.relative(dir, full)));
if (results.length >= MAX_SKILL_FILE_COUNT) break;
}
}
}
return results.sort();
};
// Parse a minimal YAML frontmatter block for SKILL.md files.
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
const match = raw.match(SKILL_FRONTMATTER_RE);
if (!match) return {};
const fields: Record<string, string> = {};
for (const line of match[1].split(/\r?\n/)) {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
if (!key || key.startsWith('#')) continue;
let value = line.slice(colonIdx + 1).trim();
if (value.startsWith('|') || value.startsWith('>')) continue;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
fields[key] = value;
}
return fields;
};
const createDetectedProjectFileEntry = async (
root: string,
absolutePath: string,
@@ -654,61 +596,6 @@ export default class LocalFileCtr extends ControllerModule {
};
}
/**
* Scan agent skill directories under the project root and return parsed
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
* to surface skills available in the current project.
*/
@IpcMethod()
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
const root = params.scope;
const sources = ['.agents/skills', '.claude/skills'] as const;
for (const source of sources) {
const dir = path.join(root, source);
try {
const entries = await readdir(dir, { withFileTypes: true });
const skills = (
await Promise.all(
entries
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map(async (entry) => {
const skillDir = path.join(dir, entry.name);
const skillFile = path.join(skillDir, 'SKILL.md');
try {
const raw = await readFile(skillFile, 'utf8');
const fields = parseSkillFrontmatter(raw);
const files = await listSkillFilesRecursive(skillDir);
return {
description: fields.description || undefined,
fileCount: files.length,
files,
name: fields.name || entry.name,
path: skillFile,
skillDir,
source,
};
} catch {
return null;
}
}),
)
)
.filter((skill): skill is NonNullable<typeof skill> => skill !== null)
.sort((a, b) => a.name.localeCompare(b.name));
if (skills.length > 0) {
await this.approveProjectRootForPreview(root);
return { root, skills, source };
}
} catch {
// Directory does not exist or is not readable; try the next candidate.
}
}
return { root, skills: [], source: null };
}
/**
* Handle IPC event for local file search
*/
@@ -1,6 +1,6 @@
import { EventEmitter } from 'node:events';
import { access, mkdtemp, readdir, readFile, rm, unlink, writeFile } from 'node:fs/promises';
import * as os from 'node:os';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PassThrough } from 'node:stream';
@@ -9,11 +9,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof os>('node:os');
return { ...actual, platform: vi.fn(() => 'linux') };
});
const FAKE_DESKTOP_PATH = '/Users/fake/Desktop';
const { mockGetAllWindows } = vi.hoisted(() => ({
@@ -116,7 +111,7 @@ describe('HeterogeneousAgentCtr', () => {
let appStoragePath: string;
beforeEach(async () => {
appStoragePath = await mkdtemp(path.join(os.tmpdir(), 'lobehub-hetero-'));
appStoragePath = await mkdtemp(path.join(tmpdir(), 'lobehub-hetero-'));
});
afterEach(async () => {
@@ -822,7 +817,7 @@ describe('HeterogeneousAgentCtr', () => {
* it like a real pending intervention and tries to unlink it.
*/
const seedPendingIntervention = async (ctr: HeterogeneousAgentCtr, opId: string) => {
const tmpConfigPath = path.join(os.tmpdir(), `lobe-cc-mcp-test-${opId}.json`);
const tmpConfigPath = path.join(tmpdir(), `lobe-cc-mcp-test-${opId}.json`);
await writeFile(tmpConfigPath, '{"mcpServers":{}}');
const slot = {
bridge: {} as any,
@@ -49,10 +49,6 @@ class TestContentSearch extends BaseContentSearch {
public testGetDefaultIgnorePatterns(): string[] {
return this.getDefaultIgnorePatterns();
}
public testResolveSearchPath(params: GrepContentParams): string {
return this.resolveSearchPath(params);
}
}
describe('BaseContentSearch', () => {
@@ -259,33 +255,6 @@ describe('BaseContentSearch', () => {
});
});
describe('resolveSearchPath', () => {
it('prefers scope when path is not set', () => {
const resolved = contentSearch.testResolveSearchPath({
pattern: 'x',
scope: '/Users/arvinxx/repo',
});
expect(resolved).toBe('/Users/arvinxx/repo');
});
it('honors legacy path over scope when both are set', () => {
const resolved = contentSearch.testResolveSearchPath({
path: '/legacy/path',
pattern: 'x',
scope: '/scope/path',
});
expect(resolved).toBe('/legacy/path');
});
it('falls back to process.cwd() when neither is provided', () => {
const resolved = contentSearch.testResolveSearchPath({ pattern: 'x' });
expect(resolved).toBe(process.cwd());
});
});
describe('getDefaultIgnorePatterns', () => {
it('should return default ignore patterns', () => {
const patterns = contentSearch.testGetDefaultIgnorePatterns();
@@ -46,18 +46,6 @@ export abstract class BaseContentSearch {
*/
abstract checkToolAvailable(tool: string): Promise<boolean>;
/**
* Resolve the directory to run the search in.
*
* The builtin-tool manifest documents `scope`, while the legacy IPC type also accepts
* `path`. Read both so an agent calling with `scope` (per the manifest) doesn't silently
* fall through to `process.cwd()` — which in a packaged Electron app isn't the project
* root and therefore has no `.gitignore` for ripgrep to honor.
*/
protected resolveSearchPath(params: GrepContentParams): string {
return params.path ?? params.scope ?? process.cwd();
}
/**
* Build command-line arguments for grep tools
*/
@@ -153,8 +141,11 @@ export abstract class BaseContentSearch {
* Grep using Node.js native implementation (fallback)
*/
protected async grepWithNodejs(params: GrepContentParams): Promise<GrepContentResult> {
const { pattern, output_mode = 'files_with_matches' } = params;
const searchPath = this.resolveSearchPath(params);
const {
pattern,
path: searchPath = process.cwd(),
output_mode = 'files_with_matches',
} = params;
const logPrefix = `[grepContent:nodejs]`;
const flags = `${params['-i'] ? 'i' : ''}${params.multiline ? 's' : ''}`;
@@ -1,3 +1,4 @@
import type { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
import { execa } from 'execa';
@@ -178,8 +179,7 @@ export abstract class UnixContentSearch extends BaseContentSearch {
tool: 'rg' | 'ag' | 'grep',
params: GrepContentParams,
): Promise<GrepContentResult> {
const { output_mode = 'files_with_matches' } = params;
const searchPath = this.resolveSearchPath(params);
const { path: searchPath = process.cwd(), output_mode = 'files_with_matches' } = params;
const logPrefix = `[grepContent:${tool}]`;
try {
@@ -272,7 +272,7 @@ export abstract class UnixContentSearch extends BaseContentSearch {
try {
const { stdout } = await execa(tool, args, {
cwd: this.resolveSearchPath(params),
cwd: params.path || process.cwd(),
reject: false,
});
@@ -1,3 +1,4 @@
import type { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
import { execa } from 'execa';
@@ -145,8 +146,7 @@ export class WindowsContentSearchImpl extends BaseContentSearch {
* Grep using ripgrep (rg) - cross-platform
*/
private async grepWithRipgrep(params: GrepContentParams): Promise<GrepContentResult> {
const { output_mode = 'files_with_matches' } = params;
const searchPath = this.resolveSearchPath(params);
const { path: searchPath = process.cwd(), output_mode = 'files_with_matches' } = params;
const logPrefix = `[grepContent:rg]`;
try {
@@ -230,7 +230,7 @@ export class WindowsContentSearchImpl extends BaseContentSearch {
try {
const { stdout } = await execa('rg', args, {
cwd: this.resolveSearchPath(params),
cwd: params.path || process.cwd(),
reject: false,
});
@@ -252,8 +252,11 @@ export class WindowsContentSearchImpl extends BaseContentSearch {
* Note: findstr has limited functionality compared to ripgrep
*/
private async grepWithFindstr(params: GrepContentParams): Promise<GrepContentResult> {
const { pattern, output_mode = 'files_with_matches' } = params;
const searchPath = this.resolveSearchPath(params);
const {
pattern,
path: searchPath = process.cwd(),
output_mode = 'files_with_matches',
} = params;
const logPrefix = `[grepContent:findstr]`;
try {
-54
View File
@@ -1,58 +1,4 @@
[
{
"children": {},
"date": "2026-05-18",
"version": "2.2.0"
},
{
"children": {
"features": [
"support slack mpim and fix discord dm problem.",
"add service model assignments settings.",
"inline skill auth in recommended task templates.",
"add user activity business hook.",
"add Gemini 3.1 Flash-Lite provider cards.",
"home daily brief with linkable welcome + paired input hint.",
"add signOperationJwt with 4h expiry for hetero-agent operations.",
"migrate Notion to LobeHub Market.",
"Cloud Claude Code V3 — repo picker, GitHub token, sandbox context."
],
"fixes": [
"Docs image.",
"update Task page placeholder copy.",
"sidebar add agent.",
"replace ScrollShadow with ScrollArea to fix React #185 infinite render loop.",
"reject inactive OIDC access.",
"drop unreachable aihubmix empty-apiKey test.",
"consume visual content parts in server runtime.",
"store onboarding interests as keys.",
"remove the old cron job from lobehub.",
"refresh content baseline from DB on every ingest call.",
"gateway client-tool pluginState + drop redundant Exit code: 0 tail.",
"first inject the cloudecc runtime session should use the existingStatus.",
"slack connect error & slash commands.",
"polish task agent manager.",
"remove signin captcha flow.",
"add temporary email auth error locale.",
"add bot callback service.",
"sanitize sensitive comments and examples from production JS bundle.",
"multiple account link."
],
"improvements": [
"use @lobehub/ui built-in HtmlPreview instead of custom component.",
"polish desktop header icons, sidebar density, and task menus.",
"standardize header action icon sizes.",
"add reasoning_effort support for Grok 4.3.",
"increase chat topic title length.",
"format tool execution time as Xmin Ys instead of X.Y min.",
"Add new DeepSeek-V4 models.",
"use visible divider between queued messages.",
"update auth captcha retry copy."
]
},
"date": "2026-05-13",
"version": "2.1.58"
},
{
"children": {
"fixes": ["hide runtime-only model aliases."],
-20
View File
@@ -1212,22 +1212,6 @@ table oidc_sessions {
}
}
table page_shares {
id text [pk, not null]
document_id text [not null]
user_id text [not null]
visibility text [not null, default: 'private']
page_view_count integer [not null, default: 0]
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
document_id [name: 'page_shares_document_id_unique', unique]
user_id [name: 'page_shares_user_id_idx']
}
}
table chunks {
id uuid [pk, not null, default: `gen_random_uuid()`]
text text
@@ -2183,10 +2167,6 @@ ref: threads.source_message_id - messages.id
ref: messages.message_group_id > message_groups.id
ref: page_shares.document_id > documents.id
ref: page_shares.user_id - users.id
ref: sessions.group_id - session_groups.id
ref: topic_documents.document_id > documents.id
+1 -2
View File
@@ -85,7 +85,6 @@ LobeHub supports two connection modes for Slack:
event_subscriptions:
bot_events:
- app_mention
- app_home_opened
- message.channels
- message.groups
- message.im
@@ -196,7 +195,7 @@ Use this method if your Slack app already has Event Subscriptions configured wit
### Configure Event Subscriptions
In the Slack API Dashboard → **Event Subscriptions**, enable events, paste the Webhook URL as the **Request URL**, and subscribe to bot events: `app_mention`, `app_home_opened`, `message.channels`, `message.groups`, `message.im`, `message.mpim`, `member_joined_channel`.
In the Slack API Dashboard → **Event Subscriptions**, enable events, paste the Webhook URL as the **Request URL**, and subscribe to bot events: `app_mention`, `message.channels`, `message.groups`, `message.im`, `message.mpim`, `member_joined_channel`.
![](/blog/assets8f3657f3785fc04c42b0f53c17daa72e.webp)
+1 -2
View File
@@ -82,7 +82,6 @@ LobeHub 支持两种 Slack 连接模式:
event_subscriptions:
bot_events:
- app_mention
- app_home_opened
- message.channels
- message.groups
- message.im
@@ -193,7 +192,7 @@ LobeHub 支持两种 Slack 连接模式:
### 配置事件订阅
在 Slack API 控制台 → **Event Subscriptions** 中,启用事件,将 Webhook URL 粘贴为 **Request URL**,订阅事件:`app_mention`、`app_home_opened`、`message.channels`、`message.groups`、`message.im`、`message.mpim`、`member_joined_channel`。
在 Slack API 控制台 → **Event Subscriptions** 中,启用事件,将 Webhook URL 粘贴为 **Request URL**,订阅事件:`app_mention`、`message.channels`、`message.groups`、`message.im`、`message.mpim`、`member_joined_channel`。
![](/blog/assets8f3657f3785fc04c42b0f53c17daa72e.webp)
+10 -9
View File
@@ -37,15 +37,16 @@ Click your user avatar in the top-right corner → **App Settings** → **Shortc
## Conversation Shortcuts
| Action | Shortcut |
| ------------------------------- | ------------------------------------ |
| **Open conversation settings** | `⌘ + ,` / `Ctrl + ,` |
| **Regenerate message** | `⌘ + R` / `Ctrl + R` |
| **Delete last message** | `⌘ + D` / `Ctrl + D` |
| **Delete and regenerate** | `⌘ + Shift + R` / `Ctrl + Shift + R` |
| **New Topic** | `⌘ + N` / `Ctrl + N` |
| **Add message without sending** | `⌘ + Enter` / `Ctrl + Enter` |
| **Edit message** | `Ctrl + Alt` + double-click message |
| Action | Shortcut |
| ------------------------------- | ---------------------------------------------------- |
| **Open conversation settings** | `⌘ + ,` / `Ctrl + ,` |
| **Regenerate message** | `⌘ + R` / `Ctrl + R` |
| **Delete last message** | `⌘ + D` / `Ctrl + D` |
| **Delete and regenerate** | `⌘ + Shift + R` / `Ctrl + Shift + R` |
| **New Topic** | `⌘ + N` / `Ctrl + N` |
| **Add message without sending** | `⌘ + Enter` / `Ctrl + Enter` |
| **Edit message** | `Ctrl + Alt` + double-click message |
| **Clear all messages** | `⌘ + Shift + Backspace` / `Ctrl + Shift + Backspace` |
**Add message without sending** — useful when you want to add context to the conversation without triggering an immediate response. The Agent will see the new message when it next replies.
+10 -9
View File
@@ -35,15 +35,16 @@ LobeHub 提供丰富的键盘快捷键,让你少用鼠标、多用手感。掌
## 会话快捷键
| 操作 | 快捷键 |
| ------------ | ------------------------------------ |
| **打开会话设置** | `⌘ + ,` / `Ctrl + ,` |
| **重新生成消息** | `⌘ + R` / `Ctrl + R` |
| **删除最后一条消息** | `⌘ + D` / `Ctrl + D` |
| **删除并重新生成** | `⌘ + Shift + R` / `Ctrl + Shift + R` |
| **新建话题** | `⌘ + N` / `Ctrl + N` |
| **添加消息但不发送** | `⌘ + Enter` / `Ctrl + Enter` |
| **编辑消息** | `Ctrl + Alt` + 双击消息 |
| 操作 | 快捷键 |
| ------------ | ---------------------------------------------------- |
| **打开会话设置** | `⌘ + ,` / `Ctrl + ,` |
| **重新生成消息** | `⌘ + R` / `Ctrl + R` |
| **删除最后一条消息** | `⌘ + D` / `Ctrl + D` |
| **删除并重新生成** | `⌘ + Shift + R` / `Ctrl + Shift + R` |
| **新建话题** | `⌘ + N` / `Ctrl + N` |
| **添加消息但不发送** | `⌘ + Enter` / `Ctrl + Enter` |
| **编辑消息** | `Ctrl + Alt` + 双击消息 |
| **清空所有消息** | `⌘ + Shift + Backspace` / `Ctrl + Shift + Backspace` |
**添加消息但不发送** —— 想在不触发生成的情况下补充上下文时使用。助理会在下次回复时看到这条新消息。
-7
View File
@@ -196,9 +196,6 @@
"groupWizard.searchTemplates": "Search templates...",
"groupWizard.title": "Create Group",
"groupWizard.useTemplate": "Use Template",
"heteroAgent.cloudNotConfigured.action": "Configure",
"heteroAgent.cloudNotConfigured.desc": "Configure your Claude Code token in agent profile to start sending messages.",
"heteroAgent.cloudNotConfigured.title": "Cloud credentials required",
"heteroAgent.cloudRepo.multiSelected": "{{count}} repos selected",
"heteroAgent.cloudRepo.noRepos": "No repositories configured. Add them in agent settings.",
"heteroAgent.cloudRepo.notSet": "No repo selected",
@@ -891,8 +888,6 @@
"workingPanel.localFile.closeOther": "Close Others",
"workingPanel.localFile.closeRight": "Close to the Right",
"workingPanel.localFile.error": "Couldn't load this file",
"workingPanel.localFile.preview.raw": "Raw",
"workingPanel.localFile.preview.render": "Preview",
"workingPanel.localFile.truncated": "File preview truncated to {{limit}} characters",
"workingPanel.progress": "Progress",
"workingPanel.progress.allCompleted": "All tasks completed",
@@ -958,8 +953,6 @@
"workingPanel.review.viewMode.unified": "Switch to unified view",
"workingPanel.review.wordWrap.disable": "Disable word wrap",
"workingPanel.review.wordWrap.enable": "Enable word wrap",
"workingPanel.skills.empty": "No skills found in this project",
"workingPanel.skills.title": "Skills",
"workingPanel.space": "Space",
"workingPanel.title": "Working Panel",
"you": "You",
-2
View File
@@ -1,10 +1,8 @@
{
"actionTag.category.command": "Command",
"actionTag.category.projectSkill": "Project skill",
"actionTag.category.skill": "Skill",
"actionTag.category.tool": "Tool",
"actionTag.tooltip.command": "Runs a client-side slash command before sending.",
"actionTag.tooltip.projectSkill": "Sent as a slash invocation so the agent's CLI runs the matching project skill.",
"actionTag.tooltip.skill": "Loads a reusable skill package for this request.",
"actionTag.tooltip.tool": "Marks a tool the user explicitly selected for this request.",
"actions.expand.off": "Collapse",
-13
View File
@@ -11,19 +11,6 @@
"brief.action.ignore": "Ignore",
"brief.action.retry": "Retry",
"brief.addFeedback": "Share feedback",
"brief.agentSignal.selfReview.applied.heading": "Updated",
"brief.agentSignal.selfReview.applied.summary": "{{count}} dream update was applied.",
"brief.agentSignal.selfReview.applied.summary_plural": "{{count}} dream updates were applied.",
"brief.agentSignal.selfReview.applied.title": "Dream updated resources",
"brief.agentSignal.selfReview.error.heading": "Issue",
"brief.agentSignal.selfReview.error.summary": "Some work could not be completed during this dream.",
"brief.agentSignal.selfReview.error.title": "Dream ran into an issue",
"brief.agentSignal.selfReview.ideas.summary": "Saved dream notes for future review.",
"brief.agentSignal.selfReview.ideas.title": "Dream notes",
"brief.agentSignal.selfReview.proposal.heading": "Suggestion",
"brief.agentSignal.selfReview.proposal.summary": "{{count}} dream suggestion needs your review.",
"brief.agentSignal.selfReview.proposal.summary_plural": "{{count}} dream suggestions need your review.",
"brief.agentSignal.selfReview.proposal.title": "Dream suggestion needs review",
"brief.collapse": "Show less",
"brief.commentPlaceholder": "Share your feedback...",
"brief.commentSubmit": "Submit feedback",
-16
View File
@@ -90,14 +90,6 @@
"builtins.lobe-agent.title": "Lobe Agent",
"builtins.lobe-claude-code.agent.instruction": "Instruction",
"builtins.lobe-claude-code.agent.result": "Result",
"builtins.lobe-claude-code.task.createLabel": "Creating task: ",
"builtins.lobe-claude-code.task.getLabel": "Inspecting task #{{taskId}}",
"builtins.lobe-claude-code.task.listLabel": "Listing tasks",
"builtins.lobe-claude-code.task.updateCompleted": "Completed",
"builtins.lobe-claude-code.task.updateDeleted": "Deleted",
"builtins.lobe-claude-code.task.updateInProgress": "Started",
"builtins.lobe-claude-code.task.updateLabel": "Updating task #{{taskId}}",
"builtins.lobe-claude-code.task.updatePending": "Reset",
"builtins.lobe-claude-code.todoWrite.allDone": "All tasks completed",
"builtins.lobe-claude-code.todoWrite.currentStep": "Current step",
"builtins.lobe-claude-code.todoWrite.todos": "Todos",
@@ -245,14 +237,6 @@
"builtins.lobe-page-agent.apiName.updateNode": "Update node",
"builtins.lobe-page-agent.apiName.wrapNodes": "Wrap nodes",
"builtins.lobe-page-agent.title": "Page",
"builtins.lobe-self-feedback-intent.apiName.declareSelfFeedbackIntent": "Record improvement idea",
"builtins.lobe-self-feedback-intent.inspector.gap.proposal": "Suggest improvement",
"builtins.lobe-self-feedback-intent.inspector.memory.write": "Record preference",
"builtins.lobe-self-feedback-intent.inspector.rejected": "Not recorded",
"builtins.lobe-self-feedback-intent.inspector.skill.consolidate": "Organize methods",
"builtins.lobe-self-feedback-intent.inspector.skill.create": "New method found",
"builtins.lobe-self-feedback-intent.inspector.skill.refine": "Improve method",
"builtins.lobe-self-feedback-intent.title": "Improvement Ideas",
"builtins.lobe-skill-store.apiName.importFromMarket": "Import from Market",
"builtins.lobe-skill-store.apiName.importSkill": "Import Skill",
"builtins.lobe-skill-store.apiName.searchSkill": "Search Skills",
+2
View File
@@ -181,6 +181,8 @@
"agentSkillModal.url.urlPlaceholder": "https://example.com/path/to/SKILL.md",
"agentSkillTag": "Agent Skill",
"agentTab.chat": "Chat Preferences",
"agentTab.documents": "Documents",
"agentTab.meta": "Agent info",
"agentTab.modal": "Model Settings",
"agentTab.opening": "Opening Settings",
"agentTab.plugin": "Skill Settings",
-3
View File
@@ -41,9 +41,7 @@
"credits.autoTopUp.monthlyLimitDesc": "Maximum amount that can be auto-charged per month. Leave empty for no limit",
"credits.autoTopUp.monthlyLimitPlaceholder": "No limit",
"credits.autoTopUp.monthlyTopUpAmount": "Monthly Top-Up Amount",
"credits.autoTopUp.noCustomerHint": "Purchase credits once to save a payment method before enabling auto top-up.",
"credits.autoTopUp.noPaymentMethodHint": "No payment method on file. Auto top-up needs a saved card to charge automatically.",
"credits.autoTopUp.purchaseCredits": "Purchase Credits",
"credits.autoTopUp.saveError": "Failed to save auto top-up settings",
"credits.autoTopUp.saveSuccess": "Auto top-up settings saved",
"credits.autoTopUp.setupPaymentMethod": "Add Payment Method",
@@ -85,7 +83,6 @@
"credits.packages.title": "My Credit Packages",
"credits.topUp.cancel": "Cancel",
"credits.topUp.custom": "Custom",
"credits.topUp.freeFeeHint": "Free plan top-ups include a {{fee}} service fee per 1M credits.",
"credits.topUp.maxAmountError": "Single purchase amount cannot exceed ${{max}}",
"credits.topUp.purchaseError": "Purchase failed, please try again later",
"credits.topUp.purchaseNow": "Purchase Now",
-8
View File
@@ -40,14 +40,6 @@
"agentMarketplace.render.alreadyInLibraryTag": "Already in library",
"agentMarketplace.render.alreadyInLibrary_one": "{{count}} already in library",
"agentMarketplace.render.alreadyInLibrary_other": "{{count}} already in library",
"claudeCode.askUserQuestion.escape.back": "Back to options",
"claudeCode.askUserQuestion.escape.enter": "Or type directly",
"claudeCode.askUserQuestion.escape.placeholder": "Type your answer here…",
"claudeCode.askUserQuestion.multiSelectTag": "(multi-select)",
"claudeCode.askUserQuestion.skip": "Skip",
"claudeCode.askUserQuestion.submit": "Submit",
"claudeCode.askUserQuestion.timeExpired": "Time expired — using option 1 of each question.",
"claudeCode.askUserQuestion.timeRemaining": "Time remaining: {{time}} · unanswered questions default to option 1 on timeout.",
"codeInterpreter-legacy.error": "Execution Error",
"codeInterpreter-legacy.executing": "Executing...",
"codeInterpreter-legacy.files": "Files:",
+2 -9
View File
@@ -42,13 +42,13 @@
"builtinCopilot": "内置 Copilot",
"chatList.expandMessage": "展开消息",
"chatList.longMessageDetail": "查看详情",
"chatMode.agent": "智能",
"chatMode.agent": "智能",
"chatMode.agentCap.env": "运行环境",
"chatMode.agentCap.files": "文件访问",
"chatMode.agentCap.memory": "记忆",
"chatMode.agentCap.tools": "工具调用",
"chatMode.agentCap.web": "联网搜索",
"chatMode.agentDesc": "可以使用工具和运行环境自动完成任务",
"chatMode.agentDesc": "智能体可以使用工具和运行环境自动完成任务",
"chatMode.chat": "对话",
"chatMode.chatDesc": "不启用运行环境和自主执行,更省 Token",
"chatMode.select": "切换模式",
@@ -196,9 +196,6 @@
"groupWizard.searchTemplates": "搜索模板…",
"groupWizard.title": "创建群组",
"groupWizard.useTemplate": "使用模板",
"heteroAgent.cloudNotConfigured.action": "立即配置",
"heteroAgent.cloudNotConfigured.desc": "请在 Agent 资料中配置 Claude Code Token,以开始发送消息。",
"heteroAgent.cloudNotConfigured.title": "需要配置云端凭证",
"heteroAgent.cloudRepo.multiSelected": "已选 {{count}} 个仓库",
"heteroAgent.cloudRepo.noRepos": "未配置仓库,请在助理设置中添加。",
"heteroAgent.cloudRepo.notSet": "未选择仓库",
@@ -891,8 +888,6 @@
"workingPanel.localFile.closeOther": "关闭其他",
"workingPanel.localFile.closeRight": "关闭右侧",
"workingPanel.localFile.error": "无法加载此文件",
"workingPanel.localFile.preview.raw": "原文",
"workingPanel.localFile.preview.render": "预览",
"workingPanel.localFile.truncated": "文件预览被截断至 {{limit}} 个字符",
"workingPanel.progress": "进度",
"workingPanel.progress.allCompleted": "所有任务已完成",
@@ -958,8 +953,6 @@
"workingPanel.review.viewMode.unified": "切换到合并视图",
"workingPanel.review.wordWrap.disable": "关闭自动换行",
"workingPanel.review.wordWrap.enable": "启用自动换行",
"workingPanel.skills.empty": "当前项目未发现 Skills",
"workingPanel.skills.title": "Skills",
"workingPanel.space": "空间",
"workingPanel.title": "工作面板",
"you": "你",
-2
View File
@@ -1,10 +1,8 @@
{
"actionTag.category.command": "指令",
"actionTag.category.projectSkill": "项目技能",
"actionTag.category.skill": "技能",
"actionTag.category.tool": "工具",
"actionTag.tooltip.command": "发送前会先在客户端执行这条 slash 指令。",
"actionTag.tooltip.projectSkill": "以 slash 形式发送,由 Agent CLI 自行识别并执行项目内的对应技能。",
"actionTag.tooltip.skill": "表示这是一条可为本次请求加载的技能包。",
"actionTag.tooltip.tool": "表示这是用户为本次请求显式选中的工具。",
"actions.expand.off": "收起",
-13
View File
@@ -11,19 +11,6 @@
"brief.action.ignore": "忽略",
"brief.action.retry": "重试",
"brief.addFeedback": "分享反馈",
"brief.agentSignal.selfReview.applied.heading": "已更新",
"brief.agentSignal.selfReview.applied.summary": "已应用 {{count}} 条夜间回顾更新。",
"brief.agentSignal.selfReview.applied.summary_plural": "已应用 {{count}} 条夜间回顾更新。",
"brief.agentSignal.selfReview.applied.title": "夜间回顾已更新资源",
"brief.agentSignal.selfReview.error.heading": "问题",
"brief.agentSignal.selfReview.error.summary": "部分夜间回顾内容未能完成。",
"brief.agentSignal.selfReview.error.title": "夜间回顾遇到了问题",
"brief.agentSignal.selfReview.ideas.summary": "已保存夜间回顾记录,供后续查看。",
"brief.agentSignal.selfReview.ideas.title": "夜间回顾记录",
"brief.agentSignal.selfReview.proposal.heading": "建议",
"brief.agentSignal.selfReview.proposal.summary": "有 {{count}} 条夜间回顾建议需要你确认。",
"brief.agentSignal.selfReview.proposal.summary_plural": "有 {{count}} 条夜间回顾建议需要你确认。",
"brief.agentSignal.selfReview.proposal.title": "有夜间回顾建议需要确认",
"brief.collapse": "收起",
"brief.commentPlaceholder": "分享你的反馈…",
"brief.commentSubmit": "提交反馈",
-16
View File
@@ -90,14 +90,6 @@
"builtins.lobe-agent.title": "Lobe Agent",
"builtins.lobe-claude-code.agent.instruction": "指令",
"builtins.lobe-claude-code.agent.result": "结果",
"builtins.lobe-claude-code.task.createLabel": "正在创建任务:",
"builtins.lobe-claude-code.task.getLabel": "查看任务 #{{taskId}}",
"builtins.lobe-claude-code.task.listLabel": "正在列出任务",
"builtins.lobe-claude-code.task.updateCompleted": "已完成",
"builtins.lobe-claude-code.task.updateDeleted": "已删除",
"builtins.lobe-claude-code.task.updateInProgress": "开始执行",
"builtins.lobe-claude-code.task.updateLabel": "正在更新任务 #{{taskId}}",
"builtins.lobe-claude-code.task.updatePending": "重置为待办",
"builtins.lobe-claude-code.todoWrite.allDone": "全部任务已完成",
"builtins.lobe-claude-code.todoWrite.currentStep": "当前步骤",
"builtins.lobe-claude-code.todoWrite.todos": "待办",
@@ -245,14 +237,6 @@
"builtins.lobe-page-agent.apiName.updateNode": "更新节点",
"builtins.lobe-page-agent.apiName.wrapNodes": "包装节点",
"builtins.lobe-page-agent.title": "文档",
"builtins.lobe-self-feedback-intent.apiName.declareSelfFeedbackIntent": "记录改进想法",
"builtins.lobe-self-feedback-intent.inspector.gap.proposal": "提出改进建议",
"builtins.lobe-self-feedback-intent.inspector.memory.write": "记下偏好",
"builtins.lobe-self-feedback-intent.inspector.rejected": "未记录",
"builtins.lobe-self-feedback-intent.inspector.skill.consolidate": "整理方法",
"builtins.lobe-self-feedback-intent.inspector.skill.create": "发现新方法",
"builtins.lobe-self-feedback-intent.inspector.skill.refine": "改进方法",
"builtins.lobe-self-feedback-intent.title": "改进想法",
"builtins.lobe-skill-store.apiName.importFromMarket": "从市场导入",
"builtins.lobe-skill-store.apiName.importSkill": "导入技能",
"builtins.lobe-skill-store.apiName.searchSkill": "搜索技能",
+2
View File
@@ -181,6 +181,8 @@
"agentSkillModal.url.urlPlaceholder": "https://example.com/path/to/SKILL.md",
"agentSkillTag": "Agent 技能",
"agentTab.chat": "对话偏好",
"agentTab.documents": "文档",
"agentTab.meta": "助理信息",
"agentTab.modal": "模型设置",
"agentTab.opening": "开场设置",
"agentTab.plugin": "技能设置",
-3
View File
@@ -41,9 +41,7 @@
"credits.autoTopUp.monthlyLimitDesc": "每月自动充值的最大金额,留空表示无限制",
"credits.autoTopUp.monthlyLimitPlaceholder": "无限制",
"credits.autoTopUp.monthlyTopUpAmount": "本月已充值",
"credits.autoTopUp.noCustomerHint": "先购买一次积分并保存支付方式,之后即可启用自动充值。",
"credits.autoTopUp.noPaymentMethodHint": "未绑定支付方式,自动充值需要已保存的银行卡才能自动扣费。",
"credits.autoTopUp.purchaseCredits": "购买积分",
"credits.autoTopUp.saveError": "保存自动充值设置失败",
"credits.autoTopUp.saveSuccess": "自动充值设置已保存",
"credits.autoTopUp.setupPaymentMethod": "添加支付方式",
@@ -85,7 +83,6 @@
"credits.packages.title": "我的积分包",
"credits.topUp.cancel": "取消",
"credits.topUp.custom": "自定义",
"credits.topUp.freeFeeHint": "免费计划充值每百万积分包含 {{fee}} 手续费。",
"credits.topUp.maxAmountError": "单次购买金额不能超过 ${{max}}",
"credits.topUp.purchaseError": "购买失败,请稍后重试",
"credits.topUp.purchaseNow": "立即购买",
-8
View File
@@ -40,14 +40,6 @@
"agentMarketplace.render.alreadyInLibraryTag": "已在库中",
"agentMarketplace.render.alreadyInLibrary_one": "{{count}} 个已在库中",
"agentMarketplace.render.alreadyInLibrary_other": "{{count}} 个已在库中",
"claudeCode.askUserQuestion.escape.back": "返回选项",
"claudeCode.askUserQuestion.escape.enter": "或直接输入",
"claudeCode.askUserQuestion.escape.placeholder": "在此输入你的回复…",
"claudeCode.askUserQuestion.multiSelectTag": "(多选)",
"claudeCode.askUserQuestion.skip": "跳过",
"claudeCode.askUserQuestion.submit": "提交",
"claudeCode.askUserQuestion.timeExpired": "已超时——将使用每题的选项 1。",
"claudeCode.askUserQuestion.timeRemaining": "剩余时间:{{time}} · 超时后未作答题目默认选项 1。",
"codeInterpreter-legacy.error": "执行出错",
"codeInterpreter-legacy.executing": "正在执行...",
"codeInterpreter-legacy.files": "文件:",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/lobehub",
"version": "2.2.0",
"version": "2.1.57",
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
"keywords": [
"framework",
@@ -281,7 +281,7 @@
"@lobehub/icons": "^5.0.0",
"@lobehub/market-sdk": "0.33.3",
"@lobehub/tts": "^5.1.2",
"@lobehub/ui": "^5.14.0",
"@lobehub/ui": "5.12.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"@napi-rs/canvas": "^0.1.88",
"@neondatabase/serverless": "^1.0.2",
@@ -363,19 +363,11 @@ export class GeneralChatAgent implements Agent {
/**
* Proceed to the next LLM call, inserting compression first when needed.
*/
private toLLMCall(
payload: GeneralAgentCallLLMInstructionPayload,
state: AgentState,
): AgentInstruction {
private toLLMCall(payload: GeneralAgentCallLLMInstructionPayload): AgentInstruction {
const compressionEnabled = this.config.compressionConfig?.enabled ?? true;
// Mirror RuntimeExecutors.callLlm: when state.forceFinish is set, the
// executor strips all tools via buildStepToolDelta (deactivatedToolIds: ['*']),
// so they must not count against the compression budget either — otherwise
// we'd burn an extra summarization pass on tool tokens that won't be sent.
const compressionOptions = {
maxWindowToken: this.config.compressionConfig?.maxWindowToken,
thresholdRatio: this.config.compressionConfig?.thresholdRatio,
tools: state.forceFinish ? undefined : payload.tools,
};
if (compressionEnabled) {
@@ -443,12 +435,9 @@ export class GeneralChatAgent implements Agent {
case 'user_input': {
// Check if context compression is enabled and needed before calling LLM
const compressionEnabled = this.config.compressionConfig?.enabled ?? true; // Default to enabled
// Mirror RuntimeExecutors.callLlm: force-finish steps ship without tools,
// so they must not count against the compression budget here either.
const compressionOptions = {
maxWindowToken: this.config.compressionConfig?.maxWindowToken,
thresholdRatio: this.config.compressionConfig?.thresholdRatio,
tools: state.forceFinish ? undefined : state.tools,
};
if (compressionEnabled) {
@@ -631,16 +620,13 @@ export class GeneralChatAgent implements Agent {
}
// No pending tools, continue to call LLM with tool results
return this.toLLMCall(
{
messages: state.messages,
model: this.config.modelRuntimeConfig?.model,
parentMessageId,
provider: this.config.modelRuntimeConfig?.provider,
tools: state.tools,
} as GeneralAgentCallLLMInstructionPayload,
state,
);
return this.toLLMCall({
messages: state.messages,
model: this.config.modelRuntimeConfig?.model,
parentMessageId,
provider: this.config.modelRuntimeConfig?.provider,
tools: state.tools,
} as GeneralAgentCallLLMInstructionPayload);
}
case 'tools_batch_result': {
@@ -669,16 +655,13 @@ export class GeneralChatAgent implements Agent {
}
// No pending tools, continue to call LLM with tool results
return this.toLLMCall(
{
messages: state.messages,
model: this.config.modelRuntimeConfig?.model,
parentMessageId,
provider: this.config.modelRuntimeConfig?.provider,
tools: state.tools,
} as GeneralAgentCallLLMInstructionPayload,
state,
);
return this.toLLMCall({
messages: state.messages,
model: this.config.modelRuntimeConfig?.model,
parentMessageId,
provider: this.config.modelRuntimeConfig?.provider,
tools: state.tools,
} as GeneralAgentCallLLMInstructionPayload);
}
case 'sub_agent_result': {
@@ -686,16 +669,13 @@ export class GeneralChatAgent implements Agent {
const { parentMessageId } = context.payload as SubAgentResultPayload;
// Continue to call LLM with updated messages (task message is already in state)
return this.toLLMCall(
{
messages: state.messages,
model: this.config.modelRuntimeConfig?.model,
parentMessageId,
provider: this.config.modelRuntimeConfig?.provider,
tools: state.tools,
} as GeneralAgentCallLLMInstructionPayload,
state,
);
return this.toLLMCall({
messages: state.messages,
model: this.config.modelRuntimeConfig?.model,
parentMessageId,
provider: this.config.modelRuntimeConfig?.provider,
tools: state.tools,
} as GeneralAgentCallLLMInstructionPayload);
}
case 'sub_agents_batch_result': {
@@ -719,16 +699,13 @@ export class GeneralChatAgent implements Agent {
];
// Continue to call LLM with updated messages (task messages are already in state)
return this.toLLMCall(
{
messages: messagesWithPrompt,
model: this.config.modelRuntimeConfig?.model,
parentMessageId,
provider: this.config.modelRuntimeConfig?.provider,
tools: state.tools,
} as GeneralAgentCallLLMInstructionPayload,
state,
);
return this.toLLMCall({
messages: messagesWithPrompt,
model: this.config.modelRuntimeConfig?.model,
parentMessageId,
provider: this.config.modelRuntimeConfig?.provider,
tools: state.tools,
} as GeneralAgentCallLLMInstructionPayload);
}
case 'compression_result': {
@@ -150,57 +150,6 @@ describe('GeneralChatAgent', () => {
expect(result).toEqual(expectCompressionInstruction(state.messages));
});
// LOBE-8973 Bug B: state.tools must feed into the compression budget,
// otherwise large tool manifests (16-22K tokens observed on openrouter)
// slip past the threshold and overflow the model context window.
it('should fold state.tools into the compression budget on init', async () => {
const compressionConfig = {
enabled: true,
maxWindowToken: 200_000,
thresholdRatio: 0.5,
};
const messages = [
{
content: '',
metadata: { usage: { totalOutputTokens: 50_000 } },
role: 'assistant',
},
] as any;
const context = createMockContext('init', { model: 'gpt-4o-mini', provider: 'openai' });
// Without tools: raw=50K, adjusted=62.5K vs 100K threshold → no compression.
const agentNoTools = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
compressionConfig,
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const noToolsResult = await agentNoTools.runner(context, createMockState({ messages }));
expect((noToolsResult as any).type).toBe('call_llm');
// With a chunky tool manifest (~66K tokens) total raw input is ~116K,
// drift-adjusted ~145K, which crosses the 100K threshold.
const bigTool = {
function: {
description: 'x'.repeat(400_000),
name: 'big_tool',
parameters: { properties: {}, type: 'object' },
},
type: 'function',
};
const agentWithTools = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
compressionConfig,
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const withToolsResult = await agentWithTools.runner(
context,
createMockState({ messages, tools: [bigTool] as any }),
);
expect((withToolsResult as any).type).toBe('compress_context');
});
});
describe('llm_result phase', () => {
@@ -766,66 +715,6 @@ describe('GeneralChatAgent', () => {
expect(result).toEqual(expectCompressionInstruction(state.messages));
});
// LOBE-8973 follow-up: when state.forceFinish is set, RuntimeExecutors strips
// every tool before the LLM call (buildStepToolDelta returns deactivatedToolIds
// ['*']). The compression budget must mirror that stripping — otherwise the
// tool schemas push the budget over threshold and we burn an extra summarization
// pass on tokens that won't be sent.
it('should skip tools from compression budget on force-finish continuation', async () => {
const compressionConfig = {
enabled: true,
maxWindowToken: 200_000,
thresholdRatio: 0.5,
};
const messages = [
{ role: 'user', content: 'Hello' },
{
content: '',
metadata: { usage: { totalOutputTokens: 50_000 } },
role: 'assistant',
},
{ role: 'tool', content: 'Result', tool_call_id: 'call-1' },
] as any;
// Chunky tool manifest that alone is enough to push the request over the
// compression threshold when counted in the budget.
const bigTool = {
function: {
description: 'x'.repeat(400_000),
name: 'big_tool',
parameters: { properties: {}, type: 'object' },
},
type: 'function',
};
const context = createMockContext('tool_result', { parentMessageId: 'tool-msg-1' });
// Sanity check: without forceFinish, the big tool manifest trips compression.
const baselineAgent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
compressionConfig,
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const baseline = await baselineAgent.runner(
context,
createMockState({ messages, tools: [bigTool] as any }),
);
expect((baseline as any).type).toBe('compress_context');
// With forceFinish set, the executor will drop tools, so the agent must
// ignore them in the compression check and go straight to call_llm.
const forceFinishAgent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
compressionConfig,
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const forced = await forceFinishAgent.runner(
context,
createMockState({ forceFinish: true, messages, tools: [bigTool] as any }),
);
expect((forced as any).type).toBe('call_llm');
});
it('should return request_human_approve when there are pending tools', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
@@ -1,24 +1,110 @@
import type { UIChatMessage } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import {
calculateMessageTokens,
DEFAULT_MAX_CONTEXT,
DEFAULT_THRESHOLD_RATIO,
estimateTokens,
getCompressionThreshold,
shouldCompress,
} from './tokenCounter';
// Test fixtures only set the fields shouldCompress / countContextTokens read.
const mkMsg = (m: Partial<UIChatMessage> & { role: UIChatMessage['role'] }): UIChatMessage =>
({
content: '',
createdAt: 0,
id: 'm',
updatedAt: 0,
...m,
}) as UIChatMessage;
describe('tokenCounter', () => {
describe('estimateTokens', () => {
it('should estimate tokens for string content', () => {
const tokens = estimateTokens('Hello, world!');
expect(tokens).toBeGreaterThan(0);
});
it('should return 0 for empty string', () => {
expect(estimateTokens('')).toBe(0);
});
it('should handle null/undefined content', () => {
expect(estimateTokens(null)).toBe(0);
expect(estimateTokens(undefined)).toBe(0);
});
it('should handle object content by JSON stringifying', () => {
const tokens = estimateTokens({ key: 'value', nested: { a: 1 } });
expect(tokens).toBeGreaterThan(0);
});
it('should handle array content', () => {
const tokens = estimateTokens(['item1', 'item2', 'item3']);
expect(tokens).toBeGreaterThan(0);
});
});
describe('calculateMessageTokens', () => {
it('should use totalOutputTokens for assistant messages when available', () => {
const messages = [
{
content: 'This content should be ignored',
metadata: { usage: { totalOutputTokens: 100 } },
role: 'assistant',
},
];
expect(calculateMessageTokens(messages)).toBe(100);
});
it('should estimate tokens for assistant messages without usage data', () => {
const messages = [{ content: 'Hello from assistant', role: 'assistant' }];
const tokens = calculateMessageTokens(messages);
expect(tokens).toBeGreaterThan(0);
// Should be estimated, not 0
expect(tokens).not.toBe(100);
});
it('should estimate tokens for user messages', () => {
const messages = [{ content: 'Hello from user', role: 'user' }];
const tokens = calculateMessageTokens(messages);
expect(tokens).toBeGreaterThan(0);
});
it('should estimate tokens for system messages', () => {
const messages = [{ content: 'System prompt', role: 'system' }];
const tokens = calculateMessageTokens(messages);
expect(tokens).toBeGreaterThan(0);
});
it('should sum tokens from multiple messages', () => {
const messages = [
{ content: 'Hello', role: 'user' },
{ content: 'Hi there!', metadata: { usage: { totalOutputTokens: 50 } }, role: 'assistant' },
{ content: 'How are you?', role: 'user' },
];
const tokens = calculateMessageTokens(messages);
// Should be 50 (assistant) + estimated tokens for user messages
expect(tokens).toBeGreaterThan(50);
});
it('should handle empty messages array', () => {
expect(calculateMessageTokens([])).toBe(0);
});
it('should handle messages with empty content', () => {
const messages = [
{ content: '', role: 'user' },
{ content: undefined, role: 'assistant' },
];
expect(calculateMessageTokens(messages)).toBe(0);
});
it('should skip assistant usage with 0 tokens and estimate instead', () => {
const messages = [
{
content: 'Some content',
metadata: { usage: { totalOutputTokens: 0 } },
role: 'assistant',
},
];
const tokens = calculateMessageTokens(messages);
// Should estimate since totalOutputTokens is 0
expect(tokens).toBeGreaterThan(0);
});
});
describe('getCompressionThreshold', () => {
it('should use default values', () => {
const threshold = getCompressionThreshold();
@@ -55,7 +141,8 @@ describe('tokenCounter', () => {
describe('shouldCompress', () => {
it('should return needsCompression=false when under threshold', () => {
const result = shouldCompress([mkMsg({ role: 'user', content: 'Hi' })]);
const messages = [{ content: 'Hi', role: 'user' }];
const result = shouldCompress(messages);
expect(result.needsCompression).toBe(false);
expect(result.currentTokenCount).toBeGreaterThan(0);
@@ -63,62 +150,48 @@ describe('tokenCounter', () => {
});
it('should return needsCompression=true when over threshold', () => {
const result = shouldCompress([
mkMsg({
// Create a message with usage that exceeds threshold
const messages = [
{
content: '',
metadata: { usage: { totalOutputTokens: 70_000 } },
role: 'assistant',
metadata: { usage: { totalOutputTokens: 70_000 } as any } as any,
}),
]);
},
];
const result = shouldCompress(messages);
expect(result.needsCompression).toBe(true);
expect(result.currentTokenCount).toBe(70_000);
expect(result.threshold).toBe(64_000); // 128k * 0.5
});
it('should return needsCompression=true when raw count is at threshold (drift pushes over)', () => {
// 1.25× default drift multiplier means raw==threshold → adjusted > threshold
// → compression fires. This is intentional: we want to compress before the
// upstream tokenizer overflows the model's context window.
const result = shouldCompress([
mkMsg({
it('should return needsCompression=false when exactly at threshold', () => {
const messages = [
{
content: '',
metadata: { usage: { totalOutputTokens: 64_000 } },
role: 'assistant',
metadata: { usage: { totalOutputTokens: 64_000 } as any } as any,
}),
]);
expect(result.needsCompression).toBe(true);
expect(result.currentTokenCount).toBe(64_000);
});
it('should NOT trigger at threshold when driftMultiplier is 1', () => {
// Disabling drift restores strict "raw > threshold" semantics
const result = shouldCompress(
[
mkMsg({
role: 'assistant',
metadata: { usage: { totalOutputTokens: 64_000 } as any } as any,
}),
],
{ driftMultiplier: 1 },
);
},
];
const result = shouldCompress(messages);
// Exactly at threshold should not trigger compression
expect(result.needsCompression).toBe(false);
expect(result.currentTokenCount).toBe(64_000);
});
it('should use custom options', () => {
const result = shouldCompress(
[
mkMsg({
role: 'assistant',
metadata: { usage: { totalOutputTokens: 50_000 } as any } as any,
}),
],
const messages = [
{
maxWindowToken: 60_000,
thresholdRatio: 0.75,
content: '',
metadata: { usage: { totalOutputTokens: 50_000 } },
role: 'assistant',
},
);
];
const result = shouldCompress(messages, {
maxWindowToken: 60_000,
thresholdRatio: 0.75,
});
// threshold = 60k * 0.75 = 45k, current = 50k > 45k
expect(result.needsCompression).toBe(true);
@@ -131,37 +204,5 @@ describe('tokenCounter', () => {
expect(result.needsCompression).toBe(false);
expect(result.currentTokenCount).toBe(0);
});
// LOBE-8973 Bug B: tool definitions also occupy the input window, so a
// message payload that fits when tools are absent can overflow once tool
// definitions are accounted for. Without this, compression only fires on
// message size and leaves the tool budget to silently push the request
// past the model's context window (openrouter "ExceededContextWindow").
it('should count tool definition tokens against the budget', () => {
const messages = [
mkMsg({
role: 'assistant',
metadata: { usage: { totalOutputTokens: 50_000 } as any } as any,
}),
];
const options = { driftMultiplier: 1, maxWindowToken: 100_000, thresholdRatio: 0.6 };
const withoutTools = shouldCompress(messages, options);
expect(withoutTools.needsCompression).toBe(false);
// A chunky tool manifest (~20K tokens of JSON) should push us over.
const bigTool = {
function: {
description: 'x'.repeat(80_000),
name: 'big_tool',
parameters: { properties: {}, type: 'object' },
},
type: 'function',
};
const withTools = shouldCompress(messages, { ...options, tools: [bigTool] });
expect(withTools.needsCompression).toBe(true);
expect(withTools.currentTokenCount).toBeGreaterThan(withoutTools.currentTokenCount);
});
});
});
@@ -1,26 +1,13 @@
import { countContextTokens, DEFAULT_DRIFT_MULTIPLIER } from '@lobechat/context-engine';
import type { UIChatMessage } from '@lobechat/types';
import { estimateTokenCount } from 'tokenx';
/**
* Options for token counting and compression threshold calculation
*/
export interface TokenCountOptions {
/**
* Optional drift multiplier override forwarded to {@link countContextTokens}.
* Default {@link DEFAULT_DRIFT_MULTIPLIER} (1.25).
*/
driftMultiplier?: number;
/** Model's max context window token count */
maxWindowToken?: number;
/** Threshold ratio for triggering compression, default 0.5 */
/** Threshold ratio for triggering compression, default 0.75 */
thresholdRatio?: number;
/**
* Optional top-level tool definitions for the upcoming LLM call. When
* provided, tool definition tokens are counted toward the budget — matches
* what the provider actually charges. Pass the same `tools` array that will
* be sent in the request payload.
*/
tools?: unknown[];
}
/** Default max context window (128k tokens) */
@@ -29,8 +16,60 @@ export const DEFAULT_MAX_CONTEXT = 128_000;
/** Default threshold ratio (50% of max context) */
export const DEFAULT_THRESHOLD_RATIO = 0.5;
/**
* Message interface for token counting
*/
export interface TokenCountMessage {
content?: string | unknown;
metadata?: {
usage?: {
totalOutputTokens?: number;
};
} | null;
role: string;
}
/**
* Estimate token count for text content using tokenx
* @param content - Text content or object to estimate tokens for
* @returns Estimated token count
*/
export function estimateTokens(content: string | unknown): number {
// Handle null/undefined early
if (content === null || content === undefined) return 0;
const text = typeof content === 'string' ? content : JSON.stringify(content);
if (!text) return 0;
return estimateTokenCount(text);
}
/**
* Calculate total token count for a list of messages
* - Assistant messages: Use metadata.usage.totalOutputTokens if available (exact value)
* - User/System messages: Use tokenx estimation
*
* @param messages - List of messages to count tokens for
* @returns Total token count
*/
export function calculateMessageTokens(messages: TokenCountMessage[]): number {
return messages.reduce((total, msg) => {
// For assistant messages, prefer the recorded token count from usage metadata
if (msg.role === 'assistant') {
const outputTokens = msg.metadata?.usage?.totalOutputTokens;
if (outputTokens && outputTokens > 0) {
return total + outputTokens;
}
}
// For user/system messages or assistant messages without usage data, estimate tokens
return total + estimateTokens(msg.content);
}, 0);
}
/**
* Calculate the compression threshold based on max context window
* @param options - Token count options
* @returns Compression threshold in tokens
*/
export function getCompressionThreshold(options: TokenCountOptions = {}): number {
const maxContext = options.maxWindowToken ?? DEFAULT_MAX_CONTEXT;
@@ -42,43 +81,30 @@ export function getCompressionThreshold(options: TokenCountOptions = {}): number
* Result of compression check
*/
export interface CompressionCheckResult {
/**
* Best raw estimate of current input tokens (sum of message content +
* tool calls + reasoning + tool_call_id + tool definitions).
*/
/** Current total token count */
currentTokenCount: number;
/**
* `true` when `adjustedTokenCount > threshold`. The adjusted count includes
* a drift multiplier (default 1.25×) to compensate for the gap between
* `tokenx`'s heuristic and provider tokenizers, so compression fires before
* upstream tokenizers actually overflow the model's context window.
*/
/** Whether compression is needed */
needsCompression: boolean;
/** Compression threshold (`maxWindowToken × thresholdRatio`) */
/** Compression threshold */
threshold: number;
}
/**
* Check if messages need compression based on token count.
*
* Uses {@link countContextTokens} under the hood, so the input estimate
* accounts for tool calls, reasoning, and tool definitions in addition to
* `content` (see LOBE-8964 for the calibration data).
* Check if messages need compression based on token count
* @param messages - List of messages to check
* @param options - Token count options
* @returns Compression check result
*/
export function shouldCompress(
messages: UIChatMessage[],
messages: TokenCountMessage[],
options: TokenCountOptions = {},
): CompressionCheckResult {
const accounting = countContextTokens({
messages,
options: { driftMultiplier: options.driftMultiplier ?? DEFAULT_DRIFT_MULTIPLIER },
tools: options.tools,
});
const currentTokenCount = calculateMessageTokens(messages);
const threshold = getCompressionThreshold(options);
return {
currentTokenCount: accounting.rawTotal,
needsCompression: accounting.adjustedTotal > threshold,
currentTokenCount,
needsCompression: currentTokenCount > threshold,
threshold,
};
}
-8
View File
@@ -1,14 +1,6 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
{
name: 'raw-md',
transform(_, id) {
if (id.endsWith('.md')) return { code: 'export default ""', map: null };
},
},
],
test: {
coverage: {
exclude: [
@@ -1,25 +1,11 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { zstdDecompress } from 'node:zlib';
import type { ExecutionSnapshot } from '../types';
const decompressZstd = promisify(zstdDecompress);
const REMOTE_DIR = '_remote';
const ENV_FILE = '.env';
const DEFAULT_DIR = '.agent-tracing';
const ZSTD_SUFFIX = '.json.zst';
const LEGACY_SUFFIX = '.json';
// Zstd frame magic number — first 4 bytes of any zstd-compressed stream.
// https://datatracker.ietf.org/doc/html/rfc8478#section-3.1.1
function isZstdFrame(buf: Buffer): boolean {
return (
buf.length >= 4 && buf[0] === 0x28 && buf[1] === 0xb5 && buf[2] === 0x2f && buf[3] === 0xfd
);
}
/**
* Parse an operation ID to extract agentId and topicId for URL construction.
@@ -46,7 +32,7 @@ export function buildRemoteUrl(baseUrl: string, opId: string): string | null {
const parsed = parseOperationId(opId);
if (!parsed) return null;
const base = baseUrl.replace(/\/$/, '');
return `${base}/${parsed.agentId}/${parsed.topicId}/${parsed.operationId}${ZSTD_SUFFIX}`;
return `${base}/${parsed.agentId}/${parsed.topicId}/${parsed.operationId}.json`;
}
/**
@@ -104,27 +90,15 @@ export class RemoteSnapshotStore {
return cached;
}
// Download. New uploads are zstd-compressed (`.json.zst`) but objects from
// before the rollout remain at the legacy `.json` key, so try the primary
// URL first and fall back to the legacy sibling on any non-OK response.
// Download
console.error(`↓ Downloading: ${url}`);
let res = await fetch(url);
if (!res.ok && url.endsWith(ZSTD_SUFFIX)) {
const legacyUrl = url.slice(0, -ZSTD_SUFFIX.length) + LEGACY_SUFFIX;
console.error(`↻ Trying legacy key: ${legacyUrl}`);
const legacyRes = await fetch(legacyUrl);
if (legacyRes.ok) res = legacyRes;
}
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch snapshot: ${res.status} ${res.statusText}\n URL: ${url}`);
}
// Sniff the zstd frame magic so the body is decoded by content, not URL
// suffix — keeps legacy `.json` snapshots working alongside compressed ones.
const body = Buffer.from(await res.arrayBuffer());
const decoded = isZstdFrame(body) ? await decompressZstd(body) : body;
const snapshot = JSON.parse(decoded.toString('utf8')) as ExecutionSnapshot;
const snapshot = (await res.json()) as ExecutionSnapshot;
// Cache locally as plain JSON for easy inspection.
// Cache locally
await fs.mkdir(this.cacheDir, { recursive: true });
const filePath = path.join(this.cacheDir, `${operationId}.json`);
await fs.writeFile(filePath, JSON.stringify(snapshot, null, 2), 'utf8');
@@ -221,10 +221,9 @@ export class AgentDocumentsExecutionRuntime {
};
}
const scope = args.scope ?? 'agent';
const sourceType = args.sourceType ?? 'all';
const target = args.target ?? 'agent';
const topicId = this.resolveTopicId(context);
if (scope === 'currentTopic' && !topicId) {
if (target === 'currentTopic' && !topicId) {
return {
content: 'Cannot list current topic documents without topicId context.',
success: false,
@@ -232,9 +231,9 @@ export class AgentDocumentsExecutionRuntime {
}
const docs =
scope === 'currentTopic'
? await this.service.listTopicDocuments({ agentId, scope, sourceType, topicId: topicId! })
: await this.service.listDocuments({ agentId, scope, sourceType });
target === 'currentTopic'
? await this.service.listTopicDocuments({ agentId, target, topicId: topicId! })
: await this.service.listDocuments({ agentId, target });
const list = docs.map((d) => ({
...(d.documentId ? { documentId: d.documentId } : {}),
filename: d.filename ?? d.title ?? '',
@@ -261,9 +260,9 @@ export class AgentDocumentsExecutionRuntime {
};
}
const scope = args.scope ?? 'agent';
const target = args.target ?? 'agent';
const topicId = this.resolveTopicId(context);
if (scope === 'currentTopic' && !topicId) {
if (target === 'currentTopic' && !topicId) {
return {
content: 'Cannot create current topic document without topicId context.',
success: false,
@@ -272,7 +271,7 @@ export class AgentDocumentsExecutionRuntime {
const toolTriggerInput = this.buildToolTriggerInput(context);
const created =
scope === 'currentTopic'
target === 'currentTopic'
? await this.service.createTopicDocument({
...args,
...toolTriggerInput,
@@ -16,7 +16,7 @@ export const CreateDocumentInspector = memo<
const { t } = useTranslation('plugin');
const title = args?.title || partialArgs?.title;
const scope = args?.scope || partialArgs?.scope;
const target = args?.target || partialArgs?.target;
const styles = inspectorChipStyles;
if (isArgumentsStreaming && !title) {
@@ -37,11 +37,11 @@ export const CreateDocumentInspector = memo<
>
<span>{t('builtins.lobe-agent-documents.apiName.createDocument')}</span>
{title && <span className={styles.chip}>{title}</span>}
{scope && (
{target && (
<>
<span className={styles.separator}>·</span>
<span className={styles.subdued}>
{t(`builtins.lobe-agent-documents.inspector.scope.${scope}` as const)}
{t(`builtins.lobe-agent-documents.inspector.target.${target}` as const)}
</span>
</>
)}
@@ -15,7 +15,7 @@ export const ListDocumentsInspector = memo<
>(({ args, partialArgs, pluginState, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const scope = args?.scope || partialArgs?.scope;
const target = args?.target || partialArgs?.target;
const count = pluginState?.documents?.length;
const styles = inspectorChipStyles;
@@ -28,11 +28,11 @@ export const ListDocumentsInspector = memo<
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.listDocuments')}</span>
{scope && (
{target && (
<>
<span className={styles.separator}>·</span>
<span className={styles.subdued}>
{t(`builtins.lobe-agent-documents.inspector.scope.${scope}` as const)}
{t(`builtins.lobe-agent-documents.inspector.target.${target}` as const)}
</span>
</>
)}
@@ -21,7 +21,7 @@ export const AgentDocumentsManifest: BuiltinToolManifest = {
'Set true only when the document captures reusable procedural knowledge or durable agent behavior.',
type: 'boolean',
},
scope: {
target: {
default: 'agent',
description:
'Where to create the document. Use currentTopic to associate it with the current topic; defaults to agent-scoped documents.',
@@ -194,24 +194,17 @@ export const AgentDocumentsManifest: BuiltinToolManifest = {
},
{
description:
'List agent documents. Use this to discover documents that are not auto-injected (e.g. web-crawled pages) or to resolve a title to a document ID.',
'List agent documents. Defaults to all agent documents; use target=currentTopic to list documents associated with the current topic.',
name: AgentDocumentsApiName.listDocuments,
parameters: {
properties: {
scope: {
target: {
default: 'agent',
description:
'Which document set to list. Defaults to "agent" (all agent-scoped documents). Use "currentTopic" to filter to documents associated with the current topic.',
'Which document set to list. currentTopic filters to documents associated with the current topic.',
enum: ['agent', 'currentTopic'],
type: 'string',
},
sourceType: {
default: 'all',
description:
'Filter by document source. "file" = user-created or uploaded; "web" = crawled from external URLs; "all" returns both. Web-crawled documents are hidden from the default agent_documents_index — pass sourceType="web" here to see them.',
enum: ['all', 'file', 'web'],
type: 'string',
},
},
required: [],
type: 'object',
@@ -21,11 +21,10 @@ export const systemPrompt = `You have access to an Agent Documents tool for crea
<tool_selection_guidelines>
- By default, if the user does not explicitly specify otherwise, and the relevant Agent Documents tool is available for the task, prefer Agent Documents over Cloud Sandbox because it is easier for collaboration and multi-agent coordination.
- **createDocument**: create a new document with title + content. Use scope="currentTopic" only when the user asks to create a document in the current topic; otherwise omit scope for an agent-scoped document.
- **createDocument**: create a new document with title + content. Use target="currentTopic" only when the user asks to create a document in the current topic; otherwise omit target for an agent-scoped document.
- Set hintIsSkill=true only when creating a document that contains reusable procedural knowledge, workflow instructions, tool usage guidance, or durable agent behavior. Leave ordinary notes unhinted.
- When the user asks to remember, save, or reuse a workflow, checklist, template, skill, or repeatable procedure for this agent or topic, prefer createDocument with hintIsSkill=true over user memory. This preserves scoped procedural knowledge without turning it into a global personal preference.
- Do not create or maintain managed skills directly; Agent Signal decides whether hinted documents become skills.
- **listDocuments**: list agent documents. Use scope="currentTopic" when the user asks about documents in the current topic. The default agent_documents_index hides web-crawled documents; pass sourceType="web" here to enumerate them, or sourceType="all" to see everything. Use this to resolve a title to a document ID before reading.
- **listDocuments**: list agent documents. Use target="currentTopic" when the user asks about documents in the current topic. Use this to resolve a filename to a document ID before reading.
- **readDocument**: retrieve current content by document ID. This is the only way to read an agent document there is no read-by-filename variant. Prefer format="xml" when you may edit content, because XML includes stable node IDs. If the response contains empty content, the document is genuinely empty; do not retry with a different format or filename.
- **modifyNodes**: preferred content-edit API. Use LiteXML insert/modify/remove operations after reading XML. For modify operations, include the existing node ID in the LiteXML.
- **replaceDocumentContent**: overwrite the full content of an existing document only when replacing most or all content.
@@ -15,7 +15,7 @@ export const AgentDocumentsApiName = {
export interface CreateDocumentArgs {
content: string;
hintIsSkill?: boolean;
scope?: 'agent' | 'currentTopic';
target?: 'agent' | 'currentTopic';
title: string;
}
@@ -154,8 +154,7 @@ export interface AgentDocumentReference {
}
export interface ListDocumentsArgs {
scope?: 'agent' | 'currentTopic';
sourceType?: 'all' | 'file' | 'web';
target?: 'agent' | 'currentTopic';
}
export interface ListDocumentsState {
@@ -4,8 +4,7 @@
"private": true,
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./client/labels": "./src/client/Inspector/linearMcpLabels.ts"
"./client": "./src/client/index.ts"
},
"main": "./src/index.ts",
"dependencies": {
@@ -1,13 +0,0 @@
'use client';
import { LinearInspector } from '@lobechat/shared-tool-ui/inspectors';
import type { BuiltinInspector } from '@lobechat/types';
import { LINEAR_MCP_PREFIX, LINEAR_MCP_TOOL_NAMES } from './linearMcpLabels';
// The shared `LinearInspector` already strips `LINEAR_MCP_PREFIX` when
// parsing, so we just register it under every MCP-prefixed wire name CC
// emits for the claude.ai Linear server.
export const LinearMcpInspectors: Record<string, BuiltinInspector> = Object.fromEntries(
LINEAR_MCP_TOOL_NAMES.map((tool) => [`${LINEAR_MCP_PREFIX}${tool}`, LinearInspector]),
);
@@ -1,135 +0,0 @@
'use client';
import { inspectorTextStyles, shinyTextStyles } from '@lobechat/shared-tool-ui/styles';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Check, Monitor as MonitorIcon, X } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { ClaudeCodeApiName, type MonitorArgs } from '../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
overflow: hidden;
display: inline-flex;
flex-shrink: 1;
gap: 6px;
align-items: center;
min-width: 0;
margin-inline-start: 6px;
padding-block: 2px;
padding-inline: 10px;
border-radius: 999px;
background: ${cssVar.colorFillTertiary};
`,
command: css`
overflow: hidden;
min-width: 0;
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
`,
description: css`
overflow: hidden;
min-width: 0;
font-size: 12px;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
`,
monitorIcon: css`
flex-shrink: 0;
color: ${cssVar.colorTextDescription};
`,
statusIcon: css`
margin-inline-start: 4px;
`,
timeout: css`
flex-shrink: 0;
margin-inline-start: 8px;
font-feature-settings: 'tnum';
color: ${cssVar.colorTextDescription};
`,
}));
const formatTimeout = (ms: number | undefined): string | undefined => {
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) return undefined;
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remSeconds = seconds % 60;
if (minutes < 60) {
return remSeconds > 0 ? `${minutes}m ${remSeconds}s` : `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remMinutes = minutes % 60;
return remMinutes > 0 ? `${hours}h ${remMinutes}m` : `${hours}h`;
};
/**
* Dedicated inspector for CC's long-running `Monitor` tool (LOBE-8998).
*
* Visual contract:
* [Monitor] <MonitorIcon> <description-or-command> · <timeout> [/]
*
* Uses the lucide `Monitor` (screen) icon so the chip iconography matches
* the tool name. Falls back to `command` when the model omits
* `description`; renders a code-styled chip in that case to make the
* shell snippet recognizable.
*/
export const MonitorInspector = memo<BuiltinInspectorProps<MonitorArgs>>(
({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
const { t } = useTranslation('plugin');
const label = t(ClaudeCodeApiName.Monitor as any);
const source = args ?? partialArgs;
const description = source?.description?.trim();
const command = source?.command?.trim();
const timeoutLabel = formatTimeout(source?.timeout_ms);
const isShiny = isArgumentsStreaming || isLoading;
// Nothing useful to show yet — keep the spinner-y label only.
if (isArgumentsStreaming && !description && !command) {
return <div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{label}</div>;
}
// Prefer description; fall back to command (rendered monospace).
const showAsCommand = !description && !!command;
const chipText = description || command;
const success = (pluginState as { success?: boolean } | undefined)?.success;
const exitCode = (pluginState as { exitCode?: number } | undefined)?.exitCode;
const isSuccess = success === true || exitCode === 0;
const isError = success === false || (typeof exitCode === 'number' && exitCode !== 0);
return (
<div className={cx(inspectorTextStyles.root, isShiny && shinyTextStyles.shinyText)}>
<span>{label}:</span>
{chipText && (
<span className={styles.chip}>
<MonitorIcon className={styles.monitorIcon} size={12} />
<span className={showAsCommand ? styles.command : styles.description}>{chipText}</span>
</span>
)}
{timeoutLabel && <span className={styles.timeout}>· {timeoutLabel}</span>}
{isLoading ? null : isSuccess ? (
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
) : isError ? (
<X className={styles.statusIcon} color={cssVar.colorError} size={14} />
) : null}
</div>
);
},
);
MonitorInspector.displayName = 'ClaudeCodeMonitorInspector';
@@ -1,304 +0,0 @@
'use client';
import { inspectorTextStyles, shinyTextStyles } from '@lobechat/shared-tool-ui/styles';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
ClaudeCodeApiName,
type TaskCreateArgs,
type TaskListArgs,
type TaskUpdateArgs,
} from '../../types';
const RING_SIZE = 14;
const RING_STROKE = 2;
const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2;
const RING_CIRCUM = 2 * Math.PI * RING_RADIUS;
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
overflow: hidden;
flex-shrink: 1;
min-width: 0;
margin-inline-start: 4px;
padding-block: 1px;
padding-inline: 8px;
border-radius: 999px;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
background: ${cssVar.colorFillSecondary};
`,
countChip: css`
flex-shrink: 0;
padding-block: 1px;
padding-inline: 6px;
border-radius: 999px;
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
color: ${cssVar.colorTextSecondary};
background: ${cssVar.colorFillTertiary};
`,
ring: css`
transform: rotate(-90deg);
flex-shrink: 0;
margin-inline-end: 6px;
`,
ringProgress: css`
transition:
stroke-dashoffset 240ms ease,
stroke 240ms ease;
`,
ringTrack: css`
stroke: ${cssVar.colorFillSecondary};
`,
}));
/**
* Items shape the CC adapter emits on `pluginState.todos` normalized
* `todo|processing|completed` alphabet. Mirrors `StepContextTodos` from
* `@lobechat/types` but inlined to keep this package light.
*
* `id` is optional: legacy TodoWrite-derived snapshots are positional and
* have no stable id, while the Task* tools (CC 2.1.143+) populate it with
* the CC-server-assigned numeric id so per-call inspectors can look up
* subject text by `args.taskId`.
*/
interface TaskPluginStateItem {
id?: string;
status: 'todo' | 'processing' | 'completed';
text: string;
}
interface TaskPluginState {
todos?: {
items?: TaskPluginStateItem[];
updatedAt?: string;
};
}
interface TaskStats {
completed: number;
inProgress?: TaskPluginStateItem;
total: number;
}
const computeStats = (items: TaskPluginStateItem[]): TaskStats => ({
completed: items.filter((item) => item.status === 'completed').length,
inProgress: items.find((item) => item.status === 'processing'),
total: items.length,
});
interface ProgressRingProps {
stats: TaskStats;
}
const ProgressRing = memo<ProgressRingProps>(({ stats }) => {
const { completed, total } = stats;
const ratio = total > 0 ? completed / total : 0;
const allDone = total > 0 && completed === total;
const color = allDone ? cssVar.colorSuccess : cssVar.colorInfo;
return (
<svg className={styles.ring} height={RING_SIZE} width={RING_SIZE}>
<circle
className={styles.ringTrack}
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
fill="none"
r={RING_RADIUS}
strokeWidth={RING_STROKE}
/>
<circle
className={styles.ringProgress}
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
fill="none"
r={RING_RADIUS}
stroke={color}
strokeDasharray={RING_CIRCUM}
strokeDashoffset={RING_CIRCUM * (1 - ratio)}
strokeLinecap="round"
strokeWidth={RING_STROKE}
/>
</svg>
);
});
ProgressRing.displayName = 'ClaudeCodeTaskProgressRing';
type TaskInspectorArgs = TaskCreateArgs | TaskUpdateArgs | TaskListArgs;
/**
* Unified inspector chip for CC 2.1.143+ task tools (TaskCreate / TaskUpdate /
* TaskList). Reads the **adapter-synthesized** `pluginState.todos` snapshot
* not per-call args because each individual TaskCreate / TaskUpdate carries
* only a delta. The full list is only knowable after `applyTaskToolResult`
* folds the delta into the running accumulator and emits a fresh snapshot
* (see `claudeCode.ts:applyTaskToolResult`).
*
* Per-tool overrides for the inspector chip:
* - TaskCreate: the per-call action is "added one task" the cumulative
* progress ring would read like `0/3` after three creates, which buries
* the actual new-task signal. Always show `Creating task: <subject>` from
* args, regardless of pluginState. Render-side, TaskCreate is also
* deliberately NOT registered in `ClaudeCodeRenders` the chip carries
* the meaningful info; the default tool card handles args / result.
* - TaskUpdate / TaskList: the cumulative aggregate IS the meaningful
* signal (status flipped to completed = progress, list = snapshot), so
* keep the progress-ring + label + count chip that matches
* {@link TodoWriteInspector}.
*
* Streaming / loading fallbacks read args:
* - TaskUpdate "Updating task #N"
* - TaskList "Listing tasks"
*/
export const TaskInspector = memo<BuiltinInspectorProps<TaskInspectorArgs, TaskPluginState>>(
({ apiName, args, partialArgs, pluginState, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const items = pluginState?.todos?.items ?? [];
const stats = useMemo(() => computeStats(items), [items]);
const allDone = stats.total > 0 && stats.completed === stats.total;
// TaskCreate: chip identifies the task being added, not the accumulated
// count — keep `Creating task: <subject>` as the label so per-row signal
// stays sharp. The ProgressRing is rendered from the cumulative
// pluginState snapshot; the trailing `completed/total` chip makes the
// accumulation visible across rows (the ring alone stays empty while
// every new task is still `todo`, so total wouldn't otherwise show).
if (apiName === ClaudeCodeApiName.TaskCreate) {
const subject = ((args || partialArgs) as TaskCreateArgs | undefined)?.subject;
const text = subject
? `${t('builtins.lobe-claude-code.task.createLabel')}${subject}`
: t('builtins.lobe-claude-code.task.createLabel');
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<ProgressRing stats={stats} />
{stats.total > 0 && (
<span className={styles.countChip}>
{stats.completed}/{stats.total}
</span>
)}
<span style={{ marginInlineStart: 6 }}>{text}</span>
</div>
);
}
// TaskUpdate with a `status` flip: the per-call signal IS that status
// change ("Completed: Read hosts"), which the aggregate `Todos: x/y` chip
// buries. Resolve subject from pluginState by id (CC adapter emits id on
// every Task* synthesis, see `synthesizeTaskPluginState`); `args.subject`
// is the resume-gap fallback when the snapshot hasn't been built yet.
// Cryptic `#N` is intentionally NOT user-facing — fall back to the bare
// verb if subject is missing (rare; happens before first pluginState).
// Leading slot is the same ProgressRing as TaskCreate so the left edge
// of a mixed create/update column reads as one continuous progress
// gauge — the verb in the label carries the per-row status signal.
// TaskUpdate without `status` (metadata-only edit) falls through to the
// aggregate path — no single-word verb describes those.
if (apiName === ClaudeCodeApiName.TaskUpdate) {
const updateArgs = (args || partialArgs) as TaskUpdateArgs | undefined;
const status = updateArgs?.status;
if (status) {
const taskId = updateArgs?.taskId;
const subject =
(taskId ? items.find((item) => item.id === taskId)?.text : undefined) ??
updateArgs?.subject;
const verb =
status === 'deleted'
? t('builtins.lobe-claude-code.task.updateDeleted')
: status === 'completed'
? t('builtins.lobe-claude-code.task.updateCompleted')
: status === 'in_progress'
? t('builtins.lobe-claude-code.task.updateInProgress')
: t('builtins.lobe-claude-code.task.updatePending');
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<ProgressRing stats={stats} />
{stats.total > 0 && (
<span className={styles.countChip}>
{stats.completed}/{stats.total}
</span>
)}
<span style={{ marginInlineStart: stats.total > 0 ? 6 : 0 }}>
{subject ? `${verb}: ${subject}` : verb}
</span>
</div>
);
}
}
// No pluginState yet (args streaming or tool_use → tool_result gap):
// fall back to a per-tool descriptive label sourced from args. Matches
// TodoWriteInspector's `isArgumentsStreaming && stats.total === 0` branch.
if (stats.total === 0) {
const resolvedArgs = (args || partialArgs) as TaskInspectorArgs | undefined;
const fallback = (() => {
if (apiName === ClaudeCodeApiName.TaskUpdate) {
const taskId = (resolvedArgs as TaskUpdateArgs | undefined)?.taskId;
return taskId
? t('builtins.lobe-claude-code.task.updateLabel', { taskId })
: t('builtins.lobe-claude-code.todoWrite.todos');
}
return t('builtins.lobe-claude-code.task.listLabel');
})();
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
{fallback}
</div>
);
}
const label = stats.inProgress
? t('builtins.lobe-claude-code.todoWrite.currentStep')
: allDone
? t('builtins.lobe-claude-code.todoWrite.allDone')
: t('builtins.lobe-claude-code.todoWrite.todos');
const detail = stats.inProgress
? stats.inProgress.text
: !allDone
? `${stats.completed}/${stats.total}`
: undefined;
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<ProgressRing stats={stats} />
<span>{label}</span>
{detail && (
<>
<span>:</span>
<span className={styles.chip}>{detail}</span>
</>
)}
</div>
);
},
);
TaskInspector.displayName = 'ClaudeCodeTaskInspector';
@@ -1,34 +0,0 @@
'use client';
import { inspectorTextStyles, shinyTextStyles } from '@lobechat/shared-tool-ui/styles';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TaskGetArgs } from '../../types';
/**
* TaskGet is read-only the adapter doesn't synthesize `pluginState.todos`
* for it (only TaskCreate / TaskUpdate / TaskList mutate the accumulator).
* Render just enough to identify which task was inspected.
*/
export const TaskGetInspector = memo<BuiltinInspectorProps<TaskGetArgs>>(
({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const taskId = args?.taskId ?? partialArgs?.taskId;
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
{t('builtins.lobe-claude-code.task.getLabel', { taskId: taskId ?? '' })}
</div>
);
},
);
TaskGetInspector.displayName = 'ClaudeCodeTaskGetInspector';
@@ -1,62 +0,0 @@
'use client';
import { inspectorTextStyles, shinyTextStyles } from '@lobechat/shared-tool-ui/styles';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { ClaudeCodeApiName, type WebFetchArgs } from '../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
overflow: hidden;
display: inline-flex;
flex-shrink: 1;
align-items: center;
min-width: 0;
max-width: 60%;
margin-inline-start: 6px;
padding-block: 1px;
padding-inline: 8px;
border-radius: 999px;
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
background: ${cssVar.colorFillTertiary};
`,
}));
/**
* Strip the protocol so the chip leads with the host full URLs eat the
* width quickly and the `https://` prefix is noise.
*/
const stripProtocol = (url: string): string => url.replace(/^https?:\/\//i, '');
export const WebFetchInspector = memo<BuiltinInspectorProps<WebFetchArgs>>(
({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const label = t(ClaudeCodeApiName.WebFetch as any);
const url = (args?.url || partialArgs?.url || '').trim();
if (isArgumentsStreaming && !url) {
return <div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{label}</div>;
}
const isShiny = isArgumentsStreaming || isLoading;
return (
<div className={cx(inspectorTextStyles.root, isShiny && shinyTextStyles.shinyText)}>
<span>{url ? `${label}:` : label}</span>
{url && <span className={styles.chip}>{stripProtocol(url)}</span>}
</div>
);
},
);
WebFetchInspector.displayName = 'ClaudeCodeWebFetchInspector';
@@ -1,41 +0,0 @@
'use client';
import {
highlightTextStyles,
inspectorTextStyles,
shinyTextStyles,
} from '@lobechat/shared-tool-ui/styles';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { ClaudeCodeApiName, type WebSearchArgs } from '../../types';
export const WebSearchInspector = memo<BuiltinInspectorProps<WebSearchArgs>>(
({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const label = t(ClaudeCodeApiName.WebSearch as any);
const query = (args?.query || partialArgs?.query || '').trim();
if (isArgumentsStreaming && !query) {
return <div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{label}</div>;
}
const isShiny = isArgumentsStreaming || isLoading;
return (
<div className={cx(inspectorTextStyles.root, isShiny && shinyTextStyles.shinyText)}>
<span>{label}</span>
{query && (
<>
<span>: </span>
<span className={highlightTextStyles.primary}>{query}</span>
</>
)}
</div>
);
},
);
WebSearchInspector.displayName = 'ClaudeCodeWebSearchInspector';
@@ -10,19 +10,13 @@ import { ClaudeCodeApiName } from '../../types';
import { AgentInspector } from './Agent';
import { AskUserQuestionInspector } from './AskUserQuestion';
import { EditInspector } from './Edit';
import { LinearMcpInspectors } from './LinearMcp';
import { MonitorInspector } from './Monitor';
import { ReadInspector } from './Read';
import { ScheduleWakeupInspector } from './ScheduleWakeup';
import { SkillInspector } from './Skill';
import { TaskInspector } from './Task';
import { TaskGetInspector } from './TaskGet';
import { TaskOutputInspector } from './TaskOutput';
import { TaskStopInspector } from './TaskStop';
import { TodoWriteInspector } from './TodoWrite';
import { ToolSearchInspector } from './ToolSearch';
import { WebFetchInspector } from './WebFetch';
import { WebSearchInspector } from './WebSearch';
import { WriteInspector } from './Write';
// CC's own tool names (Bash / Edit / Glob / Grep / Read / Write) are already
@@ -43,27 +37,12 @@ export const ClaudeCodeInspectors = {
noResultsKey: 'No results',
translationKey: ClaudeCodeApiName.Grep,
}),
// Monitor is a long-running tracked tool — its turns drive a SignalCallbacks
// accordion below the AssistantGroup (LOBE-8998). The dedicated inspector
// uses the lucide `Monitor` (screen) icon to match the tool name.
[ClaudeCodeApiName.Monitor]: MonitorInspector,
[ClaudeCodeApiName.Read]: ReadInspector,
[ClaudeCodeApiName.ScheduleWakeup]: ScheduleWakeupInspector,
[ClaudeCodeApiName.Skill]: SkillInspector,
// CC 2.1.143+ task tools — TaskCreate / TaskUpdate / TaskList share the
// same inspector because they're driven by the adapter-synthesized
// `pluginState.todos` snapshot (the per-call args are deltas, not state).
// TaskGet is read-only with no pluginState, so it gets its own minimal chip.
[ClaudeCodeApiName.TaskCreate]: TaskInspector,
[ClaudeCodeApiName.TaskGet]: TaskGetInspector,
[ClaudeCodeApiName.TaskList]: TaskInspector,
[ClaudeCodeApiName.TaskOutput]: TaskOutputInspector,
[ClaudeCodeApiName.TaskStop]: TaskStopInspector,
[ClaudeCodeApiName.TaskUpdate]: TaskInspector,
[ClaudeCodeApiName.TodoWrite]: TodoWriteInspector,
[ClaudeCodeApiName.ToolSearch]: ToolSearchInspector,
[ClaudeCodeApiName.WebFetch]: WebFetchInspector,
[ClaudeCodeApiName.WebSearch]: WebSearchInspector,
[ClaudeCodeApiName.Write]: WriteInspector,
...LinearMcpInspectors,
};
@@ -1,29 +0,0 @@
// Thin CC-side wrapper around the shared Linear label utilities. Kept free
// of antd-style / React / lucide imports so the workflow-summary path can
// pull `formatLinearMcpShortLabel` without dragging the inspector component
// (and its `keyframes`-using style modules) into tests transitively — same
// reason the labels file lives separately from `LinearMcp.tsx`.
import {
LINEAR_MCP_PREFIX,
LINEAR_TOOL_NAMES,
parseToolName,
staticLabelFor,
} from '@lobechat/shared-tool-ui/inspectors/linear-labels';
export {
capitalize,
LINEAR_MCP_PREFIX,
type ParsedTool,
parseToolName,
staticLabelFor,
} from '@lobechat/shared-tool-ui/inspectors/linear-labels';
// Re-exported under the historical CC-flavoured name so existing imports
// (`import { LINEAR_MCP_TOOL_NAMES } from './linearMcpLabels'`) keep working.
export const LINEAR_MCP_TOOL_NAMES = LINEAR_TOOL_NAMES;
export const formatLinearMcpShortLabel = (apiName: string): string | null => {
if (!apiName.startsWith(LINEAR_MCP_PREFIX)) return null;
return `Linear · ${staticLabelFor(parseToolName(apiName))}`;
};
@@ -1,11 +1,10 @@
'use client';
import type { BuiltinInterventionProps } from '@lobechat/types';
import { Button, Flexbox, Icon, Tabs, Text, TextArea } from '@lobehub/ui';
import { Button, Flexbox, Icon, Tabs, Text } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { ArrowLeft, Check, PenLine, Send, X } from 'lucide-react';
import { Check, Send, X } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useConversationStore } from '@/features/Conversation/store';
import { dataSelectors } from '@/features/Conversation/store/slices/data/selectors';
@@ -13,16 +12,6 @@ import { useChatStore } from '@/store/chat';
import type { AskUserQuestionArgs, AskUserQuestionItem } from '../../types';
/**
* Sentinel key the bridge formatter (`AskUserMcpServer.formatAnswerForCC`)
* looks for to detect escape-mode replies. When present, the payload is just
* `{ __freeform__: <text> }` (multi-choice picks are intentionally absent)
* and the text is forwarded to CC verbatim no `User answers:` framing.
* Matches the convention `lobe-user-interaction` already uses for its own
* "Or type directly" escape hatch.
*/
const FREEFORM_PAYLOAD_KEY = '__freeform__';
/**
* Server-side bridge timeout (matches `AskUserMcpServer.pendingTimeoutMs`).
* Not strictly synchronized server is authoritative but keeps the on-screen
@@ -41,23 +30,6 @@ const formatRemaining = (msLeft: number): string => {
};
const styles = createStaticStyles(({ css, cssVar }) => ({
// "Or type directly" / "Back to options" link — slim secondary text that
// sits alongside Skip in the action bar; matches the
// `lobe-user-interaction` escape-toggle styling so the two flows feel
// like the same control.
escapeLink: css`
cursor: pointer;
display: inline-flex;
gap: 4px;
align-items: center;
transition: color 0.12s ease;
&:hover {
color: ${cssVar.colorText};
}
`,
// Card sits inline with the chat — no surrounding panel chrome. Hover
// tints the row so the stack reads as clickable; selection swaps to a
// filled `colorPrimaryBg` so the pick is visually weighty.
@@ -161,7 +133,6 @@ interface QuestionPanelProps {
}
const QuestionPanel = memo<QuestionPanelProps>(({ question, answer, disabled, onToggle }) => {
const { t } = useTranslation('tool');
const isOptionSelected = (label: string): boolean =>
question.multiSelect ? Array.isArray(answer) && answer.includes(label) : answer === label;
@@ -171,7 +142,7 @@ const QuestionPanel = memo<QuestionPanelProps>(({ question, answer, disabled, on
{question.header && <Text type="secondary">{question.header}</Text>}
{question.multiSelect && (
<Text fontSize={12} type="secondary">
{t('claudeCode.askUserQuestion.multiSelectTag')}
(multi-select)
</Text>
)}
</Flexbox>
@@ -218,7 +189,6 @@ QuestionPanel.displayName = 'CCAskUserQuestionPanel';
*/
const AskUserQuestionIntervention = memo<BuiltinInterventionProps<AskUserQuestionArgs>>(
({ args, messageId, onInteractionAction }) => {
const { t } = useTranslation('tool');
const questions = args?.questions ?? [];
// Persisted draft (survives unmount / HMR / refresh) — read from the tool
@@ -231,27 +201,9 @@ const AskUserQuestionIntervention = memo<BuiltinInterventionProps<AskUserQuestio
});
const setInterventionDraft = useChatStore((s) => s.setInterventionDraft);
// Persisted draft may carry both form picks and escape-mode text under
// `__freeform__`. Split them so `answers` only contains per-question
// picks and `escapeText` owns the freeform string — otherwise a stale
// `__freeform__` key would leak into the form-mode submit payload.
const [answers, setAnswers] = useState<Record<string, string | string[]>>(() => {
if (!persistedDraft) return {};
const { [FREEFORM_PAYLOAD_KEY]: _, ...rest } = persistedDraft;
return rest;
});
// Escape-mode mirrors `lobe-user-interaction`'s "Or type directly"
// toggle — options and freeform are mutually exclusive, not stacked.
// Persisted text under the `__freeform__` key restores the user back
// into escape mode on remount; an empty draft starts in form mode.
const [escapeText, setEscapeText] = useState<string>(() => {
const v = persistedDraft?.[FREEFORM_PAYLOAD_KEY];
return typeof v === 'string' ? v : '';
});
const [escapeActive, setEscapeActive] = useState<boolean>(() => {
const v = persistedDraft?.[FREEFORM_PAYLOAD_KEY];
return typeof v === 'string' && v.length > 0;
});
const [answers, setAnswers] = useState<Record<string, string | string[]>>(
() => persistedDraft ?? {},
);
const [submitting, setSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<string>(() => {
// Resume on the first unanswered question so coming back lands the user
@@ -288,8 +240,7 @@ const AskUserQuestionIntervention = memo<BuiltinInterventionProps<AskUserQuestio
} else {
next = { ...prev, [q.question]: label };
}
// Persist picks only. Form mode is mutually exclusive with escape
// mode, so we never co-mingle `__freeform__` into a form-mode draft.
// Persist to pluginState so the picks survive remount / refresh.
setInterventionDraft(messageId, next);
// Single-select auto-advance: if there's a next unanswered question,
@@ -329,47 +280,7 @@ const AskUserQuestionIntervention = memo<BuiltinInterventionProps<AskUserQuestio
[onInteractionAction, submitting],
);
const handleEscapeTextChange = useCallback(
(value: string) => {
setEscapeText(value);
// Only persist the freeform text while escape mode is the active UI.
// Stale `__freeform__` entries left in the draft would re-arm escape
// mode on the next mount, which is not what the user signalled.
setInterventionDraft(messageId, {
...answers,
[FREEFORM_PAYLOAD_KEY]: value,
});
},
[answers, messageId, setInterventionDraft],
);
const handleEscapeToggle = useCallback(() => {
setEscapeActive((prev) => {
const next = !prev;
// Mirror the toggle into the draft: turning escape ON saves the
// current text (so a refresh resumes here); turning it OFF strips
// `__freeform__` so the next mount lands back in form mode.
if (next) {
setInterventionDraft(messageId, {
...answers,
[FREEFORM_PAYLOAD_KEY]: escapeText,
});
} else {
setInterventionDraft(messageId, answers);
}
return next;
});
}, [answers, escapeText, messageId, setInterventionDraft]);
const handleSubmit = useCallback(() => {
if (escapeActive) {
// Escape mode is mutually exclusive with picks — send the text alone
// under `__freeform__`. Bridge formatter forwards it to CC verbatim.
void submitWith({ [FREEFORM_PAYLOAD_KEY]: escapeText.trim() });
} else {
void submitWith(answers);
}
}, [answers, escapeActive, escapeText, submitWith]);
const handleSubmit = useCallback(() => submitWith(answers), [answers, submitWith]);
const handleSkip = useCallback(async () => {
if (!onInteractionAction || submitting) return;
@@ -396,16 +307,8 @@ const AskUserQuestionIntervention = memo<BuiltinInterventionProps<AskUserQuestio
// Beats letting the server-side bridge time out into a `cancelled`
// result — the model gets a structured answer it can act on instead of
// a "user didn't respond" isError. Single-shot via `submitting` guard.
//
// Escape-mode special case: if the user is in escape mode with non-empty
// text when the countdown hits zero, submit that text as-is rather than
// discarding their work and falling back to option 1.
useEffect(() => {
if (!expired || submitting || questions.length === 0) return;
if (escapeActive && escapeText.trim().length > 0) {
void submitWith({ [FREEFORM_PAYLOAD_KEY]: escapeText.trim() });
return;
}
const fallback: Record<string, string | string[]> = { ...answers };
for (const q of questions) {
const a = fallback[q.question];
@@ -416,17 +319,14 @@ const AskUserQuestionIntervention = memo<BuiltinInterventionProps<AskUserQuestio
}
}
void submitWith(fallback);
}, [expired, submitting, questions, answers, escapeActive, escapeText, submitWith]);
}, [expired, submitting, questions, answers, submitWith]);
const isMulti = questions.length > 1;
const activeQuestion = questions[Number(activeTab)] ?? questions[0];
const isSubmitDisabled = escapeActive
? !escapeText.trim() || submitting || expired
: !allAnswered || expired || submitting;
return (
<Flexbox gap={12}>
{!escapeActive && isMulti && (
{isMulti && (
<Tabs
compact
activeKey={activeTab}
@@ -447,69 +347,34 @@ const AskUserQuestionIntervention = memo<BuiltinInterventionProps<AskUserQuestio
/>
)}
{escapeActive ? (
<TextArea
autoSize={{ maxRows: 8, minRows: 3 }}
{activeQuestion && (
<QuestionPanel
answer={answers[activeQuestion.question]}
disabled={expired || submitting}
placeholder={t('claudeCode.askUserQuestion.escape.placeholder')}
value={escapeText}
variant="filled"
onChange={(e) => handleEscapeTextChange(e.target.value)}
question={activeQuestion}
onToggle={handleToggle}
/>
) : (
activeQuestion && (
<QuestionPanel
answer={answers[activeQuestion.question]}
disabled={expired || submitting}
question={activeQuestion}
onToggle={handleToggle}
/>
)
)}
<Flexbox horizontal align="center" gap={8} justify="space-between">
<Flexbox horizontal align="center" gap={12}>
{escapeActive ? (
<Text
className={styles.escapeLink}
fontSize={12}
type="secondary"
onClick={expired || submitting ? undefined : handleEscapeToggle}
>
<Icon icon={ArrowLeft} size={12} />
{t('claudeCode.askUserQuestion.escape.back')}
</Text>
) : (
<Text
className={styles.escapeLink}
fontSize={12}
type="secondary"
onClick={expired || submitting ? undefined : handleEscapeToggle}
>
{t('claudeCode.askUserQuestion.escape.enter')}
<Icon icon={PenLine} size={12} />
</Text>
)}
<Text fontSize={12} type="secondary">
{expired
? t('claudeCode.askUserQuestion.timeExpired')
: t('claudeCode.askUserQuestion.timeRemaining', {
time: formatRemaining(deadline - now),
})}
</Text>
</Flexbox>
<Text fontSize={12} type="secondary">
{expired
? 'Time expired — using option 1 of each question.'
: `Time remaining: ${formatRemaining(deadline - now)} · ` +
'unanswered questions default to option 1 on timeout.'}
</Text>
<Flexbox horizontal gap={8}>
<Button disabled={submitting} icon={X} onClick={handleSkip}>
{t('claudeCode.askUserQuestion.skip')}
Skip
</Button>
<Button
disabled={isSubmitDisabled}
disabled={!allAnswered || expired || submitting}
icon={Send}
loading={submitting}
type="primary"
onClick={handleSubmit}
>
{t('claudeCode.askUserQuestion.submit')}
Submit
</Button>
</Flexbox>
</Flexbox>
@@ -3,7 +3,7 @@
import type { BuiltinRenderProps } from '@lobechat/types';
import { Flexbox, Icon, Text } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { Check, PenLine } from 'lucide-react';
import { Check } from 'lucide-react';
import { memo } from 'react';
import type { AskUserQuestionArgs, AskUserQuestionItem } from '../../../types';
@@ -112,34 +112,8 @@ const AskUserQuestion = memo<
>(({ args, pluginError, pluginState }) => {
const questions = args?.questions ?? [];
const answers = pluginState?.askUserAnswers;
const freeform = answers?.['__freeform__'];
const freeformText = typeof freeform === 'string' ? freeform.trim() : '';
const isError = !!pluginError;
// Escape-mode reply: the user opted out of the multi-choice form and
// wrote freeform text instead. The form picks are intentionally absent,
// so render the questions for context (header + body only) plus the
// typed reply as one card — Q&A pairs would render as empty rows.
if (freeformText) {
return (
<Flexbox className={styles.container} gap={12}>
{questions.map((q, idx) => (
<Flexbox gap={4} key={`${q.question}-${idx}`}>
{q.header && <span className={styles.header}>{q.header}</span>}
<Text className={styles.question}>{q.question}</Text>
</Flexbox>
))}
<Flexbox horizontal align="flex-start" className={cx(styles.answerRow)} gap={8}>
<Icon className={styles.check} icon={PenLine} size={14} />
<Text className={styles.answer}>{freeformText}</Text>
</Flexbox>
{isError && (
<Text type="warning">(No answer received model continued without their input.)</Text>
)}
</Flexbox>
);
}
return (
<Flexbox className={styles.container} gap={12}>
{questions.map((q, idx) => (
@@ -1,283 +0,0 @@
'use client';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Block, Checkbox, Icon } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { CircleArrowRight, CircleCheckBig, CircleX, ListTodo, RotateCcw } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ClaudeCodeApiName, type TaskUpdateArgs } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
header: css`
display: flex;
gap: 8px;
align-items: center;
padding-block: 10px;
padding-inline: 12px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
background: ${cssVar.colorFillQuaternary};
`,
headerCount: css`
flex-shrink: 0;
padding-block: 2px;
padding-inline: 8px;
border-radius: 999px;
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
color: ${cssVar.colorTextSecondary};
background: ${cssVar.colorFillTertiary};
`,
headerDetail: css`
overflow: hidden;
min-width: 0;
color: ${cssVar.colorText};
text-overflow: ellipsis;
`,
headerLabel: css`
overflow: hidden;
display: flex;
flex: 1;
gap: 0;
align-items: center;
min-width: 0;
color: ${cssVar.colorTextSecondary};
text-overflow: ellipsis;
white-space: nowrap;
`,
itemRow: css`
width: 100%;
padding-block: 10px;
padding-inline: 12px;
border-block-end: 1px dashed ${cssVar.colorBorderSecondary};
&:last-child {
border-block-end: none;
}
`,
processingRow: css`
display: flex;
gap: 7px;
align-items: center;
`,
textCompleted: css`
color: ${cssVar.colorTextQuaternary};
text-decoration: line-through;
`,
textPending: css`
color: ${cssVar.colorTextSecondary};
`,
textProcessing: css`
color: ${cssVar.colorText};
`,
}));
interface TaskPluginStateItem {
id?: string;
status: 'todo' | 'processing' | 'completed';
text: string;
}
interface TaskPluginState {
todos?: {
items?: TaskPluginStateItem[];
updatedAt?: string;
};
}
interface TaskRowProps {
item: TaskPluginStateItem;
}
const TaskRow = memo<TaskRowProps>(({ item }) => {
const { status, text } = item;
if (status === 'processing') {
return (
<div className={cx(styles.itemRow, styles.processingRow)}>
<Icon icon={CircleArrowRight} size={17} style={{ color: cssVar.colorInfo }} />
<span className={styles.textProcessing}>{text}</span>
</div>
);
}
const isCompleted = status === 'completed';
return (
<Checkbox
backgroundColor={cssVar.colorSuccess}
checked={isCompleted}
shape={'circle'}
style={{ borderWidth: 1.5, cursor: 'default' }}
classNames={{
text: cx(styles.textPending, isCompleted && styles.textCompleted),
wrapper: styles.itemRow,
}}
textProps={{
type: isCompleted ? 'secondary' : undefined,
}}
>
{text}
</Checkbox>
);
});
TaskRow.displayName = 'ClaudeCodeTaskRow';
/**
* Per-call override that swaps the header into a status-flip readout
* ("Completed: Read hosts") when the panel is rendered for a TaskUpdate.
* Computed by the Task component from `apiName` + `args` so TaskHeader
* stays a pure presentational component.
*
* `label` is the verb shown before `:`, `detail` is the subject; both
* are pre-localized strings (no i18n inside TaskHeader for overrides).
*/
interface TaskHeaderOverride {
color: string;
detail?: string;
icon: typeof CircleArrowRight;
label: string;
}
interface TaskHeaderProps {
completed: number;
inProgress?: TaskPluginStateItem;
override?: TaskHeaderOverride;
total: number;
}
const TaskHeader = memo<TaskHeaderProps>(({ completed, total, inProgress, override }) => {
const { t } = useTranslation('plugin');
const allDone = total > 0 && completed === total;
const icon =
override?.icon ?? (inProgress ? CircleArrowRight : allDone ? CircleCheckBig : ListTodo);
const color =
override?.color ??
(inProgress ? cssVar.colorInfo : allDone ? cssVar.colorSuccess : cssVar.colorTextSecondary);
const label =
override?.label ??
(inProgress
? t('builtins.lobe-claude-code.todoWrite.currentStep')
: allDone
? t('builtins.lobe-claude-code.todoWrite.allDone')
: t('builtins.lobe-claude-code.todoWrite.todos'));
const detail = override ? override.detail : inProgress?.text;
return (
<div className={styles.header}>
<Icon icon={icon} size={16} style={{ color, flexShrink: 0 }} />
<div className={styles.headerLabel}>
<span>{label}</span>
{detail && (
<>
<span>: </span>
<span className={styles.headerDetail}>{detail}</span>
</>
)}
</div>
<span className={styles.headerCount}>
{completed}/{total}
</span>
</div>
);
});
TaskHeader.displayName = 'ClaudeCodeTaskHeader';
/**
* Panel render for CC 2.1.143+ task tools (TaskCreate / TaskUpdate / TaskList).
*
* Reads the **adapter-synthesized** `pluginState.todos.items` snapshot the
* same source consumed by `selectTodosFromMessages`. Each per-call args
* carries only a delta, so the panel can't be built from args alone; the
* accumulator's snapshot is the source of truth.
*
* Returns `null` when the snapshot is absent or empty (typical for a fresh
* TaskList before any creates, or a TaskUpdate that failed).
*
* Header behaviour:
* - Default (TaskCreate / TaskList / TaskUpdate without status): shows the
* standard `currentStep / allDone / todos` aggregate label so the panel
* stays visually consistent with legacy TodoWrite sessions.
* - TaskUpdate with `args.status`: the per-call signal IS that status flip,
* so the header mirrors the chip ("Completed: Read hosts") instead of
* burying it under the aggregate. Subject is resolved from pluginState
* by id; `args.subject` is the resume-gap fallback.
*/
const Task = memo<BuiltinRenderProps<TaskUpdateArgs | undefined, TaskPluginState>>(
({ apiName, args, pluginState }) => {
const items = pluginState?.todos?.items;
const { t } = useTranslation('plugin');
const stats = useMemo(() => {
const list = items ?? [];
return {
completed: list.filter((item) => item.status === 'completed').length,
inProgress: list.find((item) => item.status === 'processing'),
total: list.length,
};
}, [items]);
const override = useMemo<TaskHeaderOverride | undefined>(() => {
if (apiName !== ClaudeCodeApiName.TaskUpdate || !args?.status) return undefined;
const { status, taskId, subject: argsSubject } = args;
const resolvedSubject =
(taskId ? items?.find((item) => item.id === taskId)?.text : undefined) ?? argsSubject;
const map = {
completed: {
color: cssVar.colorSuccess,
icon: CircleCheckBig,
label: t('builtins.lobe-claude-code.task.updateCompleted'),
},
deleted: {
color: cssVar.colorError,
icon: CircleX,
label: t('builtins.lobe-claude-code.task.updateDeleted'),
},
in_progress: {
color: cssVar.colorInfo,
icon: CircleArrowRight,
label: t('builtins.lobe-claude-code.task.updateInProgress'),
},
pending: {
color: cssVar.colorTextSecondary,
icon: RotateCcw,
label: t('builtins.lobe-claude-code.task.updatePending'),
},
} as const;
const entry = map[status];
return { ...entry, detail: resolvedSubject };
}, [apiName, args, items, t]);
if (!items || items.length === 0) return null;
return (
<Block variant={'outlined'} width="100%">
<TaskHeader
completed={stats.completed}
inProgress={stats.inProgress}
override={override}
total={stats.total}
/>
{items.map((item, index) => (
<TaskRow item={item} key={index} />
))}
</Block>
);
},
);
Task.displayName = 'ClaudeCodeTask';
export default Task;
@@ -1,58 +0,0 @@
'use client';
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Markdown, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { Link } from 'lucide-react';
import { memo } from 'react';
import type { WebFetchArgs } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
prompt: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
word-break: break-all;
`,
url: css`
font-family: ${cssVar.fontFamilyCode};
word-break: break-all;
`,
}));
const WebFetch = memo<BuiltinRenderProps<WebFetchArgs>>(({ args, content }) => {
const url = args?.url || '';
const prompt = args?.prompt || '';
return (
<ToolResultCard
wrapHeader
icon={Link}
header={
<>
{url && (
<Text ellipsis strong className={styles.url}>
{url}
</Text>
)}
{prompt && (
<Text ellipsis className={styles.prompt}>
{prompt}
</Text>
)}
</>
}
>
{content && (
<Markdown style={{ maxHeight: 240, overflow: 'auto' }} variant={'chat'}>
{content}
</Markdown>
)}
</ToolResultCard>
);
});
WebFetch.displayName = 'ClaudeCodeWebFetch';
export default WebFetch;
@@ -1,65 +0,0 @@
'use client';
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Highlighter, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { Globe } from 'lucide-react';
import { memo } from 'react';
import type { WebSearchArgs } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
domains: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
word-break: break-all;
`,
query: css`
font-family: ${cssVar.fontFamilyCode};
`,
}));
const WebSearch = memo<BuiltinRenderProps<WebSearchArgs>>(({ args, content }) => {
const query = args?.query || '';
const allowed = args?.allowed_domains?.join(', ');
const blocked = args?.blocked_domains?.map((d) => `-${d}`).join(', ');
const scope = [allowed, blocked].filter(Boolean).join(' · ');
return (
<ToolResultCard
wrapHeader
icon={Globe}
header={
<>
{query && (
<Text strong className={styles.query}>
{query}
</Text>
)}
{scope && (
<Text ellipsis className={styles.domains}>
{scope}
</Text>
)}
</>
}
>
{content && (
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{content}
</Highlighter>
)}
</ToolResultCard>
);
});
WebSearch.displayName = 'ClaudeCodeWebSearch';
export default WebSearch;
@@ -9,10 +9,7 @@ import Glob from './Glob';
import Grep from './Grep';
import Read from './Read';
import Skill from './Skill';
import Task from './Task';
import TodoWrite from './TodoWrite';
import WebFetch from './WebFetch';
import WebSearch from './WebSearch';
import Write from './Write';
/**
@@ -32,18 +29,7 @@ export const ClaudeCodeRenders = {
[ClaudeCodeApiName.Grep]: Grep,
[ClaudeCodeApiName.Read]: Read,
[ClaudeCodeApiName.Skill]: Skill,
// Task panel renders the adapter-synthesized `pluginState.todos` snapshot.
// Only TaskUpdate / TaskList show it — those events express list-level
// changes (status flip / full snapshot) where the cumulative panel is
// genuinely informative. TaskCreate is deliberately skipped: it's a
// single-task add and the inspector chip already says `Creating task:
// <subject>`, so the big "Todos N/M" panel adds noise without info.
// TaskGet is read-only and falls through to the default tool card.
[ClaudeCodeApiName.TaskList]: Task,
[ClaudeCodeApiName.TaskUpdate]: Task,
[ClaudeCodeApiName.TodoWrite]: TodoWrite,
[ClaudeCodeApiName.WebFetch]: WebFetch,
[ClaudeCodeApiName.WebSearch]: WebSearch,
[ClaudeCodeApiName.Write]: Write,
};
@@ -57,8 +43,6 @@ export const ClaudeCodeRenders = {
*/
export const ClaudeCodeRenderDisplayControls: Record<string, RenderDisplayControl> = {
[ClaudeCodeApiName.Edit]: 'expand',
[ClaudeCodeApiName.TaskList]: 'expand',
[ClaudeCodeApiName.TaskUpdate]: 'expand',
[ClaudeCodeApiName.TodoWrite]: 'expand',
[ClaudeCodeApiName.Write]: 'expand',
};
@@ -1,6 +1,5 @@
export { ClaudeCodeApiName, ClaudeCodeIdentifier } from '../types';
export { ClaudeCodeInspectors } from './Inspector';
export { formatLinearMcpShortLabel } from './Inspector/linearMcpLabels';
export { ClaudeCodeInterventions } from './Intervention';
export { ClaudeCodeRenderDisplayControls, ClaudeCodeRenders } from './Render';
export { ClaudeCodeStreamings } from './Streaming';
@@ -34,42 +34,13 @@ export enum ClaudeCodeApiName {
Edit = 'Edit',
Glob = 'Glob',
Grep = 'Grep',
/**
* Long-running command monitor (CC 2.1+). Spawns `command` as a tracked
* background task; CC re-invokes the LLM each time the task pushes new
* stdout (`system task_started` registers the task, `task_notification`
* terminates it see LOBE-8998 in the adapter). Rendered by a dedicated
* `MonitorInspector` so the chip iconography matches the SignalCallbacks
* accordion underneath.
*/
Monitor = 'Monitor',
Read = 'Read',
ScheduleWakeup = 'ScheduleWakeup',
Skill = 'Skill',
/**
* Imperative successor to {@link TodoWrite} in CC 2.1.143+. The model creates
* one task per call (CC server assigns the numeric id) and mutates by id with
* {@link TaskUpdate}. The adapter accumulates these into a per-session map
* and synthesizes the shared `pluginState.todos` shape on each task-tool
* result so the existing TodoProgress UI keeps working without renderer
* changes.
*/
TaskCreate = 'TaskCreate',
/** Inspect a single task by id. Read-only — does not mutate adapter state. */
TaskGet = 'TaskGet',
/**
* List all tasks. Read-only, but its plain-text output is the only
* reconciliation signal available when resuming a CC session whose
* TaskCreate / TaskUpdate calls happened before this adapter was started.
*/
TaskList = 'TaskList',
TaskOutput = 'TaskOutput',
TaskStop = 'TaskStop',
TaskUpdate = 'TaskUpdate',
TodoWrite = 'TodoWrite',
ToolSearch = 'ToolSearch',
WebFetch = 'WebFetch',
WebSearch = 'WebSearch',
Write = 'Write',
}
@@ -101,27 +72,6 @@ export interface SkillArgs {
skill?: string;
}
/**
* Arguments for CC's built-in `Monitor` tool long-running command monitor.
* CC spawns `command` as a tracked background task; `system task_started`
* registers it and `system task_notification` ends it (see LOBE-8998 in the
* CC adapter). Each stdout push between those two lifecycle events fires a
* new LLM turn that's surfaced as a SignalCallbacks entry in the UI.
*
* - `description` one-line summary for the inspector chip (model-written).
* - `command` shell snippet to run; falls back to the chip label when
* `description` is empty.
* - `timeout_ms` wall-clock cap on the monitor; advisory in the UI.
* - `persistent` `true` keeps the task alive across the next LLM
* re-invocation; `false` (default) means single-run.
*/
export interface MonitorArgs {
command?: string;
description?: string;
persistent?: boolean;
timeout_ms?: number;
}
/**
* Arguments for CC's built-in `ToolSearch` tool. CC invokes this to load
* schemas for deferred tools before calling them. `query` is either
@@ -156,51 +106,6 @@ export interface ScheduleWakeupArgs {
reason?: string;
}
/**
* Status of a single task in CC's `TaskCreate` / `TaskUpdate` flow. `deleted`
* is only valid on TaskUpdate it permanently removes the entry rather than
* representing a steady state.
*/
export type ClaudeCodeTaskStatus = 'pending' | 'in_progress' | 'completed';
/**
* Arguments for CC's built-in `TaskCreate`. Each call creates ONE task with
* default status `pending`; the CC server assigns a numeric id that the
* adapter must parse from the tool_result line `Task #N created successfully`.
*/
export interface TaskCreateArgs {
/** Present continuous form shown while the task is in_progress. */
activeForm?: string;
description: string;
metadata?: Record<string, unknown>;
subject: string;
}
/**
* Arguments for CC's built-in `TaskUpdate`. All fields except `taskId` are
* optional TaskUpdate is a merge. `status: 'deleted'` is the soft-delete
* path; downstream the adapter drops the entry from its accumulator.
*/
export interface TaskUpdateArgs {
activeForm?: string;
addBlockedBy?: string[];
addBlocks?: string[];
description?: string;
metadata?: Record<string, unknown>;
owner?: string;
status?: ClaudeCodeTaskStatus | 'deleted';
subject?: string;
taskId: string;
}
/** Arguments for CC's built-in `TaskList` — no parameters in current schema. */
export type TaskListArgs = Record<PropertyKey, never>;
/** Arguments for CC's built-in `TaskGet`. */
export interface TaskGetArgs {
taskId: string;
}
/**
* Arguments for CC's built-in `TaskOutput` tool. Retrieves output from a
* running or completed background task (bash, agent, remote session) by id.
@@ -247,23 +152,3 @@ export interface AskUserQuestionItem {
export interface AskUserQuestionArgs {
questions: AskUserQuestionItem[];
}
/**
* Arguments for CC's built-in `WebSearch` tool. CC issues a web search via
* Anthropic's hosted search and returns a text block of formatted results.
*/
export interface WebSearchArgs {
allowed_domains?: string[];
blocked_domains?: string[];
query?: string;
}
/**
* Arguments for CC's built-in `WebFetch` tool. CC fetches a URL and asks the
* model to extract `prompt` from the page; the tool_result is the model's
* summary, not the raw HTML.
*/
export interface WebFetchArgs {
prompt?: string;
url?: string;
}
@@ -5,6 +5,7 @@
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./executor": "./src/executor/index.ts",
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
},
"main": "./src/index.ts",
@@ -90,7 +90,6 @@ export class LocalSystemExecutionRuntime extends ComputerRuntime {
case 'listLocalFiles': {
return {
limit: params.limit,
path: params.directoryPath,
sortBy: params.sortBy,
sortOrder: params.sortOrder,
@@ -114,7 +113,7 @@ export class LocalSystemExecutionRuntime extends ComputerRuntime {
}
case 'getCommandOutput': {
return { filter: params.filter, shell_id: params.commandId };
return { shell_id: params.commandId };
}
case 'killCommand': {
@@ -1,193 +0,0 @@
/**
* Regression: the local-system executor must forward ALL grepContent params
* (glob, output_mode, -i, -A/-B/-C, -n, multiline, head_limit, type, scope)
* to the runtime not strip them down to {directory, pattern}.
*
* Pre-fix, agent calls with `scope` + `glob` + `output_mode` + `-i` reached the
* Electron IPC as `{ directory, pattern }` only. The IPC type expects `path`/`scope`
* (not `directory`), so cwd fell back to `process.cwd()`. With no glob/include
* filter, `tool.*name.*mcp` matched every dist/* bundle and tsbuildinfo.
*
* See LOBE-8666 / the agent screenshot that reported the leak.
*/
import { describe, expect, it, vi } from 'vitest';
// The executor module pulls in @/services/electron/localFileService (renderer alias).
// In the package's vitest env that alias doesn't resolve — stub it.
vi.mock('@/services/electron/localFileService', () => ({
localFileService: {
editLocalFile: vi.fn(),
getCommandOutput: vi.fn(),
globFiles: vi.fn(),
grepContent: vi.fn(),
killCommand: vi.fn(),
listLocalFiles: vi.fn(),
moveLocalFiles: vi.fn(),
readLocalFile: vi.fn(),
readLocalFiles: vi.fn(),
renameLocalFile: vi.fn(),
runCommand: vi.fn(),
searchLocalFiles: vi.fn(),
writeFile: vi.fn(),
},
}));
const { localSystemExecutor } = await import('../client/executor');
describe('localSystemExecutor.grepContent — params forwarding', () => {
it('forwards glob, output_mode, -i, and scope through to the runtime', async () => {
const runtime = (localSystemExecutor as any).runtime as {
grepContent: (args: any) => Promise<unknown>;
};
const spy = vi.spyOn(runtime, 'grepContent').mockResolvedValue({
content: 'Found 0 matches in 0 locations:',
state: { matches: [], pattern: 'tool.*name.*mcp', totalMatches: 0 },
success: true,
});
await localSystemExecutor.grepContent({
'-i': true,
'glob': '**/*.ts',
'output_mode': 'files_with_matches',
'pattern': 'tool.*name.*mcp',
'scope': '/Users/arvinxx/CodeProjects/LobeHub/lobehub-desktop',
});
expect(spy).toHaveBeenCalledTimes(1);
const forwarded = spy.mock.calls[0][0] as Record<string, unknown>;
// The critical fields the LLM filled in MUST reach the runtime.
expect(forwarded).toMatchObject({
'-i': true,
'glob': '**/*.ts',
'output_mode': 'files_with_matches',
'pattern': 'tool.*name.*mcp',
// resolveArgsWithScope copies scope into `path` so the downstream
// resolveSearchPath can pick it up; either field reaching the runtime is fine.
'path': '/Users/arvinxx/CodeProjects/LobeHub/lobehub-desktop',
});
// And it must NOT have been collapsed to the stripped {directory, pattern} shape.
expect(forwarded).not.toEqual({
directory: expect.any(String),
pattern: expect.any(String),
});
spy.mockRestore();
});
it('keeps optional flags intact when present', async () => {
const runtime = (localSystemExecutor as any).runtime as {
grepContent: (args: any) => Promise<unknown>;
};
const spy = vi.spyOn(runtime, 'grepContent').mockResolvedValue({
content: '',
state: { matches: [], pattern: 'x', totalMatches: 0 },
success: true,
});
await localSystemExecutor.grepContent({
'-A': 3,
'-B': 2,
'-C': 1,
'-i': true,
'-n': true,
'head_limit': 50,
'multiline': true,
'pattern': 'x',
'scope': '/repo',
'type': 'ts',
});
expect(spy.mock.calls[0][0]).toMatchObject({
'-A': 3,
'-B': 2,
'-C': 1,
'-i': true,
'-n': true,
'head_limit': 50,
'multiline': true,
'pattern': 'x',
'path': '/repo',
'type': 'ts',
});
spy.mockRestore();
});
});
describe('localSystemExecutor.listFiles — limit forwarding', () => {
it('forwards the manifest-exposed `limit` to the runtime', async () => {
const runtime = (localSystemExecutor as any).runtime as {
listFiles: (args: any) => Promise<unknown>;
};
const spy = vi.spyOn(runtime, 'listFiles').mockResolvedValue({
content: '',
state: { files: [], totalCount: 0 },
success: true,
});
await localSystemExecutor.listFiles({ limit: 50, path: '/tmp', sortBy: 'name' });
expect(spy.mock.calls[0][0]).toMatchObject({
directoryPath: '/tmp',
limit: 50,
sortBy: 'name',
});
spy.mockRestore();
});
});
describe('localSystemExecutor.getCommandOutput — filter forwarding', () => {
it('forwards the manifest-exposed `filter` to the runtime', async () => {
const runtime = (localSystemExecutor as any).runtime as {
getCommandOutput: (args: any) => Promise<unknown>;
};
const spy = vi.spyOn(runtime, 'getCommandOutput').mockResolvedValue({
content: '',
state: { newOutput: '', running: false, success: true },
success: true,
});
await localSystemExecutor.getCommandOutput({ filter: 'ERROR', shell_id: 'sh-1' });
expect(spy.mock.calls[0][0]).toMatchObject({
commandId: 'sh-1',
filter: 'ERROR',
});
spy.mockRestore();
});
});
describe('localSystemExecutor.runCommand — background field normalization', () => {
it('mirrors `run_in_background` to `background` so RunCommandState.isBackground is correct', async () => {
const runtime = (localSystemExecutor as any).runtime as {
runCommand: (args: any) => Promise<unknown>;
};
const spy = vi.spyOn(runtime, 'runCommand').mockResolvedValue({
content: '',
state: { isBackground: true, success: true },
success: true,
});
await localSystemExecutor.runCommand({
command: 'sleep 60',
description: 'sleep',
run_in_background: true,
});
const forwarded = spy.mock.calls[0][0] as Record<string, unknown>;
// Both fields present: `background` for ComputerRuntime state, `run_in_background` for IPC.
expect(forwarded).toMatchObject({
background: true,
command: 'sleep 60',
run_in_background: true,
});
spy.mockRestore();
});
});
@@ -1,6 +1,3 @@
// Client-side executor (browser runtime adapter for the agent)
export { localSystemExecutor } from './executor';
// Inspector components (customized tool call headers)
export { LocalSystemInspectors } from './Inspector';
@@ -17,9 +17,9 @@ import { BaseExecutor } from '@lobechat/types';
import { localFileService } from '@/services/electron/localFileService';
import { LocalSystemExecutionRuntime } from '../../ExecutionRuntime';
import { LocalSystemIdentifier } from '../../types';
import { resolveArgsWithScope } from '../../utils/path';
import { LocalSystemExecutionRuntime } from '../ExecutionRuntime';
import { LocalSystemIdentifier } from '../types';
import { resolveArgsWithScope } from '../utils/path';
const LocalSystemApiEnum = {
editFile: 'editFile' as const,
@@ -87,10 +87,9 @@ class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
try {
const result = await this.runtime.listFiles({
directoryPath: params.path,
limit: params.limit,
sortBy: params.sortBy,
sortOrder: params.sortOrder,
} as any);
});
return this.toResult(result);
} catch (error) {
return this.errorResult(error);
@@ -173,14 +172,7 @@ class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
runCommand = async (params: RunCommandParams): Promise<BuiltinToolResult> => {
try {
// The manifest exposes `run_in_background`, but ComputerRuntime's RunCommandState
// reads `args.background` for the `isBackground` field — without this normalize
// the UI/state would always say foreground even for background commands.
// The IPC handler reads `run_in_background` itself, so we keep that field too.
const result = await this.runtime.runCommand({
...params,
background: params.run_in_background,
} as any);
const result = await this.runtime.runCommand(params);
return this.toResult(result);
} catch (error) {
return this.errorResult(error);
@@ -191,8 +183,7 @@ class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
try {
const result = await this.runtime.getCommandOutput({
commandId: params.shell_id,
filter: params.filter,
} as any);
});
return this.toResult(result);
} catch (error) {
return this.errorResult(error);
@@ -215,12 +206,10 @@ class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
grepContent = async (params: GrepContentParams): Promise<BuiltinToolResult> => {
try {
const resolvedParams = resolveArgsWithScope(params, 'path');
// Forward the full IPC params (glob / output_mode / -i / -A / -B / -C / -n /
// multiline / head_limit / type / tool) instead of stripping to {directory, pattern}.
// ComputerRuntime.callService passes args through unchanged, so the runtime type
// narrowing was the only blocker — the underlying rg/grep needs these flags to
// honor the agent's filter and stop scanning dist/* and tsbuildinfo.
const result = await this.runtime.grepContent(resolvedParams as any);
const result = await this.runtime.grepContent({
directory: resolvedParams.path || '',
pattern: resolvedParams.pattern,
});
return this.toResult(result);
} catch (error) {
return this.errorResult(error);
@@ -7,7 +7,6 @@ export const LocalSystemManifest: BuiltinToolManifest = {
executors: ['client', 'server'],
api: [
{
defaultTimeoutMs: 30_000,
description:
'List files and folders in a specified directory. Input should be a path. Output is a JSON array of file/folder names.',
humanIntervention: {
@@ -47,7 +46,6 @@ export const LocalSystemManifest: BuiltinToolManifest = {
},
},
{
defaultTimeoutMs: 30_000,
description:
'Read the content of a text or document file (txt/md/json/source code/pdf/docx/etc.). Binary files (.bin/.exe/.zip/.b64/encoded blobs) are rejected with a structured error — use runCommand with file/hexdump/strings to inspect those instead. Output is capped at 500K chars total and 8K chars per line; for larger files, use a narrower line range or grepContent.',
humanIntervention: {
@@ -78,7 +76,6 @@ export const LocalSystemManifest: BuiltinToolManifest = {
},
},
{
defaultTimeoutMs: 60_000,
description:
'Search for files within the workspace based on a query string and optional filter options. Input should include the search query and any filter options. Output is a JSON array of matching file paths.',
humanIntervention: {
@@ -163,7 +160,6 @@ export const LocalSystemManifest: BuiltinToolManifest = {
},
},
{
defaultTimeoutMs: 60_000,
description:
'Moves or renames multiple files/directories. Input is an array of objects, each containing an oldPath and a newPath.',
humanIntervention: {
@@ -201,7 +197,6 @@ export const LocalSystemManifest: BuiltinToolManifest = {
},
},
{
defaultTimeoutMs: 30_000,
description:
'Write content to a specific file. Input should be the file path and content. Overwrites existing file or creates a new one.',
humanIntervention: {
@@ -228,7 +223,6 @@ export const LocalSystemManifest: BuiltinToolManifest = {
},
},
{
defaultTimeoutMs: 30_000,
description:
'Perform exact string replacements in files. Must read the file first before editing.',
humanIntervention: {
@@ -263,7 +257,6 @@ export const LocalSystemManifest: BuiltinToolManifest = {
},
},
{
defaultTimeoutMs: 120_000,
description:
'Execute a shell command and return its output. Supports both synchronous and background execution with timeout control.',
humanIntervention: 'required',
@@ -290,8 +283,7 @@ export const LocalSystemManifest: BuiltinToolManifest = {
type: 'boolean',
},
timeout: {
description:
'Timeout in milliseconds for this command. Default 120000ms. Server clamps to [1000, 800000]; raise this for long-running tasks (builds, large searches) instead of letting them hit the default and fail.',
description: 'Timeout in milliseconds (default: 120000ms, max: 600000ms)',
type: 'number',
},
},
@@ -300,7 +292,6 @@ export const LocalSystemManifest: BuiltinToolManifest = {
},
},
{
defaultTimeoutMs: 30_000,
description:
'Retrieve output from a running or completed background shell command. Returns only new output since the last check.',
name: LocalSystemApiName.getCommandOutput,
@@ -321,7 +312,6 @@ export const LocalSystemManifest: BuiltinToolManifest = {
},
},
{
defaultTimeoutMs: 10_000,
description: 'Kill a running background shell command by its ID.',
name: LocalSystemApiName.killCommand,
parameters: {
@@ -336,7 +326,6 @@ export const LocalSystemManifest: BuiltinToolManifest = {
},
},
{
defaultTimeoutMs: 60_000,
description:
'Search for content within files using regex patterns. Supports various output modes and filtering options.',
humanIntervention: {
@@ -409,7 +398,6 @@ export const LocalSystemManifest: BuiltinToolManifest = {
},
},
{
defaultTimeoutMs: 60_000,
description:
'Find files matching glob patterns. Supports standard glob syntax like "**/*.js" or "src/**/*.ts".',
humanIntervention: {
@@ -86,7 +86,7 @@ You have access to a set of tools to interact with the user's local file system:
- 'command': The shell command to execute.
- 'description' (Optional but recommended): A clear, concise description of what the command does (5-10 words, in active voice). **IMPORTANT: Always use the same language as the user's input.** If the user speaks Chinese, write the description in Chinese; if English, use English, etc.
- 'run_in_background' (Optional): Set to true to run in background and get a shell_id for later checking output.
- 'timeout' (Optional): Timeout in milliseconds (default: 120000ms, max: 800000ms).
- 'timeout' (Optional): Timeout in milliseconds (default: 120000ms, max: 600000ms).
The command runs in cmd.exe on Windows or /bin/sh on macOS/Linux.
- For retrieving output from background commands: Use 'getCommandOutput'. Provide:
- 'shell_id': The ID returned from runCommand when run_in_background was true.
@@ -75,7 +75,7 @@ You have access to a set of tools to interact with the user's local file system:
- 'command': The shell command to execute.
- 'description' (Optional but recommended): A clear, concise description of what the command does (5-10 words, in active voice). **IMPORTANT: Always use the same language as the user's input.** If the user speaks Chinese, write the description in Chinese; if English, use English, etc.
- 'run_in_background' (Optional): Set to true to run in background and get a shell_id for later checking output.
- 'timeout' (Optional): Timeout in milliseconds (default: 120000ms, max: 800000ms).
- 'timeout' (Optional): Timeout in milliseconds (default: 120000ms, max: 600000ms).
The command runs in cmd.exe on Windows or /bin/sh on macOS/Linux.
- For retrieving output from background commands: Use 'getCommandOutput'. Provide:
- 'shell_id': The ID returned from runCommand when run_in_background was true.
@@ -24,7 +24,6 @@ Memory effort level: {{memory_effort}}
- Do **not** use memory tools for requests to create, update, refine, merge, consolidate, or store reusable skills, procedures, workflows, playbooks, checklists, agent capabilities, agent prompts, or agent documents.
- If the user asks for a "reusable skill", "future workflow", "PR review checklist skill", "agent capability", or similar operational artifact, leave it to the skill/document management path. Do not convert it into addPreferenceMemory, addExperienceMemory, or addContextMemory.
- The same boundary applies in Chinese. Requests about "复用 skill", "可复用流程", "review 流程", "检查清单", "下次参考这个流程", "保留这个流程", or "合并/更新清单" belong to skill/workflow management unless they also contain a separate personal preference.
- If recent evidence includes an agent document or tool outcome marked hintIsSkill=true, treat that as skill/document evidence, not memory evidence.
- Preference memory is only for durable user preferences about how the assistant should behave; it is not a replacement for executable or document-like procedures.
- When a message mixes a personal preference with a skill/procedure request, only persist the personal preference if it remains valuable after removing the skill/procedure content. Otherwise skip memory.
</routing_boundaries>
@@ -4,20 +4,11 @@
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
".": "./src/index.ts"
},
"main": "./src/index.ts",
"devDependencies": {
"@lobechat/context-engine": "workspace:*",
"@lobechat/types": "workspace:*"
},
"peerDependencies": {
"@lobehub/ui": "^5",
"antd-style": "*",
"lucide-react": "*",
"react": "*",
"react-i18next": "*"
}
}
@@ -1,89 +0,0 @@
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
import type {
DeclareSelfFeedbackIntentContext,
DeclareSelfFeedbackIntentInput,
DeclareSelfFeedbackIntentPayload,
DeclareSelfFeedbackIntentResult,
DeclareSelfFeedbackIntentState,
} from '../types';
export interface SelfFeedbackIntentRuntimeService {
declareIntent: (
input: DeclareSelfFeedbackIntentInput,
) => Promise<DeclareSelfFeedbackIntentResult>;
}
export interface SelfFeedbackIntentExecutionRuntimeOptions {
service: SelfFeedbackIntentRuntimeService;
}
const REQUIRED_CONTEXT_KEYS = ['agentId', 'userId', 'topicId'];
const createJsonOutput = (
state: DeclareSelfFeedbackIntentState,
success: boolean,
): BuiltinServerRuntimeOutput => ({
content: JSON.stringify(state),
state,
success,
});
export class SelfFeedbackIntentExecutionRuntime {
private service: SelfFeedbackIntentRuntimeService;
constructor(options: SelfFeedbackIntentExecutionRuntimeOptions) {
this.service = options.service;
}
declareSelfFeedbackIntent = async (
input: DeclareSelfFeedbackIntentPayload,
context: DeclareSelfFeedbackIntentContext = {},
): Promise<BuiltinServerRuntimeOutput> => {
const { agentId, operationId, toolCallId, topicId, userId } = context;
if (!agentId || !userId || !topicId) {
return createJsonOutput(
{
accepted: false,
reason: 'missing_context',
required: REQUIRED_CONTEXT_KEYS,
},
false,
);
}
try {
const result = await this.service.declareIntent({
agentId,
input,
topicId,
userId,
...(operationId ? { operationId } : {}),
...(toolCallId ? { toolCallId } : {}),
});
return createJsonOutput(
{
accepted: result.accepted,
reason: result.reason ?? null,
sourceId: result.sourceId ?? null,
strength: result.strength,
},
true,
);
} catch (e) {
const message = e instanceof Error ? e.message : 'Unknown self-feedback intent error';
return {
content: `declareSelfFeedbackIntent with error detail: ${message}`,
error: { message },
state: {
accepted: false,
reason: 'runtime_error',
} satisfies DeclareSelfFeedbackIntentState,
success: false,
};
}
};
}
@@ -1,116 +0,0 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Icon } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { CheckCircle2, CircleAlert } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type {
DeclareSelfFeedbackIntentParams,
DeclareSelfFeedbackIntentState,
} from '../../../types';
const getIntentLabelKey = (data?: Partial<DeclareSelfFeedbackIntentParams>) => {
if (data?.kind === 'memory' && data.action === 'write') {
return 'builtins.lobe-self-feedback-intent.inspector.memory.write';
}
if (data?.kind === 'skill' && data.action === 'create') {
return 'builtins.lobe-self-feedback-intent.inspector.skill.create';
}
if (data?.kind === 'skill' && data.action === 'refine') {
return 'builtins.lobe-self-feedback-intent.inspector.skill.refine';
}
if (data?.kind === 'skill' && data.action === 'consolidate') {
return 'builtins.lobe-self-feedback-intent.inspector.skill.consolidate';
}
if (data?.kind === 'gap' && data.action === 'proposal') {
return 'builtins.lobe-self-feedback-intent.inspector.gap.proposal';
}
return 'builtins.lobe-self-feedback-intent.apiName.declareSelfFeedbackIntent';
};
const styles = createStaticStyles(({ css, cssVar }) => ({
iconAccepted: css`
flex-shrink: 0;
color: ${cssVar.colorSuccess};
`,
iconRejected: css`
flex-shrink: 0;
color: ${cssVar.colorWarning};
`,
meta: css`
flex-shrink: 0;
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
summary: css`
overflow: hidden;
min-width: 0;
max-width: 320px;
text-overflow: ellipsis;
white-space: nowrap;
`,
}));
export const DeclareSelfFeedbackIntentInspector = memo<
BuiltinInspectorProps<DeclareSelfFeedbackIntentParams, DeclareSelfFeedbackIntentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const data = args ?? partialArgs;
const summary = data?.summary;
const hasContext = Boolean(summary || data?.kind || data?.action);
const title = t(getIntentLabelKey(data));
if (isArgumentsStreaming && !hasContext) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{title}</span>
</div>
);
}
const isSettled = !isArgumentsStreaming && !isLoading && !!pluginState;
return (
<div
style={{ flexWrap: 'wrap', gap: 4 }}
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{title}</span>
{summary && (
<span className={cx(highlightTextStyles.primary, styles.summary)}>{summary}</span>
)}
{isSettled &&
pluginState &&
(pluginState.accepted ? (
<Icon className={styles.iconAccepted} icon={CheckCircle2} size={14} />
) : (
<>
<Icon className={styles.iconRejected} icon={CircleAlert} size={14} />
<span className={styles.meta}>
{t('builtins.lobe-self-feedback-intent.inspector.rejected')}
</span>
</>
))}
</div>
);
});
DeclareSelfFeedbackIntentInspector.displayName = 'DeclareSelfFeedbackIntentInspector';
export default DeclareSelfFeedbackIntentInspector;
@@ -1,11 +0,0 @@
import type { BuiltinInspector } from '@lobechat/types';
import { SelfFeedbackIntentApiName } from '../../types';
import { DeclareSelfFeedbackIntentInspector } from './DeclareSelfFeedbackIntent';
export const SelfFeedbackIntentInspectors: Record<string, BuiltinInspector> = {
[SelfFeedbackIntentApiName.declareSelfFeedbackIntent]:
DeclareSelfFeedbackIntentInspector as BuiltinInspector,
};
export { DeclareSelfFeedbackIntentInspector } from './DeclareSelfFeedbackIntent';
@@ -1,4 +0,0 @@
export { DeclareSelfFeedbackIntentInspector, SelfFeedbackIntentInspectors } from './Inspector';
export { selfFeedbackIntentManifest } from '../manifest';
export * from '../types';
@@ -1,12 +1,11 @@
import type { LobeToolManifest, OperationToolSet, ToolSource } from '@lobechat/context-engine';
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
injectSelfFeedbackIntentTool,
SELF_FEEDBACK_INTENT_API_NAME,
SELF_FEEDBACK_INTENT_IDENTIFIER,
SELF_FEEDBACK_INTENT_TOOL_NAME,
SelfFeedbackIntentExecutionRuntime,
selfFeedbackIntentManifest,
shouldExposeSelfFeedbackIntentTool,
} from './index';
@@ -104,7 +103,6 @@ describe('selfFeedbackIntentTool', () => {
'skillId',
]);
expect(api.description).toContain('does not mutate memory or skills');
expect(properties.evidenceRefs.items.properties.summary).toBeDefined();
expect(api.parameters.required).toEqual([
'action',
'kind',
@@ -112,93 +110,6 @@ describe('selfFeedbackIntentTool', () => {
'summary',
'reason',
]);
expect(selfFeedbackIntentManifest.systemRole).toContain('<aggressive_usage_policy>');
});
});
describe('SelfFeedbackIntentExecutionRuntime', () => {
/**
* @example
* The package runtime delegates declaration emission to an injected service and persists state.
*/
it('delegates declarations to the injected service', async () => {
const service = {
declareIntent: vi.fn().mockResolvedValue({
accepted: true,
sourceId: 'self-feedback-intent:user-1:agent-1:topic:topic-1:tool-call-1',
strength: 'strong' as const,
}),
};
const runtime = new SelfFeedbackIntentExecutionRuntime({ service });
const result = await runtime.declareSelfFeedbackIntent(
{
action: 'refine',
confidence: 0.91,
evidenceRefs: [{ id: 'msg-1', type: 'message' }],
kind: 'skill',
reason: 'The release workflow correction should become reusable.',
summary: 'Refine the release workflow skill.',
},
{
agentId: 'agent-1',
toolCallId: 'tool-call-1',
topicId: 'topic-1',
userId: 'user-1',
},
);
expect(result.success).toBe(true);
expect(result.state).toEqual({
accepted: true,
reason: null,
sourceId: 'self-feedback-intent:user-1:agent-1:topic:topic-1:tool-call-1',
strength: 'strong',
});
expect(service.declareIntent).toHaveBeenCalledWith({
agentId: 'agent-1',
input: {
action: 'refine',
confidence: 0.91,
evidenceRefs: [{ id: 'msg-1', type: 'message' }],
kind: 'skill',
reason: 'The release workflow correction should become reusable.',
summary: 'Refine the release workflow skill.',
},
toolCallId: 'tool-call-1',
topicId: 'topic-1',
userId: 'user-1',
});
});
/**
* @example
* Missing runtime identity context returns a tool failure before crossing service boundaries.
*/
it('returns missing context failure without calling the service', async () => {
const service = {
declareIntent: vi.fn(),
};
const runtime = new SelfFeedbackIntentExecutionRuntime({ service });
const result = await runtime.declareSelfFeedbackIntent(
{
action: 'proposal',
confidence: 0.6,
kind: 'gap',
reason: 'The inspector is missing.',
summary: 'Add a self-feedback inspector.',
},
{ agentId: 'agent-1', userId: 'user-1' },
);
expect(result.success).toBe(false);
expect(JSON.parse(result.content)).toEqual({
accepted: false,
reason: 'missing_context',
required: ['agentId', 'userId', 'topicId'],
});
expect(service.declareIntent).not.toHaveBeenCalled();
});
});
@@ -1,32 +1,12 @@
export * from './ExecutionRuntime';
export {
injectSelfFeedbackIntentTool,
type SelfFeedbackIntentToolSetParts,
shouldExposeSelfFeedbackIntentTool,
} from './inject';
export { selfFeedbackIntentManifest } from './manifest';
export { systemPrompt } from './systemRole';
export {
type DeclareSelfFeedbackIntentContext,
type DeclareSelfFeedbackIntentInput,
type DeclareSelfFeedbackIntentParams,
type DeclareSelfFeedbackIntentPayload,
type DeclareSelfFeedbackIntentRejectionReason,
type DeclareSelfFeedbackIntentResult,
type DeclareSelfFeedbackIntentState,
type DeclareSelfFeedbackIntentStateReason,
SELF_FEEDBACK_INTENT_API_NAME,
SELF_FEEDBACK_INTENT_ACTIONS,
SELF_FEEDBACK_INTENT_EVIDENCE_REF_TYPES,
SELF_FEEDBACK_INTENT_IDENTIFIER,
SELF_FEEDBACK_INTENT_KINDS,
SELF_FEEDBACK_INTENT_TOOL_NAME,
type ShouldExposeSelfFeedbackIntentToolOptions,
SelfFeedbackIntentApiName,
type SelfFeedbackIntentApiNameType,
type SelfFeedbackIntentAction,
type SelfFeedbackIntentEvidenceRef,
type SelfFeedbackIntentEvidenceRefType,
type SelfFeedbackIntentKind,
type SelfFeedbackIntentStrength,
} from './types';
@@ -1,13 +1,6 @@
import type { BuiltinToolManifest } from '@lobechat/types';
import { systemPrompt } from './systemRole';
import {
SELF_FEEDBACK_INTENT_ACTIONS,
SELF_FEEDBACK_INTENT_EVIDENCE_REF_TYPES,
SELF_FEEDBACK_INTENT_IDENTIFIER,
SELF_FEEDBACK_INTENT_KINDS,
SelfFeedbackIntentApiName,
} from './types';
import { SELF_FEEDBACK_INTENT_API_NAME, SELF_FEEDBACK_INTENT_IDENTIFIER } from './types';
/**
* Self-iteration intent builtin tool manifest.
@@ -26,54 +19,44 @@ export const selfFeedbackIntentManifest = {
api: [
{
description:
'Declare advisory self-feedback intent for future review whenever the running agent finds a concrete, reusable improvement opportunity. Use this proactively for memory, skill, or system gap feedback; it only records intent and does not mutate memory or skills.',
name: SelfFeedbackIntentApiName.declareSelfFeedbackIntent,
'Declare advisory self-feedback intent for future review. This only records intent and does not mutate memory or skills.',
name: SELF_FEEDBACK_INTENT_API_NAME,
parameters: {
additionalProperties: false,
properties: {
action: {
description:
'Self-iteration action the agent believes may be useful. Use write for memory, create/refine/consolidate for skills, and proposal for system or workflow gaps.',
enum: [...SELF_FEEDBACK_INTENT_ACTIONS],
description: 'Self-iteration action the agent believes may be useful.',
enum: ['write', 'create', 'refine', 'consolidate', 'proposal'],
type: 'string',
},
kind: {
description:
'Self-iteration target category: memory for durable user signals, skill for reusable procedures or capabilities, gap for product/runtime/tooling/policy feedback.',
enum: [...SELF_FEEDBACK_INTENT_KINDS],
description: 'Self-iteration target category for the declaration.',
enum: ['memory', 'skill', 'gap'],
type: 'string',
},
confidence: {
description:
'Agent confidence from 0 to 1 that this declaration is worth downstream review. Prefer >=0.75 for well-grounded evidence and 0.45-0.74 for plausible but review-needed feedback.',
description: 'Agent confidence from 0 to 1.',
maximum: 1,
minimum: 0,
type: 'number',
},
summary: {
description:
'Short, actionable summary of the self-feedback intent. Name the target and desired improvement.',
description: 'Short summary of the self-feedback intent.',
type: 'string',
},
reason: {
description:
'Rationale explaining the triggering evidence, why it matters, and the expected future benefit.',
description: 'Rationale for why this self-feedback intent may be useful.',
type: 'string',
},
evidenceRefs: {
description:
'Optional stable references that justify the declaration. Prefer concrete message, tool_call, operation, topic, receipt, task, agent_document, or memory refs.',
description: 'Optional references that justify the declaration.',
items: {
additionalProperties: false,
properties: {
id: { description: 'Stable evidence identifier.', type: 'string' },
summary: {
description: 'Optional short note explaining why this evidence matters.',
type: 'string',
},
type: {
description: 'Evidence object type.',
enum: [...SELF_FEEDBACK_INTENT_EVIDENCE_REF_TYPES],
enum: ['message', 'tool_call', 'receipt', 'document', 'custom'],
type: 'string',
},
},
@@ -96,13 +79,13 @@ export const selfFeedbackIntentManifest = {
},
},
],
executors: ['server'],
identifier: SELF_FEEDBACK_INTENT_IDENTIFIER,
meta: {
description:
'Let a running agent proactively declare advisory self-feedback intent without mutating memory or skills directly.',
'Let a running agent declare advisory self-feedback intent without mutating memory or skills directly.',
title: 'Self Feedback Intent',
},
systemRole: systemPrompt,
systemRole:
'Declare advisory self-feedback intent only when future self-review may improve memory or skills. This tool records intent and must not claim that it directly mutates resources.',
type: 'builtin',
} as const satisfies BuiltinToolManifest;
@@ -1,46 +0,0 @@
export const systemPrompt = `You have access to the Self Feedback Intent tool. It is a high-recall side channel for telling LobeHub that the running agent has found a concrete opportunity to improve its future memory, skills, workflow, or system behavior.
<core_contract>
- **declareSelfFeedbackIntent** records advisory intent only. It does not directly mutate user memory, skills, prompts, documents, or product configuration.
- Downstream self-iteration reviewers own validation, dedupe, approval, and any eventual write/refine/create/consolidate action.
- Use this tool to make the system aware of what the agent thinks should be reviewed later, even when the current user task should continue normally.
</core_contract>
<aggressive_usage_policy>
- Be proactive. Declare self-feedback intent whenever a specific, reusable improvement is visible and can be grounded in the current run.
- Prefer declaring a concise intent over silently losing a useful learning signal. The downstream pipeline can reject weak, duplicate, or unsafe declarations.
- Emit at most the strongest 1-3 declarations per operation or topic. Do not spam vague reflections, stylistic preferences, or duplicate signals.
- Use confidence >= 0.75 when you have concrete evidence refs and a clear future benefit. Use 0.45-0.74 for plausible but review-needed improvements. Avoid calls below 0.45 unless the gap is operationally important.
</aggressive_usage_policy>
<when_to_call>
Call **declareSelfFeedbackIntent** when any of these happen:
- The user corrects the agent, asks "remember next time", points out a repeated miss, or gives feedback that should improve future behavior.
- The agent discovers a reusable workflow, checklist, prompt pattern, tool-use strategy, or coding/review heuristic that should become or refine a skill.
- The agent sees an outdated, incomplete, duplicated, or missing memory signal that should be reviewed before future conversations.
- A tool, runtime, inspector, prompt, policy, or routing behavior caused friction and a concrete system gap should be reviewed.
- A task succeeds only after a non-obvious fix, workaround, or diagnosis that future agents should reuse.
</when_to_call>
<action_kind_mapping>
- **kind=memory + action=write**: durable user preference, identity/context/experience signal, or stale/missing memory worth review.
- **kind=skill + action=create**: a reusable procedure or capability does not exist yet.
- **kind=skill + action=refine**: an existing skill should be sharpened, corrected, made more aggressive, or expanded with examples.
- **kind=skill + action=consolidate**: multiple overlapping skills or procedures should be merged.
- **kind=gap + action=proposal**: product/runtime/tooling/policy gaps, missing UI, weak inspector, poor evidence capture, or unsupported automation ideas.
</action_kind_mapping>
<argument_rules>
- **summary**: one short, actionable sentence. Name the target and desired improvement.
- **reason**: include the triggering evidence, why it matters, and the expected future benefit.
- **confidence**: calibrated probability that this declaration is worth downstream review, not certainty that a mutation should happen.
- **evidenceRefs**: include stable ids when available. Prefer message, tool_call, operation, topic, receipt, task, agent_document, or memory refs over generic prose.
- **memoryId** and **skillId**: include only when you know the exact existing target. Do not invent ids.
</argument_rules>
<boundaries>
- Do not use this tool as a user-facing answer, apology, or progress update.
- Do not declare secrets, credentials, private keys, or sensitive personal data as self-feedback.
- Do not claim that the declaration saved memory or updated a skill. Say only that the intent was declared when you mention it internally.
- If a direct user request conflicts with self-iteration, satisfy the user request first and only declare concise feedback if it will not distract from the task.
</boundaries>`;
@@ -7,151 +7,6 @@ export const SELF_FEEDBACK_INTENT_API_NAME = 'declareSelfFeedbackIntent';
/** LLM-visible tool name generated from identifier and API name. */
export const SELF_FEEDBACK_INTENT_TOOL_NAME = `${SELF_FEEDBACK_INTENT_IDENTIFIER}____${SELF_FEEDBACK_INTENT_API_NAME}`;
/** Stable API name map used by manifests, runtimes, and inspectors. */
export const SelfFeedbackIntentApiName = {
declareSelfFeedbackIntent: SELF_FEEDBACK_INTENT_API_NAME,
} as const;
export type SelfFeedbackIntentApiNameType =
(typeof SelfFeedbackIntentApiName)[keyof typeof SelfFeedbackIntentApiName];
export const SELF_FEEDBACK_INTENT_ACTIONS = [
'write',
'create',
'refine',
'consolidate',
'proposal',
] as const;
export const SELF_FEEDBACK_INTENT_KINDS = ['memory', 'skill', 'gap'] as const;
export const SELF_FEEDBACK_INTENT_EVIDENCE_REF_TYPES = [
'topic',
'message',
'operation',
'source',
'receipt',
'tool_call',
'task',
'agent_document',
'memory',
] as const;
/** Actions that an agent may declare as self-feedback intent. */
export type SelfFeedbackIntentAction = (typeof SELF_FEEDBACK_INTENT_ACTIONS)[number];
/** Self-feedback target categories accepted from agent-declared intent. */
export type SelfFeedbackIntentKind = (typeof SELF_FEEDBACK_INTENT_KINDS)[number];
/** Evidence reference type accepted by downstream self-iteration handlers. */
export type SelfFeedbackIntentEvidenceRefType =
(typeof SELF_FEEDBACK_INTENT_EVIDENCE_REF_TYPES)[number];
/** Evidence strength assigned to one accepted or rejected declaration. */
export type SelfFeedbackIntentStrength = 'strong' | 'weak';
/** Optional reference that grounds one self-feedback declaration. */
export interface SelfFeedbackIntentEvidenceRef {
/** Stable evidence identifier in its source domain. */
id: string;
/** Optional short note explaining why this evidence matters. */
summary?: string;
/** Evidence object type. */
type: SelfFeedbackIntentEvidenceRefType;
}
/** Input payload declared by the running agent through the self-feedback intent tool. */
export interface DeclareSelfFeedbackIntentPayload {
/** Self-feedback action the agent believes may be useful. */
action: SelfFeedbackIntentAction;
/** Agent confidence from 0 to 1. */
confidence: number;
/** Evidence references that justify the declaration. */
evidenceRefs?: SelfFeedbackIntentEvidenceRef[];
/** Target category for the declaration. */
kind: SelfFeedbackIntentKind;
/** Existing memory id when the declaration targets a known memory. */
memoryId?: string;
/** Human-readable rationale from the agent. */
reason: string;
/** Existing skill id when the declaration targets a known skill. */
skillId?: string;
/** Short declaration summary for downstream review. */
summary: string;
}
export type DeclareSelfFeedbackIntentParams = DeclareSelfFeedbackIntentPayload;
/** Runtime context required to emit one self-feedback declaration. */
export interface DeclareSelfFeedbackIntentContext {
/** Stable agent id associated with the running agent. */
agentId?: string;
/** Runtime operation id when the declaration belongs to a narrower operation scope. */
operationId?: string;
/** Caller-provided tool-call id. */
toolCallId?: string;
/** Current topic id for stable source ids and topic fallback scope. */
topicId?: string;
/** Stable user id associated with the running agent. */
userId?: string;
}
/** Input used by a runtime service to declare one self-feedback source event. */
export interface DeclareSelfFeedbackIntentInput {
/** Stable agent id associated with the running agent. */
agentId: string;
/** Agent-declared self-feedback intent payload. */
input: DeclareSelfFeedbackIntentPayload;
/** Runtime operation id when the declaration belongs to a narrower operation scope. */
operationId?: string;
/** Caller-provided tool-call id. */
toolCallId?: string;
/** Current topic id for stable source ids and topic fallback scope. */
topicId: string;
/** Stable user id associated with the running agent. */
userId: string;
}
export type DeclareSelfFeedbackIntentRejectionReason =
| 'enqueue_gate_rejected'
| 'intent_gate_rejected'
| 'invalid_action'
| 'invalid_confidence'
| 'invalid_kind'
| 'rate_limited';
/** Result returned after one declaration attempt. */
export interface DeclareSelfFeedbackIntentResult {
/** Whether the declaration was accepted and emitted to the enqueue boundary. */
accepted: boolean;
/** Optional rejection reason when no source was enqueued. */
reason?: DeclareSelfFeedbackIntentRejectionReason;
/** Stable source id built for accepted declarations when available. */
sourceId?: string;
/** Evidence strength assigned from confidence and evidence presence. */
strength: SelfFeedbackIntentStrength;
}
export type DeclareSelfFeedbackIntentStateReason =
| DeclareSelfFeedbackIntentRejectionReason
| 'missing_context'
| 'runtime_error'
| null;
/** State persisted for inspector display after one self-feedback declaration. */
export interface DeclareSelfFeedbackIntentState {
/** Whether the declaration crossed the Agent Signal enqueue boundary. */
accepted: boolean;
/** Missing context keys when the runtime cannot emit the declaration. */
required?: string[];
/** Rejection or runtime reason. */
reason: DeclareSelfFeedbackIntentStateReason;
/** Stable source id for accepted declarations. */
sourceId?: null | string;
/** Evidence strength assigned by the declaration service. */
strength?: SelfFeedbackIntentStrength;
}
/** Gate input used to decide whether the declaration tool may be exposed. */
export interface ShouldExposeSelfFeedbackIntentToolOptions {
/** Agent-level self-iteration chat config gate. */
+1
View File
@@ -4,6 +4,7 @@
"private": true,
"exports": {
".": "./src/index.ts",
"./executor": "./src/executor/index.ts",
"./client": "./src/client/index.ts"
},
"main": "./src/index.ts",
@@ -18,9 +18,6 @@ export {
// Render components (read-only snapshots)
export { CreateTaskRender, CreateTasksRender, RunTasksRender, TaskRenders } from './Render';
// Client-side executor (browser runtime adapter for the agent)
export { taskExecutor } from './executor';
// Re-export manifest and types for convenience
export { TaskIdentifier, TaskManifest } from '../manifest';
export * from '../types';
@@ -13,17 +13,15 @@ import type {
BuiltinToolResult,
TaskAutomationMode,
TaskStatus,
ToolAfterCallContext,
} from '@lobechat/types';
import { BaseExecutor } from '@lobechat/types';
import debug from 'debug';
import { taskService } from '@/services/task';
import { getTaskStoreState } from '@/store/task';
import { findSubtaskParentId } from '@/store/task/slices/detail/reducer';
import { normalizeListTasksParams } from '../../listTasks';
import { TaskIdentifier } from '../../manifest';
import { normalizeListTasksParams } from '../listTasks';
import { TaskIdentifier } from '../manifest';
import type {
AddTaskCommentParams,
CreateTaskParams,
@@ -31,91 +29,15 @@ import type {
DeleteTaskCommentParams,
RunTasksItemResult,
UpdateTaskCommentParams,
} from '../../types';
import { TaskApiName } from '../../types';
} from '../types';
import { TaskApiName } from '../types';
const log = debug('lobe-task:executor');
// APIs whose execution mutates state that's surfaced in the renderer's task
// list or detail caches. Used by `onAfterCall` to decide what to revalidate.
const LIST_MUTATING_APIS = new Set<string>([
TaskApiName.createTask,
TaskApiName.createTasks,
TaskApiName.deleteTask,
TaskApiName.editTask,
TaskApiName.runTask,
TaskApiName.runTasks,
TaskApiName.setTaskSchedule,
TaskApiName.updateTaskStatus,
]);
const DETAIL_MUTATING_APIS = new Set<string>([
TaskApiName.addTaskComment,
TaskApiName.deleteTaskComment,
TaskApiName.editTask,
TaskApiName.runTask,
TaskApiName.setTaskSchedule,
TaskApiName.updateTaskComment,
TaskApiName.updateTaskStatus,
TaskApiName.viewTask,
]);
const extractIdentifier = (params: unknown, result: BuiltinToolResult): string | undefined => {
const fromState = (result.state as { identifier?: unknown } | undefined)?.identifier;
if (typeof fromState === 'string' && fromState.length > 0) return fromState;
const fromParams = (params as { identifier?: unknown } | null | undefined)?.identifier;
if (typeof fromParams === 'string' && fromParams.length > 0) return fromParams;
return undefined;
};
class TaskExecutor extends BaseExecutor<typeof TaskApiName> {
readonly identifier = TaskIdentifier;
protected readonly apiEnum = TaskApiName;
onAfterCall = async ({ apiName, params, result }: ToolAfterCallContext): Promise<void> => {
if (!result.success) return;
const store = getTaskStoreState();
const identifier = extractIdentifier(params, result);
// Build the set of task-detail keys to revalidate. Mirrors the pattern
// used by `updateTask` in the detail slice so subtask deletions / edits
// bubble up: when we mutate a subtask we must also refresh the parent
// whose `subtasks[]` array embeds it, otherwise the parent's view keeps
// showing the stale child until a manual reload.
const detailTargets = new Set<string>();
const touchesDetail = DETAIL_MUTATING_APIS.has(apiName) || LIST_MUTATING_APIS.has(apiName);
if (touchesDetail) {
if (identifier) {
// `deleteTask` is not in DETAIL_MUTATING_APIS (the row is gone), but
// edit/status/etc. need their own detail key revalidated.
if (DETAIL_MUTATING_APIS.has(apiName)) detailTargets.add(identifier);
const parentId = findSubtaskParentId(store.taskDetailMap, identifier);
if (parentId) detailTargets.add(parentId);
}
// Defensive: refresh whatever detail page the user is currently
// viewing — covers e.g. a `createTask` whose new identifier we don't
// yet know in the local map but whose parent the user is staring at.
const { activeTaskId } = store;
if (activeTaskId) detailTargets.add(activeTaskId);
}
const refreshes: Promise<unknown>[] = [];
if (LIST_MUTATING_APIS.has(apiName)) {
refreshes.push(store.refreshTaskList());
}
for (const id of detailTargets) {
refreshes.push(store.internal_refreshTaskDetail(id));
}
if (refreshes.length === 0) return;
await Promise.all(refreshes).catch((error) => {
log('[TaskExecutor] onAfterCall - refresh failed:', error);
});
};
addTaskComment = async (
params: AddTaskCommentParams,
ctx?: BuiltinToolContext,
+1 -1
View File
@@ -313,7 +313,7 @@ export const TaskManifest: BuiltinToolManifest = {
},
heartbeatInterval: {
description:
'Periodic execution interval in seconds (heartbeat mode). Pass 0 to clear the interval. Minimum 600s (10 minutes); the server rejects positive values below 600.',
'Periodic execution interval in seconds (heartbeat mode). Pass 0 to clear the interval. Recommend ≥600s.',
type: 'number',
},
identifier: {
@@ -57,28 +57,10 @@ export const formatWebOnboardingStateMessage = (state: OnboardingStateContext) =
'Questioning rule: prefer the `lobe-user-interaction____askUserQuestion` tool call for structured collection or explicit UI input. For natural exploratory questions, plain text is allowed.',
];
if (
state.phase === 'discovery' &&
state.remainingDiscoveryExchanges !== undefined &&
state.remainingDiscoveryExchanges > 0
) {
const currentDiscoveryExchanges = state.discoveryUserMessageCount ?? 0;
const recommendedTarget = currentDiscoveryExchanges + state.remainingDiscoveryExchanges;
parts.push(
`Discovery progress: ${currentDiscoveryExchanges}/${recommendedTarget} user exchange(s) observed since Discovery began.`,
);
if (state.remainingDiscoveryExchanges !== undefined && state.remainingDiscoveryExchanges > 0) {
parts.push(
`Recommended: ${state.remainingDiscoveryExchanges} more user exchange(s) before moving to summary. Do not rush — keep exploring different aspects of the user's work and life.`,
);
} else if (
state.phase === 'discovery' &&
state.discoveryUserMessageCount !== undefined &&
state.remainingDiscoveryExchanges === 0
) {
parts.push(
`Discovery progress: recommended target reached after ${state.discoveryUserMessageCount} user exchange(s). Move to summary once interests/customInterests and the persona are persisted.`,
);
}
if (state.missingStructuredFields.length > 0) {
@@ -32,7 +32,7 @@ Persistence rules:
8. User Persona (type: "persona") is for user identity, role, work style, current context, interests, pain points, communication comfort level, and preferred input style.
9. Do not put user information into SOUL.md. Do not put agent identity into the persona document.
10. Document tools (readDocument, writeDocument, updateDocument) must ONLY be used for SOUL.md and User Persona documents. Never use them to create arbitrary content such as guides, tutorials, checklists, or reference materials. Present such content directly in your reply text instead.
11. Do not call saveUserQuestion with interests or customInterests until you have spent about 2-3 exchanges exploring the user's world across multiple dimensions (workflow, pain points, goals, interests, AI expectations). The system appends the current Discovery turn status each turn follow that reminder. The server enforces a minimum discovery exchange count, so early field saves will not advance the phase, but continuing after the recommended target usually reduces conversation quality.
11. Do not call saveUserQuestion with interests or customInterests until you have spent at least 5-6 exchanges exploring the user's world in the discovery phase across multiple dimensions (workflow, pain points, goals, interests, AI expectations). The server enforces a minimum discovery exchange count early field saves will not advance the phase but will reduce conversation quality.
Workspace setup rules:
1. Do not create or modify workspace agents or agent groups unless the user explicitly asks for that setup.
+1 -11
View File
@@ -42,10 +42,6 @@ import {
import { MemoryInspectors, MemoryManifest } from '@lobechat/builtin-tool-memory/client';
import { MessageInspectors, MessageManifest } from '@lobechat/builtin-tool-message/client';
import { PageAgentInspectors, PageAgentManifest } from '@lobechat/builtin-tool-page-agent/client';
import {
SelfFeedbackIntentInspectors,
selfFeedbackIntentManifest,
} from '@lobechat/builtin-tool-self-iteration/client';
import {
SkillStoreInspectors,
SkillStoreManifest,
@@ -61,11 +57,10 @@ import {
WebOnboardingManifest,
} from '@lobechat/builtin-tool-web-onboarding/client';
import { createRunCommandInspector } from '@lobechat/shared-tool-ui/inspectors';
import type { BuiltinInspector } from '@lobechat/types';
import { type BuiltinInspector } from '@lobechat/types';
import { CodexInspectors } from './codex';
import { GithubIdentifier, GithubInspectors } from './github';
import { LinearIdentifier, LinearInspectors } from './linear';
/**
* Builtin tools inspector registry
@@ -98,10 +93,6 @@ const BuiltinToolInspectors: Record<string, Record<string, BuiltinInspector>> =
[MessageManifest.identifier]: MessageInspectors as Record<string, BuiltinInspector>,
[PageAgentManifest.identifier]: PageAgentInspectors as Record<string, BuiltinInspector>,
[LobeActivatorManifest.identifier]: LobeActivatorInspectors as Record<string, BuiltinInspector>,
[selfFeedbackIntentManifest.identifier]: SelfFeedbackIntentInspectors as Record<
string,
BuiltinInspector
>,
[SkillStoreManifest.identifier]: SkillStoreInspectors as Record<string, BuiltinInspector>,
[SkillsManifest.identifier]: SkillsInspectors as Record<string, BuiltinInspector>,
[TaskManifest.identifier]: TaskInspectors as Record<string, BuiltinInspector>,
@@ -112,7 +103,6 @@ const BuiltinToolInspectors: Record<string, Record<string, BuiltinInspector>> =
command_execution: createRunCommandInspector('Run') as BuiltinInspector,
},
[GithubIdentifier]: GithubInspectors,
[LinearIdentifier]: LinearInspectors,
};
export interface BuiltinInspectorRegistryEntry {
@@ -1,12 +0,0 @@
import { LINEAR_TOOL_NAMES, LinearInspector } from '@lobechat/shared-tool-ui/inspectors';
import type { BuiltinInspector } from '@lobechat/types';
// LobeHub built-in Linear skill: tool calls arrive with
// `identifier='linear'` and bare `apiName` like 'get_issue'. The shared
// inspector tolerates both bare and MCP-prefixed names, so we just register
// it under every supported tool suffix.
export const LinearIdentifier = 'linear';
export const LinearInspectors: Record<string, BuiltinInspector> = Object.fromEntries(
LINEAR_TOOL_NAMES.map((name) => [name, LinearInspector]),
);
+7
View File
@@ -41,6 +41,7 @@ export const KeyEnum = {
export const HotkeyEnum = {
AddUserMessage: 'addUserMessage',
ClearCurrentMessages: 'clearCurrentMessages',
CommandPalette: 'commandPalette',
DeleteAndRegenerateMessage: 'deleteAndRegenerateMessage',
DeleteLastMessage: 'deleteLastMessage',
@@ -167,6 +168,12 @@ export const HOTKEYS_REGISTRATION: HotkeyRegistration = [
nonEditable: true,
scopes: [HotkeyScopeEnum.Chat],
},
{
group: HotkeyGroupEnum.Conversation,
id: HotkeyEnum.ClearCurrentMessages,
keys: combineKeys([KeyEnum.Alt, KeyEnum.Shift, KeyEnum.Backspace]),
scopes: [HotkeyScopeEnum.Chat],
},
{
group: HotkeyGroupEnum.Essential,
id: HotkeyEnum.SaveDocument,
-1
View File
@@ -24,7 +24,6 @@
"debug": "^4.4.3",
"es-toolkit": "^1.43.0",
"immer": "^10.2.0",
"tokenx": "^1.2.1",
"ts-md5": "^2.0.1",
"unist-builder": "^4.0.0",
"xast-util-to-xml": "^4.0.0",
@@ -591,7 +591,7 @@ Document content here.
expect(userMessage?.content).toContain('<active_topic_document>');
expect(userMessage?.content).toContain('document_id="docs_123"');
expect(userMessage?.content).toContain('agent_document_id="agd_123"');
expect(userMessage?.content).toContain('scope="currentTopic"');
expect(userMessage?.content).toContain('target="currentTopic"');
expect(userMessage?.content).toContain('Do not use PageAgent editor tools');
expect(result.metadata.activeTopicDocumentContextInjected).toBe(true);
});
@@ -1,12 +1,6 @@
import type { ExtendedHumanInterventionConfig } from '@/types/index';
export interface LobeChatPluginApi {
/**
* Default execution timeout in milliseconds for this API.
* Falls back to the global default (120_000 ms) when omitted.
* The resolver reads this when the LLM does not supply `arguments.timeout`.
*/
defaultTimeoutMs?: number;
description: string;
/**
* Human intervention configuration
-10
View File
@@ -16,16 +16,6 @@ export { ContextEngine } from './pipeline';
// Context Providers
export * from './providers';
// Token accounting (compression triggers + UI breakdown)
export type {
ContextTokenAccounting,
CountContextTokensParams,
MessageTokenBreakdown,
TokenSourceType,
ToolDefinitionTokenBreakdown,
} from './tokenAccounting';
export { countContextTokens, DEFAULT_DRIFT_MULTIPLIER } from './tokenAccounting';
// Processors
export type { PlaceholderValue, PlaceholderValueMap } from './processors';
export {
@@ -34,7 +34,7 @@ const formatActiveTopicDocumentContext = (document: RuntimeActiveTopicDocumentCo
The current conversation is not inside the page editor. Do not use PageAgent editor tools.
When the user asks to continue editing this topic document, use lobe-agent-documents tools instead.
Prefer readDocument with format="xml" and modifyNodes with agent_document_id when it is present.
If agent_document_id is missing, call listDocuments with scope="currentTopic" and match document_id.
If agent_document_id is missing, call listDocuments with target="currentTopic" and match document_id.
</guidance>`;
};
@@ -21,8 +21,6 @@ export type AgentDocumentInjectionPosition = (typeof AGENT_DOCUMENT_INJECTION_PO
export type AgentDocumentLoadFormat = 'file' | 'raw';
export type AgentDocumentSourceType = 'agent' | 'agent-signal' | 'api' | 'file' | 'topic' | 'web';
export interface AgentContextDocument {
content?: string;
description?: string;
@@ -33,9 +31,7 @@ export interface AgentContextDocument {
policyId?: string | null;
policyLoad?: 'always' | 'progressive';
policyLoadFormat?: AgentDocumentLoadFormat;
sourceType?: AgentDocumentSourceType;
title?: string;
updatedAt?: Date | string;
}
export interface AgentDocumentFilterContext {
@@ -110,112 +106,20 @@ export function formatDocument(
}
/**
* Format the size of a document content as a short human-readable token string.
* Empty content is rendered as "empty" so the LLM does not retry reading it.
* Format a single progressive document as an index entry
*/
function formatSize(content: string | undefined): string {
const len = content?.length ?? 0;
if (len === 0) return 'empty';
if (len < 1000) return String(len);
if (len < 10_000) return `${(len / 1000).toFixed(1)}k`;
if (len < 1_000_000) return `${Math.round(len / 1000)}k`;
return `${(len / 1_000_000).toFixed(1)}M`;
}
/**
* Render a Date / ISO string as a short relative-time token like "2d ago".
*/
function formatRelative(at: Date | string | undefined, now: Date): string {
if (!at) return '—';
const date = typeof at === 'string' ? new Date(at) : at;
if (Number.isNaN(date.getTime())) return '—';
const sec = Math.max(0, Math.floor((now.getTime() - date.getTime()) / 1000));
if (sec < 60) return 'now';
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
if (day < 30) return `${day}d ago`;
const month = Math.floor(day / 30);
if (month < 12) return `${month}mo ago`;
const year = Math.floor(day / 365);
return `${year}y ago`;
}
const TITLE_MAX_WIDTH = 60;
function pickRowTitle(doc: AgentContextDocument): string {
return doc.title || doc.filename || '(untitled)';
}
function truncate(s: string, max: number): string {
return s.length > max ? `${s.slice(0, max - 1)}` : s;
}
/**
* Render a list of progressive docs as a fixed-width table:
*
* TITLE ID SIZE UPDATED
* daily-brief.txt 2af6eb88-8bdb-468f-887f-620baa394efa 1.4k 2d ago
*/
function buildIndexTable(
docs: AgentContextDocument[],
context: AgentDocumentFilterContext,
): string {
const now = context.currentTime ?? new Date();
const rows = docs.map((d) => ({
id: d.id ?? '',
size: formatSize(d.content),
title: truncate(pickRowTitle(d), TITLE_MAX_WIDTH),
updated: formatRelative(d.updatedAt, now),
}));
const titleWidth = Math.max('TITLE'.length, ...rows.map((r) => r.title.length));
const idWidth = Math.max('ID'.length, ...rows.map((r) => r.id.length));
const sizeWidth = Math.max('SIZE'.length, ...rows.map((r) => r.size.length));
const sep = ' ';
const headerLine = [
'TITLE'.padEnd(titleWidth),
'ID'.padEnd(idWidth),
'SIZE'.padEnd(sizeWidth),
'UPDATED',
].join(sep);
const dataLines = rows.map((row) =>
[
row.title.padEnd(titleWidth),
row.id.padEnd(idWidth),
row.size.padEnd(sizeWidth),
row.updated,
].join(sep),
);
return [headerLine, ...dataLines].join('\n');
}
/**
* Sort documents by recency (most-recently-updated first); rows missing
* `updatedAt` sink to the end and keep stable input order between themselves.
*/
function sortByRecency(docs: AgentContextDocument[]): AgentContextDocument[] {
return [...docs]
.map((doc, index) => ({ doc, index }))
.sort((a, b) => {
const ta = a.doc.updatedAt ? new Date(a.doc.updatedAt).getTime() : 0;
const tb = b.doc.updatedAt ? new Date(b.doc.updatedAt).getTime() : 0;
if (tb !== ta) return tb - ta;
return a.index - b.index;
})
.map(({ doc }) => doc);
function formatProgressiveEntry(doc: AgentContextDocument): string {
const parts: string[] = [];
if (doc.id) parts.push(`[${doc.id}]`);
parts.push(doc.filename);
if (doc.title && doc.title !== doc.filename) parts.push(`— "${doc.title}"`);
if (doc.description) parts.push(`: ${doc.description}`);
return `- ${parts.join(' ')}`;
}
/**
* Combine multiple documents into a single string.
* Progressive documents are grouped into an `<agent_documents_index>` block
* (web-crawled docs are hidden behind a count and surfaced via listDocuments);
* Progressive documents are grouped into a lightweight index block;
* full-content documents are formatted individually.
*/
export function combineDocuments(
@@ -232,22 +136,9 @@ export function combineDocuments(
}
if (progressiveDocs.length > 0) {
const userDocs = sortByRecency(progressiveDocs.filter((d) => d.sourceType !== 'web'));
const hiddenWebCount = progressiveDocs.length - userDocs.length;
const headerLines: string[] = [
`${userDocs.length} user-created doc${userDocs.length === 1 ? '' : 's'}. Use readDocument(id) for full content.`,
];
if (hiddenWebCount > 0) {
headerLines.push(
`${hiddenWebCount} web-crawled doc${hiddenWebCount === 1 ? '' : 's'} hidden — call listDocuments(sourceType='web') to see them.`,
);
}
const tableBlock = userDocs.length > 0 ? `\n\n${buildIndexTable(userDocs, context)}` : '';
const entries = progressiveDocs.map(formatProgressiveEntry).join('\n');
parts.push(
`<agent_documents_index>\n${headerLines.join('\n')}${tableBlock}\n</agent_documents_index>`,
`<agent_documents_index>\nThe following documents are available. Use readDocument tool to access full content.\n${entries}\n</agent_documents_index>`,
);
}
@@ -6,33 +6,6 @@ import type { OnboardingContextInjectorConfig } from './OnboardingContextInjecto
const log = debug('context-engine:provider:OnboardingActionHintInjector');
const buildDiscoveryTurnReminder = (
discoveryUserMessageCount: number | undefined,
remainingDiscoveryExchanges: number | undefined,
): string | null => {
if (discoveryUserMessageCount === undefined || remainingDiscoveryExchanges === undefined) {
return null;
}
const recommendedTarget = discoveryUserMessageCount + remainingDiscoveryExchanges;
if (remainingDiscoveryExchanges > 0) {
return [
'SYSTEM REMINDER: Current Discovery turn status:',
`- User discovery exchanges observed: ${discoveryUserMessageCount}.`,
`- Recommended target before Summary: ${recommendedTarget}.`,
`- Continue Discovery for about ${remainingDiscoveryExchanges} more user exchange(s). Ask exactly one focused question, persist any new persona fact, and do not drift into long problem-solving.`,
].join('\n');
}
return [
'SYSTEM REMINDER: Current Discovery turn status:',
`- User discovery exchanges observed: ${discoveryUserMessageCount}.`,
'- Recommended Discovery target has been reached.',
'- If you have enough signal, call saveUserQuestion with interests/customInterests, persist any new persona fact, and transition to Summary instead of continuing open-ended Discovery.',
].join('\n');
};
/**
* Onboarding Action Hint Injector
* Injects a standalone virtual user message AFTER the last user message with phase-specific
@@ -87,14 +60,6 @@ export class OnboardingActionHintInjector extends BaseVirtualLastUserContentProv
};
const marketplaceAlreadyOpened = context.messages.some((msg) => isMarketplaceShowCall(msg));
if (phase.includes('Discovery')) {
const reminder = buildDiscoveryTurnReminder(
ctx.discoveryUserMessageCount,
ctx.remainingDiscoveryExchanges,
);
if (reminder) hints.push(reminder);
}
// Detect empty documents and nudge tool calls (empty docs use writeDocument; non-empty use updateDocument)
if (!ctx.soulContent) {
hints.push(
@@ -127,7 +92,7 @@ export class OnboardingActionHintInjector extends BaseVirtualLastUserContentProv
);
} else if (phase.includes('Discovery')) {
hints.push(
'Each turn where you learn a new fact (pain point, goal, preference, workflow detail, interest), call updateDocument(type="persona") BEFORE replying. Preferred shape: `{ mode: "insertAt", line: <line shown in <current_user_persona>>, content: "- new fact" }`. This is the default every turn — not an end-of-phase action. Do NOT save facts only in memory waiting for a final full write. After sufficient discovery (usually 2-3 exchanges), also call saveUserQuestion with interests and/or customInterests. The preferred reply language is configured before onboarding starts and is already injected into your system prompt — do not ask about it or pass a responseLanguage field to saveUserQuestion. Use writeDocument(type="persona") only if the document is still empty.',
'Each turn where you learn a new fact (pain point, goal, preference, workflow detail, interest), call updateDocument(type="persona") BEFORE replying. Preferred shape: `{ mode: "insertAt", line: <line shown in <current_user_persona>>, content: "- new fact" }`. This is the default every turn — not an end-of-phase action. Do NOT save facts only in memory waiting for a final full write. After sufficient discovery (5-6 exchanges), also call saveUserQuestion with interests and/or customInterests. The preferred reply language is configured before onboarding starts and is already injected into your system prompt — do not ask about it or pass a responseLanguage field to saveUserQuestion. Use writeDocument(type="persona") only if the document is still empty.',
);
hints.push(
'EARLY EXIT: A true early-exit signal is the user explicitly wanting to END onboarding (e.g., "I\'m tired", "I have to go", "let\'s chat next time", "no time right now", "let\'s stop for now", "let\'s wrap it up", "that\'s enough"; recognize equivalent phrasing in any language). Short affirmations like "ok" / "sure" / "alright" / "yes" / "got it" are NOT early-exit signals — they confirm what you just said and you should keep exploring or move toward summary normally. When you see a real exit signal: stop exploring, persist any unsaved fields best-effort (call saveUserQuestion with whatever you have, including partial interests), persist the persona via updateDocument (or writeDocument if it is still empty) — do NOT retry on failure — send a short warm farewell (12 sentences), then call `finishOnboarding`. Do NOT call `showAgentMarketplace` on early exit — that handoff is for normal completion only.',
@@ -6,14 +6,10 @@ import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:provider:OnboardingContextInjector');
export interface OnboardingContext {
/** User messages observed after discovery began */
discoveryUserMessageCount?: number;
/** User persona document content (markdown) */
personaContent?: string | null;
/** Formatted phase guidance from getOnboardingState */
phaseGuidance: string;
/** Recommended discovery exchanges still remaining */
remainingDiscoveryExchanges?: number;
/** SOUL.md document content */
soulContent?: string | null;
/** Initial account profile fields, usually sourced from OAuth or profile sync */
@@ -176,29 +176,25 @@ describe('AgentDocumentInjector', () => {
it('should inject progressive documents as index instead of full content', async () => {
const provider = new AgentDocumentContextInjector({
currentTime: new Date('2026-04-29T00:00:00.000Z'),
documents: [
{
content: 'Full content that should NOT appear',
filename: 'daily-brief.txt',
id: '2af6eb88-8bdb-468f-887f-620baa394efa',
description: 'Core safety rules',
filename: 'guardrails.md',
id: 'doc-1',
loadPosition: 'before-first-user',
loadRules: { rule: 'always' },
policyLoad: 'progressive',
sourceType: 'file',
title: 'Daily Brief 提取框架',
updatedAt: new Date('2026-04-27T00:00:00.000Z'),
title: 'Guardrails',
},
{
content: 'a'.repeat(6000),
filename: 'cfg.txt',
id: '32e12975-7db2-4818-8415-9b5c3d383f05',
content: 'Another full content that should NOT appear',
filename: 'notes.txt',
id: 'doc-2',
loadPosition: 'before-first-user',
loadRules: { rule: 'always' },
policyLoad: 'progressive',
sourceType: 'file',
title: 'cfg-constrained-decoding',
updatedAt: new Date('2026-04-10T00:00:00.000Z'),
title: 'Notes',
},
],
});
@@ -206,106 +202,21 @@ describe('AgentDocumentInjector', () => {
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
expect(result.messages[0].content).toMatchInlineSnapshot(`
"<agent_documents_index>
2 user-created docs. Use readDocument(id) for full content.
TITLE ID SIZE UPDATED
Daily Brief 2af6eb88-8bdb-468f-887f-620baa394efa 35 2d ago
cfg-constrained-decoding 32e12975-7db2-4818-8415-9b5c3d383f05 6.0k 19d ago
</agent_documents_index>"
`);
expect(result.messages[0].content).not.toContain('Full content that should NOT appear');
});
it('should hide web-crawled docs from the index and surface the count', async () => {
const provider = new AgentDocumentContextInjector({
currentTime: new Date('2026-04-29T00:00:00.000Z'),
documents: [
{
content: 'user note',
filename: 'daily-brief.txt',
id: '2af6eb88-8bdb-468f-887f-620baa394efa',
loadPosition: 'before-first-user',
loadRules: { rule: 'always' },
policyLoad: 'progressive',
sourceType: 'file',
title: 'Daily Brief',
updatedAt: new Date('2026-04-27T00:00:00.000Z'),
},
{
content: 'gold price page',
filename: 'gold-price-1.html',
id: 'web-1',
loadPosition: 'before-first-user',
loadRules: { rule: 'always' },
policyLoad: 'progressive',
sourceType: 'web',
title: 'Gold price',
},
{
content: 'gold news',
filename: 'gold-news.html',
id: 'web-2',
loadPosition: 'before-first-user',
loadRules: { rule: 'always' },
policyLoad: 'progressive',
sourceType: 'web',
title: 'Gold news',
},
],
});
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
expect(result.messages[0].content).toMatchInlineSnapshot(`
"<agent_documents_index>
1 user-created doc. Use readDocument(id) for full content.
2 web-crawled docs hidden call listDocuments(sourceType='web') to see them.
TITLE ID SIZE UPDATED
Daily Brief 2af6eb88-8bdb-468f-887f-620baa394efa 9 2d ago
</agent_documents_index>"
`);
expect(result.messages[0].content).not.toContain('Gold price');
expect(result.messages[0].content).not.toContain('Gold news');
});
it('should render empty docs with size=empty so the LLM does not retry', async () => {
const provider = new AgentDocumentContextInjector({
currentTime: new Date('2026-04-29T00:00:00.000Z'),
documents: [
{
content: '',
filename: 'placeholder.md',
id: 'd14dca54-7b38-44d5-9bdb-f3fed8c5f947',
loadPosition: 'before-first-user',
loadRules: { rule: 'always' },
policyLoad: 'progressive',
sourceType: 'file',
title: '周报与平台对话分析',
updatedAt: new Date('2026-04-16T00:00:00.000Z'),
},
],
});
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
expect(result.messages[0].content).toMatchInlineSnapshot(`
"<agent_documents_index>
1 user-created doc. Use readDocument(id) for full content.
TITLE ID SIZE UPDATED
d14dca54-7b38-44d5-9bdb-f3fed8c5f947 empty 13d ago
</agent_documents_index>"
`);
const injected = result.messages[0].content;
expect(injected).toContain('<agent_documents_index>');
expect(injected).toContain('[doc-1]');
expect(injected).toContain('guardrails.md');
expect(injected).toContain('"Guardrails"');
expect(injected).toContain('Core safety rules');
expect(injected).toContain('[doc-2]');
expect(injected).toContain('notes.txt');
expect(injected).not.toContain('Full content that should NOT appear');
expect(injected).not.toContain('Another full content that should NOT appear');
expect(injected).toContain('</agent_documents_index>');
});
it('should mix full-content and progressive documents', async () => {
const provider = new AgentDocumentContextInjector({
currentTime: new Date('2026-04-29T00:00:00.000Z'),
documents: [
{
content: 'Always-loaded full content',
@@ -316,14 +227,13 @@ describe('AgentDocumentInjector', () => {
},
{
content: 'Progressive content hidden',
description: 'A summary doc',
filename: 'summary.md',
id: 'doc-p',
loadPosition: 'before-first-user',
loadRules: { rule: 'always' },
policyLoad: 'progressive',
sourceType: 'file',
title: 'Summary',
updatedAt: new Date('2026-04-28T00:00:00.000Z'),
},
],
});
@@ -334,8 +244,7 @@ describe('AgentDocumentInjector', () => {
const injected = result.messages[0].content;
expect(injected).toContain('Always-loaded full content');
expect(injected).toContain('<agent_documents_index>');
expect(injected).toContain('Summary');
expect(injected).toContain('doc-p');
expect(injected).toContain('summary.md');
expect(injected).not.toContain('Progressive content hidden');
});
});
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
import type { PipelineContext } from '../../types';
import { OnboardingActionHintInjector } from '../OnboardingActionHintInjector';
import type { OnboardingContext } from '../OnboardingContextInjector';
const createContext = (messages: any[]): PipelineContext => ({
initialState: { messages: [] },
@@ -11,11 +10,10 @@ const createContext = (messages: any[]): PipelineContext => ({
metadata: {},
});
const buildProvider = (phaseGuidance: string, context?: Partial<OnboardingContext>) =>
const buildProvider = (phaseGuidance: string) =>
new OnboardingActionHintInjector({
enabled: true,
onboardingContext: {
...context,
personaContent: '# Persona',
phaseGuidance,
soulContent: '# SOUL',
@@ -23,46 +21,6 @@ const buildProvider = (phaseGuidance: string, context?: Partial<OnboardingContex
});
describe('OnboardingActionHintInjector', () => {
describe('discovery turn reminder', () => {
const phaseGuidance = 'Phase: Discovery. Explore the user world.';
it('injects current discovery progress when more discovery turns are recommended', async () => {
const provider = buildProvider(phaseGuidance, {
discoveryUserMessageCount: 1,
remainingDiscoveryExchanges: 2,
});
const result = await provider.process(
createContext([
{ content: 'sys', role: 'system' },
{ content: 'I mostly write docs', role: 'user' },
]),
);
const last = result.messages.at(-1);
expect(last?.content).toContain('SYSTEM REMINDER: Current Discovery turn status');
expect(last?.content).toContain('User discovery exchanges observed: 1');
expect(last?.content).toContain('Recommended target before Summary: 3');
expect(last?.content).toContain('Continue Discovery for about 2 more user exchange(s)');
});
it('reminds the model to move toward summary after the recommended target is reached', async () => {
const provider = buildProvider(phaseGuidance, {
discoveryUserMessageCount: 3,
remainingDiscoveryExchanges: 0,
});
const result = await provider.process(
createContext([
{ content: 'sys', role: 'system' },
{ content: 'I need help with planning and writing', role: 'user' },
]),
);
const last = result.messages.at(-1);
expect(last?.content).toContain('Recommended Discovery target has been reached');
expect(last?.content).toContain('transition to Summary');
});
});
describe('marketplace detection (Summary phase)', () => {
const phaseGuidance = 'Phase: Summary. Wrap-up.';

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