mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-16 20:46:08 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11318f8ab9 | |||
| feb50e7007 | |||
| 48b5927024 | |||
| 6e86912e7f | |||
| 4576059f4f | |||
| 9e9ba3e6c3 | |||
| 46602be0b3 | |||
| 14b278fba8 | |||
| 53c5708c9f | |||
| edc8920703 | |||
| 926de076d9 | |||
| 9b7beca85e | |||
| 0724d8ca60 | |||
| 9f36fe95ac | |||
| 3f148005e4 | |||
| 4e60d87514 | |||
| d2a16d0714 | |||
| ac8a9ec0f8 |
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,即可开始生成",
|
||||
|
||||
@@ -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": "视频已生成"
|
||||
}
|
||||
@@ -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": "网络代理",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@lobechat/agent-templates",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './template';
|
||||
export * from './templates';
|
||||
export * from './types';
|
||||
+12
-31
@@ -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.
|
||||
+2
-21
@@ -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,
|
||||
};
|
||||
+3
-9
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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('');
|
||||
|
||||
@@ -37,6 +37,7 @@ export const defaultToolIds = [
|
||||
LocalSystemManifest.identifier,
|
||||
CloudSandboxManifest.identifier,
|
||||
TopicReferenceManifest.identifier,
|
||||
AgentDocumentsManifest.identifier,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user