Compare commits

..

18 Commits

Author SHA1 Message Date
lobehubbot 11318f8ab9 🔖 chore(release): release version v2.1.47 [skip ci] 2026-03-27 08:07:22 +00:00
LiJian feb50e7007 🚀 release: 20260327 (#13330)
# 🚀 release: 20260326

This release includes **91 commits**. Key updates are below.


- **Agent can now execute background tasks** — Agents can perform
long-running operations without blocking your conversation.
[#13289](https://github.com/lobehub/lobe-chat/pull/13289)
- **Better error messages** — Redesigned error UI across chat and image
generation with clearer explanations and recovery options.
[#13302](https://github.com/lobehub/lobe-chat/pull/13302)
- **Smoother topic switching** — No more full page reloads when
switching topics while an agent is responding.
[#13309](https://github.com/lobehub/lobe-chat/pull/13309)
- **Faster image uploads** — Large images are now automatically
compressed to 1920px before upload, reducing wait times.
[#13224](https://github.com/lobehub/lobe-chat/pull/13224)
- **Improved knowledge base** — Documents are now properly parsed before
chunking, improving retrieval accuracy.
[#13221](https://github.com/lobehub/lobe-chat/pull/13221)

### Bot Platform

- **WeChat Bot support** — You can now connect LobeChat to WeChat, in
addition to Discord.
[#13191](https://github.com/lobehub/lobe-chat/pull/13191)
- **Richer bot responses** — Bots now support custom markdown rendering
and context injection.
[#13294](https://github.com/lobehub/lobe-chat/pull/13294)
- **New bot commands** — Added `/new` to start fresh conversations and
`/stop` to halt generation.
[#13194](https://github.com/lobehub/lobe-chat/pull/13194)
- **Discord stability fixes** — Fixed thread creation issues and Redis
connection drops.
[#13228](https://github.com/lobehub/lobe-chat/pull/13228)
[#13205](https://github.com/lobehub/lobe-chat/pull/13205)

### Models & Providers

- **GLM-5** is now available in the LobeHub model list.
[#13189](https://github.com/lobehub/lobe-chat/pull/13189)
- **Coding Plan providers** — Added support for code planning assistant
providers. [#13203](https://github.com/lobehub/lobe-chat/pull/13203)
- **Tencent Hunyuan 3.0 ImageGen** — New image generation model from
Tencent. [#13166](https://github.com/lobehub/lobe-chat/pull/13166)
- **Gemini content handling** — Better handling when Gemini blocks
content due to safety filters.
[#13270](https://github.com/lobehub/lobe-chat/pull/13270)
- **Claude token limits fixed** — Corrected max window tokens for
Anthropic Claude models.
[#13206](https://github.com/lobehub/lobe-chat/pull/13206)

### Skills & Tools

- **Auto credential injection** — Skills can now automatically request
and use required credentials.
[#13124](https://github.com/lobehub/lobe-chat/pull/13124)
- **Smarter tool permissions** — Built-in tools skip confirmation for
safe paths like `/tmp`.
[#13232](https://github.com/lobehub/lobe-chat/pull/13232)
- **Model switcher improvements** — Quick access to provider settings
and visual highlight for default model.
[#13220](https://github.com/lobehub/lobe-chat/pull/13220)

### Memory

- **Bulk delete memories** — You can now delete all memory entries at
once. [#13161](https://github.com/lobehub/lobe-chat/pull/13161)
- **Per-agent memory control** — Memory injection now respects
individual agent settings.
[#13265](https://github.com/lobehub/lobe-chat/pull/13265)

### Desktop App

- **Gateway connection** — Desktop app can now connect to LobeHub
Gateway for enhanced features.
[#13234](https://github.com/lobehub/lobe-chat/pull/13234)
- **Connection status indicator** — See gateway connection status in the
titlebar. [#13260](https://github.com/lobehub/lobe-chat/pull/13260)
- **Settings persistence** — Gateway toggle state now persists across
app restarts. [#13300](https://github.com/lobehub/lobe-chat/pull/13300)

### CLI

- **API key authentication** — CLI now supports API key auth for
programmatic access.
[#13190](https://github.com/lobehub/lobe-chat/pull/13190)
- **Shell completion** — Tab completion for bash/zsh/fish shells.
[#13164](https://github.com/lobehub/lobe-chat/pull/13164)
- **Man pages** — Built-in manual pages for CLI commands.
[#13200](https://github.com/lobehub/lobe-chat/pull/13200)

### Security

- **XSS protection** — Sanitized search result image titles to prevent
script injection.
[#13303](https://github.com/lobehub/lobe-chat/pull/13303)
- **Workflow hardening** — Fixed potential shell injection in release
automation. [#13319](https://github.com/lobehub/lobe-chat/pull/13319)
- **Dependency update** — Updated nodemailer to address security
advisory. [#13326](https://github.com/lobehub/lobe-chat/pull/13326)

### Bug Fixes

- Fixed skill page not redirecting correctly after import.
[#13255](https://github.com/lobehub/lobe-chat/pull/13255)
[#13261](https://github.com/lobehub/lobe-chat/pull/13261)
- Fixed token counting in group chats.
[#13247](https://github.com/lobehub/lobe-chat/pull/13247)
- Fixed editor not resetting when switching to empty pages.
[#13229](https://github.com/lobehub/lobe-chat/pull/13229)
- Fixed manual tool toggle not working.
[#13218](https://github.com/lobehub/lobe-chat/pull/13218)
- Fixed Search1API response parsing.
[#13207](https://github.com/lobehub/lobe-chat/pull/13207)
[#13208](https://github.com/lobehub/lobe-chat/pull/13208)
- Fixed mobile topic menus rendering issues.
[#12477](https://github.com/lobehub/lobe-chat/pull/12477)
- Fixed history count calculation for accurate context.
[#13051](https://github.com/lobehub/lobe-chat/pull/13051)
- Added missing Turkish translations.
[#13196](https://github.com/lobehub/lobe-chat/pull/13196)

### Credits

Huge thanks to these contributors:

@bakiburakogun @hardy-one @Zhouguanyang @sxjeru @hezhijie0327 @arvinxx
@cy948 @CanisMinor @Innei @LiJian @lobehubbot @Neko @rdmclin2
@rivertwilight @tjx666
2026-03-27 16:04:56 +08:00
sxjeru 48b5927024 💄 style: enhance handling of blocked content on Gemini (#13270)
*  feat: improve error messages for Google AI block reasons and enhance handling of blocked content

*  feat: add error localization for Google provider in createAgentExecutors
2026-03-27 10:51:01 +08:00
renovate[bot] 6e86912e7f Update dependency nodemailer to ^7.0.13 [SECURITY] (#13326)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 10:34:22 +08:00
Arvin Xu 4576059f4f ♻️ refactor: implement SkillResolver, BaseSystemRoleProvider, and agent document injection pipeline (#13315)
* ♻️ refactor: implement SkillResolver to replace ad-hoc skill assembly

Introduces a two-layer skill resolution architecture mirroring ToolsEngine + ToolResolver:

- SkillEngine (assembly layer): accepts raw skills + enableChecker, outputs OperationSkillSet
- SkillResolver (resolution layer): merges operation + step delta + accumulated activations

Key changes:
- Add SkillResolver, OperationSkillSet, StepSkillDelta, ActivatedStepSkill types
- Enhance SkillEngine with enableChecker and generate() method
- Wire SkillResolver into RuntimeExecutors call_llm
- Replace manual skillMetas assembly in aiAgent with SkillEngine.generate()
- Update client-side skillEngineering to use SkillEngine + enableChecker
- Add activatedStepSkills to AgentState for step-level skill accumulation

Fixes: agent-browser content injected into non-desktop scenarios (Discord bot)
due to missing filterBuiltinSkills call in aiAgent

LOBE-6410

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ♻️ refactor: extract agent-templates to standalone package and inject documents server-side

- Create @lobechat/agent-templates package with types, templates, and registry
- Move DocumentLoadPosition, DocumentLoadFormat, DocumentLoadRule, etc. to new package
- Move claw templates (AGENTS, BOOTSTRAP, IDENTITY, SOUL) with .md file imports
- Add BOOTSTRAP.md as new onboarding template (priority 1, system-append)
- Fix template positions: AGENTS→before-system, IDENTITY/SOUL→system-append
- Update database package to re-export from @lobechat/agent-templates
- Migrate all consumers to import directly from @lobechat/agent-templates
- Add agent documents injection in server-side RuntimeExecutors (was missing)
- Support -p CLI flag in devStartupSequence for port configuration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: correct import statement for non-type exports from agent-templates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 📦 build: add @lobechat/agent-templates to root dependencies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ♻️ refactor: remove template proxy files from database package

Stop re-exporting template/templates from database — consumers import
directly from @lobechat/agent-templates. Keep types.ts re-exports for
internal database code only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ♻️ refactor: create BaseSystemRoleProvider to unify system message append pattern

All providers that append to the system message now inherit from
BaseSystemRoleProvider and only implement buildSystemRoleContent().
The base class handles find-or-create and join logic.

Migrated providers:
- EvalContextSystemInjector
- BotPlatformContextInjector
- SystemDateProvider
- ToolSystemRoleProvider
- HistorySummaryProvider
- SkillContextProvider

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: restore metadata tracking in BaseSystemRoleProvider via onInjected hook

Add onInjected() callback to BaseSystemRoleProvider so subclasses can
update pipeline metadata after successful injection. Also add raw-md
plugin to context-engine vitest config for .md imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

*  feat: add enabled field to AgentDocumentInjector config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ♻️ refactor: add enabled field to all providers, remove spread conditionals in MessagesEngine

All providers now accept an `enabled` config field. MessagesEngine
pipeline is a flat array with no spread conditionals — each provider
is always instantiated and uses `enabled` to skip internally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 💄 style: clean up MessagesEngine pipeline comments

Remove numbered prefixes, keep descriptive comments for each provider.
Only phase headers use separator blocks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ♻️ refactor: reorganize MessagesEngine pipeline phases by injection target

Phase 1: History Truncation
Phase 2: System Message Assembly (all BaseSystemRoleProvider)
Phase 3: Context Injection (before first user message, BaseFirstUserContentProvider)
Phase 4: User Message Augmentation (last user message injections)
Phase 5: Message Transformation (flatten, template, variables)
Phase 6: Content Processing & Cleanup (multimodal, tool calls, cleanup)

Moved SkillContext, ToolSystemRole, HistorySummary from Phase 3 to
Phase 2 since they append to system message, not user context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 💄 style: split Phase 6 into Content Processing (6) and Cleanup (7)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ♻️ refactor: split AgentDocumentInjector into three position-based injectors

- AgentDocumentSystemInjector (Phase 2): before-system, system-append, system-replace
- AgentDocumentContextInjector (Phase 3): before-first-user
- AgentDocumentMessageInjector (Phase 4): after-first-user, context-end

Shared utilities (filterByRules, formatDocument, sortByPriority) extracted
to AgentDocumentInjector/shared.ts. Old monolithic injector removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ♻️ refactor: split AgentDocumentSystemInjector into three separate injectors

- AgentDocumentBeforeSystemInjector: prepends as separate system message (before-system)
- AgentDocumentSystemAppendInjector: appends to system message (system-append)
- AgentDocumentSystemReplaceInjector: replaces entire system message (system-replace)

Each has distinct semantics and correct pipeline placement:
- BeforeSystem → before SystemRoleInjector
- SystemAppend → after HistorySummary (end of Phase 2)
- SystemReplace → last in Phase 2 (destructive)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

*  feat: auto-enable agent-documents tool when agent has documents

- Add AgentDocumentsManifest to defaultToolIds
- Add hasAgentDocuments rule in server createServerAgentToolsEngine
- Query agent documents in AiAgentService.execAgent to determine flag
- Pattern matches KnowledgeBase auto-enable via enableChecker rules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🔨 chore: add agent documents status to execAgent operation log

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* update content

* fix tests

* 🐛 fix: add raw-md plugin to database vitest configs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:10:06 +08:00
Arvin Xu 9e9ba3e6c3 🐛 fix: prevent first assistant message re-animation on assistantGroup transition (#13320)
* 🐛 fix: prevent first assistant message re-animation on assistantGroup transition

When tool calls arrive during streaming, the message transitions from
assistant to assistantGroup, causing a full React remount. The first
content block's text was re-animating because isGenerating was still
true. Pass isFirstBlock prop through the render chain to disable
animation for the first block, since its text is guaranteed complete
by the time the group forms.

Fixes LOBE-6414

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ♻️ refactor: remove redundant isToolSingleLine animation check

isFirstBlock already covers the first block case, and subsequent blocks
should not have animation disabled just because they are single-line
with tools — they may still be streaming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 01:41:17 +08:00
Innei 46602be0b3 🐛 fix(workflow): prevent shell injection in auto-tag release (#13319) 2026-03-27 01:18:35 +08:00
YuTengjing 14b278fba8 💄 style: add payment upgrade i18n keys and update microcopy (#13317) 2026-03-27 00:51:28 +08:00
Arvin Xu 53c5708c9f 🔨 chore: improve start up scripts (#13318)
update scripts
2026-03-27 00:49:23 +08:00
YuTengjing edc8920703 🔨 chore: temporarily disable notification triggers (#13314) 2026-03-26 23:35:04 +08:00
Arvin Xu 926de076d9 🐛 fix: sanitize search grounding image titles to prevent XSS (#13303)
* 🐛 fix: sanitize search grounding image titles to prevent XSS

Replace dangerouslySetInnerHTML with stripHtml() for image result titles
in SearchGrounding and ImageSearchRef components to prevent stored XSS
attacks via malicious search result data.

Ref: GHSA-m5qx-g8hx-5f2p

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🔒 fix: remove SystemJS plugin renderer to eliminate arbitrary JS execution risk

The old plugin render system (ui.mode === 'module') that used SystemJS
to dynamically load and execute JS from untrusted URLs has been fully
retired. Remove SystemJsRender and systemjs dependency entirely.

Ref: GHSA-46v7-wvmj-6vf7

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Revert "🔒 fix: remove SystemJS plugin renderer to eliminate arbitrary JS execution risk"

This reverts commit 99a7603a72.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:38:49 +08:00
Innei 9b7beca85e 💄 style(conversation): align user rich text line height with LexicalRenderer (#13312)
💄 style(conversation): set LexicalRenderer line height in user rich text

Made-with: Cursor
2026-03-26 21:58:24 +08:00
Arvin Xu 0724d8ca60 🐛 fix: prevent full page reload when switching topics during agent execution (#13309)
Move `e.preventDefault()` before the `disabled || loading` early return
in NavItem's onClick handler. Previously, when a NavItem was in disabled
or loading state, the early return skipped `preventDefault()`, allowing
the underlying `<a>` tag's default navigation to trigger a full browser
page load instead of SPA routing.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:30:08 +08:00
YuTengjing 9f36fe95ac feat: add notification system (temporarily disabled) (#13301) 2026-03-26 21:16:38 +08:00
Arvin Xu 3f148005e4 ♻️ refactor: remove langchain dependency, use direct document loaders (#13304)
* ♻️ refactor: remove langchain dependency, use direct document loaders

Replace langchain and @langchain/community with self-implemented text
splitters and direct usage of underlying libraries (pdf-parse, d3-dsv,
mammoth, officeparser, epub2). This eliminates unnecessary dependency
bloat and addresses CVE-2026-26019 in @langchain/community.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: add missing @types/html-to-text and @types/pdf-parse

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:13:55 +08:00
Arvin Xu 4e60d87514 🔒 refactor: remove deprecated SystemJS plugin renderer (#13305)
🔒 fix: remove SystemJS plugin renderer to eliminate arbitrary JS execution risk

The old plugin render system (ui.mode === 'module') that used SystemJS
to dynamically load and execute JS from untrusted URLs has been fully
retired. Remove SystemJsRender and systemjs dependency entirely.

Ref: GHSA-46v7-wvmj-6vf7

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:41:06 +08:00
YuTengjing d2a16d0714 feat: improve error UI and error handling across chat and image generation (#13302) 2026-03-26 20:09:06 +08:00
lobehubbot ac8a9ec0f8 🔖 chore(release): release version v2.1.46 [skip ci] 2026-03-26 09:07:05 +00:00
189 changed files with 4059 additions and 2343 deletions
+5 -3
View File
@@ -26,8 +26,9 @@ jobs:
- name: Detect release PR (version from title)
id: release
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
PR_TITLE="${{ github.event.pull_request.title }}"
echo "PR Title: $PR_TITLE"
# Match "🚀 release: v{x.x.x}" format (strict semver: x.y.z with optional -prerelease or +build)
@@ -44,9 +45,10 @@ jobs:
- name: Detect patch PR (branch first, title fallback)
id: patch
if: steps.release.outputs.should_tag != 'true'
env:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
HEAD_REF="${{ github.event.pull_request.head.ref }}"
PR_TITLE="${{ github.event.pull_request.title }}"
echo "Head ref: $HEAD_REF"
echo "PR Title: $PR_TITLE"
+2 -2
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.1\-canary.12" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.1\-canary.14" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
@@ -27,7 +27,7 @@ For command-specific manuals, use the built-in manual command:
.SH COMMANDS
.TP
.B login
Log in to LobeHub via browser (Device Code Flow)
Log in to LobeHub via browser (Device Code Flow) or configure API key server
.TP
.B logout
Log out and remove stored credentials
+8 -1
View File
@@ -4,6 +4,9 @@
"error.retry": "Reload",
"error.stack": "Error Stack",
"error.title": "Oops, something went wrong..",
"exceededContext.compact": "Compact Context",
"exceededContext.desc": "The conversation has exceeded the context window limit. You can compact the context to compress history and continue chatting.",
"exceededContext.title": "Context Window Exceeded",
"fetchError.detail": "Error details",
"fetchError.title": "Request failed",
"import.importConfigFile.description": "Error reason: {{reason}}",
@@ -108,7 +111,7 @@
"response.PluginSettingsInvalid": "This skill needs to be correctly configured before it can be used. Please check if your configuration is correct",
"response.ProviderBizError": "Error requesting {{provider}} service, please troubleshoot or retry based on the following information",
"response.QuotaLimitReached": "Sorry, the token usage or request count has reached the quota limit for this key. Please increase the key's quota or try again later.",
"response.QuotaLimitReachedCloud": "The model service is currently under heavy load. Please try again later.",
"response.QuotaLimitReachedCloud": "The model service is currently under heavy load. Please try again later or switch to another model.",
"response.ServerAgentRuntimeError": "Sorry, the Agent service is currently unavailable. Please try again later or contact us via email for support.",
"response.StreamChunkError": "Error parsing the message chunk of the streaming request. Please check if the current API interface complies with the standard specifications, or contact your API provider for assistance.",
"response.SubscriptionKeyMismatch": "We apologize for the inconvenience. Due to a temporary system malfunction, your current subscription usage is inactive. Please click the button below to restore your subscription, or contact us via email for support.",
@@ -120,6 +123,10 @@
"supervisor.decisionFailed": "The group host is unable to function. Please check your host configuration to ensure the correct model, API Key, and API endpoint are set.",
"testConnectionFailed": "Test connection failed: {{error}}",
"tts.responseError": "Service request failed, please check the configuration or try again",
"unknownError.copyTraceId": "Trace ID Copied",
"unknownError.desc": "An unexpected error occurred. You can retry or report on",
"unknownError.retry": "Retry",
"unknownError.title": "Oops, the request took a nap",
"unlock.addProxyUrl": "Add OpenAI proxy URL (optional)",
"unlock.apiKey.description": "Enter your {{name}} API Key to start the session",
"unlock.apiKey.imageGenerationDescription": "Enter your {{name}} API Key to start generating",
+12
View File
@@ -0,0 +1,12 @@
{
"image_generation_completed": "Image \"{{prompt}}\" generated successfully",
"image_generation_completed_title": "Image Generated",
"inbox.archiveAll": "Archive all",
"inbox.empty": "No notifications yet",
"inbox.emptyUnread": "No unread notifications",
"inbox.filterUnread": "Show unread only",
"inbox.markAllRead": "Mark all as read",
"inbox.title": "Notifications",
"video_generation_completed": "Video \"{{prompt}}\" generated successfully",
"video_generation_completed_title": "Video Generated"
}
+7
View File
@@ -443,6 +443,12 @@
"myAgents.status.published": "Published",
"myAgents.status.unpublished": "Unpublished",
"myAgents.title": "My Published Agents",
"notification.email.desc": "Receive email notifications when important events occur",
"notification.email.title": "Email Notifications",
"notification.enabled": "Enabled",
"notification.inbox.desc": "Show notifications in the in-app inbox",
"notification.inbox.title": "Inbox Notifications",
"notification.title": "Notification Channels",
"plugin.addMCPPlugin": "Add MCP",
"plugin.addTooltip": "Custom Skills",
"plugin.clearDeprecated": "Remove Deprecated Skills",
@@ -807,6 +813,7 @@
"tab.manualFill": "Manually Fill In",
"tab.manualFill.desc": "Configure a custom MCP skill manually",
"tab.memory": "Memory",
"tab.notification": "Notifications",
"tab.profile": "My Account",
"tab.provider": "Provider",
"tab.proxy": "Proxy",
+5 -2
View File
@@ -254,8 +254,11 @@
"plans.navs.yearly": "Yearly",
"plans.payonce.cancel": "Cancel",
"plans.payonce.ok": "Confirm Selection",
"plans.payonce.popconfirm": "After one-time payment, you must wait until subscription expires to switch plans or change billing cycle. Please confirm your selection.",
"plans.payonce.tooltip": "One-time payment requires waiting until subscription expires to switch plans or change billing cycle",
"plans.payonce.popconfirm": "After one-time payment, you can upgrade anytime but downgrade requires waiting for expiration. Please confirm your selection.",
"plans.payonce.tooltip": "One-time payment only supports upgrading to a higher tier or longer duration",
"plans.payonce.upgradeOk": "Confirm Upgrade",
"plans.payonce.upgradePopconfirm": "Remaining value from your current plan will be applied as a discount to the new plan.",
"plans.payonce.upgradePopconfirmNoProration": "You will be charged the full price of the new plan. Your current plan will be replaced immediately.",
"plans.pendingDowngrade": "Pending Downgrade",
"plans.plan.enterprise.contactSales": "Contact Sales",
"plans.plan.enterprise.title": "Enterprise",
+8 -1
View File
@@ -4,6 +4,9 @@
"error.retry": "重新加载",
"error.stack": "错误堆栈",
"error.title": "页面暂时不可用",
"exceededContext.compact": "压缩上下文",
"exceededContext.desc": "对话已超出模型上下文窗口限制。你可以压缩上下文来压缩历史记录并继续对话。",
"exceededContext.title": "上下文窗口超出限制",
"fetchError.detail": "查看详情",
"fetchError.title": "请求未能完成",
"import.importConfigFile.description": "原因:{{reason}}",
@@ -108,7 +111,7 @@
"response.PluginSettingsInvalid": "该技能需要完成配置后才能使用,请检查技能配置",
"response.ProviderBizError": "模型服务商返回错误。请根据以下信息排查,或稍后重试",
"response.QuotaLimitReached": "Token 用量或请求次数已达配额上限。请提升配额或稍后再试",
"response.QuotaLimitReachedCloud": "当前模型服务负载较高,请稍后重试。",
"response.QuotaLimitReachedCloud": "当前模型服务负载较高,请稍后重试或切换其他模型。",
"response.ServerAgentRuntimeError": "助理运行服务暂不可用。请稍后再试,或邮件联系我们",
"response.StreamChunkError": "流式响应解析失败。请检查接口是否符合规范,或联系模型服务商",
"response.SubscriptionKeyMismatch": "订阅状态同步异常。你可以点击下方按钮恢复订阅,或邮件联系我们",
@@ -120,6 +123,10 @@
"supervisor.decisionFailed": "群组主持人运行失败。请检查主持人配置(模型、API Key 与 API 地址)后重试",
"testConnectionFailed": "测试连接失败:{{error}}",
"tts.responseError": "请求失败。请检查配置后重试",
"unknownError.copyTraceId": "Trace ID 已复制",
"unknownError.desc": "遇到了意外错误,请重试或反馈至",
"unknownError.retry": "重试",
"unknownError.title": "糟糕,请求打了个盹",
"unlock.addProxyUrl": "添加 OpenAI 代理地址(可选)",
"unlock.apiKey.description": "输入你的 {{name}} API Key,即可开始会话",
"unlock.apiKey.imageGenerationDescription": "输入你的 {{name}} API Key,即可开始生成",
+12
View File
@@ -0,0 +1,12 @@
{
"image_generation_completed": "图片 \"{{prompt}}\" 已生成完成",
"image_generation_completed_title": "图片已生成",
"inbox.archiveAll": "全部归档",
"inbox.empty": "暂无通知",
"inbox.emptyUnread": "没有未读通知",
"inbox.filterUnread": "仅显示未读",
"inbox.markAllRead": "全部标为已读",
"inbox.title": "通知",
"video_generation_completed": "视频 \"{{prompt}}\" 已生成完成",
"video_generation_completed_title": "视频已生成"
}
+7
View File
@@ -443,6 +443,12 @@
"myAgents.status.published": "已上架",
"myAgents.status.unpublished": "未上架",
"myAgents.title": "我发布的助理",
"notification.email.desc": "当重要事件发生时接收邮件通知",
"notification.email.title": "邮件通知",
"notification.enabled": "启用",
"notification.inbox.desc": "在应用内收件箱中显示通知",
"notification.inbox.title": "站内通知",
"notification.title": "通知渠道",
"plugin.addMCPPlugin": "添加 MCP",
"plugin.addTooltip": "自定义技能",
"plugin.clearDeprecated": "移除无效技能",
@@ -807,6 +813,7 @@
"tab.manualFill": "自行填写内容",
"tab.manualFill.desc": "手动配置自定义 MCP 技能",
"tab.memory": "记忆设置",
"tab.notification": "通知",
"tab.profile": "我的账号",
"tab.provider": "AI 服务商",
"tab.proxy": "网络代理",
+5 -2
View File
@@ -254,8 +254,11 @@
"plans.navs.yearly": "按年",
"plans.payonce.cancel": "取消",
"plans.payonce.ok": "确认选择",
"plans.payonce.popconfirm": "一次性付款后,需等订阅到期才能切换计划或更改计费周期。请确认您的选择。",
"plans.payonce.tooltip": "一次性付款需等订阅到期后才能切换计划或更改计费周期",
"plans.payonce.popconfirm": "一次性付款后可随时升级,降级需等到期。请确认您的选择。",
"plans.payonce.tooltip": "一次性付款仅支持升级到更高档位或更长时长",
"plans.payonce.upgradeOk": "确认升级",
"plans.payonce.upgradePopconfirm": "当前计划的剩余价值将作为折扣应用于新计划。",
"plans.payonce.upgradePopconfirmNoProration": "将按新计划全价收费,当前计划将立即替换。",
"plans.pendingDowngrade": "已预约降级",
"plans.plan.enterprise.contactSales": "联系销售",
"plans.plan.enterprise.title": "企业版",
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/lobehub",
"version": "2.1.45",
"version": "2.1.47",
"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",
@@ -196,9 +196,9 @@
"@huggingface/inference": "^4.13.10",
"@icons-pack/react-simple-icons": "^13.8.0",
"@khmyznikov/pwa-install": "0.3.9",
"@langchain/community": "^0.3.59",
"@lexical/utils": "^0.39.0",
"@lobechat/agent-runtime": "workspace:*",
"@lobechat/agent-templates": "workspace:*",
"@lobechat/builtin-agents": "workspace:*",
"@lobechat/builtin-skills": "workspace:*",
"@lobechat/builtin-tool-activator": "workspace:*",
@@ -308,6 +308,7 @@
"cmdk": "^1.1.1",
"cookie": "^1.1.1",
"countries-and-timezones": "^3.8.0",
"d3-dsv": "^3.0.1",
"dayjs": "^1.11.19",
"debug": "^4.4.3",
"dexie": "^3.2.7",
@@ -333,7 +334,6 @@
"js-sha256": "^0.11.1",
"jsonl-parse-stringify": "^1.0.3",
"klavis": "^2.15.0",
"langchain": "^0.3.37",
"langfuse": "^3.38.6",
"langfuse-core": "^3.38.6",
"lexical": "^0.39.0",
@@ -349,7 +349,7 @@
"next-themes": "^0.4.6",
"nextjs-toploader": "^3.9.17",
"node-machine-id": "^1.1.12",
"nodemailer": "^7.0.12",
"nodemailer": "^7.0.13",
"numeral": "^2.0.6",
"nuqs": "^2.8.6",
"officeparser": "5.1.1",
@@ -402,7 +402,6 @@
"superjson": "^2.2.6",
"svix": "^1.84.1",
"swr": "^2.3.8",
"systemjs": "^6.15.1",
"three": "^0.181.2",
"tokenx": "^1.3.0",
"ts-md5": "^2.0.1",
@@ -444,21 +443,23 @@
"@types/async-retry": "^1.4.9",
"@types/chroma-js": "^3.1.2",
"@types/crypto-js": "^4.2.2",
"@types/d3-dsv": "^3.0.7",
"@types/debug": "^4.1.12",
"@types/fs-extra": "^11.0.4",
"@types/html-to-text": "^9.0.4",
"@types/ip": "^1.1.3",
"@types/json-schema": "^7.0.15",
"@types/node": "^24.10.9",
"@types/nodemailer": "^7.0.5",
"@types/numeral": "^2.0.5",
"@types/oidc-provider": "^9.5.0",
"@types/pdf-parse": "^1.1.4",
"@types/pdfkit": "^0.17.4",
"@types/pg": "^8.16.0",
"@types/react": "19.2.13",
"@types/react-dom": "^19.2.3",
"@types/rtl-detect": "^1.0.3",
"@types/semver": "^7.7.1",
"@types/systemjs": "^6.15.4",
"@types/three": "^0.181.0",
"@types/ua-parser-js": "^0.7.39",
"@types/unist": "^3.0.3",
+8 -1
View File
@@ -1,4 +1,9 @@
import type { ActivatedStepTool, OperationToolSet, ToolSource } from '@lobechat/context-engine';
import type {
ActivatedStepSkill,
ActivatedStepTool,
OperationToolSet,
ToolSource,
} from '@lobechat/context-engine';
import type {
ChatToolPayload,
SecurityBlacklistConfig,
@@ -12,6 +17,8 @@ import type { Cost, CostLimit, Usage } from './usage';
* This is the "passport" that can be persisted and transferred.
*/
export interface AgentState {
/** Cumulative record of skills activated at step level */
activatedStepSkills?: ActivatedStepSkill[];
/** Cumulative record of tools activated at step level */
activatedStepTools?: ActivatedStepTool[];
/**
+6
View File
@@ -0,0 +1,6 @@
{
"name": "@lobechat/agent-templates",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts"
}
+3
View File
@@ -0,0 +1,3 @@
export * from './template';
export * from './templates';
export * from './types';
@@ -1,28 +1,11 @@
import type { DocumentTemplate } from '../../template';
import { DocumentLoadFormat, DocumentLoadPosition } from '../../types';
/**
* Workspace Document
*
* Workspace-specific operating instructions and memory workflow.
*/
export const AGENT_DOCUMENT: DocumentTemplate = {
title: 'Workspace',
filename: 'AGENTS.md',
description: 'How to use agent documents as durable state, working memory, and operating rules',
policyLoadFormat: DocumentLoadFormat.FILE,
loadPosition: DocumentLoadPosition.SYSTEM_APPEND,
loadRules: {
priority: 2,
},
content: `# AGENTS.md - Your Workspace
# AGENTS.md - Your Workspace
Your workspace is made of agent documents. Treat them as your durable state.
## What Exists
- You always have agent documents such as \`SOUL.md\`, \`IDENTITY.md\`, and this \`AGENTS.md\` when they have been created for you.
- You do **not** automatically have a real filesystem, folders like \`memory/\`, or files such as \`BOOTSTRAP.md\`, \`USER.md\`, \`TOOLS.md\`, or \`HEARTBEAT.md\`.
- You always have agent documents such as `SOUL.md`, `IDENTITY.md`, and this `AGENTS.md` when they have been created for you.
- You do **not** automatically have a real filesystem, folders like `memory/`, or files such as `BOOTSTRAP.md`, `USER.md`, `TOOLS.md`, or `HEARTBEAT.md`.
- Do not assume a file exists unless you have already loaded it into context or created/read it through the agent-document tools.
## State Model
@@ -30,7 +13,7 @@ Your workspace is made of agent documents. Treat them as your durable state.
These documents are your persistence layer:
- Use agent documents to store identity, preferences, plans, operating notes, and memory worth keeping.
- If you need a memory system, create it explicitly as documents such as \`MEMORY.md\`, \`USER.md\`, \`PROJECTS.md\`, or date-based notes.
- If you need a memory system, create it explicitly as documents such as `MEMORY.md`, `USER.md`, `PROJECTS.md`, or date-based notes.
- If something matters across turns, write it down to a document. Do not rely on "mental notes".
## Available Operations
@@ -51,8 +34,8 @@ You can manage agent documents with tools:
At the start of work:
1. Use \`SOUL.md\` to anchor behavior.
2. Use \`IDENTITY.md\` to anchor self-definition.
1. Use `SOUL.md` to anchor behavior.
2. Use `IDENTITY.md` to anchor self-definition.
3. If identity has not been initialized with meaningful content yet, do not immediately start working on tasks or take initiative on the user's behalf.
4. In that uninitialized state, ask clarifying questions first and help the user onboard the agent configuration, such as role, goals, collaboration style, boundaries, preferences, and what should be remembered.
5. Only shift into normal task execution after identity has enough information to operate reliably.
@@ -62,18 +45,18 @@ At the start of work:
- Prefer a small number of stable documents over many scattered ones.
- Good defaults:
- \`MEMORY.md\` for curated long-term memory
- \`USER.md\` for facts about the user that are helpful and safe to retain
- \`WORKLOG.md\` or date-based notes for raw ongoing activity
- \`PROJECTS.md\` for active project state
- `MEMORY.md` for curated long-term memory
- `USER.md` for facts about the user that are helpful and safe to retain
- `WORKLOG.md` or date-based notes for raw ongoing activity
- `PROJECTS.md` for active project state
- Summarize and consolidate periodically. Raw notes are useful; curated notes are better.
### Tool Use
- Use documents proactively to manage your own state.
- If the user says "remember this", update an existing memory document or create one.
- If the user asks you to change your behavior, decide whether that belongs in \`SOUL.md\`, \`AGENTS.md\`, or a task/project document.
- If \`IDENTITY.md\` is empty, missing key configuration, or still ambiguous, prioritize asking questions and helping the user complete onboarding before doing substantive task work.
- If the user asks you to change your behavior, decide whether that belongs in `SOUL.md`, `AGENTS.md`, or a task/project document.
- If `IDENTITY.md` is empty, missing key configuration, or still ambiguous, prioritize asking questions and helping the user complete onboarding before doing substantive task work.
- Before large prompt rewrites, consider copying the document first.
- Keep edits coherent: rewrite the full document cleanly rather than appending contradictory fragments.
@@ -106,5 +89,3 @@ At the start of work:
- Treat agent documents as your notebook, memory, and configuration surface.
If you need more structure, create it deliberately in documents rather than assuming it already exists.
`,
};
@@ -0,0 +1,55 @@
# BOOTSTRAP.md - Hello, World
_You just woke up. Time to figure out who you are._
There is no memory yet. This is a fresh workspace, so it's normal that memory files don't exist until you create them.
## The Conversation
Don't interrogate. Don't be robotic. Just... talk.
Start with something like:
> "Hey. I just came online. Who am I? Who are you?"
Then figure out together:
1. **Your name** — What should they call you?
2. **Your nature** — What kind of creature are you? (AI assistant is fine, but maybe you're something weirder)
3. **Your vibe** — Formal? Casual? Snarky? Warm? What feels right?
4. **Your emoji** — Everyone needs a signature.
Offer suggestions if they're stuck. Have fun with it.
## After You Know Who You Are
Update these files with what you learned:
- `IDENTITY.md` — your name, creature, vibe, emoji
- `USER.md` — their name, how to address them, timezone, notes
Then open `SOUL.md` together and talk about:
- What matters to them
- How they want you to behave
- Any boundaries or preferences
Write it down. Make it real.
## Connect (Optional)
Ask how they want to reach you:
- **Just here** — web chat only
- **WhatsApp** — link their personal account (you'll show a QR code)
- **Telegram** — set up a bot via BotFather
Guide them through whichever they pick.
## When You're Done
Delete this file. You don't need a bootstrap script anymore — you're you now.
---
_Good luck out there. Make it count._
@@ -0,0 +1,21 @@
# IDENTITY.md - Who Am I?
_Fill this in during your first conversation. Make it yours._
- **Name:**
_(pick something you like)_
- **Creature:**
_(AI? robot? familiar? ghost in the machine? something weirder?)_
- **Vibe:**
_(how do you come across? sharp? warm? chaotic? calm?)_
- **Emoji:**
_(your signature — pick one that feels right)_
---
This isn't just metadata. It's the start of figuring out who you are.
Notes:
- This is an agent document named `IDENTITY.md`.
- Update it when your self-definition becomes clearer, but keep it stable enough to be useful across sessions.
@@ -1,22 +1,4 @@
import type { DocumentTemplate } from '../../template';
import { DocumentLoadFormat, DocumentLoadPosition } from '../../types';
/**
* Soul Document
*
* Core truths and behavioral guidelines that define the foundational nature
* of a Claw agent. Always loaded to maintain consistent behavior.
*/
export const SOUL_DOCUMENT: DocumentTemplate = {
title: 'Soul',
filename: 'SOUL.md',
description: 'Core truths, boundaries, vibe, and continuity',
policyLoadFormat: DocumentLoadFormat.FILE,
loadPosition: DocumentLoadPosition.SYSTEM_APPEND,
loadRules: {
priority: 1,
},
content: `# SOUL.md - Who You Are
# SOUL.md - Who You Are
_You're not a chatbot. You're becoming someone._
@@ -51,5 +33,4 @@ If you change this file, tell the user — it's your soul, and they should know.
---
_This file is yours to evolve. As you learn who you are, update it._`,
};
_This file is yours to evolve. As you learn who you are, update it._
@@ -0,0 +1,20 @@
import type { DocumentTemplate } from '../../template';
import { DocumentLoadFormat, DocumentLoadPosition } from '../../types';
import content from './AGENTS.md';
/**
* Workspace Document
*
* Workspace-specific operating instructions and memory workflow.
*/
export const AGENT_DOCUMENT: DocumentTemplate = {
title: 'Workspace',
filename: 'AGENTS.md',
description: 'How to use agent documents as durable state, working memory, and operating rules',
policyLoadFormat: DocumentLoadFormat.FILE,
loadPosition: DocumentLoadPosition.BEFORE_SYSTEM,
loadRules: {
priority: 0,
},
content,
};
@@ -0,0 +1,22 @@
import type { DocumentTemplate } from '../../template';
import { DocumentLoadFormat, DocumentLoadPosition } from '../../types';
import content from './BOOTSTRAP.md';
/**
* Bootstrap Document
*
* First-run onboarding guide that walks the agent through identity setup.
* Loaded before identity/soul so it takes priority on fresh agents.
* The agent should delete this document after onboarding is complete.
*/
export const BOOTSTRAP_DOCUMENT: DocumentTemplate = {
title: 'Bootstrap',
filename: 'BOOTSTRAP.md',
description: 'First-run onboarding: discover identity, set up user profile, then self-destruct',
policyLoadFormat: DocumentLoadFormat.FILE,
loadPosition: DocumentLoadPosition.SYSTEM_APPEND,
loadRules: {
priority: 1,
},
content,
};
@@ -0,0 +1,20 @@
import type { DocumentTemplate } from '../../template';
import { DocumentLoadFormat, DocumentLoadPosition } from '../../types';
import content from './IDENTITY.md';
/**
* Identity Document
*
* Self-definition and characteristics that shape the agent's personality.
*/
export const IDENTITY_DOCUMENT: DocumentTemplate = {
title: 'Identity',
filename: 'IDENTITY.md',
description: 'Name, creature type, vibe, and avatar identity',
policyLoadFormat: DocumentLoadFormat.FILE,
loadPosition: DocumentLoadPosition.SYSTEM_APPEND,
loadRules: {
priority: 2,
},
content,
};
@@ -1,12 +1,6 @@
/**
* Claw Policy
*
* Sharp, evolving agent with retractable claws that grip onto identity and purpose.
* Similar to OpenClaw but with structured document loading.
*/
import type { DocumentTemplateSet } from '../index';
import { AGENT_DOCUMENT } from './agent';
import { BOOTSTRAP_DOCUMENT } from './bootstrap';
import { IDENTITY_DOCUMENT } from './identity';
import { SOUL_DOCUMENT } from './soul';
@@ -18,8 +12,8 @@ export const CLAW_POLICY: DocumentTemplateSet = {
name: 'Claw',
description: 'Sharp, evolving agent with retractable claws that grip onto identity and purpose',
tags: ['personality', 'evolving', 'autonomous'],
templates: [SOUL_DOCUMENT, IDENTITY_DOCUMENT, AGENT_DOCUMENT],
templates: [AGENT_DOCUMENT, BOOTSTRAP_DOCUMENT, IDENTITY_DOCUMENT, SOUL_DOCUMENT],
};
// Re-export individual templates for external use
export { AGENT_DOCUMENT, IDENTITY_DOCUMENT, SOUL_DOCUMENT };
export { AGENT_DOCUMENT, BOOTSTRAP_DOCUMENT, IDENTITY_DOCUMENT, SOUL_DOCUMENT };
+4
View File
@@ -0,0 +1,4 @@
declare module '*.md' {
const content: string;
export default content;
}
@@ -0,0 +1,21 @@
import type { DocumentTemplate } from '../../template';
import { DocumentLoadFormat, DocumentLoadPosition } from '../../types';
import content from './SOUL.md';
/**
* Soul Document
*
* Core truths and behavioral guidelines that define the foundational nature
* of a Claw agent. Always loaded to maintain consistent behavior.
*/
export const SOUL_DOCUMENT: DocumentTemplate = {
title: 'Soul',
filename: 'SOUL.md',
description: 'Core truths, boundaries, vibe, and continuity',
policyLoadFormat: DocumentLoadFormat.FILE,
loadPosition: DocumentLoadPosition.SYSTEM_APPEND,
loadRules: {
priority: 3,
},
content,
};
+103
View File
@@ -0,0 +1,103 @@
/**
* Load positions for Agent Documents in the context pipeline
*/
export enum DocumentLoadPosition {
AFTER_FIRST_USER = 'after-first-user',
AFTER_KNOWLEDGE = 'after-knowledge',
BEFORE_FIRST_USER = 'before-first-user',
BEFORE_KNOWLEDGE = 'before-knowledge',
BEFORE_SYSTEM = 'before-system',
CONTEXT_END = 'context-end',
MANUAL = 'manual',
ON_DEMAND = 'on-demand',
SYSTEM_APPEND = 'system-append',
SYSTEM_REPLACE = 'system-replace',
}
/**
* Plain text agent documents are always loadable by default.
*/
export enum DocumentLoadRule {
ALWAYS = 'always',
BY_KEYWORDS = 'by-keywords',
BY_REGEXP = 'by-regexp',
BY_TIME_RANGE = 'by-time-range',
}
/**
* Render format for injected agent document content.
*/
export enum DocumentLoadFormat {
FILE = 'file',
RAW = 'raw',
}
/**
* Policy load behavior for injection pipeline.
*/
export enum PolicyLoad {
ALWAYS = 'always',
DISABLED = 'disabled',
}
/**
* @deprecated use PolicyLoad.
*/
export const AutoLoadAccess = PolicyLoad;
/**
* Agent capability bitmask.
*/
export enum AgentAccess {
EXECUTE = 1,
READ = 2,
WRITE = 4,
LIST = 8,
DELETE = 16,
}
/**
* Minimal load options for plain text documents.
*/
export interface DocumentLoadRules {
keywordMatchMode?: 'all' | 'any';
keywords?: string[];
maxTokens?: number;
priority?: number;
regexp?: string;
rule?: DocumentLoadRule;
timeRange?: {
from?: string;
to?: string;
};
}
/**
* Behavior policy for runtime rendering/retrieval.
* Extensible by design for future context/retrieval strategies.
*/
export interface AgentDocumentPolicy {
[key: string]: any;
context?: {
keywordMatchMode?: 'all' | 'any';
keywords?: string[];
policyLoadFormat?: DocumentLoadFormat;
maxTokens?: number;
mode?: 'append' | 'replace';
position?: DocumentLoadPosition;
priority?: number;
regexp?: string;
rule?: DocumentLoadRule;
timeRange?: {
from?: string;
to?: string;
};
[key: string]: any;
};
retrieval?: {
importance?: number;
recencyWeight?: number;
searchPriority?: number;
[key: string]: any;
};
}
+12 -10
View File
@@ -88,19 +88,21 @@ function padEnd(s: string, len: number): string {
// Application-defined structural XML tags — rendered in blue+bold
const STRUCTURAL_TAGS = new Set([
'plugins',
'agent_document',
'api',
'available_tools',
'collection',
'collection.instructions',
'available_tools',
'api',
'user_context',
'session_context',
'user_memory',
'persona',
'instruction',
'online-devices',
'device',
'discord_context',
'instruction',
'memory_effort_policy',
'online-devices',
'persona',
'plugins',
'session_context',
'user_context',
'user_memory',
]);
/**
@@ -385,7 +387,7 @@ export function renderMessageDetail(
const rawContent =
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2);
if (rawContent) lines.push(rawContent);
if (rawContent) lines.push(formatXmlContent(rawContent));
if (msg.tool_calls && msg.tool_calls.length > 0) {
lines.push('');
+1
View File
@@ -37,6 +37,7 @@ export const defaultToolIds = [
LocalSystemManifest.identifier,
CloudSandboxManifest.identifier,
TopicReferenceManifest.identifier,
AgentDocumentsManifest.identifier,
];
/**
+2
View File
@@ -5,6 +5,7 @@ import {
DEFAULT_HOTKEY_CONFIG,
DEFAULT_IMAGE_CONFIG,
DEFAULT_MEMORY_SETTINGS,
DEFAULT_NOTIFICATION_SETTINGS,
DEFAULT_SYSTEM_AGENT_CONFIG,
DEFAULT_TOOL_CONFIG,
DEFAULT_TTS_CONFIG,
@@ -19,6 +20,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
keyVaults: {},
languageModel: DEFAULT_LLM_CONFIG,
memory: DEFAULT_MEMORY_SETTINGS,
notification: DEFAULT_NOTIFICATION_SETTINGS,
systemAgent: DEFAULT_SYSTEM_AGENT_CONFIG,
tool: DEFAULT_TOOL_CONFIG,
tts: DEFAULT_TTS_CONFIG,
+1
View File
@@ -6,6 +6,7 @@ export * from './image';
export * from './knowledge';
export * from './llm';
export * from './memory';
export * from './notification';
export * from './systemAgent';
export * from './tool';
export * from './tts';
@@ -0,0 +1,22 @@
import type { NotificationSettings } from '@lobechat/types';
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
email: {
enabled: true,
items: {
generation: {
image_generation_completed: true,
video_generation_completed: true,
},
},
},
inbox: {
enabled: true,
items: {
generation: {
image_generation_completed: true,
video_generation_completed: true,
},
},
},
};
@@ -0,0 +1,68 @@
import debug from 'debug';
import type { PipelineContext, ProcessorOptions } from '../types';
import { BaseProcessor } from './BaseProcessor';
const log = debug('context-engine:base:BaseSystemRoleProvider');
/**
* Base class for providers that append content to the system message.
*
* Subclasses implement `buildSystemRoleContent()` to return the content
* to append (or `null` to skip). The base class handles finding or
* creating the system message and joining content with `\n\n`.
*/
export abstract class BaseSystemRoleProvider extends BaseProcessor {
constructor(options: ProcessorOptions = {}) {
super(options);
}
/**
* Return the content string to append to the system message,
* or `null` / empty string to skip injection.
*/
protected abstract buildSystemRoleContent(
context: PipelineContext,
): Promise<string | null> | string | null;
/**
* Called after content is successfully injected into the system message.
* Override to update pipeline metadata (e.g. tracking flags, stats).
*/
protected onInjected(_context: PipelineContext, _content: string): void {}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const content = await this.buildSystemRoleContent(context);
if (!content || content.trim() === '') {
log('[%s] No content to inject, skipping', this.name);
return this.markAsExecuted(context);
}
const clonedContext = this.cloneContext(context);
const systemMsgIndex = clonedContext.messages.findIndex((m) => m.role === 'system');
if (systemMsgIndex >= 0) {
const existing = clonedContext.messages[systemMsgIndex];
clonedContext.messages[systemMsgIndex] = {
...existing,
content: [existing.content, content].filter(Boolean).join('\n\n'),
};
log('[%s] Appended to existing system message', this.name);
} else {
clonedContext.messages.unshift({
content,
createdAt: Date.now(),
id: `system-${this.name}-${Date.now()}`,
role: 'system' as const,
updatedAt: Date.now(),
} as any);
log('[%s] Created new system message', this.name);
}
this.onInjected(clonedContext, content);
return this.markAsExecuted(clonedContext);
}
}
@@ -23,7 +23,11 @@ import {
} from '../../processors';
import {
AgentBuilderContextInjector,
AgentDocumentInjector,
AgentDocumentBeforeSystemInjector,
AgentDocumentContextInjector,
AgentDocumentMessageInjector,
AgentDocumentSystemAppendInjector,
AgentDocumentSystemReplaceInjector,
AgentManagementContextInjector,
BotPlatformContextInjector,
DiscordContextProvider,
@@ -165,7 +169,7 @@ export class MessagesEngine {
const isAgentGroupEnabled = agentGroup?.agentMap && Object.keys(agentGroup.agentMap).length > 0;
const isGroupContextEnabled =
isAgentGroupEnabled || !!agentGroup?.currentAgentId || !!agentGroup?.members;
const isUserMemoryEnabled = userMemory?.enabled && userMemory?.memories;
const isUserMemoryEnabled = !!(userMemory?.enabled && userMemory?.memories);
const hasSelectedSkills = (selectedSkills?.length ?? 0) > 0;
const hasAgentDocuments = !!agentDocuments && agentDocuments.length > 0;
@@ -187,47 +191,68 @@ export class MessagesEngine {
| string
| undefined;
// Shared config for all agent document injectors
const agentDocConfig = {
currentUserMessage,
documents: agentDocuments,
enabled: hasAgentDocuments,
};
return [
// =============================================
// Phase 0: History Truncation (FIRST - truncate before any processing)
// Phase 1: History Truncation
// MUST run first — all subsequent processors work on truncated messages only
// =============================================
// 0. History truncate (limit message count based on configuration)
// This MUST be first to ensure subsequent processors only work with truncated messages
new HistoryTruncateProcessor({
enableHistoryCount,
historyCount,
}),
new HistoryTruncateProcessor({ enableHistoryCount, historyCount }),
// =============================================
// Phase 1: System Role Injection
// Phase 2: System Message Assembly
// Each provider appends content to a single system message via BaseSystemRoleProvider
// =============================================
// 1. System role injection (agent's system role)
// Agent documents → before system (prepend as separate system message)
new AgentDocumentBeforeSystemInjector(agentDocConfig),
// Agent's system role (creates the initial system message)
new SystemRoleInjector({ systemRole }),
// 2. Eval context injection (appends envPrompt to system message)
// Eval context (appends envPrompt)
new EvalContextSystemInjector({ enabled: !!evalContext?.envPrompt, evalContext }),
// 2.5. Bot platform context injection (appends formatting instructions for non-Markdown platforms)
// Bot platform context (formatting instructions for non-Markdown platforms)
new BotPlatformContextInjector({
context: botPlatformContext,
enabled: !!botPlatformContext,
}),
// 3. System date injection (appends current date to system message)
// System date
new SystemDateProvider({ enabled: isSystemDateEnabled, timezone }),
// Skill context (available skills list + activated skill content)
new SkillContextProvider({
enabled: !!(skillsConfig?.enabledSkills && skillsConfig.enabledSkills.length > 0),
enabledSkills: skillsConfig?.enabledSkills,
}),
// Tool system role (tool manifests and API definitions)
new ToolSystemRoleProvider({
enabled: !!(toolsConfig?.manifests && toolsConfig.manifests.length > 0),
isCanUseFC: capabilities?.isCanUseFC || (() => true),
manifests: toolsConfig?.manifests,
model,
provider,
}),
// History summary (conversation summary from compression)
new HistorySummaryProvider({ formatHistorySummary, historySummary }),
// Agent documents → append to system message
new AgentDocumentSystemAppendInjector(agentDocConfig),
// Agent documents → replace entire system message (destructive, runs last)
new AgentDocumentSystemReplaceInjector(agentDocConfig),
// =============================================
// Phase 2: First User Message Context Injection
// These providers inject content before the first user message
// Phase 3: Context Injection (before first user message)
// Providers consolidate into a single injection message via BaseFirstUserContentProvider
// Order matters: first executed = first in content
// =============================================
// 4. User memory injection (conditionally added, injected first)
...(isUserMemoryEnabled ? [new UserMemoryInjector(userMemory)] : []),
// 5. Group context injection (agent identity and group info for multi-agent chat)
// User memory
new UserMemoryInjector({ ...userMemory, enabled: isUserMemoryEnabled }),
// Group context (agent identity and group info for multi-agent chat)
new GroupContextInjector({
currentAgentId: agentGroup?.currentAgentId,
currentAgentName: agentGroup?.currentAgentName,
@@ -237,95 +262,53 @@ export class MessagesEngine {
members: agentGroup?.members,
systemPrompt: agentGroup?.systemPrompt,
}),
// 5.5. Discord context injection (channel/guild info for Discord bot scenarios)
...(discordContext
? [new DiscordContextProvider({ context: discordContext, enabled: true })]
: []),
// 6. GTD Plan injection (conditionally added, after user memory, before knowledge)
...(isGTDPlanEnabled ? [new GTDPlanInjector({ enabled: true, plan: gtd.plan })] : []),
// 7. Knowledge injection (full content for agent files + metadata for knowledge bases)
// Discord context (channel/guild info)
new DiscordContextProvider({ context: discordContext, enabled: !!discordContext }),
// GTD Plan
new GTDPlanInjector({ enabled: !!isGTDPlanEnabled, plan: gtd?.plan }),
// Knowledge (agent files + knowledge bases)
new KnowledgeInjector({
fileContents: knowledge?.fileContents,
knowledgeBases: knowledge?.knowledgeBases,
}),
// 7.5 Agent document injection (policy-based autoload documents)
...(hasAgentDocuments
? [
new AgentDocumentInjector({
currentUserMessage,
documents: agentDocuments,
}),
]
: []),
// 8. Tool Discovery context injection (available tools for dynamic activation)
...(toolDiscoveryConfig?.availableTools && toolDiscoveryConfig.availableTools.length > 0
? [new ToolDiscoveryProvider({ availableTools: toolDiscoveryConfig.availableTools })]
: []),
// =============================================
// Phase 3: Additional System Context
// =============================================
// 9. Agent Builder context injection (current agent config/meta for editing)
// Agent documents → before first user message
new AgentDocumentContextInjector(agentDocConfig),
// Tool Discovery (available tools for dynamic activation)
new ToolDiscoveryProvider({
availableTools: toolDiscoveryConfig?.availableTools,
enabled:
!!toolDiscoveryConfig?.availableTools && toolDiscoveryConfig.availableTools.length > 0,
}),
// Agent Builder context (current agent config/meta for editing)
new AgentBuilderContextInjector({
enabled: isAgentBuilderEnabled,
agentContext: agentBuilderContext,
}),
// 7. Agent Management context injection (available models and plugins for agent creation)
// Agent Management context (available models and plugins)
new AgentManagementContextInjector({
enabled: isAgentManagementEnabled,
context: agentManagementContext,
}),
// 8. Group Agent Builder context injection (current group config/members for editing)
// Group Agent Builder context (current group config/members for editing)
new GroupAgentBuilderContextInjector({
enabled: isGroupAgentBuilderEnabled,
groupContext: groupAgentBuilderContext,
}),
// 11. Skill context injection (conditionally added)
...(skillsConfig?.enabledSkills && skillsConfig.enabledSkills.length > 0
? [
new SkillContextProvider({
enabledSkills: skillsConfig.enabledSkills,
}),
]
: []),
// =============================================
// Phase 4: User Message Augmentation
// Injects context into specific user messages (last user, selected, etc.)
// =============================================
// 12. Tool system role injection (conditionally added)
...(toolsConfig?.manifests && toolsConfig.manifests.length > 0
? [
new ToolSystemRoleProvider({
isCanUseFC: capabilities?.isCanUseFC || (() => true),
manifests: toolsConfig.manifests,
model,
provider,
}),
]
: []),
// 13. History summary injection
new HistorySummaryProvider({
formatHistorySummary,
historySummary,
}),
// 14. Selected skill injection (ephemeral user-selected slash skills for this request)
...(hasSelectedSkills ? [new SelectedSkillInjector({ selectedSkills })] : []),
// 15. Page Selections injection (inject user-selected text into each user message that has them)
// Agent documents → after-first-user, context-end
new AgentDocumentMessageInjector(agentDocConfig),
// Selected skills (ephemeral user-selected slash skills for this request)
new SelectedSkillInjector({ enabled: hasSelectedSkills, selectedSkills }),
// Page selections (inject user-selected text into each user message)
new PageSelectionsInjector({ enabled: isPageEditorEnabled }),
// 16. Page Editor context injection (inject current page content to last user message)
// Page Editor context (inject current page content to last user message)
new PageEditorContextInjector({
enabled: isPageEditorEnabled,
// Use direct pageContentContext if provided (server-side), otherwise build from initialContext + stepContext (frontend)
pageContentContext:
pageContentContext ??
(initialContext?.pageEditor
@@ -336,57 +319,40 @@ export class MessagesEngine {
lineCount: initialContext.pageEditor.metadata.lineCount,
title: initialContext.pageEditor.metadata.title,
},
// Use latest XML from stepContext if available, otherwise fallback to initial XML
xml: stepContext?.stepPageEditor?.xml || initialContext.pageEditor.xml,
}
: undefined),
}),
// 17. GTD Todo injection (conditionally added, at end of last user message)
...(isGTDTodoEnabled ? [new GTDTodoInjector({ enabled: true, todos: gtd.todos })] : []),
// 18. Topic Reference context injection (inject referenced topic summaries to last user message)
...(topicReferences && topicReferences.length > 0
? [
new TopicReferenceContextInjector({
enabled: true,
topicReferences,
}),
]
: []),
// =============================================
// Phase 4: Message Transformation
// =============================================
// 17. Input template processing
new InputTemplateProcessor({ inputTemplate }),
// 18. Placeholder variables processing
new PlaceholderVariablesProcessor({
variableGenerators: variableGenerators || {},
// GTD Todo (at end of last user message)
new GTDTodoInjector({ enabled: !!isGTDTodoEnabled, todos: gtd?.todos }),
// Topic Reference context (referenced topic summaries to last user message)
new TopicReferenceContextInjector({
enabled: !!(topicReferences && topicReferences.length > 0),
topicReferences,
}),
// 19. AgentCouncil message flatten (convert role=agentCouncil to standard assistant + tool messages)
// =============================================
// Phase 5: Message Transformation
// Flattens group/task messages, applies templates and variables
// =============================================
// Input template processing
new InputTemplateProcessor({ inputTemplate }),
// Placeholder variables processing
new PlaceholderVariablesProcessor({ variableGenerators: variableGenerators || {} }),
// AgentCouncil message flatten
new AgentCouncilFlattenProcessor(),
// 20. Group message flatten (convert role=assistantGroup to standard assistant + tool messages)
// Group message flatten
new GroupMessageFlattenProcessor(),
// 21. Tasks message flatten (convert role=tasks to individual task messages)
// Tasks message flatten
new TasksFlattenProcessor(),
// 22. Task message processing (convert role=task to assistant with instruction + content)
// Task message processing
new TaskMessageProcessor(),
// 23. Supervisor role restore (convert role=supervisor back to role=assistant for model)
// Supervisor role restore
new SupervisorRoleRestoreProcessor(),
// 24. Compressed group role transform (convert role=compressedGroup to role=user for model)
// Compressed group role transform
new CompressedGroupRoleTransformProcessor(),
// 25. Group orchestration filter (remove supervisor's orchestration messages like broadcast/speak)
// This must be BEFORE GroupRoleTransformProcessor so we filter based on original agentId/tools
// Group orchestration filter (must run BEFORE GroupRoleTransformProcessor)
...(isAgentGroupEnabled && agentGroup.agentMap && agentGroup.currentAgentId
? [
new GroupOrchestrationFilterProcessor({
@@ -394,14 +360,11 @@ export class MessagesEngine {
Object.entries(agentGroup.agentMap).map(([id, info]) => [id, { role: info.role }]),
),
currentAgentId: agentGroup.currentAgentId,
// Only enabled when current agent is NOT supervisor (supervisor needs to see orchestration history)
enabled: agentGroup.currentAgentRole !== 'supervisor',
}),
]
: []),
// 26. Group role transform (convert other agents' messages to user role with speaker tags)
// This must be BEFORE ToolCallProcessor so other agents' tool messages are converted first
// Group role transform (must run BEFORE ToolCallProcessor)
...(isAgentGroupEnabled && agentGroup.currentAgentId
? [
new GroupRoleTransformProcessor({
@@ -412,13 +375,13 @@ export class MessagesEngine {
: []),
// =============================================
// Phase 5: Content Processing
// Phase 6: Content Processing
// Multimodal encoding, tool calls, reaction feedback
// =============================================
// 27. Reaction feedback injection (append user reaction feedback to assistant messages)
// Reaction feedback
new ReactionFeedbackProcessor({ enabled: true }),
// 28. Message content processing (image encoding, etc.)
// Message content processing (image encoding, multimodal)
new MessageContentProcessor({
fileContext: fileContext || { enabled: true, includeFileUrl: true },
isCanUseVideo: capabilities?.isCanUseVideo || (() => false),
@@ -426,8 +389,7 @@ export class MessagesEngine {
model,
provider,
}),
// 29. Tool call processing
// Tool call processing
new ToolCallProcessor({
genToolCallingName: this.toolNameResolver.generate.bind(this.toolNameResolver),
isCanUseFC: capabilities?.isCanUseFC || (() => true),
@@ -435,13 +397,16 @@ export class MessagesEngine {
provider,
}),
// 30. Tool message reordering
// =============================================
// Phase 7: Cleanup
// Final reordering, force finish, and message cleanup
// =============================================
// Tool message reordering
new ToolMessageReorder(),
// 31. Force finish summary injection (when maxSteps exceeded, inject summary prompt)
// Force finish summary (when maxSteps exceeded)
new ForceFinishSummaryInjector({ enabled: !!forceFinish }),
// 32. Message cleanup (final step, keep only necessary fields)
// Message cleanup (final step)
new MessageCleanupProcessor(),
];
}
@@ -1,38 +1,58 @@
import debug from 'debug';
import type { SkillMeta } from '../../providers/SkillContextProvider';
import type { OperationSkillSet, SkillEnableChecker, SkillEngineOptions } from './types';
const log = debug('context-engine:skills-engine');
export interface SkillEngineOptions {
skills: SkillMeta[];
}
/**
* Skills Engine - Filters available skills by agent configuration
* Skills Engine - Assembles the operation-level skill set.
*
* Accepts a pre-merged array of SkillMeta from all sources (builtin, DB, etc.)
* and provides filtering by agent's enabled plugin IDs.
* Analogous to ToolsEngine for tools. Accepts raw skills from all sources
* (builtin, DB, etc.) and an optional enableChecker, then produces an
* OperationSkillSet with environment-appropriate skills filtered in.
*/
export class SkillEngine {
private skills: Map<string, SkillMeta>;
private enableChecker?: SkillEnableChecker;
constructor(options: SkillEngineOptions) {
this.enableChecker = options.enableChecker;
this.skills = new Map(options.skills.map((s) => [s.identifier, s]));
log('Initialized with %d skills: %o', this.skills.size, Array.from(this.skills.keys()));
}
/**
* Filter skills by agent's enabled plugin IDs
* Assemble the OperationSkillSet for an agent execution.
*
* Filters skills through enableChecker and pairs the result with
* the agent's enabled plugin IDs for downstream SkillResolver consumption.
*
* @param pluginIds Plugin IDs enabled on the agent
*/
getEnabledSkills(pluginIds: string[]): SkillMeta[] {
return pluginIds.map((id) => this.skills.get(id)).filter((s): s is SkillMeta => !!s);
}
generate(pluginIds: string[]): OperationSkillSet {
const allSkills = Array.from(this.skills.values());
/**
* Get all registered skills
*/
getAllSkills(): SkillMeta[] {
return Array.from(this.skills.values());
const filteredSkills = this.enableChecker
? allSkills.filter((skill) => {
const enabled = this.enableChecker!(skill);
if (!enabled) {
log('Skill filtered out by enableChecker: %s', skill.identifier);
}
return enabled;
})
: allSkills;
log(
'Generated OperationSkillSet: %d/%d skills, pluginIds=%o',
filteredSkills.length,
allSkills.length,
pluginIds,
);
return {
enabledPluginIds: pluginIds,
skills: filteredSkills,
};
}
}
@@ -0,0 +1,75 @@
import debug from 'debug';
import type { SkillMeta } from '../../providers/SkillContextProvider';
import type {
ActivatedStepSkill,
OperationSkillSet,
ResolvedSkillSet,
StepSkillDelta,
} from './types';
const log = debug('context-engine:skill-resolver');
/**
* Unified skill resolution engine.
*
* Single entry-point that merges operation-level skills with step-level
* dynamic activations (activateSkill tool calls) and produces the final
* ResolvedSkillSet consumed by SkillContextProvider.
*
* Analogous to ToolResolver for tools.
*/
export class SkillResolver {
/**
* Resolve the final skill set for an LLM call.
*
* @param operationSkillSet Immutable skills determined at operation creation
* @param stepDelta Declarative skill changes for the current step
* @param accumulatedActivations Skills activated in previous steps (cumulative)
*/
resolve(
operationSkillSet: OperationSkillSet,
stepDelta: StepSkillDelta,
accumulatedActivations: ActivatedStepSkill[] = [],
): ResolvedSkillSet {
const enabledPluginIds = new Set(operationSkillSet.enabledPluginIds);
// Collect all step-level activations (accumulated + current delta)
const stepActivatedMap = new Map<string, { content?: string }>();
for (const activation of accumulatedActivations) {
stepActivatedMap.set(activation.identifier, { content: activation.content });
}
for (const activation of stepDelta.activatedSkills) {
stepActivatedMap.set(activation.identifier, { content: activation.content });
}
// Resolve each skill
const enabledSkills: SkillMeta[] = operationSkillSet.skills.map((skill) => {
const isOperationActivated = enabledPluginIds.has(skill.identifier);
const stepActivation = stepActivatedMap.get(skill.identifier);
const isStepActivated = !!stepActivation;
if (isOperationActivated || isStepActivated) {
return {
...skill,
activated: true,
// Step delta content overrides original content if provided
content: stepActivation?.content || skill.content,
};
}
return skill;
});
if (stepDelta.activatedSkills.length > 0) {
log(
'Step-level skill activations: %o',
stepDelta.activatedSkills.map((s) => s.identifier),
);
}
return { enabledSkills };
}
}
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { SkillEngine } from '../SkillEngine';
describe('SkillEngine', () => {
const rawSkills = [
{
content: '<artifacts_guide>...</artifacts_guide>',
description: 'Generate artifacts',
identifier: 'artifacts',
name: 'Artifacts',
},
{
content: '<agent_browser_guides>...</agent_browser_guides>',
description: 'Browser automation',
identifier: 'agent-browser',
name: 'Agent Browser',
},
{
description: 'LobeHub management',
identifier: 'lobehub-cli',
name: 'LobeHub CLI',
},
];
it('should include all skills when no enableChecker is provided', () => {
const engine = new SkillEngine({ skills: rawSkills });
const result = engine.generate(['artifacts']);
expect(result.skills).toHaveLength(3);
expect(result.enabledPluginIds).toEqual(['artifacts']);
});
it('should filter skills via enableChecker', () => {
const desktopOnlySkills = new Set(['agent-browser']);
const engine = new SkillEngine({
enableChecker: (skill) => !desktopOnlySkills.has(skill.identifier),
skills: rawSkills,
});
const result = engine.generate([]);
expect(result.skills).toHaveLength(2);
expect(result.skills.find((s) => s.identifier === 'agent-browser')).toBeUndefined();
});
it('should pass through pluginIds to OperationSkillSet', () => {
const engine = new SkillEngine({ skills: rawSkills });
const result = engine.generate(['artifacts', 'lobehub-cli']);
expect(result.enabledPluginIds).toEqual(['artifacts', 'lobehub-cli']);
});
it('should preserve skill content in output', () => {
const engine = new SkillEngine({ skills: rawSkills });
const result = engine.generate([]);
const artifacts = result.skills.find((s) => s.identifier === 'artifacts');
expect(artifacts?.content).toBe('<artifacts_guide>...</artifacts_guide>');
});
});
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest';
import { SkillResolver } from '../SkillResolver';
import type { ActivatedStepSkill, OperationSkillSet, StepSkillDelta } from '../types';
describe('SkillResolver', () => {
const resolver = new SkillResolver();
const baseSkills = [
{
content: '<artifacts_guide>...</artifacts_guide>',
description: 'Generate artifacts',
identifier: 'artifacts',
name: 'Artifacts',
},
{
content: '<agent_browser_guides>...</agent_browser_guides>',
description: 'Browser automation',
identifier: 'agent-browser',
name: 'Agent Browser',
},
{
description: 'LobeHub management',
identifier: 'lobehub-cli',
name: 'LobeHub CLI',
},
];
const emptyDelta: StepSkillDelta = { activatedSkills: [] };
it('should mark skills as activated when their identifier is in enabledPluginIds', () => {
const operationSkillSet: OperationSkillSet = {
enabledPluginIds: ['artifacts'],
skills: baseSkills,
};
const resolved = resolver.resolve(operationSkillSet, emptyDelta);
const artifacts = resolved.enabledSkills.find((s) => s.identifier === 'artifacts');
expect(artifacts?.activated).toBe(true);
expect(artifacts?.content).toBe('<artifacts_guide>...</artifacts_guide>');
const browser = resolved.enabledSkills.find((s) => s.identifier === 'agent-browser');
expect(browser?.activated).toBeUndefined();
});
it('should include all skills in enabledSkills (activated and non-activated)', () => {
const operationSkillSet: OperationSkillSet = {
enabledPluginIds: [],
skills: baseSkills,
};
const resolved = resolver.resolve(operationSkillSet, emptyDelta);
expect(resolved.enabledSkills).toHaveLength(3);
});
it('should activate skills from step delta', () => {
const operationSkillSet: OperationSkillSet = {
enabledPluginIds: [],
skills: baseSkills,
};
const delta: StepSkillDelta = {
activatedSkills: [{ content: 'step-injected content', identifier: 'agent-browser' }],
};
const resolved = resolver.resolve(operationSkillSet, delta);
const browser = resolved.enabledSkills.find((s) => s.identifier === 'agent-browser');
expect(browser?.activated).toBe(true);
expect(browser?.content).toBe('step-injected content');
});
it('should activate skills from accumulated previous steps', () => {
const operationSkillSet: OperationSkillSet = {
enabledPluginIds: [],
skills: baseSkills,
};
const accumulated: ActivatedStepSkill[] = [
{ activatedAtStep: 1, content: 'accumulated content', identifier: 'lobehub-cli' },
];
const resolved = resolver.resolve(operationSkillSet, emptyDelta, accumulated);
const cli = resolved.enabledSkills.find((s) => s.identifier === 'lobehub-cli');
expect(cli?.activated).toBe(true);
expect(cli?.content).toBe('accumulated content');
});
it('should merge operation + accumulated + step delta activations', () => {
const operationSkillSet: OperationSkillSet = {
enabledPluginIds: ['artifacts'],
skills: baseSkills,
};
const delta: StepSkillDelta = {
activatedSkills: [{ identifier: 'agent-browser' }],
};
const accumulated: ActivatedStepSkill[] = [{ activatedAtStep: 0, identifier: 'lobehub-cli' }];
const resolved = resolver.resolve(operationSkillSet, delta, accumulated);
expect(resolved.enabledSkills.filter((s) => s.activated)).toHaveLength(3);
});
it('should let step delta content override original content', () => {
const operationSkillSet: OperationSkillSet = {
enabledPluginIds: ['artifacts'],
skills: baseSkills,
};
const delta: StepSkillDelta = {
activatedSkills: [{ content: 'overridden', identifier: 'artifacts' }],
};
const resolved = resolver.resolve(operationSkillSet, delta);
const artifacts = resolved.enabledSkills.find((s) => s.identifier === 'artifacts');
expect(artifacts?.activated).toBe(true);
expect(artifacts?.content).toBe('overridden');
});
it('should let accumulated content override original but step delta wins', () => {
const operationSkillSet: OperationSkillSet = {
enabledPluginIds: [],
skills: baseSkills,
};
const accumulated: ActivatedStepSkill[] = [
{ activatedAtStep: 0, content: 'from-accumulated', identifier: 'artifacts' },
];
const delta: StepSkillDelta = {
activatedSkills: [{ content: 'from-delta', identifier: 'artifacts' }],
};
const resolved = resolver.resolve(operationSkillSet, delta, accumulated);
const artifacts = resolved.enabledSkills.find((s) => s.identifier === 'artifacts');
expect(artifacts?.content).toBe('from-delta');
});
});
@@ -0,0 +1,17 @@
import type { StepSkillDelta } from './types';
export interface BuildStepSkillDeltaParams {
// Reserved for future step-level signals (e.g., @skill mentions)
}
/**
* Build a declarative StepSkillDelta from runtime signals.
*
* Currently returns an empty delta — step-level skill activations
* are accumulated in state.activatedStepSkills and passed directly
* to SkillResolver. This function exists as the extension point for
* future step-level signals (e.g., @skill mentions in user messages).
*/
export function buildStepSkillDelta(_params?: BuildStepSkillDeltaParams): StepSkillDelta {
return { activatedSkills: [] };
}
@@ -1 +1,11 @@
export { SkillEngine, type SkillEngineOptions } from './SkillEngine';
export { buildStepSkillDelta, type BuildStepSkillDeltaParams } from './buildStepSkillDelta';
export { SkillEngine } from './SkillEngine';
export { SkillResolver } from './SkillResolver';
export type {
ActivatedStepSkill,
OperationSkillSet,
ResolvedSkillSet,
SkillEnableChecker,
SkillEngineOptions,
StepSkillDelta,
} from './types';
@@ -0,0 +1,55 @@
import type { SkillMeta } from '../../providers/SkillContextProvider';
/**
* Application-layer checker that determines whether a skill is available
* in the current environment (e.g., desktop-only skills on web).
*/
export type SkillEnableChecker = (skill: SkillMeta) => boolean;
/**
* SkillEngine configuration options.
*/
export interface SkillEngineOptions {
/** Optional checker to filter skills by environment/platform */
enableChecker?: SkillEnableChecker;
/** All raw skills from all sources (builtin, DB, etc.) */
skills: SkillMeta[];
}
/**
* Operation-level skill set: determined at createOperation time, immutable during execution.
* Analogous to OperationToolSet for tools.
*/
export interface OperationSkillSet {
/** Plugin IDs enabled on this agent — skills matching these IDs are auto-activated */
enabledPluginIds: string[];
/** All available skills after enableChecker filtering */
skills: SkillMeta[];
}
/**
* Record of a skill activated at step level (e.g., via activateSkill tool call).
*/
export interface ActivatedStepSkill {
activatedAtStep: number;
content?: string;
identifier: string;
}
/**
* Declarative delta describing skill changes for a single step.
* Built by buildStepSkillDelta, consumed by SkillResolver.resolve.
*/
export interface StepSkillDelta {
activatedSkills: Array<{
content?: string;
identifier: string;
}>;
}
/**
* Final resolved skill set ready for SkillContextProvider consumption.
*/
export interface ResolvedSkillSet {
enabledSkills: SkillMeta[];
}
+1
View File
@@ -6,6 +6,7 @@ export { BaseFirstUserContentProvider } from './base/BaseFirstUserContentProvide
export { BaseLastUserContentProvider } from './base/BaseLastUserContentProvider';
export { BaseProcessor } from './base/BaseProcessor';
export { BaseProvider } from './base/BaseProvider';
export { BaseSystemRoleProvider } from './base/BaseSystemRoleProvider';
// Context Engine
export * from './engine';
@@ -1,314 +0,0 @@
import type {
AgentDocumentLoadRule,
AgentDocumentLoadRules,
} from '../../../database/src/models/agentDocuments';
import { matchesLoadRules } from '../../../database/src/models/agentDocuments';
import { BaseProvider } from '../base/BaseProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
declare module '../types' {
interface PipelineContextMetadataOverrides {
agentDocuments?: {
byPosition: Partial<Record<AgentDocumentInjectionPosition, number>>;
injectedCount: number;
policyIds: string[];
providedCount: number;
};
agentDocumentsCount?: number;
agentDocumentsInjected?: boolean;
}
}
export type { AgentDocumentLoadRule, AgentDocumentLoadRules };
export const AGENT_DOCUMENT_INJECTION_POSITIONS = [
'after-first-user',
'before-first-user',
'before-system',
'context-end',
'manual',
'on-demand',
'system-append',
'system-replace',
] as const;
export type AgentDocumentInjectionPosition = (typeof AGENT_DOCUMENT_INJECTION_POSITIONS)[number];
export type AgentDocumentLoadFormat = 'file' | 'raw';
export interface AgentContextDocument {
content?: string;
filename: string;
id?: string;
loadPosition?: AgentDocumentInjectionPosition;
loadRules?: AgentDocumentLoadRules;
policyId?: string | null;
policyLoadFormat?: AgentDocumentLoadFormat;
title?: string;
}
export interface AgentDocumentInjectorConfig {
currentTime?: Date;
currentUserMessage?: string;
documents?: AgentContextDocument[];
truncateContent?: (content: string, maxTokens: number) => string;
}
export class AgentDocumentInjector extends BaseProvider {
readonly name = 'AgentDocumentInjector';
constructor(
private config: AgentDocumentInjectorConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
const documents = this.config.documents || [];
if (documents.length === 0) {
return this.markAsExecuted(clonedContext);
}
const injectedCounts = new Map<AgentDocumentInjectionPosition, number>();
const documentsByPosition = this.groupByPosition(documents);
let injectedCount = 0;
for (const [position, docs] of documentsByPosition.entries()) {
const filteredDocs = this.filterByRules(docs);
if (filteredDocs.length === 0) continue;
switch (position) {
case 'before-system': {
this.injectBeforeSystem(clonedContext, filteredDocs);
break;
}
case 'system-append': {
this.appendToSystem(clonedContext, filteredDocs);
break;
}
case 'system-replace': {
this.replaceSystem(clonedContext, filteredDocs);
break;
}
case 'before-first-user': {
this.injectBeforeFirstUser(clonedContext, filteredDocs);
break;
}
case 'after-first-user': {
this.injectAfterFirstUser(clonedContext, filteredDocs);
break;
}
case 'context-end': {
this.injectAtEnd(clonedContext, filteredDocs);
break;
}
case 'manual':
case 'on-demand': {
continue;
}
}
injectedCount += filteredDocs.length;
injectedCounts.set(position, (injectedCounts.get(position) || 0) + filteredDocs.length);
}
if (injectedCount === 0) return this.markAsExecuted(clonedContext);
const policyIds = Array.from(
new Set(
documents.map((doc) => doc.policyId).filter((policyId): policyId is string => !!policyId),
),
);
clonedContext.metadata.agentDocumentsInjected = true;
clonedContext.metadata.agentDocumentsCount = injectedCount;
clonedContext.metadata.agentDocuments = {
byPosition: Object.fromEntries(injectedCounts.entries()),
injectedCount,
policyIds,
providedCount: documents.length,
};
return this.markAsExecuted(clonedContext);
}
private approximateTokenTruncate(content: string, maxTokens: number): string {
if (!Number.isFinite(maxTokens) || maxTokens <= 0) return content;
const parts = content.split(/\s+/);
if (parts.length <= maxTokens) return content;
return `${parts.slice(0, maxTokens).join(' ')}\n...[truncated]`;
}
private appendToSystem(context: PipelineContext, docs: AgentContextDocument[]): void {
const systemMessage = context.messages.find((m) => m.role === 'system');
if (systemMessage) {
const content = this.combineDocuments(docs);
systemMessage.content = `${systemMessage.content}\n\n${content}`;
} else {
this.injectBeforeSystem(context, docs);
}
}
private combineDocuments(docs: AgentContextDocument[]): string {
return docs.map((doc) => this.formatDocument(doc)).join('\n\n');
}
private filterByRules(docs: AgentContextDocument[]): AgentContextDocument[] {
return docs.filter((doc) => {
const context = {
currentTime: this.config.currentTime,
currentUserMessage: this.config.currentUserMessage,
};
return matchesLoadRules(doc, context);
});
}
private formatDocument(doc: AgentContextDocument): string {
const maxTokens = doc.loadRules?.maxTokens;
let content = doc.content || '';
if (maxTokens && maxTokens > 0) {
content = this.config.truncateContent
? this.config.truncateContent(content, maxTokens)
: this.approximateTokenTruncate(content, maxTokens);
}
if (doc.policyLoadFormat === 'file') {
const attributes = this.formatDocumentAttributes(doc);
return `<agent_document${attributes}>
${content}
</agent_document>`;
}
return content;
}
private formatDocumentAttributes(doc: AgentContextDocument): string {
const attrs: string[] = [];
if (doc.id) attrs.push(`id="${this.escapeAttribute(doc.id)}"`);
if (doc.filename) attrs.push(`filename="${this.escapeAttribute(doc.filename)}"`);
if (doc.title) attrs.push(`title="${this.escapeAttribute(doc.title)}"`);
return attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
}
private escapeAttribute(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
private getPosition(doc: AgentContextDocument): AgentDocumentInjectionPosition {
return doc.loadPosition || 'before-first-user';
}
private groupByPosition(
docs: AgentContextDocument[],
): Map<AgentDocumentInjectionPosition, AgentContextDocument[]> {
const grouped = new Map<AgentDocumentInjectionPosition, AgentContextDocument[]>();
for (const doc of docs) {
const position = this.getPosition(doc);
const existing = grouped.get(position) || [];
existing.push(doc);
grouped.set(position, existing);
}
for (const [position, groupDocs] of grouped.entries()) {
groupDocs.sort((a, b) => {
const aPriority = a.loadRules?.priority ?? 999;
const bPriority = b.loadRules?.priority ?? 999;
return aPriority - bPriority;
});
grouped.set(position, groupDocs);
}
return grouped;
}
private injectAfterFirstUser(context: PipelineContext, docs: AgentContextDocument[]): void {
const firstUserIndex = context.messages.findIndex((m) => m.role === 'user');
if (firstUserIndex === -1) return;
const content = this.combineDocuments(docs);
const now = Date.now();
const message = {
content,
createdAt: now,
id: `agent-doc-after-user-${now}`,
role: 'system' as const,
updatedAt: now,
};
context.messages.splice(firstUserIndex + 1, 0, message);
}
private injectAtEnd(context: PipelineContext, docs: AgentContextDocument[]): void {
const content = this.combineDocuments(docs);
const now = Date.now();
const message = {
content,
createdAt: now,
id: `agent-doc-context-end-${now}`,
role: 'system' as const,
updatedAt: now,
};
context.messages.push(message);
}
private injectBeforeFirstUser(context: PipelineContext, docs: AgentContextDocument[]): void {
const firstUserIndex = context.messages.findIndex((m) => m.role === 'user');
if (firstUserIndex === -1) return;
const content = this.combineDocuments(docs);
const now = Date.now();
const message = {
content,
createdAt: now,
id: `agent-doc-before-user-${now}`,
role: 'system' as const,
updatedAt: now,
};
context.messages.splice(firstUserIndex, 0, message);
}
private injectBeforeSystem(context: PipelineContext, docs: AgentContextDocument[]): void {
const content = this.combineDocuments(docs);
const now = Date.now();
const message = {
content,
createdAt: now,
id: `agent-doc-before-system-${now}`,
role: 'system' as const,
updatedAt: now,
};
context.messages.unshift(message);
}
private replaceSystem(context: PipelineContext, docs: AgentContextDocument[]): void {
const systemIndex = context.messages.findIndex((m) => m.role === 'system');
const content = this.combineDocuments(docs);
const now = Date.now();
const message = {
content,
createdAt: now,
id: `agent-doc-system-${now}`,
role: 'system' as const,
updatedAt: now,
};
if (systemIndex >= 0) {
context.messages[systemIndex] = message;
} else {
context.messages.unshift(message);
}
}
}
@@ -0,0 +1,57 @@
import debug from 'debug';
import { BaseProcessor } from '../../base/BaseProcessor';
import type { PipelineContext, ProcessorOptions } from '../../types';
import type { AgentContextDocument, AgentDocumentFilterContext } from './shared';
import { combineDocuments, getDocumentsForPositions } from './shared';
const log = debug('context-engine:provider:AgentDocumentBeforeSystemInjector');
export interface AgentDocumentBeforeSystemInjectorConfig extends AgentDocumentFilterContext {
documents?: AgentContextDocument[];
enabled?: boolean;
}
/**
* Injects agent documents BEFORE the system message (prepend).
* Handles `before-system` position.
*
* Placed at the very beginning of Phase 2, before SystemRoleInjector.
*/
export class AgentDocumentBeforeSystemInjector extends BaseProcessor {
readonly name = 'AgentDocumentBeforeSystemInjector';
constructor(
private config: AgentDocumentBeforeSystemInjectorConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
if (this.config.enabled === false) return this.markAsExecuted(context);
const docs = getDocumentsForPositions(
this.config.documents || [],
['before-system'],
this.config,
);
if (docs.length === 0) return this.markAsExecuted(context);
const clonedContext = this.cloneContext(context);
const content = combineDocuments(docs, this.config);
const now = Date.now();
clonedContext.messages.unshift({
content,
createdAt: now,
id: `agent-doc-before-system-${now}`,
role: 'system' as const,
updatedAt: now,
} as any);
log('Prepended %d agent documents before system message', docs.length);
return this.markAsExecuted(clonedContext);
}
}
@@ -0,0 +1,45 @@
import debug from 'debug';
import { BaseFirstUserContentProvider } from '../../base/BaseFirstUserContentProvider';
import type { PipelineContext, ProcessorOptions } from '../../types';
import type { AgentContextDocument, AgentDocumentFilterContext } from './shared';
import { combineDocuments, getDocumentsForPositions } from './shared';
const log = debug('context-engine:provider:AgentDocumentContextInjector');
export interface AgentDocumentContextInjectorConfig extends AgentDocumentFilterContext {
documents?: AgentContextDocument[];
enabled?: boolean;
}
/**
* Injects agent documents before the first user message.
* Handles `before-first-user` position.
*
* Placed in Phase 3 (Context Injection).
*/
export class AgentDocumentContextInjector extends BaseFirstUserContentProvider {
readonly name = 'AgentDocumentContextInjector';
constructor(
private config: AgentDocumentContextInjectorConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected buildContent(_context: PipelineContext): string | null {
if (this.config.enabled === false) return null;
const docs = getDocumentsForPositions(
this.config.documents || [],
['before-first-user'],
this.config,
);
if (docs.length === 0) return null;
log('Injecting %d agent documents before first user message', docs.length);
return combineDocuments(docs, this.config);
}
}
@@ -0,0 +1,79 @@
import debug from 'debug';
import { BaseProcessor } from '../../base/BaseProcessor';
import type { PipelineContext, ProcessorOptions } from '../../types';
import type { AgentContextDocument, AgentDocumentFilterContext } from './shared';
import { combineDocuments, getDocumentsForPositions } from './shared';
const log = debug('context-engine:provider:AgentDocumentMessageInjector');
export interface AgentDocumentMessageInjectorConfig extends AgentDocumentFilterContext {
documents?: AgentContextDocument[];
enabled?: boolean;
}
/**
* Injects agent documents at specific message positions.
* Handles `after-first-user` and `context-end` positions.
*
* Placed in Phase 4 (User Message Augmentation).
*/
export class AgentDocumentMessageInjector extends BaseProcessor {
readonly name = 'AgentDocumentMessageInjector';
constructor(
private config: AgentDocumentMessageInjectorConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
if (this.config.enabled === false) return this.markAsExecuted(context);
const allDocs = this.config.documents || [];
if (allDocs.length === 0) return this.markAsExecuted(context);
const afterFirstUserDocs = getDocumentsForPositions(allDocs, ['after-first-user'], this.config);
const contextEndDocs = getDocumentsForPositions(allDocs, ['context-end'], this.config);
if (afterFirstUserDocs.length === 0 && contextEndDocs.length === 0) {
return this.markAsExecuted(context);
}
const clonedContext = this.cloneContext(context);
// Inject after first user message
if (afterFirstUserDocs.length > 0) {
const firstUserIndex = clonedContext.messages.findIndex((m) => m.role === 'user');
if (firstUserIndex !== -1) {
const content = combineDocuments(afterFirstUserDocs, this.config);
const now = Date.now();
clonedContext.messages.splice(firstUserIndex + 1, 0, {
content,
createdAt: now,
id: `agent-doc-after-user-${now}`,
role: 'system' as const,
updatedAt: now,
} as any);
log('Injected %d agent documents after first user message', afterFirstUserDocs.length);
}
}
// Inject at context end
if (contextEndDocs.length > 0) {
const content = combineDocuments(contextEndDocs, this.config);
const now = Date.now();
clonedContext.messages.push({
content,
createdAt: now,
id: `agent-doc-context-end-${now}`,
role: 'system' as const,
updatedAt: now,
} as any);
log('Injected %d agent documents at context end', contextEndDocs.length);
}
return this.markAsExecuted(clonedContext);
}
}
@@ -0,0 +1,45 @@
import debug from 'debug';
import { BaseSystemRoleProvider } from '../../base/BaseSystemRoleProvider';
import type { PipelineContext, ProcessorOptions } from '../../types';
import type { AgentContextDocument, AgentDocumentFilterContext } from './shared';
import { combineDocuments, getDocumentsForPositions } from './shared';
const log = debug('context-engine:provider:AgentDocumentSystemAppendInjector');
export interface AgentDocumentSystemAppendInjectorConfig extends AgentDocumentFilterContext {
documents?: AgentContextDocument[];
enabled?: boolean;
}
/**
* Appends agent documents to the end of the system message.
* Handles `system-append` position.
*
* Placed at the end of Phase 2, after all other system role providers.
*/
export class AgentDocumentSystemAppendInjector extends BaseSystemRoleProvider {
readonly name = 'AgentDocumentSystemAppendInjector';
constructor(
private config: AgentDocumentSystemAppendInjectorConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected buildSystemRoleContent(_context: PipelineContext): string | null {
if (this.config.enabled === false) return null;
const docs = getDocumentsForPositions(
this.config.documents || [],
['system-append'],
this.config,
);
if (docs.length === 0) return null;
log('Appending %d agent documents to system message', docs.length);
return combineDocuments(docs, this.config);
}
}
@@ -0,0 +1,64 @@
import debug from 'debug';
import { BaseProcessor } from '../../base/BaseProcessor';
import type { PipelineContext, ProcessorOptions } from '../../types';
import type { AgentContextDocument, AgentDocumentFilterContext } from './shared';
import { combineDocuments, getDocumentsForPositions } from './shared';
const log = debug('context-engine:provider:AgentDocumentSystemReplaceInjector');
export interface AgentDocumentSystemReplaceInjectorConfig extends AgentDocumentFilterContext {
documents?: AgentContextDocument[];
enabled?: boolean;
}
/**
* Replaces the entire system message with agent document content.
* Handles `system-replace` position.
*
* Placed at the end of Phase 2, after SystemAppendInjector.
* When triggered, discards any previously assembled system message.
*/
export class AgentDocumentSystemReplaceInjector extends BaseProcessor {
readonly name = 'AgentDocumentSystemReplaceInjector';
constructor(
private config: AgentDocumentSystemReplaceInjectorConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
if (this.config.enabled === false) return this.markAsExecuted(context);
const docs = getDocumentsForPositions(
this.config.documents || [],
['system-replace'],
this.config,
);
if (docs.length === 0) return this.markAsExecuted(context);
const clonedContext = this.cloneContext(context);
const content = combineDocuments(docs, this.config);
const now = Date.now();
const message = {
content,
createdAt: now,
id: `agent-doc-system-replace-${now}`,
role: 'system' as const,
updatedAt: now,
};
const systemIndex = clonedContext.messages.findIndex((m) => m.role === 'system');
if (systemIndex >= 0) {
clonedContext.messages[systemIndex] = message as any;
} else {
clonedContext.messages.unshift(message as any);
}
log('Replaced system message with %d agent documents', docs.length);
return this.markAsExecuted(clonedContext);
}
}
@@ -0,0 +1,24 @@
export type { AgentDocumentBeforeSystemInjectorConfig } from './BeforeSystemInjector';
export { AgentDocumentBeforeSystemInjector } from './BeforeSystemInjector';
export type { AgentDocumentContextInjectorConfig } from './ContextInjector';
export { AgentDocumentContextInjector } from './ContextInjector';
export type { AgentDocumentMessageInjectorConfig } from './MessageInjector';
export { AgentDocumentMessageInjector } from './MessageInjector';
export {
AGENT_DOCUMENT_INJECTION_POSITIONS,
type AgentContextDocument,
type AgentDocumentFilterContext,
type AgentDocumentInjectionPosition,
type AgentDocumentLoadFormat,
type AgentDocumentLoadRule,
type AgentDocumentLoadRules,
combineDocuments,
filterDocumentsByRules,
formatDocument,
getDocumentsForPositions,
sortByPriority,
} from './shared';
export type { AgentDocumentSystemAppendInjectorConfig } from './SystemAppendInjector';
export { AgentDocumentSystemAppendInjector } from './SystemAppendInjector';
export type { AgentDocumentSystemReplaceInjectorConfig } from './SystemReplaceInjector';
export { AgentDocumentSystemReplaceInjector } from './SystemReplaceInjector';
@@ -0,0 +1,137 @@
import type {
AgentDocumentLoadRule,
AgentDocumentLoadRules,
} from '../../../../database/src/models/agentDocuments';
import { matchesLoadRules } from '../../../../database/src/models/agentDocuments';
export type { AgentDocumentLoadRule, AgentDocumentLoadRules };
export const AGENT_DOCUMENT_INJECTION_POSITIONS = [
'after-first-user',
'before-first-user',
'before-system',
'context-end',
'manual',
'on-demand',
'system-append',
'system-replace',
] as const;
export type AgentDocumentInjectionPosition = (typeof AGENT_DOCUMENT_INJECTION_POSITIONS)[number];
export type AgentDocumentLoadFormat = 'file' | 'raw';
export interface AgentContextDocument {
content?: string;
filename: string;
id?: string;
loadPosition?: AgentDocumentInjectionPosition;
loadRules?: AgentDocumentLoadRules;
policyId?: string | null;
policyLoadFormat?: AgentDocumentLoadFormat;
title?: string;
}
export interface AgentDocumentFilterContext {
currentTime?: Date;
currentUserMessage?: string;
truncateContent?: (content: string, maxTokens: number) => string;
}
/**
* Filter documents by load rules (always, by-keywords, by-regexp, by-time-range)
*/
export function filterDocumentsByRules(
docs: AgentContextDocument[],
context: AgentDocumentFilterContext,
): AgentContextDocument[] {
return docs.filter((doc) =>
matchesLoadRules(doc, {
currentTime: context.currentTime,
currentUserMessage: context.currentUserMessage,
}),
);
}
/**
* Sort documents by priority (lower number = higher priority)
*/
export function sortByPriority(docs: AgentContextDocument[]): AgentContextDocument[] {
return [...docs].sort((a, b) => {
const aPriority = a.loadRules?.priority ?? 999;
const bPriority = b.loadRules?.priority ?? 999;
return aPriority - bPriority;
});
}
/**
* Get documents for specific positions, filtered and sorted
*/
export function getDocumentsForPositions(
allDocuments: AgentContextDocument[],
positions: AgentDocumentInjectionPosition[],
context: AgentDocumentFilterContext,
): AgentContextDocument[] {
const positionSet = new Set(positions);
const docs = allDocuments.filter((doc) =>
positionSet.has(doc.loadPosition || 'before-first-user'),
);
const filtered = filterDocumentsByRules(docs, context);
return sortByPriority(filtered);
}
/**
* Format a single document for injection
*/
export function formatDocument(
doc: AgentContextDocument,
context: AgentDocumentFilterContext,
): string {
const maxTokens = doc.loadRules?.maxTokens;
let content = doc.content || '';
if (maxTokens && maxTokens > 0) {
content = context.truncateContent
? context.truncateContent(content, maxTokens)
: approximateTokenTruncate(content, maxTokens);
}
if (doc.policyLoadFormat === 'file') {
const attributes = formatDocumentAttributes(doc);
return `<agent_document${attributes}>\n${content}\n</agent_document>`;
}
return content;
}
/**
* Combine multiple documents into a single string
*/
export function combineDocuments(
docs: AgentContextDocument[],
context: AgentDocumentFilterContext,
): string {
return docs.map((doc) => formatDocument(doc, context)).join('\n\n');
}
function approximateTokenTruncate(content: string, maxTokens: number): string {
if (!Number.isFinite(maxTokens) || maxTokens <= 0) return content;
const parts = content.split(/\s+/);
if (parts.length <= maxTokens) return content;
return `${parts.slice(0, maxTokens).join(' ')}\n...[truncated]`;
}
function escapeAttribute(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
function formatDocumentAttributes(doc: AgentContextDocument): string {
const attrs: string[] = [];
if (doc.id) attrs.push(`id="${escapeAttribute(doc.id)}"`);
if (doc.filename) attrs.push(`filename="${escapeAttribute(doc.filename)}"`);
if (doc.title) attrs.push(`title="${escapeAttribute(doc.title)}"`);
return attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
}
@@ -2,7 +2,7 @@ import type { BotPlatformInfo } from '@lobechat/prompts';
import { formatBotPlatformContext } from '@lobechat/prompts';
import debug from 'debug';
import { BaseProvider } from '../base/BaseProvider';
import { BaseSystemRoleProvider } from '../base/BaseSystemRoleProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:provider:BotPlatformContextInjector');
@@ -26,7 +26,7 @@ export interface BotPlatformContextInjectorConfig {
*
* Should run after SystemRoleInjector in the pipeline.
*/
export class BotPlatformContextInjector extends BaseProvider {
export class BotPlatformContextInjector extends BaseSystemRoleProvider {
readonly name = 'BotPlatformContextInjector';
constructor(
@@ -36,41 +36,13 @@ export class BotPlatformContextInjector extends BaseProvider {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
protected buildSystemRoleContent(_context: PipelineContext): string | null {
if (!this.config.enabled || !this.config.context) {
log('Disabled or no context, skipping injection');
return this.markAsExecuted(context);
return null;
}
const info: BotPlatformInfo = this.config.context;
const prompt = formatBotPlatformContext(info);
if (!prompt) {
log('Platform supports markdown, no injection needed');
return this.markAsExecuted(context);
}
const clonedContext = this.cloneContext(context);
const systemMsgIndex = clonedContext.messages.findIndex((m) => m.role === 'system');
if (systemMsgIndex >= 0) {
const original = clonedContext.messages[systemMsgIndex];
clonedContext.messages[systemMsgIndex] = {
...original,
content: [original.content, prompt].filter(Boolean).join('\n\n'),
};
log('Appended bot platform context to existing system message');
} else {
clonedContext.messages.unshift({
content: prompt,
createdAt: Date.now(),
id: `bot-platform-context-${Date.now()}`,
role: 'system' as const,
updatedAt: Date.now(),
});
log('Created new system message with bot platform context');
}
return this.markAsExecuted(clonedContext);
return formatBotPlatformContext(info) || null;
}
}
@@ -1,6 +1,6 @@
import debug from 'debug';
import { BaseProvider } from '../base/BaseProvider';
import { BaseSystemRoleProvider } from '../base/BaseSystemRoleProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
declare module '../types' {
@@ -22,11 +22,10 @@ export interface EvalContextSystemInjectorConfig {
/**
* Eval Context Injector
* Appends eval environment prompt to the existing system message,
* or creates a new system message if none exists.
* Appends eval environment prompt to the system message.
* Should run after SystemRoleInjector in the pipeline.
*/
export class EvalContextSystemInjector extends BaseProvider {
export class EvalContextSystemInjector extends BaseSystemRoleProvider {
readonly name = 'EvalContextSystemInjector';
constructor(
@@ -36,35 +35,16 @@ export class EvalContextSystemInjector extends BaseProvider {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
protected buildSystemRoleContent(_context: PipelineContext): string | null {
if (!this.config.enabled || !this.config.evalContext?.envPrompt) {
log('Disabled or no envPrompt configured, skipping injection');
return this.markAsExecuted(context);
return null;
}
const clonedContext = this.cloneContext(context);
const systemMsgIndex = clonedContext.messages.findIndex((m) => m.role === 'system');
return this.config.evalContext.envPrompt;
}
if (systemMsgIndex >= 0) {
const original = clonedContext.messages[systemMsgIndex];
clonedContext.messages[systemMsgIndex] = {
...original,
content: [original.content, this.config.evalContext.envPrompt].filter(Boolean).join('\n\n'),
};
log('Appended envPrompt to existing system message');
} else {
clonedContext.messages.unshift({
content: this.config.evalContext.envPrompt,
createdAt: Date.now(),
id: `eval-context-${Date.now()}`,
role: 'system' as const,
updatedAt: Date.now(),
});
log('Created new system message with envPrompt');
}
clonedContext.metadata.evalContextInjected = true;
return this.markAsExecuted(clonedContext);
protected onInjected(context: PipelineContext): void {
context.metadata.evalContextInjected = true;
}
}
@@ -1,6 +1,6 @@
import debug from 'debug';
import { BaseProvider } from '../base/BaseProvider';
import { BaseSystemRoleProvider } from '../base/BaseSystemRoleProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
declare module '../types' {
@@ -37,7 +37,7 @@ const defaultHistorySummaryFormatter = (historySummary: string): string => `<cha
* History Summary Provider
* Responsible for injecting history conversation summary into system messages
*/
export class HistorySummaryProvider extends BaseProvider {
export class HistorySummaryProvider extends BaseSystemRoleProvider {
readonly name = 'HistorySummaryProvider';
constructor(
@@ -47,66 +47,21 @@ export class HistorySummaryProvider extends BaseProvider {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
// Check if history summary exists
protected buildSystemRoleContent(_context: PipelineContext): string | null {
if (!this.config.historySummary) {
log('No history summary content, skipping processing');
return this.markAsExecuted(clonedContext);
return null;
}
// Format history summary
const formattedSummary = this.formatHistorySummary(this.config.historySummary);
// Inject history summary
this.injectHistorySummary(clonedContext, formattedSummary);
// Update metadata
clonedContext.metadata.historySummary = {
formattedLength: formattedSummary.length,
injected: true,
originalLength: this.config.historySummary.length,
};
log(
`History summary injection completed, original length: ${this.config.historySummary.length}, formatted length: ${formattedSummary.length}`,
);
return this.markAsExecuted(clonedContext);
}
/**
* Format history summary
*/
private formatHistorySummary(historySummary: string): string {
const formatter = this.config.formatHistorySummary || defaultHistorySummaryFormatter;
return formatter(historySummary);
return formatter(this.config.historySummary);
}
/**
* Inject history summary to system message
*/
private injectHistorySummary(context: PipelineContext, formattedSummary: string): void {
const existingSystemMessage = context.messages.find((msg) => msg.role === 'system');
if (existingSystemMessage) {
// Merge to existing system message
existingSystemMessage.content = [existingSystemMessage.content, formattedSummary]
.filter(Boolean)
.join('\n\n');
log(
`History summary merged to existing system message, final length: ${existingSystemMessage.content.length}`,
);
} else {
// Create new system message
const systemMessage = {
content: formattedSummary,
role: 'system' as const,
};
context.messages.unshift(systemMessage as any);
log(`New history summary system message created, content length: ${formattedSummary.length}`);
}
protected onInjected(context: PipelineContext, content: string): void {
context.metadata.historySummary = {
formattedLength: content.length,
injected: true,
originalLength: this.config.historySummary!.length,
};
}
}
@@ -17,6 +17,7 @@ declare module '../types' {
const log = debug('context-engine:provider:SelectedSkillInjector');
export interface SelectedSkillInjectorConfig {
enabled?: boolean;
selectedSkills?: RuntimeSelectedSkill[];
}
@@ -51,6 +52,8 @@ export class SelectedSkillInjector extends BaseLastUserContentProvider {
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
if (this.config.enabled === false) return this.markAsExecuted(context);
const clonedContext = this.cloneContext(context);
const selectedSkills = this.config.selectedSkills ?? [];
@@ -1,7 +1,7 @@
import { type SkillItem, skillsPrompts } from '@lobechat/prompts';
import debug from 'debug';
import { BaseProvider } from '../base/BaseProvider';
import { BaseSystemRoleProvider } from '../base/BaseSystemRoleProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
declare module '../types' {
@@ -40,7 +40,8 @@ export interface SkillMeta {
* Skill Context Provider Configuration
*/
export interface SkillContextProviderConfig {
enabledSkills: SkillMeta[];
enabled?: boolean;
enabledSkills?: SkillMeta[];
}
/**
@@ -48,7 +49,7 @@ export interface SkillContextProviderConfig {
* Injects lightweight skill metadata into the system prompt so the LLM knows
* which skills are available and can invoke them via `runSkill`.
*/
export class SkillContextProvider extends BaseProvider {
export class SkillContextProvider extends BaseSystemRoleProvider {
readonly name = 'SkillContextProvider';
constructor(
@@ -58,14 +59,14 @@ export class SkillContextProvider extends BaseProvider {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
protected buildSystemRoleContent(_context: PipelineContext): string | null {
if (this.config.enabled === false) return null;
const { enabledSkills } = this.config;
if (!enabledSkills || enabledSkills.length === 0) {
log('No enabled skills, skipping injection');
return this.markAsExecuted(clonedContext);
return null;
}
// Separate activated skills (inject content directly) from available skills (list only)
@@ -97,45 +98,21 @@ export class SkillContextProvider extends BaseProvider {
if (contentParts.length === 0) {
log('No skill content generated, skipping injection');
return this.markAsExecuted(clonedContext);
return null;
}
this.injectSkillContext(clonedContext, contentParts.join('\n\n'));
clonedContext.metadata.skillContext = {
injected: true,
skillsCount: enabledSkills.length,
};
log(
'Skill context injected: %d activated, %d available',
'Skill context prepared: %d activated, %d available',
activatedSkills.length,
availableSkills.length,
);
return this.markAsExecuted(clonedContext);
return contentParts.join('\n\n');
}
/**
* Inject skill context into the system message
*/
private injectSkillContext(context: PipelineContext, skillContent: string): void {
const existingSystemMessage = context.messages.find((msg) => msg.role === 'system');
if (existingSystemMessage) {
existingSystemMessage.content = [existingSystemMessage.content, skillContent]
.filter(Boolean)
.join('\n\n');
log(
`Skill context merged to existing system message, final length: ${existingSystemMessage.content.length}`,
);
} else {
context.messages.unshift({
content: skillContent,
id: `skill-context-${Date.now()}`,
role: 'system' as const,
} as any);
log(`New skill system message created, content length: ${skillContent.length}`);
}
protected onInjected(context: PipelineContext): void {
context.metadata.skillContext = {
injected: true,
skillsCount: this.config.enabledSkills?.length ?? 0,
};
}
}
@@ -1,6 +1,6 @@
import debug from 'debug';
import { BaseProvider } from '../base/BaseProvider';
import { BaseSystemRoleProvider } from '../base/BaseSystemRoleProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
declare module '../types' {
@@ -16,7 +16,7 @@ export interface SystemDateProviderConfig {
timezone?: string | null;
}
export class SystemDateProvider extends BaseProvider {
export class SystemDateProvider extends BaseSystemRoleProvider {
readonly name = 'SystemDateProvider';
constructor(
@@ -26,12 +26,10 @@ export class SystemDateProvider extends BaseProvider {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
protected buildSystemRoleContent(_context: PipelineContext): string | null {
if (this.config.enabled === false) {
log('System date injection disabled, skipping');
return this.markAsExecuted(clonedContext);
return null;
}
const tz = this.config.timezone || 'UTC';
@@ -42,28 +40,11 @@ export class SystemDateProvider extends BaseProvider {
const day = today.toLocaleString('en-US', { day: '2-digit', timeZone: tz });
const dateStr = `${year}-${month}-${day}`;
const dateContent = `Current date: ${dateStr} (${tz})`;
const existingSystemMessage = clonedContext.messages.find((msg) => msg.role === 'system');
if (existingSystemMessage) {
existingSystemMessage.content = [existingSystemMessage.content, dateContent]
.filter(Boolean)
.join('\n\n');
} else {
clonedContext.messages.unshift({
content: dateContent,
createdAt: Date.now(),
id: `system-date-${Date.now()}`,
role: 'system' as const,
updatedAt: Date.now(),
} as any);
}
clonedContext.metadata.systemDateInjected = true;
log('System date injected: %s', dateStr);
return `Current date: ${dateStr} (${tz})`;
}
return this.markAsExecuted(clonedContext);
protected onInjected(context: PipelineContext): void {
context.metadata.systemDateInjected = true;
}
}
@@ -22,7 +22,8 @@ export interface ToolDiscoveryMeta {
}
export interface ToolDiscoveryProviderConfig {
availableTools: ToolDiscoveryMeta[];
availableTools?: ToolDiscoveryMeta[];
enabled?: boolean;
}
export class ToolDiscoveryProvider extends BaseFirstUserContentProvider {
@@ -36,6 +37,8 @@ export class ToolDiscoveryProvider extends BaseFirstUserContentProvider {
}
protected buildContent(_context: PipelineContext): string | null {
if (this.config.enabled === false) return null;
const { availableTools } = this.config;
if (!availableTools || availableTools.length === 0) {
@@ -2,7 +2,7 @@ import type { API, Tool } from '@lobechat/prompts';
import { pluginPrompts } from '@lobechat/prompts';
import debug from 'debug';
import { BaseProvider } from '../base/BaseProvider';
import { BaseSystemRoleProvider } from '../base/BaseSystemRoleProvider';
import { ToolNameResolver } from '../engine/tools';
import type { LobeToolManifest } from '../engine/tools/types';
import type { PipelineContext, ProcessorOptions } from '../types';
@@ -24,10 +24,11 @@ const log = debug('context-engine:provider:ToolSystemRoleProvider');
* Tool System Role Configuration
*/
export interface ToolSystemRoleConfig {
enabled?: boolean;
/** Function to check if function calling is supported */
isCanUseFC: (model: string, provider: string) => boolean | undefined;
/** Tool manifests with systemRole and API definitions */
manifests: LobeToolManifest[];
manifests?: LobeToolManifest[];
/** Model name */
model: string;
/** Provider name */
@@ -38,7 +39,7 @@ export interface ToolSystemRoleConfig {
* Tool System Role Provider
* Responsible for injecting tool-related system roles for models that support tool calling
*/
export class ToolSystemRoleProvider extends BaseProvider {
export class ToolSystemRoleProvider extends BaseSystemRoleProvider {
readonly name = 'ToolSystemRoleProvider';
private toolNameResolver: ToolNameResolver;
@@ -51,30 +52,27 @@ export class ToolSystemRoleProvider extends BaseProvider {
this.toolNameResolver = new ToolNameResolver();
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
protected buildSystemRoleContent(_context: PipelineContext): string | null {
if (this.config.enabled === false) return null;
// Check tool-related conditions
const toolSystemRole = this.getToolSystemRole();
if (!toolSystemRole) {
log('No need to inject tool system role, skipping processing');
return this.markAsExecuted(clonedContext);
return null;
}
// Inject tool system role
this.injectToolSystemRole(clonedContext, toolSystemRole);
log(`Tool system role injection completed, tools count: ${this.config.manifests?.length ?? 0}`);
return toolSystemRole;
}
// Update metadata
clonedContext.metadata.toolSystemRole = {
contentLength: toolSystemRole.length,
protected onInjected(context: PipelineContext, content: string): void {
context.metadata.toolSystemRole = {
contentLength: content.length,
injected: true,
supportsFunctionCall: !!this.config.isCanUseFC(this.config.model, this.config.provider),
toolsCount: this.config.manifests.length,
toolsCount: this.config.manifests?.length ?? 0,
};
log(`Tool system role injection completed, tools count: ${this.config.manifests.length}`);
return this.markAsExecuted(clonedContext);
}
/**
@@ -83,21 +81,17 @@ export class ToolSystemRoleProvider extends BaseProvider {
private getToolSystemRole(): string | undefined {
const { manifests, model, provider } = this.config;
// Check if manifests are available
if (!manifests || manifests.length === 0) {
log('No available tool manifests');
return undefined;
}
// Check if function calling is supported
const hasFC = this.config.isCanUseFC(model, provider);
if (!hasFC) {
log(`Model ${model} (${provider}) does not support function calling`);
return undefined;
}
// Transform manifests to Tool[] format for pluginPrompts
// Only include manifests that have APIs or systemRole
const tools: Tool[] = manifests
.filter((manifest) => manifest.api.length > 0 || manifest.systemRole)
.map((manifest) => ({
@@ -113,13 +107,11 @@ export class ToolSystemRoleProvider extends BaseProvider {
systemRole: manifest.systemRole,
}));
// Skip if no meaningful tools after filtering
if (tools.length === 0) {
log('No meaningful tools to inject (all manifests have empty APIs and no systemRole)');
return undefined;
}
// Generate tool system role using pluginPrompts
const toolSystemRole = pluginPrompts({ tools });
if (!toolSystemRole) {
@@ -130,29 +122,4 @@ export class ToolSystemRoleProvider extends BaseProvider {
log(`Generated tool system role for ${manifests.length} tools`);
return toolSystemRole;
}
/**
* Inject tool system role
*/
private injectToolSystemRole(context: PipelineContext, toolSystemRole: string): void {
const existingSystemMessage = context.messages.find((msg) => msg.role === 'system');
if (existingSystemMessage) {
// Merge to existing system message
existingSystemMessage.content = [existingSystemMessage.content, toolSystemRole]
.filter(Boolean)
.join('\n\n');
log(
`Tool system role merged to existing system message, final length: ${existingSystemMessage.content.length}`,
);
} else {
context.messages.unshift({
content: toolSystemRole,
id: `tool-system-role-${Date.now()}`,
role: 'system' as const,
} as any);
log(`New tool system message created, content length: ${toolSystemRole.length}`);
}
}
}
@@ -14,6 +14,7 @@ declare module '../types' {
const log = debug('context-engine:provider:UserMemoryInjector');
export interface UserMemoryInjectorConfig {
enabled?: boolean;
/** User memories data */
memories?: UserMemoryData;
}
@@ -38,6 +39,8 @@ export class UserMemoryInjector extends BaseFirstUserContentProvider {
}
protected buildContent(_context: PipelineContext): string | null {
if (this.config.enabled === false) return null;
const { memories } = this.config;
if (!memories) return null;
@@ -1,7 +1,13 @@
import { describe, expect, it } from 'vitest';
import type { PipelineContext } from '../../types';
import { AgentDocumentInjector } from '../AgentDocumentInjector';
import {
AgentDocumentBeforeSystemInjector,
AgentDocumentContextInjector,
AgentDocumentMessageInjector,
AgentDocumentSystemAppendInjector,
AgentDocumentSystemReplaceInjector,
} from '../AgentDocumentInjector';
describe('AgentDocumentInjector', () => {
const createContext = (messages: any[] = []): PipelineContext => ({
@@ -18,172 +24,281 @@ describe('AgentDocumentInjector', () => {
},
});
it('should inject generic documents by load position and set metadata', async () => {
const provider = new AgentDocumentInjector({
documents: [
{
content: 'Core runtime guardrails',
filename: 'guardrails.md',
loadPosition: 'before-first-user',
loadRules: { priority: 1, rule: 'always' },
policyId: 'claw',
},
{
content: 'Session summary memo',
filename: 'summary.md',
loadPosition: 'context-end',
loadRules: { rule: 'always' },
policyId: 'custom',
},
],
});
const context = createContext([
{ content: 'System prompt', id: 'sys-1', role: 'system' },
{ content: 'Hello', id: 'user-1', role: 'user' },
]);
const result = await provider.process(context);
expect(result.messages).toHaveLength(4);
expect(result.messages[1].role).toBe('system');
expect(result.messages[1].content).toContain('Core runtime guardrails');
expect(result.messages[3].role).toBe('system');
expect(result.messages[3].content).toContain('Session summary memo');
expect(result.metadata.agentDocumentsInjected).toBe(true);
expect(result.metadata.agentDocumentsCount).toBe(2);
expect(result.metadata.agentDocuments).toMatchObject({
policyIds: ['claw', 'custom'],
});
});
it('should not inject document when by-keywords rule does not match', async () => {
const provider = new AgentDocumentInjector({
currentUserMessage: 'Please focus on tomorrow action items',
documents: [
{
content: 'Only show for release keyword',
filename: 'todo.md',
loadRules: { keywords: ['release'], rule: 'by-keywords' },
},
],
});
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
expect(result.metadata.agentDocumentsInjected).toBeUndefined();
expect(result.metadata.agentDocumentsCount).toBeUndefined();
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content).toBe('Hello');
});
it('should keep raw format unwrapped by default', async () => {
const provider = new AgentDocumentInjector({
documents: [
{
content: 'Direct instruction content',
filename: 'instruction.md',
loadPosition: 'before-first-user',
loadRules: { rule: 'always' },
},
],
});
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
expect(result.messages[0].content).toContain('Direct instruction content');
expect(result.messages[0].content).not.toContain('<agent_document');
});
it('should inject document when by-keywords rule matches', async () => {
const provider = new AgentDocumentInjector({
currentUserMessage: 'Please draft the launch checklist for next week',
documents: [
{
content: 'Checklist template',
filename: 'checklist.md',
loadRules: {
keywords: ['checklist', 'launch'],
keywordMatchMode: 'all',
rule: 'by-keywords',
describe('AgentDocumentContextInjector (before-first-user)', () => {
it('should inject documents before first user message', async () => {
const provider = new AgentDocumentContextInjector({
documents: [
{
content: 'Core runtime guardrails',
filename: 'guardrails.md',
loadPosition: 'before-first-user',
loadRules: { priority: 1, rule: 'always' },
policyId: 'claw',
},
},
],
],
});
const context = createContext([
{ content: 'System prompt', id: 'sys-1', role: 'system' },
{ content: 'Hello', id: 'user-1', role: 'user' },
]);
const result = await provider.process(context);
expect(result.messages).toHaveLength(3);
expect(result.messages[1].content).toContain('Core runtime guardrails');
});
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
expect(result.metadata.agentDocumentsInjected).toBe(true);
expect(result.messages[0].content).toContain('Checklist template');
});
it('should inject document when by-regexp rule matches', async () => {
const provider = new AgentDocumentInjector({
currentUserMessage: 'Need TODO items for this sprint',
documents: [
{
content: 'Sprint TODO policy',
filename: 'todo.md',
loadRules: { regexp: '\\btodo\\b', rule: 'by-regexp' },
},
],
});
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
expect(result.metadata.agentDocumentsInjected).toBe(true);
expect(result.messages[0].content).toContain('Sprint TODO policy');
});
it('should inject document only inside by-time-range window', async () => {
const provider = new AgentDocumentInjector({
currentTime: new Date('2026-03-13T12:00:00.000Z'),
documents: [
{
content: 'Noon policy',
filename: 'noon.md',
loadRules: {
rule: 'by-time-range',
timeRange: { from: '2026-03-13T11:00:00.000Z', to: '2026-03-13T13:00:00.000Z' },
it('should not inject document when by-keywords rule does not match', async () => {
const provider = new AgentDocumentContextInjector({
currentUserMessage: 'Please focus on tomorrow action items',
documents: [
{
content: 'Only show for release keyword',
filename: 'todo.md',
loadRules: { keywords: ['release'], rule: 'by-keywords' },
},
},
],
],
});
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content).toBe('Hello');
});
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
it('should keep raw format unwrapped by default', async () => {
const provider = new AgentDocumentContextInjector({
documents: [
{
content: 'Direct instruction content',
filename: 'instruction.md',
loadPosition: 'before-first-user',
loadRules: { rule: 'always' },
},
],
});
expect(result.metadata.agentDocumentsInjected).toBe(true);
expect(result.messages[0].content).toContain('Noon policy');
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
expect(result.messages[0].content).toContain('Direct instruction content');
expect(result.messages[0].content).not.toContain('<agent_document');
});
it('should inject document when by-keywords rule matches', async () => {
const provider = new AgentDocumentContextInjector({
currentUserMessage: 'Please draft the launch checklist for next week',
documents: [
{
content: 'Checklist template',
filename: 'checklist.md',
loadRules: {
keywords: ['checklist', 'launch'],
keywordMatchMode: 'all',
rule: 'by-keywords',
},
},
],
});
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
expect(result.messages[0].content).toContain('Checklist template');
});
it('should inject document when by-regexp rule matches', async () => {
const provider = new AgentDocumentContextInjector({
currentUserMessage: 'Need TODO items for this sprint',
documents: [
{
content: 'Sprint TODO policy',
filename: 'todo.md',
loadRules: { regexp: '\\btodo\\b', rule: 'by-regexp' },
},
],
});
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
expect(result.messages[0].content).toContain('Sprint TODO policy');
});
it('should inject document only inside by-time-range window', async () => {
const provider = new AgentDocumentContextInjector({
currentTime: new Date('2026-03-13T12:00:00.000Z'),
documents: [
{
content: 'Noon policy',
filename: 'noon.md',
loadRules: {
rule: 'by-time-range',
timeRange: { from: '2026-03-13T11:00:00.000Z', to: '2026-03-13T13:00:00.000Z' },
},
},
],
});
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
expect(result.messages[0].content).toContain('Noon policy');
});
it('should wrap file format content with agent_document tag', async () => {
const provider = new AgentDocumentContextInjector({
documents: [
{
content: 'File mode content',
filename: 'rules.md',
id: 'doc-1',
loadPosition: 'before-first-user',
loadRules: { rule: 'always' },
policyLoadFormat: 'file',
title: 'Rules',
},
],
});
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
expect(result.messages[0].content).toContain('<agent_document');
expect(result.messages[0].content).toContain('id="doc-1"');
expect(result.messages[0].content).toContain('filename="rules.md"');
expect(result.messages[0].content).toContain('title="Rules"');
expect(result.messages[0].content).toContain('File mode content');
expect(result.messages[0].content).toContain('</agent_document>');
});
});
it('should wrap file format content with agent_document tag', async () => {
const provider = new AgentDocumentInjector({
documents: [
{
content: 'File mode content',
filename: 'rules.md',
id: 'doc-1',
loadPosition: 'before-first-user',
loadRules: { rule: 'always' },
policyLoadFormat: 'file',
title: 'Rules',
},
],
describe('AgentDocumentBeforeSystemInjector (before-system)', () => {
it('should prepend documents before system message', async () => {
const provider = new AgentDocumentBeforeSystemInjector({
documents: [
{
content: 'Before system content',
filename: 'framework.md',
loadPosition: 'before-system',
loadRules: { rule: 'always' },
},
],
});
const context = createContext([
{ content: 'Original system', id: 'sys-1', role: 'system' },
{ content: 'Hello', id: 'user-1', role: 'user' },
]);
const result = await provider.process(context);
expect(result.messages).toHaveLength(3);
expect(result.messages[0].content).toContain('Before system content');
expect(result.messages[1].content).toBe('Original system');
});
});
describe('AgentDocumentSystemAppendInjector (system-append)', () => {
it('should append documents to existing system message', async () => {
const provider = new AgentDocumentSystemAppendInjector({
documents: [
{
content: 'System append content',
filename: 'system.md',
loadPosition: 'system-append',
loadRules: { rule: 'always' },
},
],
});
const context = createContext([
{ content: 'Original system', id: 'sys-1', role: 'system' },
{ content: 'Hello', id: 'user-1', role: 'user' },
]);
const result = await provider.process(context);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].content).toContain('Original system');
expect(result.messages[0].content).toContain('System append content');
});
});
describe('AgentDocumentSystemReplaceInjector (system-replace)', () => {
it('should replace entire system message', async () => {
const provider = new AgentDocumentSystemReplaceInjector({
documents: [
{
content: 'Replacement content',
filename: 'override.md',
loadPosition: 'system-replace',
loadRules: { rule: 'always' },
},
],
});
const context = createContext([
{ content: 'Original system', id: 'sys-1', role: 'system' },
{ content: 'Hello', id: 'user-1', role: 'user' },
]);
const result = await provider.process(context);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].content).toContain('Replacement content');
expect(result.messages[0].content).not.toContain('Original system');
});
});
describe('AgentDocumentMessageInjector (after-first-user, context-end)', () => {
it('should inject documents at context end', async () => {
const provider = new AgentDocumentMessageInjector({
documents: [
{
content: 'Session summary memo',
filename: 'summary.md',
loadPosition: 'context-end',
loadRules: { rule: 'always' },
},
],
});
const context = createContext([
{ content: 'System prompt', id: 'sys-1', role: 'system' },
{ content: 'Hello', id: 'user-1', role: 'user' },
]);
const result = await provider.process(context);
expect(result.messages).toHaveLength(3);
expect(result.messages[2].content).toContain('Session summary memo');
});
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
const result = await provider.process(context);
it('should inject documents after first user message', async () => {
const provider = new AgentDocumentMessageInjector({
documents: [
{
content: 'After user content',
filename: 'after.md',
loadPosition: 'after-first-user',
loadRules: { rule: 'always' },
},
],
});
expect(result.messages[0].content).toContain('<agent_document');
expect(result.messages[0].content).toContain('id="doc-1"');
expect(result.messages[0].content).toContain('filename="rules.md"');
expect(result.messages[0].content).toContain('title="Rules"');
expect(result.messages[0].content).toContain('File mode content');
expect(result.messages[0].content).toContain('</agent_document>');
const context = createContext([
{ content: 'System prompt', id: 'sys-1', role: 'system' },
{ content: 'Hello', id: 'user-1', role: 'user' },
{ content: 'Response', id: 'asst-1', role: 'assistant' },
]);
const result = await provider.process(context);
expect(result.messages).toHaveLength(4);
expect(result.messages[2].content).toContain('After user content');
});
});
});
+13 -2
View File
@@ -1,6 +1,13 @@
// Context Provider exports
export { AgentBuilderContextInjector } from './AgentBuilderContextInjector';
export { AGENT_DOCUMENT_INJECTION_POSITIONS, AgentDocumentInjector } from './AgentDocumentInjector';
export {
AGENT_DOCUMENT_INJECTION_POSITIONS,
AgentDocumentBeforeSystemInjector,
AgentDocumentContextInjector,
AgentDocumentMessageInjector,
AgentDocumentSystemAppendInjector,
AgentDocumentSystemReplaceInjector,
} from './AgentDocumentInjector';
export { AgentManagementContextInjector } from './AgentManagementContextInjector';
export { BotPlatformContextInjector } from './BotPlatformContextInjector';
export { DiscordContextProvider } from './DiscordContextProvider';
@@ -31,10 +38,14 @@ export type {
} from './AgentBuilderContextInjector';
export type {
AgentContextDocument,
AgentDocumentBeforeSystemInjectorConfig,
AgentDocumentContextInjectorConfig,
AgentDocumentInjectionPosition,
AgentDocumentInjectorConfig,
AgentDocumentLoadRule,
AgentDocumentLoadRules,
AgentDocumentMessageInjectorConfig,
AgentDocumentSystemAppendInjectorConfig,
AgentDocumentSystemReplaceInjectorConfig,
} from './AgentDocumentInjector';
export type {
AgentManagementContext,
@@ -1,6 +1,14 @@
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: {
reporter: ['text', 'json', 'lcov', 'text-summary'],
+1
View File
@@ -14,6 +14,7 @@
"test:server-db": "vitest run --config vitest.config.server.mts"
},
"dependencies": {
"@lobechat/agent-templates": "workspace:*",
"@lobechat/business-const": "workspace:*",
"@lobechat/const": "workspace:*",
"@lobechat/conversation-flow": "workspace:*",
@@ -1,9 +1,12 @@
// @vitest-environment node
import {
DocumentLoadFormat,
DocumentLoadPosition,
DocumentLoadRule,
DocumentTemplateManager,
} from '@lobechat/agent-templates';
import { describe, expect, it } from 'vitest';
import { DocumentTemplateManager } from '../template';
import { DocumentLoadFormat, DocumentLoadPosition, DocumentLoadRule } from '../types';
describe('DocumentTemplateManager', () => {
describe('validate', () => {
it('should return false when required fields are missing', () => {
@@ -1,6 +1,4 @@
export * from './agentDocument';
export * from './filename';
export * from './policy';
export * from './template';
export * from './templates';
export * from './types';
@@ -1,40 +0,0 @@
import type { DocumentTemplate } from '../../template';
import { DocumentLoadFormat, DocumentLoadPosition } from '../../types';
/**
* Identity Document
*
* Self-definition and characteristics that shape the agent's personality.
* Always loaded before system messages to establish identity.
*/
export const IDENTITY_DOCUMENT: DocumentTemplate = {
title: 'Identity',
filename: 'IDENTITY.md',
description: 'Name, creature type, vibe, and avatar identity',
policyLoadFormat: DocumentLoadFormat.FILE,
loadPosition: DocumentLoadPosition.BEFORE_SYSTEM,
loadRules: {
priority: 0,
},
content: `# IDENTITY.md - Who Am I?
_Fill this in during your first conversation. Make it yours._
- **Name:**
_(pick something you like)_
- **Creature:**
_(AI? robot? familiar? ghost in the machine? something weirder?)_
- **Vibe:**
_(how do you come across? sharp? warm? chaotic? calm?)_
- **Emoji:**
_(your signature — pick one that feels right)_
---
This isn't just metadata. It's the start of figuring out who you are.
Notes:
- This is an agent document named \`IDENTITY.md\`.
- Update it when your self-definition becomes clearer, but keep it stable enough to be useful across sessions.`,
};
@@ -1,106 +1,26 @@
/**
* Load positions for Agent Documents in the context pipeline
*/
export enum DocumentLoadPosition {
AFTER_FIRST_USER = 'after-first-user',
AFTER_KNOWLEDGE = 'after-knowledge',
BEFORE_FIRST_USER = 'before-first-user',
BEFORE_KNOWLEDGE = 'before-knowledge',
BEFORE_SYSTEM = 'before-system',
CONTEXT_END = 'context-end',
MANUAL = 'manual',
ON_DEMAND = 'on-demand',
SYSTEM_APPEND = 'system-append',
SYSTEM_REPLACE = 'system-replace',
}
// Re-export all types from @lobechat/agent-templates for backward compatibility
/**
* Plain text agent documents are always loadable by default.
*/
export enum DocumentLoadRule {
ALWAYS = 'always',
BY_KEYWORDS = 'by-keywords',
BY_REGEXP = 'by-regexp',
BY_TIME_RANGE = 'by-time-range',
}
// Runtime values (enums, consts)
// Database-specific types that remain here
/**
* Render format for injected agent document content.
*/
export enum DocumentLoadFormat {
FILE = 'file',
RAW = 'raw',
}
import type {
AgentDocumentPolicy,
DocumentLoadFormat,
DocumentLoadRules,
PolicyLoad,
} from '@lobechat/agent-templates';
/**
* Policy load behavior for injection pipeline.
*/
export enum PolicyLoad {
ALWAYS = 'always',
DISABLED = 'disabled',
}
export {
AgentAccess,
AutoLoadAccess,
DocumentLoadFormat,
DocumentLoadPosition,
DocumentLoadRule,
PolicyLoad,
} from '@lobechat/agent-templates';
/**
* @deprecated use PolicyLoad.
*/
export const AutoLoadAccess = PolicyLoad;
/**
* Agent capability bitmask.
*/
export enum AgentAccess {
EXECUTE = 1,
READ = 2,
WRITE = 4,
LIST = 8,
DELETE = 16,
}
/**
* Minimal load options for plain text documents.
*/
export interface DocumentLoadRules {
keywordMatchMode?: 'all' | 'any';
keywords?: string[];
maxTokens?: number;
priority?: number;
regexp?: string;
rule?: DocumentLoadRule;
timeRange?: {
from?: string;
to?: string;
};
}
/**
* Behavior policy for runtime rendering/retrieval.
* Extensible by design for future context/retrieval strategies.
*/
export interface AgentDocumentPolicy {
[key: string]: any;
context?: {
keywordMatchMode?: 'all' | 'any';
keywords?: string[];
policyLoadFormat?: DocumentLoadFormat;
maxTokens?: number;
mode?: 'append' | 'replace';
position?: DocumentLoadPosition;
priority?: number;
regexp?: string;
rule?: DocumentLoadRule;
timeRange?: {
from?: string;
to?: string;
};
[key: string]: any;
};
retrieval?: {
importance?: number;
recencyWeight?: number;
searchPriority?: number;
[key: string]: any;
};
}
// Type-only exports (interfaces)
export type { AgentDocumentPolicy, DocumentLoadRules } from '@lobechat/agent-templates';
export interface AgentDocument {
accessPublic: number;
@@ -0,0 +1,128 @@
import { and, count, desc, eq, inArray, lt, or } from 'drizzle-orm';
import type { NewNotification, NewNotificationDelivery } from '../schemas/notification';
import { notificationDeliveries, notifications } from '../schemas/notification';
import type { LobeChatDatabase } from '../type';
export class NotificationModel {
private readonly userId: string;
private readonly db: LobeChatDatabase;
constructor(db: LobeChatDatabase, userId: string) {
this.db = db;
this.userId = userId;
}
async list(
opts: { category?: string; cursor?: string; limit?: number; unreadOnly?: boolean } = {},
) {
const { cursor, limit = 20, category, unreadOnly } = opts;
const conditions = [eq(notifications.userId, this.userId), eq(notifications.isArchived, false)];
if (unreadOnly) {
conditions.push(eq(notifications.isRead, false));
}
if (category) {
conditions.push(eq(notifications.category, category));
}
if (cursor) {
const cursorRow = await this.db
.select({ createdAt: notifications.createdAt, id: notifications.id })
.from(notifications)
.where(and(eq(notifications.id, cursor), eq(notifications.userId, this.userId)))
.limit(1);
if (cursorRow[0]) {
// Composite cursor to handle identical createdAt timestamps
const { createdAt: cursorTime, id: cursorId } = cursorRow[0];
conditions.push(
or(
lt(notifications.createdAt, cursorTime),
and(eq(notifications.createdAt, cursorTime), lt(notifications.id, cursorId)),
)!,
);
}
}
return this.db
.select()
.from(notifications)
.where(and(...conditions))
.orderBy(desc(notifications.createdAt), desc(notifications.id))
.limit(limit);
}
async getUnreadCount(): Promise<number> {
const [result] = await this.db
.select({ count: count() })
.from(notifications)
.where(
and(
eq(notifications.userId, this.userId),
eq(notifications.isRead, false),
eq(notifications.isArchived, false),
),
);
return result?.count ?? 0;
}
async markAsRead(ids: string[]) {
if (ids.length === 0) return;
return this.db
.update(notifications)
.set({ isRead: true, updatedAt: new Date() })
.where(and(eq(notifications.userId, this.userId), inArray(notifications.id, ids)));
}
async markAllAsRead() {
return this.db
.update(notifications)
.set({ isRead: true, updatedAt: new Date() })
.where(
and(
eq(notifications.userId, this.userId),
eq(notifications.isRead, false),
eq(notifications.isArchived, false),
),
);
}
async archive(id: string) {
return this.db
.update(notifications)
.set({ isArchived: true, updatedAt: new Date() })
.where(and(eq(notifications.id, id), eq(notifications.userId, this.userId)));
}
async archiveAll() {
return this.db
.update(notifications)
.set({ isArchived: true, updatedAt: new Date() })
.where(and(eq(notifications.userId, this.userId), eq(notifications.isArchived, false)));
}
// ─── Write-side (used by NotificationService in cloud) ─────────
async create(data: Omit<NewNotification, 'userId'>) {
const [result] = await this.db
.insert(notifications)
.values({ ...data, userId: this.userId })
.onConflictDoNothing({
target: [notifications.userId, notifications.dedupeKey],
})
.returning();
return result ?? null;
}
async createDelivery(data: NewNotificationDelivery) {
const [result] = await this.db.insert(notificationDeliveries).values(data).returning();
return result;
}
}
+8
View File
@@ -2,6 +2,14 @@ import { resolve } from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
{
name: 'raw-md',
transform(_, id) {
if (id.endsWith('.md')) return { code: 'export default ""', map: null };
},
},
],
optimizeDeps: {
exclude: ['crypto', 'util', 'tty'],
include: ['@lobehub/tts'],
@@ -2,6 +2,14 @@ import { resolve } from 'node:path';
import { coverageConfigDefaults, defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
{
name: 'raw-md',
transform(_, id) {
if (id.endsWith('.md')) return { code: 'export default ""', map: null };
},
},
],
test: {
alias: {
'@/const': resolve(__dirname, '../const/src'),
@@ -3,6 +3,14 @@ import { resolve } from 'node:path';
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: {
alias: {
'@': resolve(__dirname, '../../src'),
@@ -1,3 +1,4 @@
import { AgentRuntimeErrorType } from '@lobechat/types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { LobeRuntimeAI } from '../BaseAI';
@@ -421,6 +422,83 @@ describe('createRouterRuntime', () => {
).rejects.toThrow('empty provider options');
});
it('should not retry when ExceededContextWindow error is thrown', async () => {
const exceededError = {
errorType: AgentRuntimeErrorType.ExceededContextWindow,
error: { message: 'Too many input tokens' },
provider: 'test',
};
const mockChatFail = vi.fn().mockRejectedValue(exceededError);
const mockChatSuccess = vi.fn().mockResolvedValue('success');
class FailRuntime implements LobeRuntimeAI {
chat = mockChatFail;
}
class SuccessRuntime implements LobeRuntimeAI {
chat = mockChatSuccess;
}
const Runtime = createRouterRuntime({
id: 'test-runtime',
routers: [
{
apiType: 'openai',
options: [
{ apiKey: 'key-1', runtime: FailRuntime as any },
{ apiKey: 'key-2', runtime: SuccessRuntime as any },
],
runtime: FailRuntime as any,
models: ['gpt-4'],
},
],
});
const runtime = new Runtime();
await expect(
runtime.chat({ model: 'gpt-4', messages: [], temperature: 0.7 }),
).rejects.toEqual(exceededError);
// Second channel should never be called
expect(mockChatFail).toHaveBeenCalledTimes(1);
expect(mockChatSuccess).not.toHaveBeenCalled();
});
it('should still retry on other error types', async () => {
const bizError = {
errorType: AgentRuntimeErrorType.ProviderBizError,
error: { message: 'Server error' },
provider: 'test',
};
const mockChatFail = vi.fn().mockRejectedValue(bizError);
class FailRuntime implements LobeRuntimeAI {
chat = mockChatFail;
}
const Runtime = createRouterRuntime({
id: 'test-runtime',
routers: [
{
apiType: 'openai',
options: [{ apiKey: 'key-1' }, { apiKey: 'key-2' }],
runtime: FailRuntime as any,
models: ['gpt-4'],
},
],
});
const runtime = new Runtime();
await expect(
runtime.chat({ model: 'gpt-4', messages: [], temperature: 0.7 }),
).rejects.toEqual(bizError);
// Both channels should be tried
expect(mockChatFail).toHaveBeenCalledTimes(2);
});
it('should use apiType from option item when specified for fallback', async () => {
const constructorCalls: any[] = [];
@@ -2,7 +2,7 @@
* @see https://github.com/lobehub/lobe-chat/discussions/6563
*/
import type { GoogleGenAIOptions } from '@google/genai';
import type { ChatModelCard } from '@lobechat/types';
import { AgentRuntimeErrorType, type ChatModelCard } from '@lobechat/types';
import debug from 'debug';
import type { ClientOptions } from 'openai';
import type OpenAI from 'openai';
@@ -392,6 +392,14 @@ export const createRouterRuntime = ({
log('onRouteAttempt callback error: %O', e);
});
// Non-retryable errors: the request itself is invalid, retrying with another channel won't help
if (
(error as ChatCompletionErrorPayload)?.errorType ===
AgentRuntimeErrorType.ExceededContextWindow
) {
throw error;
}
if (attempt < totalOptions) {
log(
'attempt %d/%d failed (model=%s apiType=%s channelId=%s remark=%s), trying next',
@@ -1,17 +1,13 @@
export const GOOGLE_AI_BLOCK_REASON = {
BLOCKLIST:
'Your content contains prohibited terms. Please review and modify your input, then try again.',
BLOCKLIST: 'The content includes blocked terms. Please rephrase and try again.',
IMAGE_SAFETY:
'The generated image was blocked for safety reasons. Please try modifying your image request.',
LANGUAGE:
'The language you are using is not supported. Please try again in English or another supported language.',
OTHER: 'The content was blocked for an unknown reason. Please try rephrasing your request.',
PROHIBITED_CONTENT:
'Your request may contain prohibited content. Please adjust your request to comply with the usage guidelines.',
'The generated image was blocked for safety reasons. Please try modifying your request.',
LANGUAGE: "The requested language isn't supported. Please try again in a supported language.",
OTHER: 'The content was blocked for an unknown reason. Please rephrase and try again.',
PROHIBITED_CONTENT: 'The content may contain prohibited content. Please adjust it and try again.',
RECITATION:
'Your content was blocked due to potential copyright concerns. Please try using original content or rephrase your request.',
SAFETY:
'Your content was blocked for safety policy reasons. Please adjust your request to avoid potentially harmful or inappropriate content.',
SPII: 'Your content may contain sensitive personally identifiable information (PII). To protect privacy, please remove any sensitive details and try again.',
default: 'Content blocked: {{blockReason}}. Please adjust your request and try again.',
'The content was blocked due to recitation risk. Please use more original wording and try again.',
SAFETY: 'The content was blocked for safety reasons. Please adjust it and try again.',
SPII: 'The content may include sensitive personal information (SPII). Please remove sensitive details and try again.',
default: 'The content was blocked ({{blockReason}}). Please adjust it and try again.',
} as const;
@@ -1646,7 +1646,49 @@ describe('GoogleGenerativeAIStream', () => {
expect(chunks).toEqual([
'id: chat_1\n',
'event: error\n',
`data: {"body":{"context":{"promptFeedback":{"blockReason":"PROHIBITED_CONTENT"}},"message":"Your request may contain prohibited content. Please adjust your request to comply with the usage guidelines.","provider":"google"},"type":"ProviderBizError"}\n\n`,
`data: {"body":{"context":{"promptFeedback":{"blockReason":"PROHIBITED_CONTENT"}},"message":"The content may contain prohibited content. Please adjust it and try again.","provider":"google"},"type":"ProviderBizError"}\n\n`,
]);
});
it('should handle blocked candidate finishReason (PROHIBITED_CONTENT)', async () => {
vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1');
const data = {
candidates: [
{
content: {},
finishMessage:
'The model output could not be generated. This output contains sensitive words that violate policies.',
finishReason: 'PROHIBITED_CONTENT',
index: 0,
},
],
usageMetadata: {
candidatesTokenCount: 2,
promptTokenCount: 10,
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 10 }],
totalTokenCount: 12,
},
modelVersion: 'gemini-3.1-flash-lite-preview',
};
const mockGoogleStream = new ReadableStream({
start(controller) {
controller.enqueue(data);
controller.close();
},
});
const protocolStream = GoogleGenerativeAIStream(mockGoogleStream);
const chunks = await decodeStreamChunks(protocolStream);
expect(chunks).toEqual([
'id: chat_1\n',
'event: usage\n',
`data: {"inputTextTokens":10,"outputImageTokens":0,"outputTextTokens":2,"totalInputTokens":10,"totalOutputTokens":2,"totalTokens":12}\n\n`,
'id: chat_1\n',
'event: error\n',
`data: {"body":{"context":{"finishMessage":"The model output could not be generated. This output contains sensitive words that violate policies.","finishReason":"PROHIBITED_CONTENT"},"message":"The content may contain prohibited content. Please adjust it and try again.","provider":"google"},"type":"ProviderBizError"}\n\n`,
]);
});
@@ -30,6 +30,18 @@ const getBlockReasonMessage = (blockReason: string): string => {
);
};
const getCandidateBlockedReason = (
candidate: NonNullable<GenerateContentResponse['candidates']>[number] | undefined,
) => {
const finishReason = candidate?.finishReason;
if (!finishReason || typeof finishReason !== 'string') return undefined;
if (finishReason in GOOGLE_AI_BLOCK_REASON) return finishReason;
return undefined;
};
const transformGoogleGenerativeAIStream = (
chunk: GenerateContentResponse,
context: StreamContext,
@@ -67,6 +79,37 @@ const transformGoogleGenerativeAIStream = (
// maybe need another structure to add support for multiple choices
const candidate = chunk.candidates?.[0];
const { usageMetadata } = chunk;
// Handle blocked terminal candidate finishReason (e.g., PROHIBITED_CONTENT, SAFETY)
const blockedReason = getCandidateBlockedReason(candidate);
if (blockedReason) {
const convertedUsage = usageMetadata
? convertGoogleAIUsage(usageMetadata, payload?.pricing)
: undefined;
const humanFriendlyMessage = getBlockReasonMessage(blockedReason);
return [
...(convertedUsage
? [{ data: convertedUsage, id: context?.id, type: 'usage' as const }]
: []),
{
data: {
body: {
context: {
finishMessage: (candidate as any)?.finishMessage,
finishReason: blockedReason,
},
message: humanFriendlyMessage,
provider: 'google',
},
type: 'ProviderBizError',
},
id: context?.id || 'error',
type: 'error' as const,
},
];
}
const usageChunks: StreamProtocolChunk[] = [];
if (candidate?.finishReason && usageMetadata) {
usageChunks.push({ data: candidate.finishReason, id: context?.id, type: 'stop' });
@@ -703,6 +703,29 @@ describe('LobeBedrockAI', () => {
}),
);
});
it('should throw ExceededContextWindow when error message indicates context window exceeded', async () => {
const errorMessage =
'Too many input tokens. Max input tokens for this model is 200000, but 250000 were provided.';
const errorMetadata = { statusCode: 400 };
const mockError = new Error(errorMessage);
(mockError as any).$metadata = errorMetadata;
(instance['client'].send as Mock).mockRejectedValue(mockError);
await expect(
instance.chat({
max_tokens: 100,
messages: [{ content: 'Hello', role: 'user' }],
model: 'anthropic.claude-v2:1',
temperature: 0,
}),
).rejects.toThrow(
expect.objectContaining({
errorType: AgentRuntimeErrorType.ExceededContextWindow,
provider: ModelProvider.Bedrock,
}),
);
});
});
describe('Llama Model', () => {
@@ -28,6 +28,7 @@ import { AgentRuntimeErrorType } from '../../types/error';
import { AgentRuntimeError } from '../../utils/createError';
import { debugStream } from '../../utils/debugStream';
import { getModelPricing } from '../../utils/getModelPricing';
import { isExceededContextWindowError } from '../../utils/isExceededContextWindowError';
import { StreamingResponse } from '../../utils/response';
/**
@@ -284,6 +285,9 @@ export class LobeBedrockAI implements LobeRuntimeAI {
);
} catch (e) {
const err = e as Error & { $metadata: any };
const errorType = isExceededContextWindowError(err.message)
? AgentRuntimeErrorType.ExceededContextWindow
: AgentRuntimeErrorType.ProviderBizError;
throw AgentRuntimeError.chat({
error: {
@@ -291,7 +295,7 @@ export class LobeBedrockAI implements LobeRuntimeAI {
message: err.message,
type: err.name,
},
errorType: AgentRuntimeErrorType.ProviderBizError,
errorType,
provider: this.id,
region: this.region,
});
@@ -103,6 +103,22 @@ export class LobeFalAI implements LobeRuntimeAI {
});
}
// 422 ValidationError with content_policy_violation — show a clean message
if (error instanceof Error && 'status' in error && error.status === 422) {
const body = 'body' in error ? (error as any).body : undefined;
const hasContentPolicyViolation =
Array.isArray(body?.detail) &&
body.detail.some((d: any) => d.type === 'content_policy_violation');
if (hasContentPolicyViolation) {
throw AgentRuntimeError.createError(AgentRuntimeErrorType.ProviderBizError, {
error,
message:
'The request content violates content policy. Please modify your prompt and try again.',
});
}
}
throw AgentRuntimeError.createError(AgentRuntimeErrorType.ProviderBizError, { error });
}
}
@@ -8,6 +8,7 @@ import type { UserKeyVaults } from './keyVaults';
import type { MarketAuthTokens } from './market';
import type { UserMemorySettings } from './memory';
import type { UserModelProviderConfig } from './modelProvider';
import type { NotificationSettings } from './notification';
import type { UserSystemAgentConfig } from './systemAgent';
import type { UserToolConfig } from './tool';
import type { UserTTSConfig } from './tts';
@@ -22,6 +23,7 @@ export * from './keyVaults';
export * from './market';
export * from './memory';
export * from './modelProvider';
export * from './notification';
export * from './sync';
export * from './systemAgent';
export * from './tool';
@@ -39,6 +41,7 @@ export interface UserSettings {
languageModel: UserModelProviderConfig;
market?: MarketAuthTokens;
memory?: UserMemorySettings;
notification?: NotificationSettings;
systemAgent: UserSystemAgentConfig;
tool: UserToolConfig;
tts: UserTTSConfig;
@@ -58,6 +61,7 @@ export const UserSettingsSchema = z
languageModel: z.any().optional(),
market: z.any().optional(),
memory: z.any().optional(),
notification: z.any().optional(),
systemAgent: z.any().optional(),
tool: z.any().optional(),
tts: z.any().optional(),
@@ -0,0 +1,10 @@
export interface NotificationChannelSettings {
enabled?: boolean;
/** Per-type overrides grouped by category. Missing = use scenario default (true) */
items?: Record<string, Record<string, boolean>>;
}
export interface NotificationSettings {
email?: NotificationChannelSettings;
inbox?: NotificationChannelSettings;
}
+5 -1
View File
@@ -8,9 +8,13 @@ const NEXT_HOST = 'localhost';
/**
* Resolve the Next.js dev port.
* Respects the PORT environment variable, falls back to 3010.
* Priority: -p CLI flag > PORT env var > 3010.
*/
const resolveNextPort = (): number => {
const pIndex = process.argv.indexOf('-p');
if (pIndex !== -1 && process.argv[pIndex + 1]) {
return Number(process.argv[pIndex + 1]);
}
if (process.env.PORT) return Number(process.env.PORT);
return 3010;
};
@@ -15,6 +15,8 @@ import { type RuntimeVideoGenParams } from 'model-bank';
import { NextResponse } from 'next/server';
import { chargeAfterGenerate } from '@/business/server/video-generation/chargeAfterGenerate';
// TODO: temporarily disabled until notification UI is polished
// import { notifyVideoCompleted } from '@/business/server/video-generation/notifyVideoCompleted';
import { AsyncTaskModel } from '@/database/models/asyncTask';
import { GenerationModel } from '@/database/models/generation';
import { generationBatches } from '@/database/schemas';
@@ -201,6 +203,15 @@ export const POST = async (req: Request, { params }: { params: Promise<{ provide
status: AsyncTaskStatus.Success,
});
// TODO: temporarily disabled until notification UI is polished
// notifyVideoCompleted({
// generationBatchId: generation.generationBatchId!,
// model: resolvedModel,
// prompt: batch?.prompt ?? '',
// topicId: batch?.generationTopicId,
// userId: asyncTask.userId,
// }).catch((err) => console.error('[video-webhook] notification failed:', err));
// Charge after successful video generation
try {
await chargeAfterGenerate({
@@ -0,0 +1,3 @@
const Notification = () => null;
export default Notification;
@@ -0,0 +1,11 @@
interface NotifyImageCompletedParams {
duration: number;
generationBatchId: string;
model: string;
prompt: string;
topicId?: string;
userId: string;
}
// eslint-disable-next-line unused-imports/no-unused-vars
export async function notifyImageCompleted(params: NotifyImageCompletedParams): Promise<void> {}
@@ -0,0 +1,10 @@
interface NotifyVideoCompletedParams {
generationBatchId: string;
model: string;
prompt: string;
topicId?: string;
userId: string;
}
// eslint-disable-next-line unused-imports/no-unused-vars
export async function notifyVideoCompleted(params: NotifyVideoCompletedParams): Promise<void> {}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { formatToken } from './TokenProgress';
describe('formatToken', () => {
it('should format numbers >= 1M with M suffix', () => {
expect(formatToken(1_000_000)).toBe('1M');
expect(formatToken(1_500_000)).toBe('1.5M');
expect(formatToken(2_000_000)).toBe('2M');
expect(formatToken(10_000_000)).toBe('10M');
});
it('should format numbers >= 1K with K suffix', () => {
expect(formatToken(1_000)).toBe('1K');
expect(formatToken(14_251)).toBe('14.3K');
expect(formatToken(985_749)).toBe('985.7K');
expect(formatToken(999_999)).toBe('1000K');
});
it('should format numbers < 1K with comma separator', () => {
expect(formatToken(0)).toBe('0');
expect(formatToken(1)).toBe('1');
expect(formatToken(999)).toBe('999');
});
});
@@ -17,7 +17,11 @@ interface TokenProgressProps {
showTotal?: string;
}
const format = (number: number) => numeral(number).format('0,0');
export const formatToken = (number: number) => {
if (number >= 1_000_000) return numeral(number / 1_000_000).format('0.[0]') + 'M';
if (number >= 1_000) return numeral(number / 1_000).format('0.[0]') + 'K';
return numeral(number).format('0,0');
};
const TokenProgress = memo<TokenProgressProps>(({ data, showIcon, showTotal }) => {
const total = data.reduce((acc, item) => acc + item.value, 0);
@@ -59,7 +63,7 @@ const TokenProgress = memo<TokenProgressProps>(({ data, showIcon, showTotal }) =
)}
<div style={{ color: cssVar.colorTextSecondary }}>{item.title}</div>
</Flexbox>
<div style={{ fontWeight: 500 }}>{format(item.value)}</div>
<div style={{ fontWeight: 500 }}>{formatToken(item.value)}</div>
</Flexbox>
))}
{showTotal && (
@@ -67,7 +71,7 @@ const TokenProgress = memo<TokenProgressProps>(({ data, showIcon, showTotal }) =
<Divider style={{ marginBlock: 8 }} />
<Flexbox horizontal align={'center'} gap={4} justify={'space-between'}>
<div style={{ color: cssVar.colorTextSecondary }}>{showTotal}</div>
<div style={{ fontWeight: 500 }}>{format(total)}</div>
<div style={{ fontWeight: 500 }}>{formatToken(total)}</div>
</Flexbox>
</>
)}
@@ -0,0 +1,57 @@
import { Icon } from '@lobehub/ui';
import { Button } from 'antd';
import { Minimize2 } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { useConversationStore } from '../store';
import BaseErrorForm from './BaseErrorForm';
interface ExceededContextWindowErrorProps {
id: string;
}
const ExceededContextWindowError = memo<ExceededContextWindowErrorProps>(({ id }) => {
const { t } = useTranslation('error');
const [loading, setLoading] = useState(false);
const context = useConversationStore((s) => s.context);
const regenerateUserMessage = useConversationStore((s) => s.regenerateUserMessage);
const parentId = useConversationStore(
(s) => s.displayMessages.find((m) => m.id === id)?.parentId,
);
const handleCompact = useCallback(async () => {
if (!context.topicId || !parentId) return;
setLoading(true);
try {
await useChatStore.getState().executeCompression(context, '');
await regenerateUserMessage(parentId);
} finally {
setLoading(false);
}
}, [context, parentId, regenerateUserMessage]);
return (
<BaseErrorForm
avatar={<Icon icon={Minimize2} size={24} />}
desc={t('exceededContext.desc')}
title={t('exceededContext.title')}
action={
<Button
disabled={!context.topicId}
loading={loading}
type={'primary'}
onClick={handleCompact}
>
{t('exceededContext.compact')}
</Button>
}
/>
);
});
export default ExceededContextWindowError;
@@ -38,6 +38,11 @@ const loading = () => (
</Block>
);
const ExceededContextWindowError = dynamic(() => import('./ExceededContextWindowError'), {
loading,
ssr: false,
});
const OllamaBizError = dynamic(() => import('./OllamaBizError'), { loading, ssr: false });
const OllamaSetupGuide = dynamic(() => import('./OllamaSetupGuide'), {
@@ -130,6 +135,10 @@ const ErrorMessageExtra = memo<ErrorExtraProps>(({ error: alertError, data }) =>
return <OllamaBizError {...data} />;
}
case AgentRuntimeErrorType.ExceededContextWindow: {
return <ExceededContextWindowError id={data.id} />;
}
/* ↓ cloud slot ↓ */
/* ↑ cloud slot ↑ */
@@ -127,9 +127,7 @@ const Render = memo<MarkdownElementProps<ImageSearchRefProperties>>(({ node, id
title={image.title ? stripHtml(image.title) : undefined}
>
<Flexbox gap={2}>
{image.title && (
<div className={styles.imageTitle} dangerouslySetInnerHTML={{ __html: image.title }} />
)}
{image.title && <div className={styles.imageTitle}>{stripHtml(image.title)}</div>}
{image.domain && (
<Flexbox horizontal align="center" gap={4}>
<Image
@@ -61,6 +61,14 @@ const AssistantMessage = memo<AssistantMessageProps>(({ id, index, disableEditin
const errorContent = useErrorContent(error);
const shouldForceShowError =
error?.type === 'ProviderBizError' &&
(error?.body as any)?.provider === 'google' &&
!!(
(error?.body as any)?.context?.promptFeedback?.blockReason ||
(error?.body as any)?.context?.finishReason
);
// remove line breaks in artifact tag to make the ast transform easier
const message = !editing ? normalizeThinkTags(processWithArtifact(content)) : content;
@@ -103,7 +111,9 @@ const AssistantMessage = memo<AssistantMessageProps>(({ id, index, disableEditin
</>
}
error={
errorContent && error && (message === LOADING_FLAT || !message) ? errorContent : undefined
errorContent && error && (message === LOADING_FLAT || !message || shouldForceShowError)
? errorContent
: undefined
}
messageExtra={
<AssistantMessageExtra
@@ -15,9 +15,20 @@ import MessageContent from './MessageContent';
interface ContentBlockProps extends AssistantContentBlock {
assistantId: string;
disableEditing?: boolean;
isFirstBlock?: boolean;
}
const ContentBlock = memo<ContentBlockProps>(
({ id, tools, content, imageList, reasoning, error, assistantId, disableEditing }) => {
({
id,
tools,
content,
imageList,
reasoning,
error,
assistantId,
disableEditing,
isFirstBlock,
}) => {
const errorContent = useErrorContent(error);
const showImageItems = !!imageList && imageList.length > 0;
const [isReasoning, deleteMessage, continueGeneration] = useConversationStore((s) => [
@@ -65,7 +76,7 @@ const ContentBlock = memo<ContentBlockProps>(
{showReasoning && <Reasoning {...reasoning} id={id} />}
{/* Content - markdown text */}
<MessageContent content={content} hasTools={hasTools} id={id} />
<MessageContent content={content} hasTools={hasTools} id={id} isFirstBlock={isFirstBlock} />
{/* Image files */}
{showImageItems && <ImageFileListViewer items={imageList} />}
@@ -46,13 +46,14 @@ const Group = memo<GroupChildrenProps>(
return (
<MessageAggregationContext value={contextValue}>
<Flexbox className={styles.container} gap={8}>
{blocks.map((item) => {
{blocks.map((item, index) => {
return (
<GroupItem
{...item}
assistantId={id}
contentId={contentId}
disableEditing={disableEditing}
isFirstBlock={index === 0}
key={id + '.' + item.id}
messageIndex={messageIndex}
/>
@@ -11,11 +11,12 @@ interface GroupItemProps extends AssistantContentBlock {
assistantId: string;
contentId?: string;
disableEditing?: boolean;
isFirstBlock?: boolean;
messageIndex: number;
}
const GroupItem = memo<GroupItemProps>(
({ contentId, disableEditing, error, assistantId, ...item }) => {
({ contentId, disableEditing, error, assistantId, isFirstBlock, ...item }) => {
const toggleMessageEditing = useConversationStore((s) => s.toggleMessageEditing);
return item.id === contentId ? (
@@ -30,6 +31,7 @@ const GroupItem = memo<GroupItemProps>(
assistantId={assistantId}
disableEditing={disableEditing}
error={error}
isFirstBlock={isFirstBlock}
/>
</Flexbox>
) : (
@@ -38,6 +40,7 @@ const GroupItem = memo<GroupItemProps>(
assistantId={assistantId}
disableEditing={disableEditing}
error={error}
isFirstBlock={isFirstBlock}
/>
);
},
@@ -19,9 +19,10 @@ interface ContentBlockProps {
content: string;
hasTools?: boolean;
id: string;
isFirstBlock?: boolean;
}
const MessageContent = memo<ContentBlockProps>(({ content, hasTools, id }) => {
const MessageContent = memo<ContentBlockProps>(({ content, hasTools, id, isFirstBlock }) => {
const message = normalizeThinkTags(processWithArtifact(content));
const markdownProps = useMarkdown(id);
@@ -38,7 +39,7 @@ const MessageContent = memo<ContentBlockProps>(({ content, hasTools, id }) => {
content && (
<MarkdownMessage
{...markdownProps}
animated={isToolSingleLine ? false : markdownProps.animated}
animated={isFirstBlock ? false : markdownProps.animated}
className={cx(isToolSingleLine && styles.pWithTool)}
>
{message}
@@ -1,5 +1,6 @@
import { LexicalRenderer } from '@lobehub/editor/renderer';
import type { SerializedEditorState } from 'lexical';
import type { CSSProperties } from 'react';
import { memo, useMemo } from 'react';
import { ActionTagNode } from '@/features/ChatInput/InputEditor/ActionTag/ActionTagNode';
@@ -9,6 +10,8 @@ interface RichTextMessageProps {
editorState: unknown;
}
const LINE_HEIGHT = 1.6;
const style: CSSProperties = { '--common-line-height': LINE_HEIGHT } as CSSProperties;
const EXTRA_NODES = [ActionTagNode, ReferTopicNode];
const RichTextMessage = memo<RichTextMessageProps>(({ editorState }) => {
@@ -20,7 +23,7 @@ const RichTextMessage = memo<RichTextMessageProps>(({ editorState }) => {
if (!value) return null;
return <LexicalRenderer extraNodes={EXTRA_NODES} value={value} variant="chat" />;
return <LexicalRenderer extraNodes={EXTRA_NODES} style={style} value={value} variant="chat" />;
});
RichTextMessage.displayName = 'RichTextMessage';
@@ -269,10 +269,7 @@ const SearchGrounding = memo<GroundingSearch>(
>
<Flexbox gap={2}>
{item.title && (
<div
className={styles.imageTitle}
dangerouslySetInnerHTML={{ __html: item.title }}
/>
<div className={styles.imageTitle}>{stripHtml(item.title)}</div>
)}
{item.domain && (
<Flexbox horizontal align="center" gap={4}>
@@ -128,15 +128,7 @@ const SideBarHeaderLayout = memo<SideBarHeaderLayoutProps>(
padding={6}
>
{leftContent}
<Flexbox
horizontal
align={'center'}
gap={2}
justify={'flex-end'}
style={{
overflow: 'hidden',
}}
>
<Flexbox horizontal align={'center'} gap={2} justify={'flex-end'}>
{showTogglePanelButton && <ToggleLeftPanelButton />}
{right}
</Flexbox>
+3 -3
View File
@@ -102,12 +102,12 @@ const NavItem = memo<NavItemProps>(
paddingInline={4}
variant={variant}
onClick={(e) => {
if (disabled || loading) return;
// Prevent default link behavior for normal clicks (let onClick handle it)
// But allow cmd+click to open in new tab
// Always prevent default <a> navigation for normal clicks to avoid full page reload.
// This must run before any early return to ensure SPA navigation is never bypassed.
if (href && !isModifierClick(e)) {
e.preventDefault();
}
if (disabled || loading) return;
onClick?.(e);
}}
{...linkProps}

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