Compare commits

...

1 Commits

Author SHA1 Message Date
arvinxx 93a3ed6f53 feat: agent task system — CLI, review rubrics, workspace, comments, brief tool split
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:02:08 +08:00
153 changed files with 37751 additions and 3069 deletions
+69 -4
View File
@@ -200,20 +200,85 @@ The base directory (`~/.lobehub/`) can be overridden with the `LOBEHUB_CLI_HOME`
## Development
### Running in Dev Mode
Dev mode uses `LOBEHUB_CLI_HOME=.lobehub-dev` to isolate credentials from the global `~/.lobehub/` directory, so dev and production configs never conflict.
```bash
# Run directly (dev mode, uses ~/.lobehub-dev for credentials)
# Run a command in dev mode (from apps/cli/)
cd apps/cli && bun run dev -- <command>
# Build
# This is equivalent to:
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
```
### Connecting to Local Dev Server
To test CLI against a local dev server (e.g. `localhost:3011`):
**Step 1: Start the local server**
```bash
# From cloud repo root
bun run dev
# Server starts on http://localhost:3011 (or configured port)
```
**Step 2: Login to local server via Device Code Flow**
```bash
cd apps/cli && bun run dev -- login --server http://localhost:3011
```
This will:
1. Call `POST http://localhost:3011/oidc/device/auth` to get a device code
2. Print a URL like `http://localhost:3011/oidc/device?user_code=XXXX-YYYY`
3. Open the URL in your browser — log in and authorize
4. Save credentials to `apps/cli/.lobehub-dev/credentials.json`
5. Save server URL to `apps/cli/.lobehub-dev/settings.json`
After login, all subsequent `bun run dev -- <command>` calls will use the local server.
**Step 3: Run commands against local server**
```bash
cd apps/cli && bun run dev -- task list
cd apps/cli && bun run dev -- task create -i "Test task" -n "My Task"
cd apps/cli && bun run dev -- agent list
```
**Troubleshooting:**
- If login returns `invalid_grant`, make sure the local OIDC provider is properly configured (check `OIDC_*` env vars in `.env`)
- If you get `UNAUTHORIZED` on API calls, your token may have expired — run `bun run dev -- login --server http://localhost:3011` again
- Dev credentials are stored in `apps/cli/.lobehub-dev/` (gitignored), not in `~/.lobehub/`
### Switching Between Local and Production
```bash
# Dev mode (local server) — uses .lobehub-dev/
cd apps/cli && bun run dev -- <command>
# Production (app.lobehub.com) — uses ~/.lobehub/
lh <command>
```
The two environments are completely isolated by different credential directories.
### Build & Test
```bash
# Build CLI
cd apps/cli && bun run build
# Test (unit tests)
# Unit tests
cd apps/cli && bun run test
# E2E tests (requires authenticated CLI)
cd apps/cli && bunx vitest run e2e/kb.e2e.test.ts
# Link globally for testing
# Link globally for testing (installs lh/lobe/lobehub commands)
cd apps/cli && bun run cli:link
```
+2 -6
View File
@@ -101,10 +101,6 @@ DROP TABLE "old_table";
CREATE INDEX "users_email_idx" ON "users" ("email");
```
## Step 4: Regenerate Client After SQL Edits
## Step 4: Update Journal Tag
After modifying the generated SQL (e.g., adding `IF NOT EXISTS`), regenerate the client:
```bash
bun run db:generate:client
```
After renaming the migration SQL file in Step 2, update the `tag` field in `packages/database/migrations/meta/_journal.json` to match the new filename (without `.sql` extension).
+123
View File
@@ -0,0 +1,123 @@
---
name: trpc-router
description: TRPC router development guide. Use when creating or modifying TRPC routers (src/server/routers/**), adding procedures, or working with server-side API endpoints. Triggers on TRPC router creation, procedure implementation, or API endpoint tasks.
---
# TRPC Router Guide
## File Location
- Routers: `src/server/routers/lambda/<domain>.ts`
- Helpers: `src/server/routers/lambda/_helpers/`
- Schemas: `src/server/routers/lambda/_schema/`
## Router Structure
### Imports
```typescript
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { SomeModel } from '@/database/models/some';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
```
### Middleware: Inject Models into ctx
**Always use middleware to inject models into `ctx`** instead of creating `new Model(ctx.serverDB, ctx.userId)` inside every procedure.
```typescript
const domainProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
return opts.next({
ctx: {
fooModel: new FooModel(ctx.serverDB, ctx.userId),
barModel: new BarModel(ctx.serverDB, ctx.userId),
},
});
});
```
Then use `ctx.fooModel` in procedures:
```typescript
// Good
const model = ctx.fooModel;
// Bad - don't create models inside procedures
const model = new FooModel(ctx.serverDB, ctx.userId);
```
**Exception**: When a model needs a different `userId` (e.g., watchdog iterating over multiple users' tasks), create it inline.
### Procedure Pattern
```typescript
export const fooRouter = router({
// Query
find: domainProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
try {
const item = await ctx.fooModel.findById(input.id);
if (!item) throw new TRPCError({ code: 'NOT_FOUND', message: 'Not found' });
return { data: item, success: true };
} catch (error) {
if (error instanceof TRPCError) throw error;
console.error('[foo:find]', error);
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to find item',
});
}
}),
// Mutation
create: domainProcedure.input(createSchema).mutation(async ({ input, ctx }) => {
try {
const item = await ctx.fooModel.create(input);
return { data: item, message: 'Created', success: true };
} catch (error) {
if (error instanceof TRPCError) throw error;
console.error('[foo:create]', error);
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to create',
});
}
}),
});
```
### Aggregated Detail Endpoint
For views that need multiple related data, create a single `detail` procedure that fetches everything in parallel:
```typescript
detail: domainProcedure.input(idInput).query(async ({ input, ctx }) => {
const item = await resolveOrThrow(ctx.fooModel, input.id);
const [children, related] = await Promise.all([
ctx.fooModel.findChildren(item.id),
ctx.barModel.findByFooId(item.id),
]);
return {
data: { ...item, children, related },
success: true,
};
}),
```
This avoids the CLI or frontend making N sequential requests.
## Conventions
- Return shape: `{ data, success: true }` for queries, `{ data?, message, success: true }` for mutations
- Error handling: re-throw `TRPCError`, wrap others with `console.error` + new `TRPCError`
- Input validation: use `zod` schemas, define at file top
- Router name: `export const fooRouter = router({ ... })`
- Procedure names: alphabetical order within the router object
- Log prefix: `[domain:procedure]` format, e.g. `[task:create]`
-44
View File
@@ -1,44 +0,0 @@
# @lobehub/cli
LobeHub command-line interface.
## Local Development
| Task | Command |
| ------------------------------------------ | -------------------------- |
| Run in dev mode | `bun run dev -- <command>` |
| Build the CLI | `bun run build` |
| Link `lh`/`lobe`/`lobehub` into your shell | `bun run cli:link` |
| Remove the global link | `bun run cli:unlink` |
- `bun run build` only generates `dist/index.js`.
- To make `lh` available in your shell, run `bun run cli:link`.
- After linking, if your shell still cannot find `lh`, run `rehash` in `zsh`.
## Shell Completion
### Install completion for a linked CLI
| Shell | Command |
| ------ | ------------------------------ |
| `zsh` | `source <(lh completion zsh)` |
| `bash` | `source <(lh completion bash)` |
### Use completion during local development
| Shell | Command |
| ------ | -------------------------------------------- |
| `zsh` | `source <(bun src/index.ts completion zsh)` |
| `bash` | `source <(bun src/index.ts completion bash)` |
- Completion is context-aware. For example, `lh agent <Tab>` shows agent subcommands instead of top-level commands.
- If you update completion logic locally, re-run the corresponding `source <(...)` command to reload it in the current shell session.
- Completion only registers shell functions. It does not install the `lh` binary by itself.
## Quick Check
```bash
which lh
lh --help
lh agent <TAB>
```
+11 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.1-canary.12",
"version": "0.0.1-canary.14",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -11,7 +11,7 @@
"dist"
],
"scripts": {
"build": "tsdown",
"build": "npx tsup",
"cli:link": "bun link",
"cli:unlink": "bun unlink",
"dev": "LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts",
@@ -20,22 +20,24 @@
"test:coverage": "bunx vitest run --config vitest.config.mts --coverage",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@lobechat/device-gateway-client": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"dependencies": {
"@trpc/client": "^11.8.1",
"@types/node": "^22.13.5",
"@types/ws": "^8.18.1",
"commander": "^13.1.0",
"debug": "^4.4.0",
"diff": "^8.0.3",
"fast-glob": "^3.3.3",
"picocolors": "^1.1.1",
"superjson": "^2.2.6",
"tsdown": "^0.21.4",
"typescript": "^5.9.3",
"ws": "^8.18.1"
},
"devDependencies": {
"@lobechat/device-gateway-client": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@types/node": "^22.13.5",
"@types/ws": "^8.18.1",
"tsup": "^8.4.0",
"typescript": "^5.9.3"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
+1 -8
View File
@@ -5,7 +5,7 @@ import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable } from '../utils/format';
import { log } from '../utils/logger';
const SUPPORTED_PLATFORMS = ['discord', 'slack', 'telegram', 'lark', 'feishu', 'wechat'];
const SUPPORTED_PLATFORMS = ['discord', 'slack', 'telegram', 'lark', 'feishu'];
const PLATFORM_CREDENTIAL_FIELDS: Record<string, string[]> = {
discord: ['botToken', 'publicKey'],
@@ -13,7 +13,6 @@ const PLATFORM_CREDENTIAL_FIELDS: Record<string, string[]> = {
lark: ['appSecret'],
slack: ['botToken', 'signingSecret'],
telegram: ['botToken'],
wechat: ['botToken', 'botId'],
};
function parseCredentials(
@@ -23,7 +22,6 @@ function parseCredentials(
const creds: Record<string, string> = {};
if (options.botToken) creds.botToken = options.botToken;
if (options.botId) creds.botId = options.botId;
if (options.publicKey) creds.publicKey = options.publicKey;
if (options.signingSecret) creds.signingSecret = options.signingSecret;
if (options.appSecret) creds.appSecret = options.appSecret;
@@ -127,7 +125,6 @@ export function registerBotCommand(program: Command) {
.requiredOption('--platform <platform>', `Platform: ${SUPPORTED_PLATFORMS.join(', ')}`)
.requiredOption('--app-id <appId>', 'Application ID for webhook routing')
.option('--bot-token <token>', 'Bot token')
.option('--bot-id <id>', 'Bot ID (WeChat)')
.option('--public-key <key>', 'Public key (Discord)')
.option('--signing-secret <secret>', 'Signing secret (Slack)')
.option('--app-secret <secret>', 'App secret (Lark/Feishu)')
@@ -136,7 +133,6 @@ export function registerBotCommand(program: Command) {
agent: string;
appId: string;
appSecret?: string;
botId?: string;
botToken?: string;
platform: string;
publicKey?: string;
@@ -179,7 +175,6 @@ export function registerBotCommand(program: Command) {
.command('update <botId>')
.description('Update a bot integration')
.option('--bot-token <token>', 'New bot token')
.option('--bot-id <id>', 'New bot ID (WeChat)')
.option('--public-key <key>', 'New public key')
.option('--signing-secret <secret>', 'New signing secret')
.option('--app-secret <secret>', 'New app secret')
@@ -191,7 +186,6 @@ export function registerBotCommand(program: Command) {
options: {
appId?: string;
appSecret?: string;
botId?: string;
botToken?: string;
platform?: string;
publicKey?: string;
@@ -202,7 +196,6 @@ export function registerBotCommand(program: Command) {
const credentials: Record<string, string> = {};
if (options.botToken) credentials.botToken = options.botToken;
if (options.botId) credentials.botId = options.botId;
if (options.publicKey) credentials.publicKey = options.publicKey;
if (options.signingSecret) credentials.signingSecret = options.signingSecret;
if (options.appSecret) credentials.appSecret = options.appSecret;
+211
View File
@@ -0,0 +1,211 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
export function registerBriefCommand(program: Command) {
const brief = program.command('brief').description('Manage briefs (Agent reports)');
// ── list ──────────────────────────────────────────────
brief
.command('list')
.description('List briefs')
.option('--unresolved', 'Only show unresolved briefs (default)')
.option('--all', 'Show all briefs')
.option('--type <type>', 'Filter by type (decision/result/insight/error)')
.option('-L, --limit <n>', 'Page size', '50')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
all?: boolean;
json?: string | boolean;
limit?: string;
type?: string;
unresolved?: boolean;
}) => {
const client = await getTrpcClient();
let items: any[];
if (options.all) {
const input: Record<string, any> = {};
if (options.type) input.type = options.type;
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
const result = await client.brief.list.query(input as any);
items = result.data;
} else {
const result = await client.brief.listUnresolved.query();
items = result.data;
}
if (options.json !== undefined) {
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (!items || items.length === 0) {
log.info('No briefs found.');
return;
}
const rows = items.map((b: any) => [
typeBadge(b.type, b.priority),
truncate(b.title, 40),
truncate(b.summary, 50),
b.taskId ? pc.dim(b.taskId) : b.cronJobId ? pc.dim(b.cronJobId) : '-',
b.resolvedAt ? pc.green('resolved') : b.readAt ? pc.dim('read') : 'new',
timeAgo(b.createdAt),
]);
printTable(rows, ['TYPE', 'TITLE', 'SUMMARY', 'SOURCE', 'STATUS', 'CREATED']);
},
);
// ── view ──────────────────────────────────────────────
brief
.command('view <id>')
.description('View brief details (auto marks as read)')
.option('--json [fields]', 'Output JSON')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.brief.find.query({ id });
const b = result.data;
if (options.json !== undefined) {
outputJson(b, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (!b) {
log.error('Brief not found.');
return;
}
// Auto mark as read
if (!b.readAt) {
await client.brief.markRead.mutate({ id });
}
const resolvedLabel = b.resolvedAt
? (() => {
const actions = (b.actions as any[]) || [];
const matched = actions.find((a: any) => a.key === (b as any).resolvedAction);
return pc.green(` ${matched?.label || '✓ resolved'}`);
})()
: '';
console.log(`\n${typeBadge(b.type, b.priority)} ${pc.bold(b.title)}${resolvedLabel}`);
console.log(`${pc.dim('Type:')} ${b.type} ${pc.dim('Created:')} ${timeAgo(b.createdAt)}`);
if (b.agentId) console.log(`${pc.dim('Agent:')} ${b.agentId}`);
if (b.taskId) console.log(`${pc.dim('Task:')} ${b.taskId}`);
if (b.cronJobId) console.log(`${pc.dim('CronJob:')} ${b.cronJobId}`);
if (b.topicId) console.log(`${pc.dim('Topic:')} ${b.topicId}`);
console.log(`\n${b.summary}`);
if (b.artifacts && (b.artifacts as string[]).length > 0) {
console.log(`\n${pc.dim('Artifacts:')}`);
for (const a of b.artifacts as string[]) {
console.log(` 📎 ${a}`);
}
}
console.log();
if (!b.resolvedAt) {
const actions = (b.actions as any[]) || [];
if (actions.length > 0) {
console.log('Actions:');
for (const a of actions) {
const cmd =
a.type === 'comment'
? `lh brief resolve ${b.id} --action ${a.key} -m "内容"`
: `lh brief resolve ${b.id} --action ${a.key}`;
console.log(` ${a.label} ${pc.dim(cmd)}`);
}
} else {
console.log(pc.dim('Actions:'));
console.log(pc.dim(` lh brief resolve ${b.id} # 确认通过`));
console.log(pc.dim(` lh brief resolve ${b.id} --reply "修改意见" # 反馈修改`));
}
} else if ((b as any).resolvedComment) {
console.log(`${pc.dim('Comment:')} ${(b as any).resolvedComment}`);
}
});
// ── resolve ──────────────────────────────────────────────
brief
.command('resolve <id>')
.description('Resolve a brief (approve, reply, or custom action)')
.option('--action <key>', 'Execute a specific action (e.g. approve, feedback)')
.option('--reply <text>', 'Reply with feedback')
.option('-m, --message <text>', 'Message for comment-type actions')
.action(async (id: string, options: { action?: string; message?: string; reply?: string }) => {
const client = await getTrpcClient();
const actionKey = options.action || (options.reply ? 'feedback' : 'approve');
const actionMessage = options.message || options.reply;
const briefResult = await client.brief.find.query({ id });
const b = briefResult.data;
// For comment-type actions, add comment to task
if (actionMessage && b?.taskId) {
await client.task.addComment.mutate({
briefId: id,
content: actionMessage,
id: b.taskId,
});
}
await client.brief.resolve.mutate({
action: actionKey,
comment: actionMessage,
id,
});
const actions = (b?.actions as any[]) || [];
const matchedAction = actions.find((a: any) => a.key === actionKey);
const label = matchedAction?.label || actionKey;
log.info(`${label} — Brief ${pc.dim(id)} resolved.`);
});
// ── delete ──────────────────────────────────────────────
brief
.command('delete <id>')
.description('Delete a brief')
.action(async (id: string) => {
const client = await getTrpcClient();
await client.brief.delete.mutate({ id });
log.info(`Brief ${pc.dim(id)} deleted.`);
});
}
function typeBadge(type: string, priority?: string): string {
if (priority === 'urgent') {
return pc.red('🔴');
}
switch (type) {
case 'decision': {
return pc.yellow('🟡');
}
case 'result': {
return pc.green('✅');
}
case 'insight': {
return '💬';
}
case 'error': {
return pc.red('❌');
}
default: {
return '·';
}
}
}
-102
View File
@@ -1,102 +0,0 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerCompletionCommand } from './completion';
describe('completion command', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
const originalShell = process.env.SHELL;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
delete process.env.LOBEHUB_COMP_CWORD;
process.env.SHELL = originalShell;
});
function createProgram() {
const program = new Command();
program.exitOverride();
program
.command('agent')
.description('Agent commands')
.command('list')
.description('List agents');
program.command('generate').alias('gen').description('Generate content');
program.command('usage').description('Usage').option('--month <YYYY-MM>', 'Month to query');
program.command('internal', { hidden: true });
registerCompletionCommand(program);
return program;
}
it('should output zsh completion script by default', async () => {
process.env.SHELL = '/bin/zsh';
const program = createProgram();
await program.parseAsync(['node', 'test', 'completion']);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('compdef _lobehub_completion'));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('lh lobe lobehub'));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"${(@)words[@]:1}"'));
});
it('should output bash completion script when requested', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'completion', 'bash']);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('complete -o nosort'));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('__complete'));
});
it('should suggest root commands and aliases', async () => {
process.env.LOBEHUB_COMP_CWORD = '0';
const program = createProgram();
await program.parseAsync(['node', 'test', '__complete', 'g']);
expect(consoleSpy.mock.calls.map(([value]) => value)).toEqual(['gen', 'generate']);
});
it('should suggest nested subcommands in the current command context', async () => {
process.env.LOBEHUB_COMP_CWORD = '1';
const program = createProgram();
await program.parseAsync(['node', 'test', '__complete', 'agent']);
expect(consoleSpy).toHaveBeenCalledWith('list');
});
it('should suggest command options after leaf commands', async () => {
process.env.LOBEHUB_COMP_CWORD = '1';
const program = createProgram();
await program.parseAsync(['node', 'test', '__complete', 'usage']);
expect(consoleSpy).toHaveBeenCalledWith('--month');
});
it('should not suggest commands while completing an option value', async () => {
process.env.LOBEHUB_COMP_CWORD = '2';
const program = createProgram();
await program.parseAsync(['node', 'test', '__complete', 'usage', '--month']);
expect(consoleSpy).not.toHaveBeenCalled();
});
it('should not expose hidden commands', async () => {
process.env.LOBEHUB_COMP_CWORD = '0';
const program = createProgram();
await program.parseAsync(['node', 'test', '__complete']);
expect(consoleSpy.mock.calls.map(([value]) => value)).not.toContain('internal');
expect(consoleSpy.mock.calls.map(([value]) => value)).not.toContain('__complete');
});
});
-30
View File
@@ -1,30 +0,0 @@
import type { Command } from 'commander';
import {
getCompletionCandidates,
parseCompletionWordIndex,
renderCompletionScript,
resolveCompletionShell,
} from '../utils/completion';
export function registerCompletionCommand(program: Command) {
program
.command('completion [shell]')
.description('Output shell completion script')
.action((shell?: string) => {
console.log(renderCompletionScript(resolveCompletionShell(shell)));
});
program
.command('__complete', { hidden: true })
.allowUnknownOption()
.argument('[words...]')
.action((words: string[] = []) => {
const currentWordIndex = parseCompletionWordIndex(process.env.LOBEHUB_COMP_CWORD, words);
const candidates = getCompletionCandidates(program, words, currentWordIndex);
for (const candidate of candidates) {
console.log(candidate);
}
});
}
+95
View File
@@ -0,0 +1,95 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../../api/client';
import { log } from '../../utils/logger';
export function registerCheckpointCommands(task: Command) {
// ── checkpoint ──────────────────────────────────────────────
const cp = task.command('checkpoint').description('Manage task checkpoints');
cp.command('view <id>')
.description('View checkpoint config for a task')
.action(async (id: string) => {
const client = await getTrpcClient();
const result = await client.task.getCheckpoint.query({ id });
const c = result.data as any;
console.log(`\n${pc.bold('Checkpoint config:')}`);
console.log(` onAgentRequest: ${c.onAgentRequest ?? pc.dim('not set (default: true)')}`);
if (c.topic) {
console.log(` topic.before: ${c.topic.before ?? false}`);
console.log(` topic.after: ${c.topic.after ?? false}`);
}
if (c.tasks?.beforeIds?.length > 0) {
console.log(` tasks.beforeIds: ${c.tasks.beforeIds.join(', ')}`);
}
if (c.tasks?.afterIds?.length > 0) {
console.log(` tasks.afterIds: ${c.tasks.afterIds.join(', ')}`);
}
if (
!c.topic &&
!c.tasks?.beforeIds?.length &&
!c.tasks?.afterIds?.length &&
c.onAgentRequest === undefined
) {
console.log(` ${pc.dim('(no checkpoints configured)')}`);
}
console.log();
});
cp.command('set <id>')
.description('Configure checkpoints')
.option('--on-agent-request <bool>', 'Allow agent to request review (true/false)')
.option('--topic-before <bool>', 'Pause before each topic (true/false)')
.option('--topic-after <bool>', 'Pause after each topic (true/false)')
.option('--before <ids>', 'Pause before these subtask identifiers (comma-separated)')
.option('--after <ids>', 'Pause after these subtask identifiers (comma-separated)')
.action(
async (
id: string,
options: {
after?: string;
before?: string;
onAgentRequest?: string;
topicAfter?: string;
topicBefore?: string;
},
) => {
const client = await getTrpcClient();
// Get current config first
const current = (await client.task.getCheckpoint.query({ id })).data as any;
const checkpoint: any = { ...current };
if (options.onAgentRequest !== undefined) {
checkpoint.onAgentRequest = options.onAgentRequest === 'true';
}
if (options.topicBefore !== undefined || options.topicAfter !== undefined) {
checkpoint.topic = { ...checkpoint.topic };
if (options.topicBefore !== undefined)
checkpoint.topic.before = options.topicBefore === 'true';
if (options.topicAfter !== undefined)
checkpoint.topic.after = options.topicAfter === 'true';
}
if (options.before !== undefined) {
checkpoint.tasks = { ...checkpoint.tasks };
checkpoint.tasks.beforeIds = options.before
.split(',')
.map((s: string) => s.trim())
.filter(Boolean);
}
if (options.after !== undefined) {
checkpoint.tasks = { ...checkpoint.tasks };
checkpoint.tasks.afterIds = options.after
.split(',')
.map((s: string) => s.trim())
.filter(Boolean);
}
await client.task.updateCheckpoint.mutate({ checkpoint, id });
log.info('Checkpoint updated.');
},
);
}
+56
View File
@@ -0,0 +1,56 @@
import type { Command } from 'commander';
import { getTrpcClient } from '../../api/client';
import { outputJson, printTable, timeAgo } from '../../utils/format';
import { log } from '../../utils/logger';
export function registerDepCommands(task: Command) {
// ── dep ──────────────────────────────────────────────
const dep = task.command('dep').description('Manage task dependencies');
dep
.command('add <taskId> <dependsOnId>')
.description('Add dependency (taskId blocks on dependsOnId)')
.option('--type <type>', 'Dependency type (blocks/relates)', 'blocks')
.action(async (taskId: string, dependsOnId: string, options: { type?: string }) => {
const client = await getTrpcClient();
await client.task.addDependency.mutate({
dependsOnId,
taskId,
type: (options.type || 'blocks') as any,
});
log.info(`Dependency added: ${taskId} ${options.type || 'blocks'} on ${dependsOnId}`);
});
dep
.command('rm <taskId> <dependsOnId>')
.description('Remove dependency')
.action(async (taskId: string, dependsOnId: string) => {
const client = await getTrpcClient();
await client.task.removeDependency.mutate({ dependsOnId, taskId });
log.info(`Dependency removed.`);
});
dep
.command('list <taskId>')
.description('List dependencies for a task')
.option('--json [fields]', 'Output JSON')
.action(async (taskId: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.task.getDependencies.query({ id: taskId });
if (options.json !== undefined) {
outputJson(result.data, options.json);
return;
}
if (!result.data || result.data.length === 0) {
log.info('No dependencies.');
return;
}
const rows = result.data.map((d: any) => [d.type, d.dependsOnId, timeAgo(d.createdAt)]);
printTable(rows, ['TYPE', 'DEPENDS ON', 'CREATED']);
});
}
+102
View File
@@ -0,0 +1,102 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../../api/client';
import { log } from '../../utils/logger';
export function registerDocCommands(task: Command) {
// ── doc ──────────────────────────────────────────────
const dc = task.command('doc').description('Manage task workspace documents');
dc.command('create <id>')
.description('Create a document and pin it to the task')
.requiredOption('-t, --title <title>', 'Document title')
.option('-b, --body <content>', 'Document content')
.option('--parent <docId>', 'Parent document/folder ID')
.option('--folder', 'Create as folder')
.action(
async (
id: string,
options: { body?: string; folder?: boolean; parent?: string; title: string },
) => {
const client = await getTrpcClient();
// Create document
const fileType = options.folder ? 'custom/folder' : undefined;
const content = options.body || '';
const result = await client.document.createDocument.mutate({
content,
editorData: options.folder ? undefined : JSON.stringify({ content, type: 'doc' }),
fileType,
parentId: options.parent,
title: options.title,
});
// Pin to task
await client.task.pinDocument.mutate({
documentId: result.id,
pinnedBy: 'user',
taskId: id,
});
const icon = options.folder ? '📁' : '📄';
log.info(`${icon} Created & pinned: ${pc.bold(options.title)} ${pc.dim(result.id)}`);
},
);
dc.command('pin <id> <documentId>')
.description('Pin an existing document to a task')
.action(async (id: string, documentId: string) => {
const client = await getTrpcClient();
await client.task.pinDocument.mutate({ documentId, pinnedBy: 'user', taskId: id });
log.info(`Pinned ${pc.dim(documentId)} to ${pc.bold(id)}.`);
});
dc.command('unpin <id> <documentId>')
.description('Unpin a document from a task')
.action(async (id: string, documentId: string) => {
const client = await getTrpcClient();
await client.task.unpinDocument.mutate({ documentId, taskId: id });
log.info(`Unpinned ${pc.dim(documentId)} from ${pc.bold(id)}.`);
});
dc.command('mv <id> <documentId> <folder>')
.description('Move a document into a folder (auto-creates folder if not found)')
.action(async (id: string, documentId: string, folder: string) => {
const client = await getTrpcClient();
// Check if folder is a document ID or a folder name
let folderId = folder;
if (!folder.startsWith('docs_')) {
// folder is a name, find or create it
const detail = await client.task.detail.query({ id });
const folders = detail.data.workspace || [];
// Search for existing folder by name
const existingFolder = folders.find((f) => f.title === folder);
if (existingFolder) {
folderId = existingFolder.documentId;
} else {
// Create folder and pin to task
const result = await client.document.createDocument.mutate({
content: '',
fileType: 'custom/folder',
title: folder,
});
await client.task.pinDocument.mutate({
documentId: result.id,
pinnedBy: 'user',
taskId: id,
});
folderId = result.id;
log.info(`📁 Created folder: ${pc.bold(folder)} ${pc.dim(folderId)}`);
}
}
// Move document into folder
await client.document.updateDocument.mutate({ id: documentId, parentId: folderId });
log.info(`Moved ${pc.dim(documentId)} → 📁 ${pc.bold(folder)}`);
});
}
+74
View File
@@ -0,0 +1,74 @@
import pc from 'picocolors';
export function statusBadge(status: string): string {
const pad = (s: string) => s.padEnd(9);
switch (status) {
case 'backlog': {
return pc.dim(`${pad('backlog')}`);
}
case 'blocked': {
return pc.red(`${pad('blocked')}`);
}
case 'running': {
return pc.blue(`${pad('running')}`);
}
case 'paused': {
return pc.yellow(`${pad('paused')}`);
}
case 'completed': {
return pc.green(`${pad('completed')}`);
}
case 'failed': {
return pc.red(`${pad('failed')}`);
}
case 'timeout': {
return pc.red(`${pad('timeout')}`);
}
case 'canceled': {
return pc.dim(`${pad('canceled')}`);
}
default: {
return status;
}
}
}
export function briefIcon(type: string): string {
switch (type) {
case 'decision': {
return '📋';
}
case 'result': {
return '✅';
}
case 'insight': {
return '💡';
}
case 'error': {
return '❌';
}
default: {
return '📌';
}
}
}
export function priorityLabel(priority: number | null | undefined): string {
switch (priority) {
case 1: {
return pc.red('urgent');
}
case 2: {
return pc.yellow('high');
}
case 3: {
return 'normal';
}
case 4: {
return pc.dim('low');
}
default: {
return pc.dim('-');
}
}
}
+555
View File
@@ -0,0 +1,555 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../../api/client';
import {
confirm,
displayWidth,
outputJson,
printTable,
timeAgo,
truncate,
} from '../../utils/format';
import { log } from '../../utils/logger';
import { registerCheckpointCommands } from './checkpoint';
import { registerDepCommands } from './dep';
import { registerDocCommands } from './doc';
import { briefIcon, priorityLabel, statusBadge } from './helpers';
import { registerLifecycleCommands } from './lifecycle';
import { registerReviewCommands } from './review';
import { registerTopicCommands } from './topic';
export function registerTaskCommand(program: Command) {
const task = program.command('task').description('Manage agent tasks');
// ── list ──────────────────────────────────────────────
task
.command('list')
.description('List tasks')
.option(
'--status <status>',
'Filter by status (pending/running/paused/completed/failed/canceled)',
)
.option('--root', 'Only show root tasks (no parent)')
.option('--parent <id>', 'Filter by parent task ID')
.option('--agent <id>', 'Filter by assignee agent ID')
.option('-L, --limit <n>', 'Page size', '50')
.option('--offset <n>', 'Offset', '0')
.option('--tree', 'Display as tree structure')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
agent?: string;
json?: string | boolean;
limit?: string;
offset?: string;
parent?: string;
root?: boolean;
status?: string;
tree?: boolean;
}) => {
const client = await getTrpcClient();
const input: Record<string, any> = {};
if (options.status) input.status = options.status;
if (options.root) input.parentTaskId = null;
if (options.parent) input.parentTaskId = options.parent;
if (options.agent) input.assigneeAgentId = options.agent;
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
// For tree mode, fetch all tasks (no pagination limit)
if (options.tree) {
input.limit = 100;
delete input.offset;
}
const result = await client.task.list.query(input as any);
if (options.json !== undefined) {
outputJson(result.data, options.json);
return;
}
if (!result.data || result.data.length === 0) {
log.info('No tasks found.');
return;
}
if (options.tree) {
// Build tree display
const taskMap = new Map<string, any>();
for (const t of result.data) taskMap.set(t.id, t);
const roots = result.data.filter((t: any) => !t.parentTaskId);
const children = new Map<string, any[]>();
for (const t of result.data) {
if (t.parentTaskId) {
const list = children.get(t.parentTaskId) || [];
list.push(t);
children.set(t.parentTaskId, list);
}
}
// Sort children by sortOrder first, then seq
for (const [, list] of children) {
list.sort(
(a: any, b: any) =>
(a.sortOrder ?? 0) - (b.sortOrder ?? 0) || (a.seq ?? 0) - (b.seq ?? 0),
);
}
const printNode = (t: any, prefix: string, isLast: boolean, isRoot: boolean) => {
const connector = isRoot ? '' : isLast ? '└── ' : '├── ';
const name = truncate(t.name || t.instruction, 40);
console.log(
`${prefix}${connector}${pc.dim(t.identifier)} ${statusBadge(t.status)} ${name}`,
);
const childList = children.get(t.id) || [];
const newPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ ');
childList.forEach((child: any, i: number) => {
printNode(child, newPrefix, i === childList.length - 1, false);
});
};
for (const root of roots) {
printNode(root, '', true, true);
}
log.info(`Total: ${result.total}`);
return;
}
const rows = result.data.map((t: any) => [
pc.dim(t.identifier),
truncate(t.name || t.instruction, 40),
statusBadge(t.status),
priorityLabel(t.priority),
t.assigneeAgentId ? pc.dim(t.assigneeAgentId) : '-',
t.parentTaskId ? pc.dim('↳ subtask') : '',
timeAgo(t.createdAt),
]);
printTable(rows, ['ID', 'NAME', 'STATUS', 'PRI', 'AGENT', 'TYPE', 'CREATED']);
log.info(`Total: ${result.total}`);
},
);
// ── view ──────────────────────────────────────────────
task
.command('view <id>')
.description('View task details (by ID or identifier like TASK-1)')
.option('--json [fields]', 'Output JSON')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.task.detail.query({ id });
if (options.json !== undefined) {
outputJson(result.data, options.json);
return;
}
const t = result.data;
// ── Header ──
console.log(`\n${pc.bold(t.identifier)} ${t.name || ''}`);
console.log(
`${pc.dim('Status:')} ${statusBadge(t.status)} ${pc.dim('Priority:')} ${priorityLabel(t.priority)}`,
);
console.log(`${pc.dim('Instruction:')} ${t.instruction}`);
if (t.description) console.log(`${pc.dim('Description:')} ${t.description}`);
if (t.agentId) console.log(`${pc.dim('Agent:')} ${t.agentId}`);
if (t.userId) console.log(`${pc.dim('User:')} ${t.userId}`);
if (t.parent) {
console.log(`${pc.dim('Parent:')} ${t.parent.identifier} ${t.parent.name || ''}`);
}
const topicInfo = t.topicCount ? `${t.topicCount}` : '0';
const createdInfo = t.createdAt ? timeAgo(t.createdAt) : '-';
console.log(`${pc.dim('Topics:')} ${topicInfo} ${pc.dim('Created:')} ${createdInfo}`);
if (t.heartbeat?.timeout && t.heartbeat.lastAt) {
const hb = timeAgo(t.heartbeat.lastAt);
const interval = t.heartbeat.interval ? `${t.heartbeat.interval}s` : '-';
const elapsed = (Date.now() - new Date(t.heartbeat.lastAt).getTime()) / 1000;
const isStuck = t.status === 'running' && elapsed > t.heartbeat.timeout;
console.log(
`${pc.dim('Heartbeat:')} ${isStuck ? pc.red(hb) : hb} ${pc.dim('interval:')} ${interval} ${pc.dim('timeout:')} ${t.heartbeat.timeout}s${isStuck ? pc.red(' ⚠ TIMEOUT') : ''}`,
);
}
if (t.error) console.log(`${pc.red('Error:')} ${t.error}`);
// ── Subtasks ──
if (t.subtasks && t.subtasks.length > 0) {
// Build lookup: which subtasks are completed
const completedIdentifiers = new Set(
t.subtasks.filter((s) => s.status === 'completed').map((s) => s.identifier),
);
console.log(`\n${pc.bold('Subtasks:')}`);
for (const s of t.subtasks) {
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
// Show 'blocked' instead of 'backlog' if task has unresolved dependencies
const isBlocked = s.blockedBy && !completedIdentifiers.has(s.blockedBy);
const displayStatus = s.status === 'backlog' && isBlocked ? 'blocked' : s.status;
console.log(
` ${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
);
}
}
// ── Dependencies ──
if (t.dependencies && t.dependencies.length > 0) {
console.log(`\n${pc.bold('Dependencies:')}`);
for (const d of t.dependencies) {
const depName = d.name ? ` ${d.name}` : '';
console.log(` ${pc.dim(d.type || 'blocks')}: ${d.dependsOn}${depName}`);
}
}
// ── Checkpoint ──
{
const cp = t.checkpoint || {};
console.log(`\n${pc.bold('Checkpoint:')}`);
const hasConfig =
cp.onAgentRequest !== undefined ||
cp.topic?.before ||
cp.topic?.after ||
cp.tasks?.beforeIds?.length ||
cp.tasks?.afterIds?.length;
if (hasConfig) {
if (cp.onAgentRequest !== undefined)
console.log(` onAgentRequest: ${cp.onAgentRequest}`);
if (cp.topic?.before) console.log(` topic.before: ${cp.topic.before}`);
if (cp.topic?.after) console.log(` topic.after: ${cp.topic.after}`);
if (cp.tasks?.beforeIds?.length)
console.log(` tasks.before: ${cp.tasks.beforeIds.join(', ')}`);
if (cp.tasks?.afterIds?.length)
console.log(` tasks.after: ${cp.tasks.afterIds.join(', ')}`);
} else {
console.log(` ${pc.dim('(not configured, default: onAgentRequest=true)')}`);
}
}
// ── Review ──
{
const rv = t.review as any;
console.log(`\n${pc.bold('Review:')}`);
if (rv && rv.enabled) {
console.log(
` judge: ${rv.judge?.model || 'default'}${rv.judge?.provider ? ` (${rv.judge.provider})` : ''}`,
);
console.log(` maxIterations: ${rv.maxIterations} autoRetry: ${rv.autoRetry}`);
if (rv.rubrics?.length > 0) {
for (let i = 0; i < rv.rubrics.length; i++) {
const rb = rv.rubrics[i];
const threshold = rb.threshold ? `${Math.round(rb.threshold * 100)}%` : '';
const typeTag = pc.dim(`[${rb.type}]`);
let configInfo = '';
if (rb.type === 'llm-rubric') configInfo = rb.config?.criteria || '';
else if (rb.type === 'contains' || rb.type === 'equals')
configInfo = `value="${rb.config?.value}"`;
else if (rb.type === 'regex') configInfo = `pattern="${rb.config?.pattern}"`;
console.log(` ${i + 1}. ${rb.name} ${typeTag}${threshold} ${pc.dim(configInfo)}`);
}
}
} else {
console.log(` ${pc.dim('(not configured)')}`);
}
}
// ── Workspace ──
{
const nodes = t.workspace || [];
if (nodes.length === 0) {
console.log(`\n${pc.bold('Workspace:')}`);
console.log(` ${pc.dim('No documents yet.')}`);
} else {
const countNodes = (list: typeof nodes): number =>
list.reduce((sum, n) => sum + 1 + (n.children ? countNodes(n.children) : 0), 0);
console.log(`\n${pc.bold(`Workspace (${countNodes(nodes)}):`)}`);
const formatSize = (chars: number | null | undefined) => {
if (!chars) return '';
if (chars >= 10_000) return `${(chars / 1000).toFixed(1)}k`;
return `${chars}`;
};
const LEFT_COL = 56;
const FROM_WIDTH = 10;
const renderNodes = (list: typeof nodes, indent: string) => {
for (let i = 0; i < list.length; i++) {
const node = list[i];
const isFolder = node.fileType === 'custom/folder';
const isLast = i === list.length - 1;
const icon = isFolder ? '📁' : '📄';
const prefix = `${indent}${icon} `;
const titleStr = truncate(node.title || 'Untitled', LEFT_COL - displayWidth(prefix));
const titlePad = ' '.repeat(
Math.max(1, LEFT_COL - displayWidth(prefix) - displayWidth(titleStr)),
);
const fromStr = node.sourceTaskIdentifier ? `${node.sourceTaskIdentifier}` : '';
const fromPad = fromStr
? ' '.repeat(Math.max(1, FROM_WIDTH - fromStr.length + 1))
: '';
const size =
!isFolder && node.size ? formatSize(node.size).padStart(6) + ' chars' : '';
console.log(
`${prefix}${titleStr}${titlePad}${pc.dim(`(${node.documentId})`)} ${fromStr}${fromPad}${pc.dim(size)}`,
);
if (node.children && node.children.length > 0) {
const childIndent = indent + (isLast ? ' ' : ' ');
renderNodes(node.children, childIndent);
}
}
};
renderNodes(nodes, ' ');
}
}
// ── Activities ──
{
const tl = t.timeline;
const activities: { text: string; time: string }[] = [];
for (const tp of tl?.topics || []) {
const sBadge = statusBadge(tp.status || 'running');
activities.push({
text: ` 💬 ${pc.dim((tp.time || '').padStart(7))} Topic #${tp.seq || '?'} ${tp.title || 'Untitled'} ${sBadge} ${pc.dim(tp.id || '')}`,
time: tp.time || '',
});
}
for (const b of tl?.briefs || []) {
const icon = briefIcon(b.type);
const pri =
b.priority === 'urgent'
? pc.red(' [urgent]')
: b.priority === 'normal'
? pc.yellow(' [normal]')
: '';
const resolved = b.resolvedAction ? pc.green(` ✏️ ${b.resolvedAction}`) : '';
const typeLabel = pc.dim(`[${b.type}]`);
activities.push({
text: ` ${icon} ${pc.dim((b.time || '').padStart(7))} Brief ${typeLabel} ${b.title}${pri}${resolved} ${pc.dim(b.id || '')}`,
time: b.time || '',
});
}
for (const c of tl?.comments || []) {
const author = c.agentId ? `🤖 ${c.agentId}` : '👤 user';
activities.push({
text: ` 💭 ${pc.dim((c.time || '').padStart(7))} ${pc.cyan(author)} ${c.content}`,
time: c.time || '',
});
}
console.log(`\n${pc.bold('Activities:')}`);
if (activities.length === 0) {
console.log(` ${pc.dim('No activities yet.')}`);
} else {
// Activities are already sorted by the service
for (const act of activities) {
console.log(act.text);
}
}
}
console.log();
});
// ── create ──────────────────────────────────────────────
task
.command('create')
.description('Create a new task')
.requiredOption('-i, --instruction <text>', 'Task instruction')
.option('-n, --name <name>', 'Task name')
.option('--agent <id>', 'Assign to agent')
.option('--parent <id>', 'Parent task ID')
.option('--priority <n>', 'Priority (0=none, 1=urgent, 2=high, 3=normal, 4=low)', '0')
.option('--prefix <prefix>', 'Identifier prefix', 'TASK')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
agent?: string;
instruction: string;
json?: string | boolean;
name?: string;
parent?: string;
prefix?: string;
priority?: string;
}) => {
const client = await getTrpcClient();
const input: Record<string, any> = {
instruction: options.instruction,
};
if (options.name) input.name = options.name;
if (options.agent) input.assigneeAgentId = options.agent;
if (options.parent) input.parentTaskId = options.parent;
if (options.priority) input.priority = Number.parseInt(options.priority, 10);
if (options.prefix) input.identifierPrefix = options.prefix;
const result = await client.task.create.mutate(input as any);
if (options.json !== undefined) {
outputJson(result.data, options.json);
return;
}
log.info(`Task created: ${pc.bold(result.data.identifier)} ${result.data.name || ''}`);
},
);
// ── edit ──────────────────────────────────────────────
task
.command('edit <id>')
.description('Update a task')
.option('-n, --name <name>', 'Task name')
.option('-i, --instruction <text>', 'Task instruction')
.option('--agent <id>', 'Assign to agent')
.option('--priority <n>', 'Priority (0-4)')
.option('--heartbeat-interval <n>', 'Heartbeat interval in seconds')
.option('--heartbeat-timeout <n>', 'Heartbeat timeout in seconds (0 to disable)')
.option('--description <text>', 'Task description')
.option('--json [fields]', 'Output JSON')
.action(
async (
id: string,
options: {
agent?: string;
description?: string;
heartbeatInterval?: string;
heartbeatTimeout?: string;
instruction?: string;
json?: string | boolean;
name?: string;
priority?: string;
},
) => {
const client = await getTrpcClient();
const input: Record<string, any> = { id };
if (options.name) input.name = options.name;
if (options.instruction) input.instruction = options.instruction;
if (options.description) input.description = options.description;
if (options.agent) input.assigneeAgentId = options.agent;
if (options.priority) input.priority = Number.parseInt(options.priority, 10);
if (options.heartbeatInterval)
input.heartbeatInterval = Number.parseInt(options.heartbeatInterval, 10);
if (options.heartbeatTimeout !== undefined) {
const val = Number.parseInt(options.heartbeatTimeout, 10);
input.heartbeatTimeout = val === 0 ? null : val;
}
const result = await client.task.update.mutate(input as any);
if (options.json !== undefined) {
outputJson(result.data, typeof options.json === 'string' ? options.json : undefined);
return;
}
log.info(`Task updated: ${pc.bold(result.data.identifier)}`);
},
);
// ── delete ──────────────────────────────────────────────
task
.command('delete <id>')
.description('Delete a task')
.option('-y, --yes', 'Skip confirmation')
.action(async (id: string, options: { yes?: boolean }) => {
if (!options.yes) {
const ok = await confirm(`Delete task ${pc.bold(id)}?`);
if (!ok) return;
}
const client = await getTrpcClient();
await client.task.delete.mutate({ id });
log.info(`Task ${pc.bold(id)} deleted.`);
});
// ── clear ──────────────────────────────────────────────
task
.command('clear')
.description('Delete all tasks')
.option('-y, --yes', 'Skip confirmation')
.action(async (options: { yes?: boolean }) => {
if (!options.yes) {
const ok = await confirm(`Delete ${pc.red('ALL')} tasks? This cannot be undone.`);
if (!ok) return;
}
const client = await getTrpcClient();
const result = (await client.task.clearAll.mutate()) as any;
log.info(`${result.count} task(s) deleted.`);
});
// ── tree ──────────────────────────────────────────────
task
.command('tree <id>')
.description('Show task tree (subtasks + dependencies)')
.option('--json [fields]', 'Output JSON')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.task.getTaskTree.query({ id });
if (options.json !== undefined) {
outputJson(result.data, options.json);
return;
}
if (!result.data || result.data.length === 0) {
log.info('No tasks found.');
return;
}
// Build tree display (raw SQL returns snake_case)
const taskMap = new Map<string, any>();
for (const t of result.data) taskMap.set(t.id, t);
const printNode = (taskId: string, indent: number) => {
const t = taskMap.get(taskId);
if (!t) return;
const prefix = indent === 0 ? '' : ' '.repeat(indent) + '├── ';
const name = t.name || t.identifier || '';
const status = t.status || 'pending';
const identifier = t.identifier || t.id;
console.log(`${prefix}${pc.dim(identifier)} ${statusBadge(status)} ${name}`);
// Print children (handle both camelCase and snake_case)
for (const child of result.data) {
const childParent = child.parentTaskId || child.parent_task_id;
if (childParent === taskId) {
printNode(child.id, indent + 1);
}
}
};
// Find root - resolve identifier first
const resolved = await client.task.find.query({ id });
const rootId = resolved.data.id;
const root = result.data.find((t: any) => t.id === rootId);
if (root) printNode(root.id, 0);
else log.info('Root task not found in tree.');
});
// Register subcommand groups
registerLifecycleCommands(task);
registerCheckpointCommands(task);
registerReviewCommands(task);
registerDepCommands(task);
registerTopicCommands(task);
registerDocCommands(task);
}
+303
View File
@@ -0,0 +1,303 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../../api/client';
import { getAuthInfo } from '../../api/http';
import { streamAgentEvents } from '../../utils/agentStream';
import { log } from '../../utils/logger';
export function registerLifecycleCommands(task: Command) {
// ── start ──────────────────────────────────────────────
task
.command('start <id>')
.description('Start a task (pending → running)')
.option('--no-run', 'Only update status, do not trigger agent execution')
.option('-p, --prompt <text>', 'Additional context for the agent')
.option('-f, --follow', 'Follow agent output in real-time (default: run in background)')
.option('--json', 'Output full JSON event stream')
.option('-v, --verbose', 'Show detailed tool call info')
.action(
async (
id: string,
options: {
follow?: boolean;
json?: boolean;
prompt?: string;
run?: boolean;
verbose?: boolean;
},
) => {
const client = await getTrpcClient();
// Check if already running
const taskDetail = await client.task.find.query({ id });
if (taskDetail.data.status === 'running') {
log.info(`Task ${pc.bold(taskDetail.data.identifier)} is already running.`);
return;
}
const statusResult = await client.task.updateStatus.mutate({ id, status: 'running' });
log.info(`Task ${pc.bold(statusResult.data.identifier)} started.`);
// Auto-run unless --no-run
if (options.run === false) return;
// Default agent to inbox if not assigned
if (!taskDetail.data.assigneeAgentId) {
await client.task.update.mutate({ assigneeAgentId: 'inbox', id });
log.info(`Assigned default agent: ${pc.dim('inbox')}`);
}
const result = (await client.task.run.mutate({
id,
...(options.prompt && { prompt: options.prompt }),
})) as any;
if (!result.success) {
log.error(`Failed to run task: ${result.error || result.message || 'Unknown error'}`);
process.exit(1);
}
log.info(
`Operation: ${pc.dim(result.operationId)} · Topic: ${pc.dim(result.topicId || 'n/a')}`,
);
if (!options.follow) {
log.info(
`Agent running in background. Use ${pc.dim(`lh task view ${id}`)} to check status.`,
);
return;
}
const { serverUrl, headers } = await getAuthInfo();
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(result.operationId)}`;
await streamAgentEvents(streamUrl, headers, {
json: options.json,
verbose: options.verbose,
});
// Send heartbeat after completion
try {
await client.task.heartbeat.mutate({ id });
} catch {
// ignore heartbeat errors
}
},
);
// ── run ──────────────────────────────────────────────
task
.command('run <id>')
.description('Run a task — trigger agent execution')
.option('-p, --prompt <text>', 'Additional context for the agent')
.option('-c, --continue <topicId>', 'Continue running on an existing topic')
.option('-f, --follow', 'Follow agent output in real-time (default: run in background)')
.option('--topics <n>', 'Run N topics in sequence (default: 1, implies --follow)', '1')
.option('--delay <s>', 'Delay between topics in seconds', '0')
.option('--json', 'Output full JSON event stream')
.option('-v, --verbose', 'Show detailed tool call info')
.action(
async (
id: string,
options: {
continue?: string;
delay?: string;
follow?: boolean;
json?: boolean;
prompt?: string;
topics?: string;
verbose?: boolean;
},
) => {
const topicCount = Number.parseInt(options.topics || '1', 10);
const delaySec = Number.parseInt(options.delay || '0', 10);
// --topics > 1 implies --follow
const shouldFollow = options.follow || topicCount > 1;
for (let i = 0; i < topicCount; i++) {
if (i > 0) {
log.info(`\n${'─'.repeat(60)}`);
log.info(`Topic ${i + 1}/${topicCount}`);
if (delaySec > 0) {
log.info(`Waiting ${delaySec}s before next topic...`);
await new Promise((r) => setTimeout(r, delaySec * 1000));
}
}
const client = await getTrpcClient();
// Auto-assign inbox agent on first topic if not assigned
if (i === 0) {
const taskDetail = await client.task.find.query({ id });
if (!taskDetail.data.assigneeAgentId) {
await client.task.update.mutate({ assigneeAgentId: 'inbox', id });
log.info(`Assigned default agent: ${pc.dim('inbox')}`);
}
}
// Only pass extra prompt and continue on first topic
const result = (await client.task.run.mutate({
id,
...(i === 0 && options.prompt && { prompt: options.prompt }),
...(i === 0 && options.continue && { continueTopicId: options.continue }),
})) as any;
if (!result.success) {
log.error(`Failed to run task: ${result.error || result.message || 'Unknown error'}`);
process.exit(1);
}
const operationId = result.operationId;
if (i === 0) {
log.info(`Task ${pc.bold(result.taskIdentifier)} running`);
}
log.info(`Operation: ${pc.dim(operationId)} · Topic: ${pc.dim(result.topicId || 'n/a')}`);
if (!shouldFollow) {
log.info(
`Agent running in background. Use ${pc.dim(`lh task view ${id}`)} to check status.`,
);
return;
}
// Connect to SSE stream and wait for completion
const { serverUrl, headers } = await getAuthInfo();
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
await streamAgentEvents(streamUrl, headers, {
json: options.json,
verbose: options.verbose,
});
// Update heartbeat after each topic
try {
await client.task.heartbeat.mutate({ id });
} catch {
// ignore heartbeat errors
}
}
},
);
// ── comment ──────────────────────────────────────────────
task
.command('comment <id>')
.description('Add a comment to a task')
.requiredOption('-m, --message <text>', 'Comment content')
.action(async (id: string, options: { message: string }) => {
const client = await getTrpcClient();
await client.task.addComment.mutate({ content: options.message, id });
log.info('Comment added.');
});
// ── pause ──────────────────────────────────────────────
task
.command('pause <id>')
.description('Pause a running task')
.action(async (id: string) => {
const client = await getTrpcClient();
const result = await client.task.updateStatus.mutate({ id, status: 'paused' });
log.info(`Task ${pc.bold(result.data.identifier)} paused.`);
});
// ── resume ──────────────────────────────────────────────
task
.command('resume <id>')
.description('Resume a paused task')
.action(async (id: string) => {
const client = await getTrpcClient();
const result = await client.task.updateStatus.mutate({ id, status: 'running' });
log.info(`Task ${pc.bold(result.data.identifier)} resumed.`);
});
// ── complete ──────────────────────────────────────────────
task
.command('complete <id>')
.description('Mark a task as completed')
.action(async (id: string) => {
const client = await getTrpcClient();
const result = (await client.task.updateStatus.mutate({ id, status: 'completed' })) as any;
log.info(`Task ${pc.bold(result.data.identifier)} completed.`);
if (result.unlocked?.length > 0) {
log.info(`Unlocked: ${result.unlocked.map((id: string) => pc.bold(id)).join(', ')}`);
}
if (result.paused?.length > 0) {
log.info(
`Paused (checkpoint): ${result.paused.map((id: string) => pc.yellow(id)).join(', ')}`,
);
}
if (result.checkpointTriggered) {
log.info(`${pc.yellow('Checkpoint triggered')} — parent task paused for review.`);
}
if (result.allSubtasksDone) {
log.info(`All subtasks of parent task completed.`);
}
});
// ── cancel ──────────────────────────────────────────────
task
.command('cancel <id>')
.description('Cancel a task')
.action(async (id: string) => {
const client = await getTrpcClient();
const result = await client.task.updateStatus.mutate({ id, status: 'canceled' });
log.info(`Task ${pc.bold(result.data.identifier)} canceled.`);
});
// ── sort ──────────────────────────────────────────────
task
.command('sort <id> <identifiers...>')
.description('Reorder subtasks (e.g. lh task sort TASK-1 TASK-2 TASK-4 TASK-3)')
.action(async (id: string, identifiers: string[]) => {
const client = await getTrpcClient();
const result = (await client.task.reorderSubtasks.mutate({
id,
order: identifiers,
})) as any;
log.info('Subtasks reordered:');
for (const item of result.data) {
console.log(` ${pc.dim(`#${item.sortOrder}`)} ${item.identifier}`);
}
});
// ── heartbeat ──────────────────────────────────────────────
task
.command('heartbeat <id>')
.description('Manually send heartbeat for a running task')
.action(async (id: string) => {
const client = await getTrpcClient();
await client.task.heartbeat.mutate({ id });
log.info(`Heartbeat sent for ${pc.bold(id)}.`);
});
// ── watchdog ──────────────────────────────────────────────
task
.command('watchdog')
.description('Run watchdog check — detect and fail stuck tasks')
.action(async () => {
const client = await getTrpcClient();
const result = (await client.task.watchdog.mutate()) as any;
if (result.failed?.length > 0) {
log.info(
`${pc.red('Stuck tasks failed:')} ${result.failed.map((id: string) => pc.bold(id)).join(', ')}`,
);
} else {
log.info('No stuck tasks found.');
}
});
}
+306
View File
@@ -0,0 +1,306 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../../api/client';
import { printTable, truncate } from '../../utils/format';
import { log } from '../../utils/logger';
export function registerReviewCommands(task: Command) {
// ── review ──────────────────────────────────────────────
const rv = task.command('review').description('Manage task review (LLM-as-Judge)');
rv.command('view <id>')
.description('View review config for a task')
.action(async (id: string) => {
const client = await getTrpcClient();
const result = await client.task.getReview.query({ id });
const r = result.data as any;
if (!r || !r.enabled) {
log.info('Review not configured for this task.');
return;
}
console.log(`\n${pc.bold('Review config:')}`);
console.log(` enabled: ${r.enabled}`);
if (r.judge?.model)
console.log(` judge: ${r.judge.model}${r.judge.provider ? ` (${r.judge.provider})` : ''}`);
console.log(` maxIterations: ${r.maxIterations}`);
console.log(` autoRetry: ${r.autoRetry}`);
if (r.rubrics?.length > 0) {
console.log(` rubrics:`);
for (let i = 0; i < r.rubrics.length; i++) {
const rb = r.rubrics[i];
const threshold = rb.threshold ? `${Math.round(rb.threshold * 100)}%` : '';
const typeTag = pc.dim(`[${rb.type}]`);
let configInfo = '';
if (rb.type === 'llm-rubric') configInfo = rb.config?.criteria || '';
else if (rb.type === 'contains' || rb.type === 'equals')
configInfo = `value="${rb.config?.value}"`;
else if (rb.type === 'regex') configInfo = `pattern="${rb.config?.pattern}"`;
console.log(` ${i + 1}. ${rb.name} ${typeTag}${threshold} ${pc.dim(configInfo)}`);
}
} else {
console.log(` rubrics: ${pc.dim('(none)')}`);
}
console.log();
});
rv.command('set <id>')
.description('Enable review and configure judge settings')
.option('--model <model>', 'Judge model')
.option('--provider <provider>', 'Judge provider')
.option('--max-iterations <n>', 'Max review iterations', '3')
.option('--no-auto-retry', 'Disable auto retry on failure')
.option('--recursive', 'Apply to all subtasks as well')
.action(
async (
id: string,
options: {
autoRetry?: boolean;
maxIterations?: string;
model?: string;
provider?: string;
recursive?: boolean;
},
) => {
const client = await getTrpcClient();
// Read current review config to preserve rubrics
const current = (await client.task.getReview.query({ id })).data as any;
const existingRubrics = current?.rubrics || [];
const review = {
autoRetry: options.autoRetry !== false,
enabled: true,
judge: {
...(options.model && { model: options.model }),
...(options.provider && { provider: options.provider }),
},
maxIterations: Number.parseInt(options.maxIterations || '3', 10),
rubrics: existingRubrics,
};
await client.task.updateReview.mutate({ id, review });
if (options.recursive) {
const subtasks = await client.task.getSubtasks.query({ id });
for (const s of subtasks.data || []) {
const subCurrent = (await client.task.getReview.query({ id: s.id })).data as any;
await client.task.updateReview.mutate({
id: s.id,
review: { ...review, rubrics: subCurrent?.rubrics || existingRubrics },
});
}
log.info(
`Review enabled for ${pc.bold(id)} + ${(subtasks.data || []).length} subtask(s).`,
);
} else {
log.info('Review enabled.');
}
},
);
// ── review criteria ──────────────────────────────────────
const rc = rv.command('criteria').description('Manage review rubrics');
rc.command('list <id>')
.description('List review rubrics for a task')
.action(async (id: string) => {
const client = await getTrpcClient();
const result = await client.task.getReview.query({ id });
const r = result.data as any;
const rubrics = r?.rubrics || [];
if (rubrics.length === 0) {
log.info('No rubrics configured.');
return;
}
const rows = rubrics.map((r: any, i: number) => {
const config = r.config || {};
const configStr =
r.type === 'llm-rubric'
? config.criteria || ''
: r.type === 'contains' || r.type === 'equals'
? `value: "${config.value}"`
: r.type === 'regex'
? `pattern: "${config.pattern}"`
: JSON.stringify(config);
return [
String(i + 1),
r.name,
r.type,
r.threshold ? `${Math.round(r.threshold * 100)}%` : '-',
String(r.weight ?? 1),
truncate(configStr, 40),
];
});
printTable(rows, ['#', 'NAME', 'TYPE', 'THRESHOLD', 'WEIGHT', 'CONFIG']);
});
rc.command('add <id>')
.description('Add a review rubric')
.requiredOption('-n, --name <name>', 'Rubric name (e.g. "内容准确性")')
.option('--type <type>', 'Rubric type (default: llm-rubric)', 'llm-rubric')
.option('-t, --threshold <n>', 'Pass threshold 0-100 (converted to 0-1)')
.option('-d, --description <text>', 'Criteria description (for llm-rubric type)')
.option('--value <value>', 'Expected value (for contains/equals type)')
.option('--pattern <pattern>', 'Regex pattern (for regex type)')
.option('-w, --weight <n>', 'Weight for scoring (default: 1)')
.option('--recursive', 'Add to all subtasks as well')
.action(
async (
id: string,
options: {
description?: string;
name: string;
pattern?: string;
recursive?: boolean;
threshold?: string;
type: string;
value?: string;
weight?: string;
},
) => {
const client = await getTrpcClient();
// Build rubric config based on type
const buildConfig = (): Record<string, any> | null => {
switch (options.type) {
case 'llm-rubric': {
return { criteria: options.description || options.name };
}
case 'contains':
case 'equals':
case 'starts-with':
case 'ends-with': {
if (!options.value) {
log.error(`--value is required for type "${options.type}"`);
return null;
}
return { value: options.value };
}
case 'regex': {
if (!options.pattern) {
log.error('--pattern is required for type "regex"');
return null;
}
return { pattern: options.pattern };
}
default: {
return { criteria: options.description || options.name };
}
}
};
const config = buildConfig();
if (!config) return;
const rubric: Record<string, any> = {
config,
id: `rubric-${Date.now()}`,
name: options.name,
type: options.type,
weight: options.weight ? Number.parseFloat(options.weight) : 1,
};
if (options.threshold) {
rubric.threshold = Number.parseInt(options.threshold, 10) / 100;
}
const addToTask = async (taskId: string) => {
const current = (await client.task.getReview.query({ id: taskId })).data as any;
const rubrics = current?.rubrics || [];
// Replace if same name exists, otherwise append
const filtered = rubrics.filter((r: any) => r.name !== options.name);
filtered.push(rubric);
await client.task.updateReview.mutate({
id: taskId,
review: {
autoRetry: current?.autoRetry ?? true,
enabled: current?.enabled ?? true,
judge: current?.judge ?? {},
maxIterations: current?.maxIterations ?? 3,
rubrics: filtered,
},
});
};
await addToTask(id);
if (options.recursive) {
const subtasks = await client.task.getSubtasks.query({ id });
for (const s of subtasks.data || []) {
await addToTask(s.id);
}
log.info(
`Rubric "${options.name}" [${options.type}] added to ${pc.bold(id)} + ${(subtasks.data || []).length} subtask(s).`,
);
} else {
log.info(`Rubric "${options.name}" [${options.type}] added.`);
}
},
);
rc.command('rm <id>')
.description('Remove a review rubric')
.requiredOption('-n, --name <name>', 'Rubric name to remove')
.option('--recursive', 'Remove from all subtasks as well')
.action(async (id: string, options: { name: string; recursive?: boolean }) => {
const client = await getTrpcClient();
const removeFromTask = async (taskId: string) => {
const current = (await client.task.getReview.query({ id: taskId })).data as any;
if (!current) return;
const rubrics = (current.rubrics || []).filter((r: any) => r.name !== options.name);
await client.task.updateReview.mutate({
id: taskId,
review: { ...current, rubrics },
});
};
await removeFromTask(id);
if (options.recursive) {
const subtasks = await client.task.getSubtasks.query({ id });
for (const s of subtasks.data || []) {
await removeFromTask(s.id);
}
log.info(
`Rubric "${options.name}" removed from ${pc.bold(id)} + ${(subtasks.data || []).length} subtask(s).`,
);
} else {
log.info(`Rubric "${options.name}" removed.`);
}
});
rv.command('run <id>')
.description('Manually run review on content')
.requiredOption('--content <text>', 'Content to review')
.action(async (id: string, options: { content: string }) => {
const client = await getTrpcClient();
const result = (await client.task.runReview.mutate({
content: options.content,
id,
})) as any;
const r = result.data;
console.log(
`\n${r.passed ? pc.green('✓ Review passed') : pc.red('✗ Review failed')} (${r.overallScore}%)`,
);
for (const s of r.rubricResults || []) {
const icon = s.passed ? pc.green('✓') : pc.red('✗');
const pct = Math.round(s.score * 100);
console.log(` ${icon} ${s.rubricId}: ${pct}%${s.reason ? `${s.reason}` : ''}`);
}
console.log();
});
}
+117
View File
@@ -0,0 +1,117 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../../api/client';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../../utils/format';
import { log } from '../../utils/logger';
import { statusBadge } from './helpers';
export function registerTopicCommands(task: Command) {
// ── topic ──────────────────────────────────────────────
const tp = task.command('topic').description('Manage task topics');
tp.command('list <id>')
.description('List topics for a task')
.option('--json [fields]', 'Output JSON')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.task.getTopics.query({ id });
if (options.json !== undefined) {
outputJson(result.data, options.json);
return;
}
if (!result.data || result.data.length === 0) {
log.info('No topics found for this task.');
return;
}
const rows = result.data.map((t: any) => [
`#${t.seq}`,
t.id,
statusBadge(t.status || 'running'),
truncate(t.title || 'Untitled', 40),
t.operationId ? pc.dim(truncate(t.operationId, 20)) : '-',
timeAgo(t.createdAt),
]);
printTable(rows, ['SEQ', 'TOPIC ID', 'STATUS', 'TITLE', 'OPERATION', 'CREATED']);
});
tp.command('view <id> <topicId>')
.description('View messages of a topic (topicId can be a seq number like "1")')
.option('--json [fields]', 'Output JSON')
.action(async (id: string, topicId: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
let resolvedTopicId = topicId;
// If it's a number, treat as seq index
const seqNum = Number.parseInt(topicId, 10);
if (!Number.isNaN(seqNum) && String(seqNum) === topicId) {
const topicsResult = await client.task.getTopics.query({ id });
const match = (topicsResult.data || []).find((t: any) => t.seq === seqNum);
if (!match) {
log.error(`Topic #${seqNum} not found for this task.`);
return;
}
resolvedTopicId = match.id;
log.info(
`Topic #${seqNum}: ${pc.bold(match.title || 'Untitled')} ${pc.dim(resolvedTopicId)}`,
);
}
const messages = await client.message.getMessages.query({ topicId: resolvedTopicId });
const items = Array.isArray(messages) ? messages : [];
if (options.json !== undefined) {
outputJson(items, options.json);
return;
}
if (items.length === 0) {
log.info('No messages in this topic.');
return;
}
console.log();
for (const msg of items) {
const role =
msg.role === 'assistant'
? pc.green('Assistant')
: msg.role === 'user'
? pc.blue('User')
: pc.dim(msg.role);
console.log(`${pc.bold(role)} ${pc.dim(timeAgo(msg.createdAt))}`);
if (msg.content) {
console.log(msg.content);
}
console.log();
}
});
tp.command('cancel <topicId>')
.description('Cancel a running topic and pause the task')
.action(async (topicId: string) => {
const client = await getTrpcClient();
await client.task.cancelTopic.mutate({ topicId });
log.info(`Topic ${pc.bold(topicId)} canceled. Task paused.`);
});
tp.command('delete <topicId>')
.description('Delete a topic and its messages')
.option('-y, --yes', 'Skip confirmation')
.action(async (topicId: string, options: { yes?: boolean }) => {
if (!options.yes) {
const ok = await confirm(`Delete topic ${pc.bold(topicId)} and all its messages?`);
if (!ok) return;
}
const client = await getTrpcClient();
await client.task.deleteTopic.mutate({ topicId });
log.info(`Topic ${pc.bold(topicId)} deleted.`);
});
}
+32 -2
View File
@@ -5,7 +5,7 @@ import { Command } from 'commander';
import { registerAgentCommand } from './commands/agent';
import { registerAgentGroupCommand } from './commands/agent-group';
import { registerBotCommand } from './commands/bot';
import { registerCompletionCommand } from './commands/completion';
import { registerBriefCommand } from './commands/brief';
import { registerConfigCommand } from './commands/config';
import { registerConnectCommand } from './commands/connect';
import { registerCronCommand } from './commands/cron';
@@ -26,6 +26,7 @@ import { registerSearchCommand } from './commands/search';
import { registerSessionGroupCommand } from './commands/session-group';
import { registerSkillCommand } from './commands/skill';
import { registerStatusCommand } from './commands/status';
import { registerTaskCommand } from './commands/task';
import { registerThreadCommand } from './commands/thread';
import { registerTopicCommand } from './commands/topic';
import { registerUserCommand } from './commands/user';
@@ -42,7 +43,6 @@ program
registerLoginCommand(program);
registerLogoutCommand(program);
registerCompletionCommand(program);
registerConnectCommand(program);
registerDeviceCommand(program);
registerStatusCommand(program);
@@ -53,11 +53,13 @@ registerMemoryCommand(program);
registerAgentCommand(program);
registerAgentGroupCommand(program);
registerBotCommand(program);
registerBriefCommand(program);
registerCronCommand(program);
registerGenerateCommand(program);
registerFileCommand(program);
registerSkillCommand(program);
registerSessionGroupCommand(program);
registerTaskCommand(program);
registerThreadCommand(program);
registerTopicCommand(program);
registerMessageCommand(program);
@@ -68,4 +70,32 @@ registerUserCommand(program);
registerConfigCommand(program);
registerEvalCommand(program);
// Global error handler for clean TRPC error output
process.on('uncaughtException', (error: any) => {
if (error?.name === 'TRPCClientError' || error?.constructor?.name?.includes('TRPCClientError')) {
const message = error.message || 'Unknown error';
const code = error.data?.code || error.shape?.data?.code || '';
const path = error.data?.path || error.shape?.data?.path || '';
console.error(`\x1B[31mError${path ? ` [${path}]` : ''}: ${message}\x1B[0m`);
if (code) console.error(`\x1B[2mCode: ${code}\x1B[0m`);
process.exit(1);
}
// Re-throw non-TRPC errors
console.error(error?.message || error);
process.exit(1);
});
process.on('unhandledRejection', (error: any) => {
if (error?.name === 'TRPCClientError' || error?.constructor?.name?.includes('TRPCClientError')) {
const message = error.message || 'Unknown error';
const code = error.data?.code || error.shape?.data?.code || '';
const path = error.data?.path || error.shape?.data?.path || '';
console.error(`\x1B[31mError${path ? ` [${path}]` : ''}: ${message}\x1B[0m`);
if (code) console.error(`\x1B[2mCode: ${code}\x1B[0m`);
process.exit(1);
}
console.error(error?.message || error);
process.exit(1);
});
program.parse();
-157
View File
@@ -1,157 +0,0 @@
import type { Command, Option } from 'commander';
import { InvalidArgumentError } from 'commander';
const CLI_BIN_NAMES = ['lh', 'lobe', 'lobehub'] as const;
const SUPPORTED_SHELLS = ['bash', 'zsh'] as const;
type SupportedShell = (typeof SUPPORTED_SHELLS)[number];
interface HiddenCommand extends Command {
_hidden?: boolean;
}
interface HiddenOption extends Option {
hidden: boolean;
}
function isVisibleCommand(command: Command) {
return !(command as HiddenCommand)._hidden;
}
function isVisibleOption(option: Option) {
return !(option as HiddenOption).hidden;
}
function listCommandTokens(command: Command) {
return [command.name(), ...command.aliases()].filter(Boolean);
}
function listOptionTokens(command: Command) {
return command.options
.filter(isVisibleOption)
.flatMap((option) => [option.short, option.long].filter(Boolean) as string[]);
}
function findSubcommand(command: Command, token: string) {
return command.commands.find(
(subcommand) => isVisibleCommand(subcommand) && listCommandTokens(subcommand).includes(token),
);
}
function findOption(command: Command, token: string) {
return command.options.find(
(option) =>
isVisibleOption(option) && (option.short === token || option.long === token || false),
);
}
function filterCandidates(candidates: string[], currentWord: string) {
const unique = [...new Set(candidates)];
if (!currentWord) return unique.sort();
return unique.filter((candidate) => candidate.startsWith(currentWord)).sort();
}
function resolveCommandContext(program: Command, completedWords: string[]) {
let command = program;
let expectsOptionValue = false;
for (const token of completedWords) {
if (expectsOptionValue) {
expectsOptionValue = false;
continue;
}
if (!token) continue;
if (token.startsWith('-')) {
const option = findOption(command, token);
expectsOptionValue = Boolean(
option && (option.required || option.optional || option.variadic),
);
continue;
}
const subcommand = findSubcommand(command, token);
if (subcommand) {
command = subcommand;
}
}
return { command, expectsOptionValue };
}
export function getCompletionCandidates(
program: Command,
words: string[],
currentWordIndex = words.length,
) {
const safeCurrentWordIndex = Math.min(Math.max(currentWordIndex, 0), words.length);
const completedWords = words.slice(0, safeCurrentWordIndex);
const currentWord = safeCurrentWordIndex < words.length ? words[safeCurrentWordIndex] || '' : '';
const { command, expectsOptionValue } = resolveCommandContext(program, completedWords);
if (expectsOptionValue) return [];
const commandCandidates = currentWord.startsWith('-')
? []
: command.commands
.filter(isVisibleCommand)
.flatMap((subcommand) => listCommandTokens(subcommand));
if (commandCandidates.length > 0) {
return filterCandidates(commandCandidates, currentWord);
}
return filterCandidates(listOptionTokens(command), currentWord);
}
export function parseCompletionWordIndex(rawValue: string | undefined, words: string[]) {
const parsedValue = rawValue ? Number.parseInt(rawValue, 10) : Number.NaN;
if (Number.isNaN(parsedValue)) return words.length;
return Math.min(Math.max(parsedValue, 0), words.length);
}
export function resolveCompletionShell(shell?: string): SupportedShell {
const fallbackShell = process.env.SHELL?.split('/').pop() || 'zsh';
const resolvedShell = (shell || fallbackShell).toLowerCase();
if ((SUPPORTED_SHELLS as readonly string[]).includes(resolvedShell)) {
return resolvedShell as SupportedShell;
}
throw new InvalidArgumentError(
`Unsupported shell "${resolvedShell}". Supported shells: ${SUPPORTED_SHELLS.join(', ')}`,
);
}
export function renderCompletionScript(shell: SupportedShell) {
if (shell === 'bash') {
return [
'# shellcheck shell=bash',
'_lobehub_completion() {',
" local IFS=$'\\n'",
' local current_index=$((COMP_CWORD - 1))',
' local completions',
' completions=$(LOBEHUB_COMP_CWORD="$current_index" "${COMP_WORDS[0]}" __complete "${COMP_WORDS[@]:1}")',
' COMPREPLY=($(printf \'%s\\n\' "$completions"))',
'}',
`complete -o nosort -F _lobehub_completion ${CLI_BIN_NAMES.join(' ')}`,
].join('\n');
}
return [
`#compdef ${CLI_BIN_NAMES.join(' ')}`,
'_lobehub_completion() {',
' local -a completions',
' local current_index=$((CURRENT - 2))',
' completions=("${(@f)$(LOBEHUB_COMP_CWORD="$current_index" "$words[1]" __complete "${(@)words[@]:1}")}")',
" _describe 'values' completions",
'}',
`compdef _lobehub_completion ${CLI_BIN_NAMES.join(' ')}`,
].join('\n');
}
+1 -1
View File
@@ -87,7 +87,7 @@ function stripAnsi(s: string): string {
* Calculate the display width of a string in the terminal.
* CJK characters and fullwidth symbols occupy 2 columns.
*/
function displayWidth(s: string): number {
export function displayWidth(s: string): number {
const plain = stripAnsi(s);
let width = 0;
for (const char of plain) {
-14
View File
@@ -1,14 +0,0 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
banner: { js: '#!/usr/bin/env node' },
clean: true,
deps: {
neverBundle: ['@napi-rs/canvas'],
},
entry: ['src/index.ts'],
fixedExtension: false,
format: ['esm'],
platform: 'node',
target: 'node18',
});
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'tsup';
export default defineConfig({
banner: { js: '#!/usr/bin/env node' },
clean: true,
entry: ['src/index.ts'],
external: ['@napi-rs/canvas', 'fast-glob', 'diff', 'debug'],
format: ['esm'],
noExternal: [
'@lobechat/device-gateway-client',
'@lobechat/local-file-shell',
'@lobechat/file-loaders',
'@trpc/client',
'superjson',
],
platform: 'node',
target: 'node18',
});
+1 -1
View File
@@ -1828,4 +1828,4 @@ ref: topic_documents.document_id > documents.id
ref: topic_documents.topic_id > topics.id
ref: topics.session_id - sessions.id
ref: topics.session_id - sessions.id
+1 -8
View File
@@ -79,12 +79,5 @@
"channel.validationError": "Please fill in Application ID and Token",
"channel.verificationToken": "Verification Token",
"channel.verificationTokenHint": "Optional. Used to verify webhook event source.",
"channel.verificationTokenPlaceholder": "Paste your verification token here",
"channel.wechat.description": "Connect this assistant to WeChat via iLink Bot for private and group chats.",
"channel.wechatQrExpired": "QR code expired. Please refresh to get a new one.",
"channel.wechatQrRefresh": "Refresh QR Code",
"channel.wechatQrScaned": "QR code scanned. Please confirm the login in WeChat.",
"channel.wechatQrWait": "Open WeChat and scan the QR code to connect.",
"channel.wechatScanTitle": "Connect WeChat Bot",
"channel.wechatScanToConnect": "Scan QR Code to Connect"
"channel.verificationTokenPlaceholder": "Paste your verification token here"
}
+1 -8
View File
@@ -79,12 +79,5 @@
"channel.validationError": "请填写应用 ID 和 Token",
"channel.verificationToken": "Verification Token",
"channel.verificationTokenHint": "可选。用于验证事件推送来源。",
"channel.verificationTokenPlaceholder": "在此粘贴你的 Verification Token",
"channel.wechat.description": "通过 iLink Bot 将助手连接到微信,支持私聊和群聊。",
"channel.wechatQrExpired": "二维码已过期,请刷新获取新的二维码。",
"channel.wechatQrRefresh": "刷新二维码",
"channel.wechatQrScaned": "已扫码,请在微信中确认登录。",
"channel.wechatQrWait": "打开微信扫描二维码以连接。",
"channel.wechatScanTitle": "连接微信机器人",
"channel.wechatScanToConnect": "扫码连接"
"channel.verificationTokenPlaceholder": "在此粘贴你的 Verification Token"
}
+2 -1
View File
@@ -204,6 +204,7 @@
"@lobechat/builtin-tool-agent-builder": "workspace:*",
"@lobechat/builtin-tool-agent-documents": "workspace:*",
"@lobechat/builtin-tool-agent-management": "workspace:*",
"@lobechat/builtin-tool-brief": "workspace:*",
"@lobechat/builtin-tool-calculator": "workspace:*",
"@lobechat/builtin-tool-cloud-sandbox": "workspace:*",
"@lobechat/builtin-tool-group-agent-builder": "workspace:*",
@@ -217,6 +218,7 @@
"@lobechat/builtin-tool-remote-device": "workspace:*",
"@lobechat/builtin-tool-skill-store": "workspace:*",
"@lobechat/builtin-tool-skills": "workspace:*",
"@lobechat/builtin-tool-task": "workspace:*",
"@lobechat/builtin-tool-tools": "workspace:*",
"@lobechat/builtin-tool-topic-reference": "workspace:*",
"@lobechat/builtin-tool-web-browsing": "workspace:*",
@@ -225,7 +227,6 @@
"@lobechat/business-const": "workspace:*",
"@lobechat/chat-adapter-feishu": "workspace:*",
"@lobechat/chat-adapter-qq": "workspace:*",
"@lobechat/chat-adapter-wechat": "workspace:*",
"@lobechat/config": "workspace:*",
"@lobechat/const": "workspace:*",
"@lobechat/context-engine": "workspace:*",
@@ -818,6 +818,22 @@ export function renderStepDetail(
}
}
// Default view: show tool errors even without -t flag
if (!hasSpecificFlag && step.toolsResult) {
const failedResults = step.toolsResult.filter((tr) => tr.isSuccess === false);
if (failedResults.length > 0) {
lines.push('');
lines.push(bold(red('Errors:')));
for (const tr of failedResults) {
lines.push(` ${red('✗')} ${cyan(tr.identifier || tr.apiName)}`);
if (tr.output) {
const output = tr.output.length > 500 ? tr.output.slice(0, 500) + '...' : tr.output;
lines.push(` ${red(output)}`);
}
}
}
}
if (options?.tools) {
if (step.toolsCalling && step.toolsCalling.length > 0) {
lines.push('');
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@lobechat/builtin-tool-brief",
"version": "1.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
},
"main": "./src/index.ts",
"devDependencies": {
"@lobechat/types": "workspace:*"
}
}
+2
View File
@@ -0,0 +1,2 @@
export { BriefIdentifier, BriefManifest } from './manifest';
export { BriefApiName } from './types';
@@ -0,0 +1,83 @@
import type { BuiltinToolManifest } from '@lobechat/types';
import { systemPrompt } from './systemRole';
import { BriefApiName } from './types';
export const BriefIdentifier = 'lobe-brief';
export const BriefManifest: BuiltinToolManifest = {
api: [
{
description:
"Create a brief to report progress, deliver results, or request decisions from the user. Use type 'decision' when you need user input, 'result' for deliverables, 'insight' for observations. Default actions are auto-generated based on type, but you can customize them.",
name: BriefApiName.createBrief,
parameters: {
properties: {
actions: {
description:
'Custom action buttons for the user. If omitted, defaults are generated based on type. Each action has key (identifier), label (display text), and type ("resolve" to close, "comment" to prompt feedback).',
items: {
properties: {
key: { description: 'Action identifier, e.g. "approve", "split"', type: 'string' },
label: { description: 'Display label, e.g. "✅ 同意拆分"', type: 'string' },
type: {
description: '"resolve" closes the brief, "comment" prompts for text input',
enum: ['resolve', 'comment'],
type: 'string',
},
},
required: ['key', 'label', 'type'],
type: 'object',
},
type: 'array',
},
priority: {
description: "Priority of the brief. Default is 'normal'.",
enum: ['urgent', 'normal', 'info'],
type: 'string',
},
summary: {
description: 'Detailed summary content of the brief.',
type: 'string',
},
title: {
description: 'A short title for the brief.',
type: 'string',
},
type: {
description:
"The type of brief: 'decision' for user input needed, 'result' for deliverables, 'insight' for observations.",
enum: ['decision', 'result', 'insight'],
type: 'string',
},
},
required: ['type', 'title', 'summary'],
type: 'object',
},
},
{
description:
'Pause execution and request the user to review your work before continuing. Use at natural review points.',
humanIntervention: 'required',
name: BriefApiName.requestCheckpoint,
parameters: {
properties: {
reason: {
description: 'The reason for requesting a checkpoint.',
type: 'string',
},
},
required: ['reason'],
type: 'object',
},
},
],
identifier: BriefIdentifier,
meta: {
avatar: '📋',
description: 'Report progress, deliver results, and request user decisions',
title: 'Brief Tools',
},
systemRole: systemPrompt,
type: 'builtin',
};
@@ -0,0 +1,9 @@
export const systemPrompt = `You have access to Brief communication tools. Use them to interact with the user:
- **createBrief**: Report progress, deliver results, or request decisions from the user. Use type 'decision' when you need user input, 'result' for deliverables, 'insight' for observations. You can define custom action buttons for the user to respond with
- **requestCheckpoint**: Pause execution and ask the user to review your work before continuing. Use at natural review points
When communicating:
1. Use createBrief to deliver results and request feedback at key milestones
2. Use requestCheckpoint when you need explicit approval before proceeding
3. For decision briefs, provide clear action options (e.g. approve, reject, modify)`;
+9
View File
@@ -0,0 +1,9 @@
export const BriefApiName = {
/** Create a brief to report progress, results, or request decisions */
createBrief: 'createBrief',
/** Pause execution and request user review */
requestCheckpoint: 'requestCheckpoint',
} as const;
export type BriefApiNameType = (typeof BriefApiName)[keyof typeof BriefApiName];
@@ -27,6 +27,11 @@ interface DocumentServiceResult {
}
interface NotebookService {
/**
* Associate a document with a task (optional, for task execution context)
*/
associateDocumentWithTask?: (documentId: string, taskId: string) => Promise<void>;
/**
* Associate a document with a topic
*/
@@ -115,11 +120,18 @@ export class NotebookExecutionRuntime {
*/
async createDocument(
args: CreateDocumentArgs,
options?: { topicId?: string | null },
options?: { taskId?: string | null; topicId?: string | null },
): Promise<BuiltinServerRuntimeOutput> {
try {
const { title, content, type = 'markdown' } = args;
if (!content) {
return {
content: 'Error: Missing content. The document content is required.',
success: false,
};
}
if (!options?.topicId) {
return {
content: 'Error: No topic context. Documents must be created within a topic.',
@@ -141,6 +153,11 @@ export class NotebookExecutionRuntime {
// Associate with topic
await this.notebookService.associateDocumentWithTopic(doc.id, options.topicId);
// Associate with task if in task execution context
if (options.taskId && this.notebookService.associateDocumentWithTask) {
await this.notebookService.associateDocumentWithTask(doc.id, options.taskId);
}
const notebookDoc = toNotebookDocument(doc);
const state: CreateDocumentState = { document: notebookDoc };
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@lobechat/builtin-tool-task",
"version": "1.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
},
"main": "./src/index.ts",
"devDependencies": {
"@lobechat/types": "workspace:*"
}
}
+2
View File
@@ -0,0 +1,2 @@
export { TaskIdentifier, TaskManifest } from './manifest';
export { TaskApiName } from './types';
+209
View File
@@ -0,0 +1,209 @@
import type { BuiltinToolManifest } from '@lobechat/types';
import { systemPrompt } from './systemRole';
import { TaskApiName } from './types';
export const TaskIdentifier = 'lobe-task';
export const TaskManifest: BuiltinToolManifest = {
api: [
// ==================== Task CRUD ====================
{
description:
'Create a new task. Optionally attach it as a subtask by specifying parentIdentifier. Review config is inherited from parent task by default.',
name: TaskApiName.createTask,
parameters: {
properties: {
instruction: {
description: 'Detailed instruction for what the task should accomplish.',
type: 'string',
},
name: {
description: 'A short, descriptive name for the task.',
type: 'string',
},
parentIdentifier: {
description:
'Identifier of the parent task (e.g. "TASK-1"). If provided, the new task becomes a subtask. Defaults to the current task if omitted.',
type: 'string',
},
priority: {
description: 'Priority level: 0=none, 1=urgent, 2=high, 3=normal, 4=low. Default is 0.',
type: 'number',
},
sortOrder: {
description:
'Sort order within parent task. Lower values appear first. Use to control display order (e.g. chapter 1=0, chapter 2=1, etc.).',
type: 'number',
},
review: {
description:
'Review config. If omitted, inherits from parent task. Set to configure LLM-as-Judge auto-review.',
properties: {
autoRetry: {
description: 'Auto-retry on failure. Default true.',
type: 'boolean',
},
criteria: {
description: 'Review criteria with name and threshold (0-100).',
items: {
properties: {
name: { description: 'Criterion name, e.g. "内容准确性"', type: 'string' },
threshold: { description: 'Pass threshold (0-100)', type: 'number' },
},
required: ['name', 'threshold'],
type: 'object',
},
type: 'array',
},
enabled: { description: 'Enable review. Default false.', type: 'boolean' },
maxIterations: {
description: 'Max review iterations. Default 3.',
type: 'number',
},
},
type: 'object',
},
},
required: ['name', 'instruction'],
type: 'object',
},
},
{
description:
'List tasks with optional filters. Without filters, lists subtasks of the current task.',
name: TaskApiName.listTasks,
parameters: {
properties: {
parentIdentifier: {
description:
'List subtasks of a specific parent task. Defaults to the current task if omitted.',
type: 'string',
},
status: {
description: 'Filter by status.',
enum: ['backlog', 'running', 'paused', 'completed', 'failed', 'canceled'],
type: 'string',
},
},
required: [],
type: 'object',
},
},
{
description:
'View details of a specific task. If no identifier is provided, returns the current task.',
name: TaskApiName.viewTask,
parameters: {
properties: {
identifier: {
description:
'The task identifier to view (e.g. "TASK-1"). Defaults to the current task if omitted.',
type: 'string',
},
},
required: [],
type: 'object',
},
},
{
description:
"Edit a task's name, instruction, priority, or dependencies. Use addDependency/removeDependency to manage execution order.",
name: TaskApiName.editTask,
parameters: {
properties: {
addDependency: {
description:
'Add a dependency — this task will block until the specified task completes. Provide the identifier (e.g. "TASK-2").',
type: 'string',
},
identifier: {
description: 'The identifier of the task to edit.',
type: 'string',
},
instruction: {
description: 'Updated instruction for the task.',
type: 'string',
},
name: {
description: 'Updated name for the task.',
type: 'string',
},
priority: {
description: 'Updated priority level: 0=none, 1=urgent, 2=high, 3=normal, 4=low.',
type: 'number',
},
removeDependency: {
description: 'Remove a dependency. Provide the identifier of the dependency to remove.',
type: 'string',
},
review: {
description: 'Update review config.',
properties: {
autoRetry: { type: 'boolean' },
criteria: {
items: {
properties: {
name: { type: 'string' },
threshold: { type: 'number' },
},
required: ['name', 'threshold'],
type: 'object',
},
type: 'array',
},
enabled: { type: 'boolean' },
maxIterations: { type: 'number' },
},
type: 'object',
},
},
required: ['identifier'],
type: 'object',
},
},
{
description:
"Update a task's status. Use to mark tasks as completed, canceled, or change lifecycle state. Defaults to the current task if no identifier provided.",
name: TaskApiName.updateTaskStatus,
parameters: {
properties: {
identifier: {
description:
'The task identifier (e.g. "TASK-1"). Defaults to the current task if omitted.',
type: 'string',
},
status: {
description: 'New status for the task.',
enum: ['backlog', 'running', 'paused', 'completed', 'failed', 'canceled'],
type: 'string',
},
},
required: ['status'],
type: 'object',
},
},
{
description: 'Delete a task by identifier.',
name: TaskApiName.deleteTask,
parameters: {
properties: {
identifier: {
description: 'The identifier of the task to delete.',
type: 'string',
},
},
required: ['identifier'],
type: 'object',
},
},
],
identifier: TaskIdentifier,
meta: {
avatar: '\uD83D\uDCCB',
description: 'Create, list, edit, delete tasks with dependencies and review config',
title: 'Task Tools',
},
systemRole: systemPrompt,
type: 'builtin',
};
@@ -0,0 +1,14 @@
export const systemPrompt = `You have access to Task management tools. Use them to:
- **createTask**: Create a new task. Use parentIdentifier to make it a subtask. Review config is inherited from parent by default, or specify custom review criteria
- **listTasks**: List tasks, optionally filtered by parent or status
- **viewTask**: View details of a specific task (defaults to your current task)
- **editTask**: Modify a task's name, instruction, priority, dependencies (addDependency/removeDependency), or review config
- **updateTaskStatus**: Change a task's status (e.g. mark as completed when done, or cancel if no longer needed)
- **deleteTask**: Delete a task
When planning work:
1. Create tasks for each major piece of work (use parentIdentifier to organize as subtasks)
2. Use editTask with addDependency to control execution order
3. Configure review criteria on tasks that need quality gates
4. Use updateTaskStatus to mark the current task as completed when you finish all work`;
+21
View File
@@ -0,0 +1,21 @@
export const TaskApiName = {
/** Create a new task, optionally as a subtask of another task */
createTask: 'createTask',
/** Delete a task */
deleteTask: 'deleteTask',
/** Edit a task's name, instruction, priority, dependencies, or review config */
editTask: 'editTask',
/** List tasks with optional filters */
listTasks: 'listTasks',
/** Update a task's status (e.g. complete, cancel) */
updateTaskStatus: 'updateTaskStatus',
/** View details of a specific task */
viewTask: 'viewTask',
} as const;
export type TaskApiNameType = (typeof TaskApiName)[keyof typeof TaskApiName];
+2
View File
@@ -18,6 +18,7 @@
"dependencies": {
"@lobechat/builtin-tool-agent-builder": "workspace:*",
"@lobechat/builtin-tool-agent-documents": "workspace:*",
"@lobechat/builtin-tool-brief": "workspace:*",
"@lobechat/builtin-tool-cloud-sandbox": "workspace:*",
"@lobechat/builtin-tool-group-agent-builder": "workspace:*",
"@lobechat/builtin-tool-group-management": "workspace:*",
@@ -30,6 +31,7 @@
"@lobechat/builtin-tool-remote-device": "workspace:*",
"@lobechat/builtin-tool-skill-store": "workspace:*",
"@lobechat/builtin-tool-skills": "workspace:*",
"@lobechat/builtin-tool-task": "workspace:*",
"@lobechat/builtin-tool-tools": "workspace:*",
"@lobechat/builtin-tool-topic-reference": "workspace:*",
"@lobechat/builtin-tool-web-browsing": "workspace:*",
+17 -13
View File
@@ -1,6 +1,7 @@
import { AgentBuilderManifest } from '@lobechat/builtin-tool-agent-builder';
import { AgentDocumentsManifest } from '@lobechat/builtin-tool-agent-documents';
import { AgentManagementManifest } from '@lobechat/builtin-tool-agent-management';
import { BriefManifest } from '@lobechat/builtin-tool-brief';
import { CalculatorManifest } from '@lobechat/builtin-tool-calculator';
import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox';
import { GroupAgentBuilderManifest } from '@lobechat/builtin-tool-group-agent-builder';
@@ -14,10 +15,11 @@ import { PageAgentManifest } from '@lobechat/builtin-tool-page-agent';
import { RemoteDeviceManifest } from '@lobechat/builtin-tool-remote-device';
import { SkillStoreManifest } from '@lobechat/builtin-tool-skill-store';
import { SkillsManifest } from '@lobechat/builtin-tool-skills';
import { TaskManifest } from '@lobechat/builtin-tool-task';
import { LobeToolsManifest } from '@lobechat/builtin-tool-tools';
import { TopicReferenceManifest } from '@lobechat/builtin-tool-topic-reference';
import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing';
import { isDesktop, RECOMMENDED_SKILLS, RecommendedSkillType } from '@lobechat/const';
import { isDesktop } from '@lobechat/const';
import { type LobeBuiltinTool } from '@lobechat/types';
/**
@@ -163,16 +165,18 @@ export const builtinTools: LobeBuiltinTool[] = [
manifest: TopicReferenceManifest,
type: 'builtin',
},
{
discoverable: false,
hidden: true,
identifier: TaskManifest.identifier,
manifest: TaskManifest,
type: 'builtin',
},
{
discoverable: false,
hidden: true,
identifier: BriefManifest.identifier,
manifest: BriefManifest,
type: 'builtin',
},
];
/**
* Non-hidden builtin tools that are NOT in RECOMMENDED_SKILLS.
* These tools default to uninstalled and must be explicitly installed by the user from the Skill Store.
*/
const recommendedBuiltinIds = new Set(
RECOMMENDED_SKILLS.filter((s) => s.type === RecommendedSkillType.Builtin).map((s) => s.id),
);
export const defaultUninstalledBuiltinTools = builtinTools
.filter((t) => !t.hidden && !recommendedBuiltinIds.has(t.identifier))
.map((t) => t.identifier);
-26
View File
@@ -1,26 +0,0 @@
{
"name": "@lobechat/chat-adapter-wechat",
"version": "0.1.0",
"description": "WeChat (iLink) Bot adapter for chat SDK",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"chat": "^4.14.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.2"
}
}
@@ -1,357 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createWechatAdapter, WechatAdapter } from './adapter';
import type { WechatRawMessage } from './types';
import { MessageItemType, MessageState, MessageType } from './types';
// ---- helpers ----
function makeRawMessage(overrides: Partial<WechatRawMessage> = {}): WechatRawMessage {
return {
client_id: 'client_1',
context_token: 'ctx_tok',
create_time_ms: 1700000000000,
from_user_id: 'user_abc@im.wechat',
item_list: [{ text_item: { text: 'hello' }, type: MessageItemType.TEXT }],
message_id: 42,
message_state: MessageState.FINISH,
message_type: MessageType.USER,
to_user_id: 'bot_id',
...overrides,
};
}
function makeRequest(body: unknown): Request {
return new Request('http://localhost/webhook', {
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
}
// ---- tests ----
describe('WechatAdapter', () => {
let adapter: WechatAdapter;
const mockChat = {
getLogger: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
})),
getUserName: vi.fn(() => 'TestBot'),
processMessage: vi.fn(),
};
beforeEach(() => {
vi.resetAllMocks();
adapter = new WechatAdapter({ botId: 'bot_123', botToken: 'tok' });
adapter.initialize(mockChat as any);
});
afterEach(() => {
vi.restoreAllMocks();
});
// ---------- constructor & initialize ----------
describe('constructor', () => {
it('should set botUserId from config', () => {
expect(adapter.botUserId).toBe('bot_123');
});
it('should default userName to "wechat-bot"', () => {
const a = new WechatAdapter({ botToken: 'tok' });
// Before initialize, userName comes from config
expect(a.userName).toBe('wechat-bot');
});
it('should use custom userName if provided', () => {
const a = new WechatAdapter({ botToken: 'tok', userName: 'MyBot' });
expect(a.userName).toBe('MyBot');
});
});
describe('initialize', () => {
it('should set userName from chat instance', () => {
expect(adapter.userName).toBe('TestBot');
});
});
// ---------- thread ID encoding/decoding ----------
describe('encodeThreadId / decodeThreadId', () => {
it('should encode thread ID with wechat prefix', () => {
const encoded = adapter.encodeThreadId({ id: 'user_abc@im.wechat', type: 'single' });
expect(encoded).toBe('wechat:single:user_abc@im.wechat');
});
it('should encode group thread ID', () => {
const encoded = adapter.encodeThreadId({ id: 'group_1', type: 'group' });
expect(encoded).toBe('wechat:group:group_1');
});
it('should decode valid thread ID', () => {
const decoded = adapter.decodeThreadId('wechat:single:user_abc@im.wechat');
expect(decoded).toEqual({ id: 'user_abc@im.wechat', type: 'single' });
});
it('should decode thread ID with colons in user ID', () => {
const decoded = adapter.decodeThreadId('wechat:single:id:with:colons');
expect(decoded).toEqual({ id: 'id:with:colons', type: 'single' });
});
it('should fallback for invalid thread ID', () => {
const decoded = adapter.decodeThreadId('some-random-id');
expect(decoded).toEqual({ id: 'some-random-id', type: 'single' });
});
it('should round-trip encode/decode', () => {
const original = { id: 'user_xyz@im.wechat', type: 'single' as const };
const encoded = adapter.encodeThreadId(original);
const decoded = adapter.decodeThreadId(encoded);
expect(decoded).toEqual(original);
});
});
// ---------- isDM ----------
describe('isDM', () => {
it('should return true for single type', () => {
const threadId = adapter.encodeThreadId({ id: 'u', type: 'single' });
expect(adapter.isDM(threadId)).toBe(true);
});
it('should return false for group type', () => {
const threadId = adapter.encodeThreadId({ id: 'g', type: 'group' });
expect(adapter.isDM(threadId)).toBe(false);
});
});
// ---------- channelIdFromThreadId ----------
describe('channelIdFromThreadId', () => {
it('should return threadId as-is', () => {
expect(adapter.channelIdFromThreadId('wechat:single:u')).toBe('wechat:single:u');
});
});
// ---------- handleWebhook ----------
describe('handleWebhook', () => {
it('should return 400 for invalid JSON', async () => {
const req = new Request('http://localhost/webhook', {
body: 'not json',
method: 'POST',
});
const res = await adapter.handleWebhook(req);
expect(res.status).toBe(400);
});
it('should skip bot messages', async () => {
const msg = makeRawMessage({ message_type: MessageType.BOT });
const res = await adapter.handleWebhook(makeRequest(msg));
expect(res.status).toBe(200);
expect(mockChat.processMessage).not.toHaveBeenCalled();
});
it('should skip non-finished messages', async () => {
const msg = makeRawMessage({ message_state: MessageState.GENERATING });
const res = await adapter.handleWebhook(makeRequest(msg));
expect(res.status).toBe(200);
expect(mockChat.processMessage).not.toHaveBeenCalled();
});
it('should skip empty text messages', async () => {
const msg = makeRawMessage({
item_list: [{ text_item: { text: ' ' }, type: MessageItemType.TEXT }],
});
const res = await adapter.handleWebhook(makeRequest(msg));
expect(res.status).toBe(200);
expect(mockChat.processMessage).not.toHaveBeenCalled();
});
it('should process valid user message', async () => {
const msg = makeRawMessage();
const res = await adapter.handleWebhook(makeRequest(msg));
expect(res.status).toBe(200);
expect(mockChat.processMessage).toHaveBeenCalledTimes(1);
expect(mockChat.processMessage).toHaveBeenCalledWith(
adapter,
'wechat:single:user_abc@im.wechat',
expect.any(Function),
undefined,
);
});
it('should cache context token from message', async () => {
const msg = makeRawMessage({ context_token: 'new_ctx' });
await adapter.handleWebhook(makeRequest(msg));
const threadId = adapter.encodeThreadId({ id: msg.from_user_id, type: 'single' });
expect(adapter.getContextToken(threadId)).toBe('new_ctx');
});
});
// ---------- parseMessage ----------
describe('parseMessage', () => {
it('should parse text message', () => {
const raw = makeRawMessage();
const message = adapter.parseMessage(raw);
expect(message.text).toBe('hello');
expect(message.id).toBe('42');
expect(message.author.userId).toBe('user_abc@im.wechat');
expect(message.author.isBot).toBe(false);
});
it('should parse bot message', () => {
const raw = makeRawMessage({ message_type: MessageType.BOT });
const message = adapter.parseMessage(raw);
expect(message.author.isBot).toBe(true);
});
it('should extract image placeholder', () => {
const raw = makeRawMessage({
item_list: [
{
image_item: { media: { aes_key: '', encrypt_query_param: '' } },
type: MessageItemType.IMAGE,
},
],
});
const message = adapter.parseMessage(raw);
expect(message.text).toBe('[image]');
});
it('should extract voice text or placeholder', () => {
const raw = makeRawMessage({
item_list: [
{
type: MessageItemType.VOICE,
voice_item: {
media: { aes_key: '', encrypt_query_param: '' },
text: 'transcribed text',
},
},
],
});
const message = adapter.parseMessage(raw);
expect(message.text).toBe('transcribed text');
});
it('should extract file name', () => {
const raw = makeRawMessage({
item_list: [
{
file_item: { file_name: 'doc.pdf', media: { aes_key: '', encrypt_query_param: '' } },
type: MessageItemType.FILE,
},
],
});
const message = adapter.parseMessage(raw);
expect(message.text).toBe('[file: doc.pdf]');
});
it('should extract video placeholder', () => {
const raw = makeRawMessage({
item_list: [
{
type: MessageItemType.VIDEO,
video_item: { media: { aes_key: '', encrypt_query_param: '' } },
},
],
});
const message = adapter.parseMessage(raw);
expect(message.text).toBe('[video]');
});
it('should join multiple items with newline', () => {
const raw = makeRawMessage({
item_list: [
{ text_item: { text: 'line1' }, type: MessageItemType.TEXT },
{ text_item: { text: 'line2' }, type: MessageItemType.TEXT },
],
});
const message = adapter.parseMessage(raw);
expect(message.text).toBe('line1\nline2');
});
});
// ---------- context token management ----------
describe('context token management', () => {
it('should get and set context tokens', () => {
adapter.setContextToken('thread_1', 'token_a');
expect(adapter.getContextToken('thread_1')).toBe('token_a');
});
it('should return undefined for unknown thread', () => {
expect(adapter.getContextToken('unknown')).toBeUndefined();
});
});
// ---------- fetchThread ----------
describe('fetchThread', () => {
it('should return thread info for single chat', async () => {
const threadId = adapter.encodeThreadId({ id: 'user_1', type: 'single' });
const info = await adapter.fetchThread(threadId);
expect(info.id).toBe(threadId);
expect(info.isDM).toBe(true);
expect(info.metadata).toEqual({ id: 'user_1', type: 'single' });
});
it('should return thread info for group chat', async () => {
const threadId = adapter.encodeThreadId({ id: 'group_1', type: 'group' });
const info = await adapter.fetchThread(threadId);
expect(info.isDM).toBe(false);
});
});
// ---------- fetchMessages ----------
describe('fetchMessages', () => {
it('should return empty result', async () => {
const result = await adapter.fetchMessages('any');
expect(result).toEqual({ messages: [], nextCursor: undefined });
});
});
// ---------- no-op methods ----------
describe('no-op methods', () => {
it('addReaction should resolve', async () => {
await expect(adapter.addReaction('t', 'm', 'emoji')).resolves.toBeUndefined();
});
it('removeReaction should resolve', async () => {
await expect(adapter.removeReaction('t', 'm', 'emoji')).resolves.toBeUndefined();
});
it('startTyping should resolve', async () => {
await expect(adapter.startTyping('t')).resolves.toBeUndefined();
});
});
});
// ---------- createWechatAdapter factory ----------
describe('createWechatAdapter', () => {
it('should return a WechatAdapter instance', () => {
const adapter = createWechatAdapter({ botToken: 'tok' });
expect(adapter).toBeInstanceOf(WechatAdapter);
expect(adapter.name).toBe('wechat');
});
});
-332
View File
@@ -1,332 +0,0 @@
import type {
Adapter,
AdapterPostableMessage,
Author,
ChatInstance,
EmojiValue,
FetchOptions,
FetchResult,
FormattedContent,
Logger,
RawMessage,
ThreadInfo,
WebhookOptions,
} from 'chat';
import { Message, parseMarkdown } from 'chat';
import { WechatApiClient } from './api';
import { WechatFormatConverter } from './format-converter';
import type { WechatAdapterConfig, WechatRawMessage, WechatThreadId } from './types';
import { MessageItemType, MessageState, MessageType } from './types';
/**
* Extract text content from a WechatRawMessage's item_list.
*/
function extractText(msg: WechatRawMessage): string {
const parts: string[] = [];
for (const item of msg.item_list) {
switch (item.type) {
case MessageItemType.TEXT: {
if (item.text_item?.text) parts.push(item.text_item.text);
break;
}
case MessageItemType.IMAGE: {
parts.push('[image]');
break;
}
case MessageItemType.VOICE: {
parts.push(item.voice_item?.text || '[voice]');
break;
}
case MessageItemType.FILE: {
parts.push(`[file: ${item.file_item?.file_name || 'unknown'}]`);
break;
}
case MessageItemType.VIDEO: {
parts.push('[video]');
break;
}
}
}
return parts.join('\n');
}
/**
* WeChat (iLink) adapter for Chat SDK.
*
* Handles webhook requests forwarded by the long-polling monitor
* and message operations via iLink Bot API.
*/
export class WechatAdapter implements Adapter<WechatThreadId, WechatRawMessage> {
readonly name = 'wechat';
private readonly api: WechatApiClient;
private readonly formatConverter: WechatFormatConverter;
private _userName: string;
private _botUserId?: string;
private chat!: ChatInstance;
private logger!: Logger;
/**
* Per-thread contextToken cache.
* WeChat requires echoing the context_token from the latest inbound message.
*/
private contextTokens = new Map<string, string>();
get userName(): string {
return this._userName;
}
get botUserId(): string | undefined {
return this._botUserId;
}
constructor(config: WechatAdapterConfig & { userName?: string }) {
this.api = new WechatApiClient(config.botToken, config.botId);
this.formatConverter = new WechatFormatConverter();
this._userName = config.userName || 'wechat-bot';
this._botUserId = config.botId;
}
async initialize(chat: ChatInstance): Promise<void> {
this.chat = chat;
this.logger = chat.getLogger(this.name);
this._userName = chat.getUserName();
this.logger.info('Initialized WeChat adapter (botUserId=%s)', this._botUserId);
}
// ------------------------------------------------------------------
// Webhook handling — processes forwarded messages from the monitor
// ------------------------------------------------------------------
async handleWebhook(request: Request, options?: WebhookOptions): Promise<Response> {
const bodyText = await request.text();
let msg: WechatRawMessage;
try {
msg = JSON.parse(bodyText);
} catch {
return new Response('Invalid JSON', { status: 400 });
}
// Skip bot's own messages and non-finished messages
if (msg.message_type === MessageType.BOT) {
return Response.json({ ok: true });
}
if (msg.message_state !== undefined && msg.message_state !== MessageState.FINISH) {
return Response.json({ ok: true });
}
const text = extractText(msg);
if (!text.trim()) {
return Response.json({ ok: true });
}
// Build thread ID and cache context token
const threadId = this.encodeThreadId({ id: msg.from_user_id, type: 'single' });
this.contextTokens.set(threadId, msg.context_token);
const messageFactory = () => this.parseRawEvent(msg, threadId, text);
this.chat.processMessage(this, threadId, messageFactory, options);
return Response.json({ ok: true });
}
// ------------------------------------------------------------------
// Message operations
// ------------------------------------------------------------------
async postMessage(
threadId: string,
message: AdapterPostableMessage,
): Promise<RawMessage<WechatRawMessage>> {
const { id } = this.decodeThreadId(threadId);
const text = this.formatConverter.renderPostable(message);
const contextToken = this.contextTokens.get(threadId) || '';
await this.api.sendMessage(id, text, contextToken);
return {
id: `bot_${Date.now()}`,
raw: {
client_id: `lobehub_${Date.now()}`,
context_token: contextToken,
create_time_ms: Date.now(),
from_user_id: this._botUserId || '',
item_list: [{ text_item: { text }, type: MessageItemType.TEXT }],
message_id: 0,
message_state: MessageState.FINISH,
message_type: MessageType.BOT,
to_user_id: id,
},
threadId,
};
}
async editMessage(
threadId: string,
_messageId: string,
message: AdapterPostableMessage,
): Promise<RawMessage<WechatRawMessage>> {
// WeChat doesn't support editing — fall back to posting a new message
return this.postMessage(threadId, message);
}
async deleteMessage(_threadId: string, _messageId: string): Promise<void> {
this.logger.warn('Message deletion not supported for WeChat');
}
async fetchMessages(
_threadId: string,
_options?: FetchOptions,
): Promise<FetchResult<WechatRawMessage>> {
return { messages: [], nextCursor: undefined };
}
async fetchThread(threadId: string): Promise<ThreadInfo> {
const { type, id } = this.decodeThreadId(threadId);
return {
channelId: threadId,
id: threadId,
isDM: type === 'single',
metadata: { id, type },
};
}
// ------------------------------------------------------------------
// Message parsing
// ------------------------------------------------------------------
parseMessage(raw: WechatRawMessage): Message<WechatRawMessage> {
const text = extractText(raw);
const formatted = parseMarkdown(text);
const threadId = this.encodeThreadId({ id: raw.from_user_id, type: 'single' });
return new Message({
attachments: [],
author: {
fullName: raw.from_user_id,
isBot: raw.message_type === MessageType.BOT,
isMe: raw.message_type === MessageType.BOT,
userId: raw.from_user_id,
userName: raw.from_user_id,
},
formatted,
id: String(raw.message_id || 0),
metadata: {
dateSent: new Date(raw.create_time_ms || Date.now()),
edited: false,
},
raw,
text,
threadId,
});
}
private async parseRawEvent(
msg: WechatRawMessage,
threadId: string,
text: string,
): Promise<Message<WechatRawMessage>> {
const formatted = parseMarkdown(text);
const author: Author = {
fullName: msg.from_user_id,
isBot: false,
isMe: false,
userId: msg.from_user_id,
userName: msg.from_user_id,
};
return new Message({
attachments: [],
author,
formatted,
id: String(msg.message_id || 0),
metadata: {
dateSent: new Date(msg.create_time_ms || Date.now()),
edited: false,
},
raw: msg,
text,
threadId,
});
}
// ------------------------------------------------------------------
// Reactions & typing (limited support)
// ------------------------------------------------------------------
async addReaction(
_threadId: string,
_messageId: string,
_emoji: EmojiValue | string,
): Promise<void> {}
async removeReaction(
_threadId: string,
_messageId: string,
_emoji: EmojiValue | string,
): Promise<void> {}
async startTyping(threadId: string): Promise<void> {
const { id } = this.decodeThreadId(threadId);
const contextToken = this.contextTokens.get(threadId);
if (!contextToken) return;
await this.api.startTyping(id, contextToken);
}
// ------------------------------------------------------------------
// Thread ID encoding
// ------------------------------------------------------------------
encodeThreadId(data: WechatThreadId): string {
return `wechat:${data.type}:${data.id}`;
}
decodeThreadId(threadId: string): WechatThreadId {
const parts = threadId.split(':');
if (parts.length < 3 || parts[0] !== 'wechat') {
return { id: threadId, type: 'single' };
}
return { id: parts.slice(2).join(':'), type: parts[1] as WechatThreadId['type'] };
}
channelIdFromThreadId(threadId: string): string {
return threadId;
}
isDM(threadId: string): boolean {
const { type } = this.decodeThreadId(threadId);
return type === 'single';
}
// ------------------------------------------------------------------
// Format rendering
// ------------------------------------------------------------------
renderFormatted(content: FormattedContent): string {
return this.formatConverter.fromAst(content);
}
// ------------------------------------------------------------------
// Context token management (public for platform client use)
// ------------------------------------------------------------------
getContextToken(threadId: string): string | undefined {
return this.contextTokens.get(threadId);
}
setContextToken(threadId: string, token: string): void {
this.contextTokens.set(threadId, token);
}
}
/**
* Factory function to create a WechatAdapter.
*/
export function createWechatAdapter(
config: WechatAdapterConfig & { userName?: string },
): WechatAdapter {
return new WechatAdapter(config);
}
@@ -1,246 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_BASE_URL, fetchQrCode, pollQrStatus, WechatApiClient } from './api';
import { WECHAT_RET_CODES } from './types';
// ---- helpers ----
const mockFetch = vi.fn<typeof fetch>();
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status,
});
}
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.restoreAllMocks();
});
// ---- tests ----
describe('WechatApiClient', () => {
let client: WechatApiClient;
beforeEach(() => {
mockFetch.mockReset();
client = new WechatApiClient('test-token', 'bot-123');
});
// ---------- constructor ----------
describe('constructor', () => {
it('should use default base URL when none provided', () => {
const c = new WechatApiClient('tok');
expect(c.botId).toBe('');
});
it('should strip trailing slashes from base URL', async () => {
const c = new WechatApiClient('tok', 'id', 'https://example.com///');
mockFetch.mockResolvedValueOnce(jsonResponse({ ret: 0, msgs: [], get_updates_buf: '' }));
await c.getUpdates();
expect(mockFetch).toHaveBeenCalledWith(
'https://example.com/ilink/bot/getupdates',
expect.anything(),
);
});
});
// ---------- getUpdates ----------
describe('getUpdates', () => {
it('should return parsed response on success', async () => {
const payload = { ret: 0, msgs: [], get_updates_buf: 'cursor_1' };
mockFetch.mockResolvedValueOnce(jsonResponse(payload));
const result = await client.getUpdates();
expect(result).toEqual(payload);
});
it('should send cursor in request body', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({ ret: 0, msgs: [], get_updates_buf: 'cursor_2' }),
);
await client.getUpdates('prev_cursor');
const body = JSON.parse(mockFetch.mock.calls[0][1]!.body as string);
expect(body.get_updates_buf).toBe('prev_cursor');
});
it('should throw on HTTP error', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ errmsg: 'Unauthorized' }, 401));
await expect(client.getUpdates()).rejects.toThrow('Unauthorized');
});
it('should throw on non-zero ret code', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({ ret: WECHAT_RET_CODES.SESSION_EXPIRED, errmsg: 'session expired' }),
);
await expect(client.getUpdates()).rejects.toThrow('session expired');
});
it('should include Authorization and X-WECHAT-UIN headers', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ ret: 0, msgs: [], get_updates_buf: '' }));
await client.getUpdates();
const headers = mockFetch.mock.calls[0][1]!.headers as Record<string, string>;
expect(headers['Authorization']).toBe('Bearer test-token');
expect(headers['X-WECHAT-UIN']).toBeDefined();
});
});
// ---------- sendMessage ----------
describe('sendMessage', () => {
it('should send a short text in a single call', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ ret: 0 }));
const result = await client.sendMessage('user_1', 'hello', 'ctx_token');
expect(result).toEqual({ ret: 0 });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should chunk long text into multiple requests', async () => {
mockFetch.mockImplementation(() => Promise.resolve(jsonResponse({ ret: 0 })));
const longText = 'a'.repeat(4500); // > 2 * 2000
await client.sendMessage('user_1', longText, 'ctx');
// 4500 / 2000 = 3 chunks
expect(mockFetch).toHaveBeenCalledTimes(3);
});
it('should include correct fields in request body', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ ret: 0 }));
await client.sendMessage('user_1', 'hi', 'ctx_tok');
const body = JSON.parse(mockFetch.mock.calls[0][1]!.body as string);
expect(body.msg.to_user_id).toBe('user_1');
expect(body.msg.context_token).toBe('ctx_tok');
expect(body.msg.from_user_id).toBe('');
expect(body.msg.item_list[0].text_item.text).toBe('hi');
expect(body.msg.message_state).toBe(2); // FINISH
expect(body.msg.message_type).toBe(2); // BOT
});
it('should throw on API error', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ ret: -1, errmsg: 'send failed' }));
await expect(client.sendMessage('u', 'hi', 'ctx')).rejects.toThrow('send failed');
});
});
// ---------- sendTyping ----------
describe('sendTyping', () => {
it('should not throw on success', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ ret: 0 }));
await expect(client.sendTyping('user_1', 'ticket_1')).resolves.toBeUndefined();
});
it('should not throw on network error (best-effort)', async () => {
mockFetch.mockRejectedValueOnce(new Error('network error'));
await expect(client.sendTyping('user_1', 'ticket_1')).resolves.toBeUndefined();
});
it('should send status=1 for start and status=2 for stop', async () => {
mockFetch.mockResolvedValue(jsonResponse({ ret: 0 }));
await client.sendTyping('u', 'tk', true);
const startBody = JSON.parse(mockFetch.mock.calls[0][1]!.body as string);
expect(startBody.status).toBe(1);
await client.sendTyping('u', 'tk', false);
const stopBody = JSON.parse(mockFetch.mock.calls[1][1]!.body as string);
expect(stopBody.status).toBe(2);
});
});
// ---------- getConfig ----------
describe('getConfig', () => {
it('should return config with typing_ticket', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ ret: 0, typing_ticket: 'ticket_abc' }));
const config = await client.getConfig('user_1', 'ctx_tok');
expect(config.typing_ticket).toBe('ticket_abc');
});
it('should throw on non-zero ret', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ ret: -14, errmsg: 'expired' }));
await expect(client.getConfig('u', 'c')).rejects.toThrow('expired');
});
});
});
// ---- QR code helpers ----
describe('fetchQrCode', () => {
beforeEach(() => mockFetch.mockReset());
it('should return qr code data on success', async () => {
const payload = { qrcode: 'qr_123', qrcode_img_content: 'base64...' };
mockFetch.mockResolvedValueOnce(jsonResponse(payload));
const result = await fetchQrCode();
expect(result).toEqual(payload);
expect(mockFetch).toHaveBeenCalledWith(
`${DEFAULT_BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3`,
expect.objectContaining({ method: 'GET' }),
);
});
it('should throw on HTTP error', async () => {
mockFetch.mockResolvedValueOnce(new Response('Not Found', { status: 404 }));
await expect(fetchQrCode()).rejects.toThrow('iLink get_bot_qrcode failed');
});
it('should strip trailing slashes from custom base URL', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ qrcode: 'x', qrcode_img_content: 'y' }));
await fetchQrCode('https://custom.example.com//');
expect(mockFetch).toHaveBeenCalledWith(
'https://custom.example.com/ilink/bot/get_bot_qrcode?bot_type=3',
expect.anything(),
);
});
});
describe('pollQrStatus', () => {
beforeEach(() => mockFetch.mockReset());
it('should return status on success', async () => {
const payload = { status: 'wait' as const };
mockFetch.mockResolvedValueOnce(jsonResponse(payload));
const result = await pollQrStatus('qr_123');
expect(result.status).toBe('wait');
});
it('should encode qrcode in URL', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ status: 'scaned' }));
await pollQrStatus('qr=special&chars');
const url = mockFetch.mock.calls[0][0] as string;
expect(url).toContain(encodeURIComponent('qr=special&chars'));
});
it('should throw on HTTP error', async () => {
mockFetch.mockResolvedValueOnce(new Response('error', { status: 500 }));
await expect(pollQrStatus('qr')).rejects.toThrow('iLink get_qrcode_status failed');
});
});
-272
View File
@@ -1,272 +0,0 @@
import type {
BaseInfo,
MessageItem,
WechatGetConfigResponse,
WechatGetUpdatesResponse,
WechatSendMessageResponse,
} from './types';
import { MessageItemType, MessageState, MessageType, WECHAT_RET_CODES } from './types';
export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com';
/** Strip trailing slashes without regex (avoids ReDoS on untrusted input). */
function stripTrailingSlashes(url: string): string {
let end = url.length;
while (end > 0 && url[end - 1] === '/') end--;
return url.slice(0, end);
}
const CHANNEL_VERSION = '1.0.0';
const MAX_TEXT_LENGTH = 2000;
const POLL_TIMEOUT_MS = 40_000;
const DEFAULT_TIMEOUT_MS = 15_000;
const BASE_INFO: BaseInfo = { channel_version: CHANNEL_VERSION };
/**
* Generate a random X-WECHAT-UIN header value as required by the iLink API.
*/
function randomUin(): string {
const uint32 = Math.floor(Math.random() * 0xffff_ffff);
return btoa(String(uint32));
}
function buildHeaders(botToken: string): Record<string, string> {
return {
'Authorization': `Bearer ${botToken}`,
'AuthorizationType': 'ilink_bot_token',
'Content-Type': 'application/json',
'X-WECHAT-UIN': randomUin(),
};
}
/**
* Parse JSON response. Throws if HTTP error or ret is non-zero.
* Matches reference: only throws when ret IS a number AND not 0.
*/
async function parseResponse<T>(response: Response, label: string): Promise<T> {
const text = await response.text();
const payload = text ? (JSON.parse(text) as T) : ({} as T);
if (!response.ok) {
const msg =
(payload as { errmsg?: string } | null)?.errmsg ??
`${label} failed with HTTP ${response.status}`;
throw new Error(msg);
}
const ret = (payload as { ret?: number } | null)?.ret;
if (typeof ret === 'number' && ret !== WECHAT_RET_CODES.OK) {
const body = payload as { errcode?: number; errmsg?: string; ret: number };
throw Object.assign(new Error(body.errmsg ?? `${label} failed with ret=${ret}`), {
code: body.errcode ?? ret,
});
}
return payload;
}
/**
* Build a combined AbortSignal from an optional external signal and a timeout.
*/
function combinedSignal(signal?: AbortSignal, timeoutMs: number = POLL_TIMEOUT_MS): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
}
export class WechatApiClient {
private readonly botToken: string;
private readonly baseUrl: string;
botId: string;
constructor(botToken: string, botId?: string, baseUrl?: string) {
this.botToken = botToken;
this.botId = botId || '';
this.baseUrl = stripTrailingSlashes(baseUrl || DEFAULT_BASE_URL);
}
/**
* Long-poll for new messages via iLink Bot API.
* Server holds connection for ~35 seconds.
*/
async getUpdates(cursor?: string, signal?: AbortSignal): Promise<WechatGetUpdatesResponse> {
const body = {
base_info: BASE_INFO,
get_updates_buf: cursor || '',
};
const response = await fetch(`${this.baseUrl}/ilink/bot/getupdates`, {
body: JSON.stringify(body),
headers: buildHeaders(this.botToken),
method: 'POST',
signal: combinedSignal(signal, POLL_TIMEOUT_MS),
});
return parseResponse<WechatGetUpdatesResponse>(response, 'getupdates');
}
/**
* Send a text message via iLink Bot API.
* Reference: from_user_id is empty string, client_id is random UUID.
*/
async sendMessage(
toUserId: string,
text: string,
contextToken: string,
): Promise<WechatSendMessageResponse> {
const chunks = chunkText(text, MAX_TEXT_LENGTH);
let lastResponse: WechatSendMessageResponse = { ret: 0 };
for (const chunk of chunks) {
const item: MessageItem = {
text_item: { text: chunk },
type: MessageItemType.TEXT,
};
const body = {
base_info: BASE_INFO,
msg: {
client_id: crypto.randomUUID(),
context_token: contextToken,
from_user_id: '',
item_list: [item],
message_state: MessageState.FINISH,
message_type: MessageType.BOT,
to_user_id: toUserId,
},
};
const response = await fetch(`${this.baseUrl}/ilink/bot/sendmessage`, {
body: JSON.stringify(body),
headers: buildHeaders(this.botToken),
method: 'POST',
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
lastResponse = await parseResponse<WechatSendMessageResponse>(response, 'sendmessage');
}
return lastResponse;
}
/**
* Send typing indicator via iLink Bot API.
*/
async sendTyping(toUserId: string, typingTicket: string, start = true): Promise<void> {
await fetch(`${this.baseUrl}/ilink/bot/sendtyping`, {
body: JSON.stringify({
base_info: BASE_INFO,
ilink_user_id: toUserId,
status: start ? 1 : 2,
typing_ticket: typingTicket,
}),
headers: buildHeaders(this.botToken),
method: 'POST',
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
}).catch(() => {
// Typing is best-effort
});
}
/**
* Convenience: getConfig + sendTyping in one call. Best-effort, never throws.
*/
async startTyping(toUserId: string, contextToken: string): Promise<void> {
try {
const config = await this.getConfig(toUserId, contextToken);
if (config.typing_ticket) {
await this.sendTyping(toUserId, config.typing_ticket);
}
} catch {
// typing is best-effort
}
}
/**
* Get bot configuration (including typing_ticket).
* Requires userId and contextToken per reference implementation.
*/
async getConfig(userId: string, contextToken: string): Promise<WechatGetConfigResponse> {
const response = await fetch(`${this.baseUrl}/ilink/bot/getconfig`, {
body: JSON.stringify({
base_info: BASE_INFO,
context_token: contextToken,
ilink_user_id: userId,
}),
headers: buildHeaders(this.botToken),
method: 'POST',
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
return parseResponse<WechatGetConfigResponse>(response, 'getconfig');
}
}
// ============================================================================
// QR Code Authentication (unauthenticated endpoints)
// ============================================================================
export interface QrCodeResponse {
qrcode: string;
qrcode_img_content: string;
}
export interface QrStatusResponse {
baseurl?: string;
bot_token?: string;
ilink_bot_id?: string;
ilink_user_id?: string;
status: 'wait' | 'scaned' | 'confirmed' | 'expired';
}
/**
* Request a new QR code for bot login.
*/
export async function fetchQrCode(baseUrl: string = DEFAULT_BASE_URL): Promise<QrCodeResponse> {
const url = `${stripTrailingSlashes(baseUrl)}/ilink/bot/get_bot_qrcode?bot_type=3`;
const response = await fetch(url, { method: 'GET' });
if (!response.ok) {
const text = await response.text();
throw new Error(`iLink get_bot_qrcode failed: ${response.status} ${text}`);
}
return response.json() as Promise<QrCodeResponse>;
}
/**
* Poll the QR code scan status.
*/
export async function pollQrStatus(
qrcode: string,
baseUrl: string = DEFAULT_BASE_URL,
): Promise<QrStatusResponse> {
const url = `${stripTrailingSlashes(baseUrl)}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
const response = await fetch(url, {
headers: { 'iLink-App-ClientVersion': '1' },
method: 'GET',
});
if (!response.ok) {
const text = await response.text();
throw new Error(`iLink get_qrcode_status failed: ${response.status} ${text}`);
}
return response.json() as Promise<QrStatusResponse>;
}
// ============================================================================
// Utilities
// ============================================================================
function chunkText(text: string, limit: number): string[] {
if (text.length <= limit) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
chunks.push(remaining.slice(0, limit));
remaining = remaining.slice(limit);
}
return chunks;
}
@@ -1,46 +0,0 @@
import { parseMarkdown } from 'chat';
import { describe, expect, it } from 'vitest';
import { WechatFormatConverter } from './format-converter';
describe('WechatFormatConverter', () => {
const converter = new WechatFormatConverter();
describe('toAst', () => {
it('should convert plain text to AST', () => {
const ast = converter.toAst('hello world');
expect(ast.type).toBe('root');
expect(ast.children.length).toBeGreaterThan(0);
});
it('should trim whitespace before parsing', () => {
const ast = converter.toAst(' hello ');
const text = converter.fromAst(ast);
expect(text.trim()).toBe('hello');
});
});
describe('fromAst', () => {
it('should convert AST back to text', () => {
const ast = parseMarkdown('hello world');
const text = converter.fromAst(ast);
expect(text.trim()).toBe('hello world');
});
it('should handle markdown formatting', () => {
const ast = parseMarkdown('**bold** and *italic*');
const text = converter.fromAst(ast);
expect(text).toContain('bold');
expect(text).toContain('italic');
});
});
describe('round-trip', () => {
it('should preserve plain text through round-trip', () => {
const original = 'simple text message';
const ast = converter.toAst(original);
const result = converter.fromAst(ast);
expect(result.trim()).toBe(original);
});
});
});
@@ -1,19 +0,0 @@
import type { Root } from 'chat';
import { BaseFormatConverter, parseMarkdown, stringifyMarkdown } from 'chat';
export class WechatFormatConverter extends BaseFormatConverter {
/**
* Convert mdast AST to WeChat-compatible text.
* WeChat does not support Markdown; convert to plain text.
*/
fromAst(ast: Root): string {
return stringifyMarkdown(ast);
}
/**
* Convert WeChat message text to mdast AST.
*/
toAst(text: string): Root {
return parseMarkdown(text.trim());
}
}
-13
View File
@@ -1,13 +0,0 @@
export { createWechatAdapter, WechatAdapter } from './adapter';
export type { QrCodeResponse, QrStatusResponse } from './api';
export { DEFAULT_BASE_URL, fetchQrCode, pollQrStatus, WechatApiClient } from './api';
export { WechatFormatConverter } from './format-converter';
export type {
WechatAdapterConfig,
WechatGetConfigResponse,
WechatGetUpdatesResponse,
WechatRawMessage,
WechatSendMessageResponse,
WechatThreadId,
} from './types';
export { MessageItemType, MessageState, MessageType, WECHAT_RET_CODES } from './types';
-154
View File
@@ -1,154 +0,0 @@
export interface WechatAdapterConfig {
/** Bot's iLink user ID (from QR login) */
botId?: string;
/** Bot token obtained from iLink QR code authentication */
botToken: string;
}
export interface WechatThreadId {
/** The WeChat user ID (xxx@im.wechat format) */
id: string;
/** Chat type */
type: 'single' | 'group';
}
// ---------- iLink protocol enums ----------
export enum MessageType {
USER = 1,
BOT = 2,
}
export enum MessageState {
NEW = 0,
GENERATING = 1,
FINISH = 2,
}
export enum MessageItemType {
TEXT = 1,
IMAGE = 2,
VOICE = 3,
FILE = 4,
VIDEO = 5,
}
// ---------- iLink API raw types ----------
export interface BaseInfo {
channel_version: string;
}
export interface CDNMedia {
aes_key: string;
encrypt_query_param: string;
encrypt_type?: 0 | 1;
}
export interface TextItem {
text: string;
}
export interface ImageItem {
aeskey?: string;
media: CDNMedia;
url?: string;
}
export interface VoiceItem {
encode_type?: number;
media: CDNMedia;
playtime?: number;
text?: string;
}
export interface FileItem {
file_name?: string;
len?: string;
md5?: string;
media: CDNMedia;
}
export interface VideoItem {
media: CDNMedia;
play_length?: number;
thumb_media?: CDNMedia;
video_size?: string | number;
}
export interface MessageItem {
file_item?: FileItem;
image_item?: ImageItem;
text_item?: TextItem;
type: MessageItemType;
video_item?: VideoItem;
voice_item?: VoiceItem;
}
/** Raw message from getupdates */
export interface WechatRawMessage {
client_id: string;
context_token: string;
create_time_ms: number;
from_user_id: string;
item_list: MessageItem[];
message_id: number;
message_state: MessageState;
message_type: MessageType;
to_user_id: string;
}
/** getupdates response */
export interface WechatGetUpdatesResponse {
errcode?: number;
errmsg?: string;
get_updates_buf: string;
longpolling_timeout_ms?: number;
msgs: WechatRawMessage[];
ret: number;
}
/** sendmessage request body */
export interface WechatSendMessageReq {
base_info: BaseInfo;
msg: {
client_id: string;
context_token: string;
from_user_id: string;
item_list: MessageItem[];
message_state: MessageState;
message_type: MessageType;
to_user_id: string;
};
}
/** sendmessage response */
export interface WechatSendMessageResponse {
errmsg?: string;
ret: number;
}
/** getconfig response */
export interface WechatGetConfigResponse {
errcode?: number;
errmsg?: string;
ret?: number;
typing_ticket?: string;
}
/** sendtyping request body */
export interface WechatSendTypingReq {
base_info: BaseInfo;
ilink_user_id: string;
/** 1 = start, 2 = stop */
status: 1 | 2;
typing_ticket: string;
}
/** iLink API return codes */
export const WECHAT_RET_CODES = {
/** Success */
OK: 0,
/** Session expired — requires re-authentication via QR code */
SESSION_EXPIRED: -14,
} as const;
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["ES2022"]
},
"exclude": ["node_modules", "dist"],
"include": ["src/**/*"]
}
@@ -1,8 +0,0 @@
import { defineConfig } from 'tsup';
export default defineConfig({
dts: true,
entry: ['src/index.ts'],
format: ['esm'],
sourcemap: true,
});
@@ -1,10 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
all: false,
},
environment: 'node',
},
});
+1
View File
@@ -16,6 +16,7 @@ export const RECOMMENDED_SKILLS: RecommendedSkillItem[] = [
{ id: 'lobe-cloud-sandbox', type: RecommendedSkillType.Builtin },
{ id: 'lobe-gtd', type: RecommendedSkillType.Builtin },
{ id: 'lobe-notebook', type: RecommendedSkillType.Builtin },
{ id: 'lobe-calculator', type: RecommendedSkillType.Builtin },
// Klavis skills
{ id: 'gmail', type: RecommendedSkillType.Klavis },
{ id: 'notion', type: RecommendedSkillType.Klavis },
@@ -0,0 +1,151 @@
CREATE TABLE IF NOT EXISTS "briefs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"task_id" text,
"cron_job_id" text,
"topic_id" text,
"agent_id" text,
"type" text NOT NULL,
"priority" text DEFAULT 'info',
"title" text NOT NULL,
"summary" text NOT NULL,
"artifacts" jsonb,
"actions" jsonb,
"comment_type" text,
"resolved_action" text,
"resolved_comment" text,
"read_at" timestamp with time zone,
"resolved_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "task_comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"task_id" text NOT NULL,
"user_id" text,
"agent_id" text,
"content" text NOT NULL,
"editor_data" jsonb,
"brief_id" uuid,
"topic_id" text,
"accessed_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "task_dependencies" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"task_id" text NOT NULL,
"depends_on_id" text NOT NULL,
"type" text DEFAULT 'blocks' NOT NULL,
"condition" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "task_documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"task_id" text NOT NULL,
"document_id" text NOT NULL,
"pinned_by" text DEFAULT 'agent' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "task_topics" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"task_id" text NOT NULL,
"topic_id" text NOT NULL,
"seq" integer NOT NULL,
"operation_id" text,
"status" text DEFAULT 'running' NOT NULL,
"review_passed" integer,
"review_score" integer,
"review_scores" jsonb,
"review_iteration" integer,
"reviewed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "tasks" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"seq" integer NOT NULL,
"created_by_user_id" text NOT NULL,
"created_by_agent_id" text,
"assignee_user_id" text,
"assignee_agent_id" text,
"parent_task_id" text,
"name" text,
"description" varchar(255),
"instruction" text NOT NULL,
"status" text DEFAULT 'backlog' NOT NULL,
"priority" integer DEFAULT 0,
"sort_order" integer DEFAULT 0,
"heartbeat_interval" integer DEFAULT 300,
"heartbeat_timeout" integer,
"last_heartbeat_at" timestamp with time zone,
"schedule_pattern" text,
"schedule_timezone" text DEFAULT 'UTC',
"total_topics" integer DEFAULT 0,
"max_topics" integer,
"current_topic_id" text,
"context" jsonb DEFAULT '{}'::jsonb,
"config" jsonb DEFAULT '{}'::jsonb,
"error" text,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"accessed_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "briefs" DROP CONSTRAINT IF EXISTS "briefs_user_id_users_id_fk";--> statement-breakpoint
ALTER TABLE "briefs" ADD CONSTRAINT "briefs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "briefs" DROP CONSTRAINT IF EXISTS "briefs_task_id_tasks_id_fk";--> statement-breakpoint
ALTER TABLE "briefs" ADD CONSTRAINT "briefs_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "briefs" DROP CONSTRAINT IF EXISTS "briefs_cron_job_id_agent_cron_jobs_id_fk";--> statement-breakpoint
ALTER TABLE "briefs" ADD CONSTRAINT "briefs_cron_job_id_agent_cron_jobs_id_fk" FOREIGN KEY ("cron_job_id") REFERENCES "public"."agent_cron_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "task_comments" DROP CONSTRAINT IF EXISTS "task_comments_task_id_tasks_id_fk";--> statement-breakpoint
ALTER TABLE "task_comments" ADD CONSTRAINT "task_comments_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "task_dependencies" DROP CONSTRAINT IF EXISTS "task_dependencies_task_id_tasks_id_fk";--> statement-breakpoint
ALTER TABLE "task_dependencies" ADD CONSTRAINT "task_dependencies_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "task_dependencies" DROP CONSTRAINT IF EXISTS "task_dependencies_depends_on_id_tasks_id_fk";--> statement-breakpoint
ALTER TABLE "task_dependencies" ADD CONSTRAINT "task_dependencies_depends_on_id_tasks_id_fk" FOREIGN KEY ("depends_on_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "task_documents" DROP CONSTRAINT IF EXISTS "task_documents_task_id_tasks_id_fk";--> statement-breakpoint
ALTER TABLE "task_documents" ADD CONSTRAINT "task_documents_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "task_documents" DROP CONSTRAINT IF EXISTS "task_documents_document_id_documents_id_fk";--> statement-breakpoint
ALTER TABLE "task_documents" ADD CONSTRAINT "task_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "task_topics" DROP CONSTRAINT IF EXISTS "task_topics_task_id_tasks_id_fk";--> statement-breakpoint
ALTER TABLE "task_topics" ADD CONSTRAINT "task_topics_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tasks" DROP CONSTRAINT IF EXISTS "tasks_created_by_user_id_users_id_fk";--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "briefs_user_id_idx" ON "briefs" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "briefs_task_id_idx" ON "briefs" USING btree ("task_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "briefs_cron_job_id_idx" ON "briefs" USING btree ("cron_job_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "briefs_agent_id_idx" ON "briefs" USING btree ("agent_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "briefs_type_idx" ON "briefs" USING btree ("type");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "briefs_priority_idx" ON "briefs" USING btree ("priority");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "briefs_unresolved_idx" ON "briefs" USING btree ("user_id","resolved_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "task_comments_task_id_idx" ON "task_comments" USING btree ("task_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "task_comments_user_id_idx" ON "task_comments" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "task_comments_agent_id_idx" ON "task_comments" USING btree ("agent_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "task_comments_brief_id_idx" ON "task_comments" USING btree ("brief_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "task_comments_topic_id_idx" ON "task_comments" USING btree ("topic_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "task_deps_unique_idx" ON "task_dependencies" USING btree ("task_id","depends_on_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "task_deps_task_id_idx" ON "task_dependencies" USING btree ("task_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "task_deps_depends_on_id_idx" ON "task_dependencies" USING btree ("depends_on_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "task_docs_unique_idx" ON "task_documents" USING btree ("task_id","document_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "task_docs_task_id_idx" ON "task_documents" USING btree ("task_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "task_docs_document_id_idx" ON "task_documents" USING btree ("document_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "task_topics_unique_idx" ON "task_topics" USING btree ("task_id","topic_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "task_topics_task_id_idx" ON "task_topics" USING btree ("task_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "task_topics_topic_id_idx" ON "task_topics" USING btree ("topic_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "task_topics_status_idx" ON "task_topics" USING btree ("task_id","status");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "tasks_identifier_idx" ON "tasks" USING btree ("identifier","created_by_user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tasks_created_by_user_id_idx" ON "tasks" USING btree ("created_by_user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tasks_created_by_agent_id_idx" ON "tasks" USING btree ("created_by_agent_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tasks_assignee_user_id_idx" ON "tasks" USING btree ("assignee_user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tasks_assignee_agent_id_idx" ON "tasks" USING btree ("assignee_agent_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tasks_parent_task_id_idx" ON "tasks" USING btree ("parent_task_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tasks_status_idx" ON "tasks" USING btree ("status");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tasks_priority_idx" ON "tasks" USING btree ("priority");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tasks_heartbeat_idx" ON "tasks" USING btree ("status","last_heartbeat_at");
@@ -0,0 +1,7 @@
ALTER TABLE "task_topics" ADD COLUMN "user_id" text NOT NULL;--> statement-breakpoint
ALTER TABLE "task_topics" ADD COLUMN "handoff_title" text;--> statement-breakpoint
ALTER TABLE "task_topics" ADD COLUMN "handoff_summary" text;--> statement-breakpoint
ALTER TABLE "task_topics" ADD COLUMN "handoff_key_findings" jsonb;--> statement-breakpoint
ALTER TABLE "task_topics" ADD COLUMN "handoff_next_action" text;--> statement-breakpoint
ALTER TABLE "task_topics" ADD CONSTRAINT "task_topics_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "task_topics_user_id_idx" ON "task_topics" USING btree ("user_id");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -665,6 +665,20 @@
"when": 1773764776073,
"tag": "0094_agent_bot_providers_add_settings",
"breakpoints": true
},
{
"idx": 95,
"version": "7",
"when": 1774180022586,
"tag": "0095_add_agent_task_system",
"breakpoints": true
},
{
"idx": 96,
"version": "7",
"when": 1774189914853,
"tag": "0096_lush_praxagora",
"breakpoints": true
}
],
"version": "6"
@@ -0,0 +1,186 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getTestDB } from '../../core/getTestDB';
import { users } from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { BriefModel } from '../brief';
const serverDB: LobeChatDatabase = await getTestDB();
const userId = 'brief-test-user-id';
const userId2 = 'brief-test-user-id-2';
beforeEach(async () => {
await serverDB.delete(users);
await serverDB.insert(users).values([{ id: userId }, { id: userId2 }]);
});
afterEach(async () => {
await serverDB.delete(users);
});
describe('BriefModel', () => {
describe('create', () => {
it('should create a brief', async () => {
const model = new BriefModel(serverDB, userId);
const brief = await model.create({
summary: 'Outline is ready for review',
title: 'Outline completed',
type: 'decision',
});
expect(brief).toBeDefined();
expect(brief.id).toBeDefined();
expect(brief.userId).toBe(userId);
expect(brief.type).toBe('decision');
expect(brief.priority).toBe('info');
expect(brief.readAt).toBeNull();
expect(brief.resolvedAt).toBeNull();
});
it('should create a brief with all fields', async () => {
const model = new BriefModel(serverDB, userId);
const brief = await model.create({
actions: [{ label: 'Approve', type: 'approve' }],
agentId: 'agent-1',
artifacts: ['doc-1', 'doc-2'],
commentType: 'suggestion',
priority: 'urgent',
summary: 'Chapter too long, suggest splitting',
taskId: null,
title: 'Chapter 4 needs split',
topicId: 'topic-1',
type: 'decision',
});
expect(brief.priority).toBe('urgent');
expect(brief.agentId).toBe('agent-1');
expect(brief.commentType).toBe('suggestion');
expect(brief.actions).toEqual([{ label: 'Approve', type: 'approve' }]);
expect(brief.artifacts).toEqual(['doc-1', 'doc-2']);
});
});
describe('findById', () => {
it('should find brief by id', async () => {
const model = new BriefModel(serverDB, userId);
const created = await model.create({
summary: 'Test',
title: 'Test brief',
type: 'result',
});
const found = await model.findById(created.id);
expect(found).toBeDefined();
expect(found!.id).toBe(created.id);
});
it('should not find brief owned by another user', async () => {
const model1 = new BriefModel(serverDB, userId);
const model2 = new BriefModel(serverDB, userId2);
const brief = await model1.create({
summary: 'Test',
title: 'Test',
type: 'result',
});
const found = await model2.findById(brief.id);
expect(found).toBeNull();
});
});
describe('list', () => {
it('should list briefs for user', async () => {
const model = new BriefModel(serverDB, userId);
await model.create({ summary: 'A', title: 'Brief 1', type: 'result' });
await model.create({ summary: 'B', title: 'Brief 2', type: 'decision' });
const { briefs, total } = await model.list();
expect(total).toBe(2);
expect(briefs).toHaveLength(2);
});
it('should filter by type', async () => {
const model = new BriefModel(serverDB, userId);
await model.create({ summary: 'A', title: 'Brief 1', type: 'result' });
await model.create({ summary: 'B', title: 'Brief 2', type: 'decision' });
const { briefs } = await model.list({ type: 'decision' });
expect(briefs).toHaveLength(1);
expect(briefs[0].type).toBe('decision');
});
});
describe('listUnresolved', () => {
it('should return unresolved briefs sorted by priority', async () => {
const model = new BriefModel(serverDB, userId);
await model.create({ priority: 'info', summary: 'Low', title: 'Info', type: 'result' });
await model.create({
priority: 'urgent',
summary: 'High',
title: 'Urgent',
type: 'decision',
});
await model.create({
priority: 'normal',
summary: 'Mid',
title: 'Normal',
type: 'insight',
});
const unresolved = await model.listUnresolved();
expect(unresolved).toHaveLength(3);
expect(unresolved[0].priority).toBe('urgent');
expect(unresolved[1].priority).toBe('normal');
expect(unresolved[2].priority).toBe('info');
});
it('should exclude resolved briefs', async () => {
const model = new BriefModel(serverDB, userId);
const b1 = await model.create({ summary: 'A', title: 'Brief 1', type: 'result' });
await model.create({ summary: 'B', title: 'Brief 2', type: 'result' });
await model.resolve(b1.id);
const unresolved = await model.listUnresolved();
expect(unresolved).toHaveLength(1);
});
});
describe('markRead', () => {
it('should mark brief as read', async () => {
const model = new BriefModel(serverDB, userId);
const brief = await model.create({ summary: 'A', title: 'Test', type: 'result' });
const updated = await model.markRead(brief.id);
expect(updated!.readAt).toBeDefined();
expect(updated!.resolvedAt).toBeNull();
});
});
describe('resolve', () => {
it('should mark brief as resolved and read', async () => {
const model = new BriefModel(serverDB, userId);
const brief = await model.create({ summary: 'A', title: 'Test', type: 'decision' });
const updated = await model.resolve(brief.id);
expect(updated!.readAt).toBeDefined();
expect(updated!.resolvedAt).toBeDefined();
});
});
describe('delete', () => {
it('should delete brief', async () => {
const model = new BriefModel(serverDB, userId);
const brief = await model.create({ summary: 'A', title: 'Test', type: 'result' });
const deleted = await model.delete(brief.id);
expect(deleted).toBe(true);
const found = await model.findById(brief.id);
expect(found).toBeNull();
});
});
});
@@ -0,0 +1,827 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getTestDB } from '../../core/getTestDB';
import { documents, users } from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { TaskModel } from '../task';
const serverDB: LobeChatDatabase = await getTestDB();
const userId = 'task-test-user-id';
const userId2 = 'task-test-user-id-2';
beforeEach(async () => {
await serverDB.delete(users);
await serverDB.insert(users).values([{ id: userId }, { id: userId2 }]);
});
afterEach(async () => {
await serverDB.delete(users);
});
describe('TaskModel', () => {
describe('constructor', () => {
it('should create model with db and userId', () => {
const model = new TaskModel(serverDB, userId);
expect(model).toBeInstanceOf(TaskModel);
});
});
describe('create', () => {
it('should create a task with auto-generated identifier', async () => {
const model = new TaskModel(serverDB, userId);
const result = await model.create({
instruction: 'Write a book about AI agents',
name: 'Write AI Book',
});
expect(result).toBeDefined();
expect(result.identifier).toBe('TASK-1');
expect(result.seq).toBe(1);
expect(result.name).toBe('Write AI Book');
expect(result.instruction).toBe('Write a book about AI agents');
expect(result.status).toBe('backlog');
expect(result.createdByUserId).toBe(userId);
});
it('should auto-increment seq for same user', async () => {
const model = new TaskModel(serverDB, userId);
const task1 = await model.create({ instruction: 'Task 1' });
const task2 = await model.create({ instruction: 'Task 2' });
const task3 = await model.create({ instruction: 'Task 3' });
expect(task1.seq).toBe(1);
expect(task2.seq).toBe(2);
expect(task3.seq).toBe(3);
expect(task1.identifier).toBe('TASK-1');
expect(task2.identifier).toBe('TASK-2');
expect(task3.identifier).toBe('TASK-3');
});
it('should support custom identifier prefix', async () => {
const model = new TaskModel(serverDB, userId);
const result = await model.create({
identifierPrefix: 'PROJ',
instruction: 'Build WAKE system',
});
expect(result.identifier).toBe('PROJ-1');
});
it('should create task with all optional fields', async () => {
const model = new TaskModel(serverDB, userId);
const result = await model.create({
assigneeAgentId: 'agent-1',
assigneeUserId: userId,
description: 'A detailed description',
instruction: 'Do something',
name: 'Full Task',
priority: 2,
});
expect(result.assigneeAgentId).toBe('agent-1');
expect(result.assigneeUserId).toBe(userId);
expect(result.priority).toBe(2);
});
it('should create subtask with parentTaskId', async () => {
const model = new TaskModel(serverDB, userId);
const parent = await model.create({ instruction: 'Parent task' });
const child = await model.create({
instruction: 'Child task',
parentTaskId: parent.id,
});
expect(child.parentTaskId).toBe(parent.id);
});
it('should isolate seq between users', async () => {
const model1 = new TaskModel(serverDB, userId);
const model2 = new TaskModel(serverDB, userId2);
const task1 = await model1.create({ instruction: 'User 1 task' });
const task2 = await model2.create({ instruction: 'User 2 task' });
expect(task1.seq).toBe(1);
expect(task2.seq).toBe(1);
});
it('should handle concurrent creates without seq collision', async () => {
const model = new TaskModel(serverDB, userId);
// Create 5 tasks concurrently (simulates parallel tool calls)
const results = await Promise.all([
model.create({ instruction: 'Concurrent 1' }),
model.create({ instruction: 'Concurrent 2' }),
model.create({ instruction: 'Concurrent 3' }),
model.create({ instruction: 'Concurrent 4' }),
model.create({ instruction: 'Concurrent 5' }),
]);
// All should succeed with unique seqs
const seqs = results.map((r) => r.seq);
const uniqueSeqs = new Set(seqs);
expect(uniqueSeqs.size).toBe(5);
// All identifiers should be unique
const identifiers = results.map((r) => r.identifier);
const uniqueIdentifiers = new Set(identifiers);
expect(uniqueIdentifiers.size).toBe(5);
});
});
describe('findById', () => {
it('should find task by id', async () => {
const model = new TaskModel(serverDB, userId);
const created = await model.create({ instruction: 'Test task' });
const found = await model.findById(created.id);
expect(found).toBeDefined();
expect(found!.id).toBe(created.id);
});
it('should not find task owned by another user', async () => {
const model1 = new TaskModel(serverDB, userId);
const model2 = new TaskModel(serverDB, userId2);
const task = await model1.create({ instruction: 'User 1 task' });
const found = await model2.findById(task.id);
expect(found).toBeNull();
});
});
describe('findByIdentifier', () => {
it('should find task by identifier', async () => {
const model = new TaskModel(serverDB, userId);
await model.create({ instruction: 'Test task' });
const found = await model.findByIdentifier('TASK-1');
expect(found).toBeDefined();
expect(found!.identifier).toBe('TASK-1');
});
});
describe('update', () => {
it('should update task fields', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Original' });
const updated = await model.update(task.id, {
instruction: 'Updated instruction',
name: 'Updated name',
});
expect(updated!.instruction).toBe('Updated instruction');
expect(updated!.name).toBe('Updated name');
});
it('should not update task owned by another user', async () => {
const model1 = new TaskModel(serverDB, userId);
const model2 = new TaskModel(serverDB, userId2);
const task = await model1.create({ instruction: 'User 1 task' });
const result = await model2.update(task.id, { name: 'Hacked' });
expect(result).toBeNull();
});
});
describe('delete', () => {
it('should delete task', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'To be deleted' });
const deleted = await model.delete(task.id);
expect(deleted).toBe(true);
const found = await model.findById(task.id);
expect(found).toBeNull();
});
it('should not delete task owned by another user', async () => {
const model1 = new TaskModel(serverDB, userId);
const model2 = new TaskModel(serverDB, userId2);
const task = await model1.create({ instruction: 'User 1 task' });
const deleted = await model2.delete(task.id);
expect(deleted).toBe(false);
});
});
describe('list', () => {
it('should list tasks for user', async () => {
const model = new TaskModel(serverDB, userId);
await model.create({ instruction: 'Task 1' });
await model.create({ instruction: 'Task 2' });
const { tasks, total } = await model.list();
expect(total).toBe(2);
expect(tasks).toHaveLength(2);
});
it('should filter by status', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Task 1' });
await model.updateStatus(task.id, 'running', { startedAt: new Date() });
await model.create({ instruction: 'Task 2' });
const { tasks } = await model.list({ status: 'running' });
expect(tasks).toHaveLength(1);
expect(tasks[0].status).toBe('running');
});
it('should filter root tasks only', async () => {
const model = new TaskModel(serverDB, userId);
const parent = await model.create({ instruction: 'Parent' });
await model.create({ instruction: 'Child', parentTaskId: parent.id });
const { tasks } = await model.list({ parentTaskId: null });
expect(tasks).toHaveLength(1);
expect(tasks[0].parentTaskId).toBeNull();
});
it('should paginate results', async () => {
const model = new TaskModel(serverDB, userId);
for (let i = 0; i < 5; i++) {
await model.create({ instruction: `Task ${i}` });
}
const { tasks, total } = await model.list({ limit: 2, offset: 0 });
expect(total).toBe(5);
expect(tasks).toHaveLength(2);
});
});
describe('findSubtasks', () => {
it('should find direct subtasks', async () => {
const model = new TaskModel(serverDB, userId);
const parent = await model.create({ instruction: 'Parent' });
await model.create({ instruction: 'Child 1', parentTaskId: parent.id });
await model.create({ instruction: 'Child 2', parentTaskId: parent.id });
const subtasks = await model.findSubtasks(parent.id);
expect(subtasks).toHaveLength(2);
});
});
describe('getTaskTree', () => {
it('should return full task tree recursively', async () => {
const model = new TaskModel(serverDB, userId);
const root = await model.create({ instruction: 'Root' });
const child = await model.create({ instruction: 'Child', parentTaskId: root.id });
await model.create({ instruction: 'Grandchild', parentTaskId: child.id });
const tree = await model.getTaskTree(root.id);
expect(tree).toHaveLength(3);
});
});
describe('updateStatus', () => {
it('should update status with timestamps', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
const startedAt = new Date();
const updated = await model.updateStatus(task.id, 'running', { startedAt });
expect(updated!.status).toBe('running');
expect(updated!.startedAt).toBeDefined();
});
});
describe('heartbeat', () => {
it('should update heartbeat timestamp', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
await model.updateHeartbeat(task.id);
const found = await model.findById(task.id);
expect(found!.lastHeartbeatAt).toBeDefined();
});
});
describe('dependencies', () => {
it('should add and query dependencies', async () => {
const model = new TaskModel(serverDB, userId);
const taskA = await model.create({ instruction: 'Task A' });
const taskB = await model.create({ instruction: 'Task B' });
await model.addDependency(taskB.id, taskA.id);
const deps = await model.getDependencies(taskB.id);
expect(deps).toHaveLength(1);
expect(deps[0].dependsOnId).toBe(taskA.id);
});
it('should check all dependencies completed', async () => {
const model = new TaskModel(serverDB, userId);
const taskA = await model.create({ instruction: 'Task A' });
const taskB = await model.create({ instruction: 'Task B' });
const taskC = await model.create({ instruction: 'Task C' });
await model.addDependency(taskC.id, taskA.id);
await model.addDependency(taskC.id, taskB.id);
// Neither completed
let allDone = await model.areAllDependenciesCompleted(taskC.id);
expect(allDone).toBe(false);
// Complete A only
await model.updateStatus(taskA.id, 'completed');
allDone = await model.areAllDependenciesCompleted(taskC.id);
expect(allDone).toBe(false);
// Complete B too
await model.updateStatus(taskB.id, 'completed');
allDone = await model.areAllDependenciesCompleted(taskC.id);
expect(allDone).toBe(true);
});
it('should remove dependency', async () => {
const model = new TaskModel(serverDB, userId);
const taskA = await model.create({ instruction: 'Task A' });
const taskB = await model.create({ instruction: 'Task B' });
await model.addDependency(taskB.id, taskA.id);
await model.removeDependency(taskB.id, taskA.id);
const deps = await model.getDependencies(taskB.id);
expect(deps).toHaveLength(0);
});
it('should get dependents (reverse lookup)', async () => {
const model = new TaskModel(serverDB, userId);
const taskA = await model.create({ instruction: 'Task A' });
const taskB = await model.create({ instruction: 'Task B' });
const taskC = await model.create({ instruction: 'Task C' });
await model.addDependency(taskB.id, taskA.id);
await model.addDependency(taskC.id, taskA.id);
const dependents = await model.getDependents(taskA.id);
expect(dependents).toHaveLength(2);
});
it('should find unlocked tasks after dependency completes', async () => {
const model = new TaskModel(serverDB, userId);
const taskA = await model.create({ instruction: 'Task A' });
const taskB = await model.create({ instruction: 'Task B' });
const taskC = await model.create({ instruction: 'Task C' });
// C blocks on A and B
await model.addDependency(taskC.id, taskA.id);
await model.addDependency(taskC.id, taskB.id);
// Complete A — C still blocked by B
await model.updateStatus(taskA.id, 'completed');
let unlocked = await model.getUnlockedTasks(taskA.id);
expect(unlocked).toHaveLength(0);
// Complete B — C now unlocked
await model.updateStatus(taskB.id, 'completed');
unlocked = await model.getUnlockedTasks(taskB.id);
expect(unlocked).toHaveLength(1);
expect(unlocked[0].id).toBe(taskC.id);
});
it('should not unlock tasks that are not in backlog', async () => {
const model = new TaskModel(serverDB, userId);
const taskA = await model.create({ instruction: 'Task A' });
const taskB = await model.create({ instruction: 'Task B' });
await model.addDependency(taskB.id, taskA.id);
// Move B to running manually (not backlog)
await model.updateStatus(taskB.id, 'running', { startedAt: new Date() });
await model.updateStatus(taskA.id, 'completed');
const unlocked = await model.getUnlockedTasks(taskA.id);
expect(unlocked).toHaveLength(0); // B is already running, not unlocked
});
it('should check all subtasks completed', async () => {
const model = new TaskModel(serverDB, userId);
const parent = await model.create({ instruction: 'Parent' });
const child1 = await model.create({ instruction: 'Child 1', parentTaskId: parent.id });
const child2 = await model.create({ instruction: 'Child 2', parentTaskId: parent.id });
expect(await model.areAllSubtasksCompleted(parent.id)).toBe(false);
await model.updateStatus(child1.id, 'completed');
expect(await model.areAllSubtasksCompleted(parent.id)).toBe(false);
await model.updateStatus(child2.id, 'completed');
expect(await model.areAllSubtasksCompleted(parent.id)).toBe(true);
});
});
describe('documents', () => {
it('should pin and get documents', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
// Create a test document
const [doc] = await serverDB
.insert(documents)
.values({
content: '',
fileType: 'text/plain',
source: 'test',
sourceType: 'file',
title: 'Test Doc',
totalCharCount: 0,
totalLineCount: 0,
userId,
})
.returning();
await model.pinDocument(task.id, doc.id);
const pinned = await model.getPinnedDocuments(task.id);
expect(pinned).toHaveLength(1);
expect(pinned[0].documentId).toBe(doc.id);
});
it('should unpin document', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
const [doc] = await serverDB
.insert(documents)
.values({
content: '',
fileType: 'text/plain',
source: 'test',
sourceType: 'file',
title: 'Test Doc',
totalCharCount: 0,
totalLineCount: 0,
userId,
})
.returning();
await model.pinDocument(task.id, doc.id);
await model.unpinDocument(task.id, doc.id);
const pinned = await model.getPinnedDocuments(task.id);
expect(pinned).toHaveLength(0);
});
it('should not duplicate pin', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
const [doc] = await serverDB
.insert(documents)
.values({
content: '',
fileType: 'text/plain',
source: 'test',
sourceType: 'file',
title: 'Test Doc',
totalCharCount: 0,
totalLineCount: 0,
userId,
})
.returning();
await model.pinDocument(task.id, doc.id);
await model.pinDocument(task.id, doc.id); // duplicate
const pinned = await model.getPinnedDocuments(task.id);
expect(pinned).toHaveLength(1);
});
});
describe('checkpoint', () => {
it('should get and update checkpoint config', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
// Initially empty
const empty = model.getCheckpointConfig(task);
expect(empty).toEqual({});
// Set checkpoint
const updated = await model.updateCheckpointConfig(task.id, {
onAgentRequest: true,
tasks: { afterIds: ['TASK-2'], beforeIds: ['TASK-3'] },
topic: { after: true },
});
const config = model.getCheckpointConfig(updated!);
expect(config.onAgentRequest).toBe(true);
expect(config.topic?.after).toBe(true);
expect(config.tasks?.beforeIds).toEqual(['TASK-3']);
expect(config.tasks?.afterIds).toEqual(['TASK-2']);
});
it('should check shouldPauseBeforeStart', async () => {
const model = new TaskModel(serverDB, userId);
const parent = await model.create({ instruction: 'Parent' });
await model.updateCheckpointConfig(parent.id, {
tasks: { beforeIds: ['TASK-5'] },
});
const parentUpdated = (await model.findById(parent.id))!;
expect(model.shouldPauseBeforeStart(parentUpdated, 'TASK-5')).toBe(true);
expect(model.shouldPauseBeforeStart(parentUpdated, 'TASK-6')).toBe(false);
});
it('should pause on topic complete by default (no config)', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
// No checkpoint configured → should pause (default behavior)
expect(model.shouldPauseOnTopicComplete(task)).toBe(true);
});
it('should pause on topic complete when topic.after is true', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
await model.updateCheckpointConfig(task.id, {
topic: { after: true },
});
const updated = (await model.findById(task.id))!;
expect(model.shouldPauseOnTopicComplete(updated)).toBe(true);
});
it('should not pause on topic complete when only onAgentRequest is set', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
await model.updateCheckpointConfig(task.id, {
onAgentRequest: true,
});
const updated = (await model.findById(task.id))!;
// Has explicit config but topic.after is not true → don't auto-pause
expect(model.shouldPauseOnTopicComplete(updated)).toBe(false);
});
it('should not pause on topic complete when topic.after is false', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
await model.updateCheckpointConfig(task.id, {
topic: { after: false },
});
const updated = (await model.findById(task.id))!;
expect(model.shouldPauseOnTopicComplete(updated)).toBe(false);
});
it('should check shouldPauseAfterComplete', async () => {
const model = new TaskModel(serverDB, userId);
const parent = await model.create({ instruction: 'Parent' });
await model.updateCheckpointConfig(parent.id, {
tasks: { afterIds: ['TASK-2', 'TASK-3'] },
});
const parentUpdated = (await model.findById(parent.id))!;
expect(model.shouldPauseAfterComplete(parentUpdated, 'TASK-2')).toBe(true);
expect(model.shouldPauseAfterComplete(parentUpdated, 'TASK-3')).toBe(true);
expect(model.shouldPauseAfterComplete(parentUpdated, 'TASK-4')).toBe(false);
});
});
describe('topic management', () => {
it('should increment topic count', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
await model.incrementTopicCount(task.id);
await model.incrementTopicCount(task.id);
const found = await model.findById(task.id);
expect(found!.totalTopics).toBe(2);
});
it('should update current topic', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
await model.updateCurrentTopic(task.id, 'topic-123');
const found = await model.findById(task.id);
expect(found!.currentTopicId).toBe('topic-123');
});
});
describe('deleteAll', () => {
it('should delete all tasks for user', async () => {
const model = new TaskModel(serverDB, userId);
await model.create({ instruction: 'Task 1' });
await model.create({ instruction: 'Task 2' });
await model.create({ instruction: 'Task 3' });
const count = await model.deleteAll();
expect(count).toBe(3);
const { total } = await model.list();
expect(total).toBe(0);
});
it('should not delete tasks of other users', async () => {
const model1 = new TaskModel(serverDB, userId);
const model2 = new TaskModel(serverDB, userId2);
await model1.create({ instruction: 'User 1 task' });
await model2.create({ instruction: 'User 2 task' });
await model1.deleteAll();
const { total: total1 } = await model1.list();
const { total: total2 } = await model2.list();
expect(total1).toBe(0);
expect(total2).toBe(1);
});
});
describe('getDependenciesByTaskIds', () => {
it('should get dependencies for multiple tasks', async () => {
const model = new TaskModel(serverDB, userId);
const taskA = await model.create({ instruction: 'A' });
const taskB = await model.create({ instruction: 'B' });
const taskC = await model.create({ instruction: 'C' });
await model.addDependency(taskB.id, taskA.id);
await model.addDependency(taskC.id, taskB.id);
const deps = await model.getDependenciesByTaskIds([taskB.id, taskC.id]);
expect(deps).toHaveLength(2);
});
it('should return empty for empty input', async () => {
const model = new TaskModel(serverDB, userId);
const deps = await model.getDependenciesByTaskIds([]);
expect(deps).toHaveLength(0);
});
});
describe('comments', () => {
it('should add and get comments', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
await model.addComment({ content: 'First comment', taskId: task.id, userId });
await model.addComment({ content: 'Second comment', taskId: task.id, userId });
const comments = await model.getComments(task.id);
expect(comments).toHaveLength(2);
expect(comments[0].content).toBe('First comment');
expect(comments[1].content).toBe('Second comment');
});
it('should add comment with briefId and topicId', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
const comment = await model.addComment({
briefId: '00000000-0000-0000-0000-000000000001',
content: 'Reply to brief',
taskId: task.id,
topicId: 'tpc_abc',
userId,
});
expect(comment.briefId).toBe('00000000-0000-0000-0000-000000000001');
expect(comment.topicId).toBe('tpc_abc');
});
it('should add comment from agent', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
const comment = await model.addComment({
agentId: 'agt_xxx',
content: 'Agent observation',
taskId: task.id,
});
expect(comment.agentId).toBe('agt_xxx');
expect(comment.userId).toBeNull();
});
it('should delete own comment', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
const comment = await model.addComment({
content: 'To be deleted',
taskId: task.id,
userId,
});
const deleted = await model.deleteComment(comment.id);
expect(deleted).toBe(true);
const comments = await model.getComments(task.id);
expect(comments).toHaveLength(0);
});
it('should not delete comment from another user', async () => {
const model1 = new TaskModel(serverDB, userId);
const model2 = new TaskModel(serverDB, userId2);
const task = await model1.create({ instruction: 'Test' });
const comment = await model1.addComment({
content: 'User 1 comment',
taskId: task.id,
userId,
});
const deleted = await model2.deleteComment(comment.id);
expect(deleted).toBe(false);
});
it('should return comments ordered by createdAt', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({ instruction: 'Test' });
await model.addComment({ content: 'First', taskId: task.id, userId });
await model.addComment({ content: 'Second', taskId: task.id, userId });
await model.addComment({ content: 'Third', taskId: task.id, userId });
const comments = await model.getComments(task.id);
expect(comments).toHaveLength(3);
expect(comments[0].content).toBe('First');
expect(comments[2].content).toBe('Third');
});
});
describe('review rubrics', () => {
it('should store EvalBenchmarkRubric format in config', async () => {
const model = new TaskModel(serverDB, userId);
const task = await model.create({
config: {
review: {
enabled: true,
maxIterations: 3,
rubrics: [
{
config: { criteria: '技术概念是否准确' },
id: 'r1',
name: '内容准确性',
threshold: 0.8,
type: 'llm-rubric',
weight: 1,
},
{
config: { value: '```' },
id: 'r2',
name: '包含代码示例',
type: 'contains',
weight: 1,
},
],
},
},
instruction: 'Test with rubrics',
});
const review = model.getReviewConfig(task);
expect(review).toBeDefined();
expect(review!.enabled).toBe(true);
expect(review!.rubrics).toHaveLength(2);
expect(review!.rubrics[0].type).toBe('llm-rubric');
expect(review!.rubrics[0].threshold).toBe(0.8);
expect(review!.rubrics[1].type).toBe('contains');
expect(review!.rubrics[1].config.value).toBe('```');
});
it('should inherit rubrics from parent when creating subtask', async () => {
const model = new TaskModel(serverDB, userId);
const rubrics = [
{
config: { criteria: '准确性检查' },
id: 'r1',
name: '准确性',
threshold: 0.8,
type: 'llm-rubric',
weight: 1,
},
];
const parent = await model.create({
config: { review: { enabled: true, rubrics } },
instruction: 'Parent with rubrics',
});
const parentConfig = parent.config as Record<string, any>;
const child = await model.create({
config: parentConfig?.review ? { review: parentConfig.review } : undefined,
instruction: 'Child task',
parentTaskId: parent.id,
});
const childReview = model.getReviewConfig(child);
expect(childReview).toBeDefined();
expect(childReview!.rubrics).toHaveLength(1);
expect(childReview!.rubrics[0].type).toBe('llm-rubric');
});
});
});
@@ -0,0 +1,167 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getTestDB } from '../../core/getTestDB';
import { users } from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { TaskModel } from '../task';
import { TaskTopicModel } from '../taskTopic';
const serverDB: LobeChatDatabase = await getTestDB();
const userId = 'task-topic-test-user-id';
const userId2 = 'task-topic-test-user-id-2';
beforeEach(async () => {
await serverDB.delete(users);
await serverDB.insert(users).values([{ id: userId }, { id: userId2 }]);
});
afterEach(async () => {
await serverDB.delete(users);
});
describe('TaskTopicModel', () => {
describe('add and findByTaskId', () => {
it('should add topic and get topics', async () => {
const taskModel = new TaskModel(serverDB, userId);
const topicModel = new TaskTopicModel(serverDB, userId);
const task = await taskModel.create({ instruction: 'Test' });
await topicModel.add(task.id, 'tpc_aaa', { operationId: 'op_1', seq: 1 });
await topicModel.add(task.id, 'tpc_bbb', { operationId: 'op_2', seq: 2 });
const topics = await topicModel.findByTaskId(task.id);
expect(topics).toHaveLength(2);
expect(topics[0].seq).toBe(2); // ordered by seq desc
expect(topics[1].seq).toBe(1);
expect(topics[0].operationId).toBe('op_2');
expect(topics[0].userId).toBe(userId);
});
it('should not duplicate topic (onConflictDoNothing)', async () => {
const taskModel = new TaskModel(serverDB, userId);
const topicModel = new TaskTopicModel(serverDB, userId);
const task = await taskModel.create({ instruction: 'Test' });
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 }); // duplicate
const topics = await topicModel.findByTaskId(task.id);
expect(topics).toHaveLength(1);
});
});
describe('updateStatus', () => {
it('should update topic status', async () => {
const taskModel = new TaskModel(serverDB, userId);
const topicModel = new TaskTopicModel(serverDB, userId);
const task = await taskModel.create({ instruction: 'Test' });
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
await topicModel.updateStatus(task.id, 'tpc_aaa', 'completed');
const topics = await topicModel.findByTaskId(task.id);
expect(topics[0].status).toBe('completed');
});
});
describe('timeoutRunning', () => {
it('should timeout running topics only', async () => {
const taskModel = new TaskModel(serverDB, userId);
const topicModel = new TaskTopicModel(serverDB, userId);
const task = await taskModel.create({ instruction: 'Test' });
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
await topicModel.add(task.id, 'tpc_bbb', { seq: 2 });
await topicModel.updateStatus(task.id, 'tpc_aaa', 'completed');
const count = await topicModel.timeoutRunning(task.id);
expect(count).toBe(1);
const topics = await topicModel.findByTaskId(task.id);
const tpcA = topics.find((t) => t.topicId === 'tpc_aaa');
const tpcB = topics.find((t) => t.topicId === 'tpc_bbb');
expect(tpcA!.status).toBe('completed');
expect(tpcB!.status).toBe('timeout');
});
});
describe('updateHandoff', () => {
it('should store handoff data', async () => {
const taskModel = new TaskModel(serverDB, userId);
const topicModel = new TaskTopicModel(serverDB, userId);
const task = await taskModel.create({ instruction: 'Test' });
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
await topicModel.updateHandoff(task.id, 'tpc_aaa', {
keyFindings: ['Finding 1', 'Finding 2'],
nextAction: 'Continue writing',
summary: 'Completed chapter 1',
title: '第1章完成',
});
const topics = await topicModel.findByTaskId(task.id);
expect(topics[0].handoffTitle).toBe('第1章完成');
expect(topics[0].handoffSummary).toBe('Completed chapter 1');
expect(topics[0].handoffNextAction).toBe('Continue writing');
expect(topics[0].handoffKeyFindings).toEqual(['Finding 1', 'Finding 2']);
});
});
describe('updateReview', () => {
it('should store review results', async () => {
const taskModel = new TaskModel(serverDB, userId);
const topicModel = new TaskTopicModel(serverDB, userId);
const task = await taskModel.create({ instruction: 'Test' });
await topicModel.add(task.id, 'tpc_review', { seq: 1 });
await topicModel.updateReview(task.id, 'tpc_review', {
iteration: 1,
passed: true,
score: 85,
scores: [
{ passed: true, reason: 'Good accuracy', rubricId: 'r1', score: 0.88 },
{ passed: true, reason: 'Code found', rubricId: 'r2', score: 1 },
],
});
const topics = await topicModel.findByTaskId(task.id);
expect(topics[0].reviewPassed).toBe(1);
expect(topics[0].reviewScore).toBe(85);
expect(topics[0].reviewIteration).toBe(1);
expect(topics[0].reviewedAt).toBeDefined();
const scores = topics[0].reviewScores as any[];
expect(scores).toHaveLength(2);
expect(scores[0].rubricId).toBe('r1');
expect(scores[1].score).toBe(1);
});
});
describe('remove', () => {
it('should remove topic association', async () => {
const taskModel = new TaskModel(serverDB, userId);
const topicModel = new TaskTopicModel(serverDB, userId);
const task = await taskModel.create({ instruction: 'Test' });
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
const removed = await topicModel.remove(task.id, 'tpc_aaa');
expect(removed).toBe(true);
const topics = await topicModel.findByTaskId(task.id);
expect(topics).toHaveLength(0);
});
it('should not remove topics of other users', async () => {
const taskModel = new TaskModel(serverDB, userId);
const topicModel1 = new TaskTopicModel(serverDB, userId);
const topicModel2 = new TaskTopicModel(serverDB, userId2);
const task = await taskModel.create({ instruction: 'Test' });
await topicModel1.add(task.id, 'tpc_aaa', { seq: 1 });
const removed = await topicModel2.remove(task.id, 'tpc_aaa');
expect(removed).toBe(false);
});
});
});
+131
View File
@@ -0,0 +1,131 @@
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import type { BriefItem, NewBrief } from '../schemas/brief';
import { briefs } from '../schemas/brief';
import type { LobeChatDatabase } from '../type';
export class BriefModel {
private readonly userId: string;
private readonly db: LobeChatDatabase;
constructor(db: LobeChatDatabase, userId: string) {
this.db = db;
this.userId = userId;
}
async create(data: Omit<NewBrief, 'id' | 'userId'>): Promise<BriefItem> {
const result = await this.db
.insert(briefs)
.values({ ...data, userId: this.userId })
.returning();
return result[0];
}
async findById(id: string): Promise<BriefItem | null> {
const result = await this.db
.select()
.from(briefs)
.where(and(eq(briefs.id, id), eq(briefs.userId, this.userId)))
.limit(1);
return result[0] || null;
}
async list(options?: {
limit?: number;
offset?: number;
type?: string;
}): Promise<{ briefs: BriefItem[]; total: number }> {
const { type, limit = 50, offset = 0 } = options || {};
const conditions = [eq(briefs.userId, this.userId)];
if (type) conditions.push(eq(briefs.type, type));
const where = and(...conditions);
const countResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(briefs)
.where(where);
const items = await this.db
.select()
.from(briefs)
.where(where)
.orderBy(desc(briefs.createdAt))
.limit(limit)
.offset(offset);
return { briefs: items, total: Number(countResult[0].count) };
}
// For Daily Brief homepage — unresolved briefs sorted by priority
async listUnresolved(): Promise<BriefItem[]> {
return this.db
.select()
.from(briefs)
.where(and(eq(briefs.userId, this.userId), isNull(briefs.resolvedAt)))
.orderBy(
sql`CASE
WHEN ${briefs.priority} = 'urgent' THEN 0
WHEN ${briefs.priority} = 'normal' THEN 1
ELSE 2
END`,
desc(briefs.createdAt),
);
}
async findByTaskId(taskId: string): Promise<BriefItem[]> {
return this.db
.select()
.from(briefs)
.where(and(eq(briefs.taskId, taskId), eq(briefs.userId, this.userId)))
.orderBy(desc(briefs.createdAt));
}
async findByCronJobId(cronJobId: string): Promise<BriefItem[]> {
return this.db
.select()
.from(briefs)
.where(and(eq(briefs.cronJobId, cronJobId), eq(briefs.userId, this.userId)))
.orderBy(desc(briefs.createdAt));
}
async markRead(id: string): Promise<BriefItem | null> {
const result = await this.db
.update(briefs)
.set({ readAt: new Date() })
.where(and(eq(briefs.id, id), eq(briefs.userId, this.userId)))
.returning();
return result[0] || null;
}
async resolve(
id: string,
options?: { action?: string; comment?: string },
): Promise<BriefItem | null> {
const result = await this.db
.update(briefs)
.set({
readAt: new Date(),
resolvedAction: options?.action,
resolvedAt: new Date(),
resolvedComment: options?.comment,
})
.where(and(eq(briefs.id, id), eq(briefs.userId, this.userId)))
.returning();
return result[0] || null;
}
async delete(id: string): Promise<boolean> {
const result = await this.db
.delete(briefs)
.where(and(eq(briefs.id, id), eq(briefs.userId, this.userId)))
.returning();
return result.length > 0;
}
}
+522
View File
@@ -0,0 +1,522 @@
import type {
CheckpointConfig,
WorkspaceData,
WorkspaceDocNode,
WorkspaceTreeNode,
} from '@lobechat/types';
import { and, desc, eq, inArray, isNotNull, isNull, ne, sql } from 'drizzle-orm';
import type { NewTask, NewTaskComment, TaskCommentItem, TaskItem } from '../schemas/task';
import { taskComments, taskDependencies, taskDocuments, tasks } from '../schemas/task';
import type { LobeChatDatabase } from '../type';
export class TaskModel {
private readonly userId: string;
private readonly db: LobeChatDatabase;
constructor(db: LobeChatDatabase, userId: string) {
this.db = db;
this.userId = userId;
}
// ========== CRUD ==========
async create(
data: Omit<NewTask, 'id' | 'identifier' | 'seq' | 'createdByUserId'> & {
identifierPrefix?: string;
},
): Promise<TaskItem> {
const { identifierPrefix = 'TASK', ...rest } = data;
// Retry loop to handle concurrent creates (parallel tool calls)
const maxRetries = 5;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
// Get next seq for this user
const seqResult = await this.db
.select({ maxSeq: sql<number>`COALESCE(MAX(${tasks.seq}), 0)` })
.from(tasks)
.where(eq(tasks.createdByUserId, this.userId));
const nextSeq = Number(seqResult[0].maxSeq) + 1;
const identifier = `${identifierPrefix}-${nextSeq}`;
const result = await this.db
.insert(tasks)
.values({
...rest,
createdByUserId: this.userId,
identifier,
seq: nextSeq,
} as NewTask)
.returning();
return result[0];
} catch (error: any) {
// Retry on unique constraint violation (concurrent seq conflict)
// Check error itself, cause, and stringified message for PG error code 23505
const errStr =
String(error?.message || '') +
String(error?.cause?.code || '') +
String(error?.code || '');
const isUniqueViolation =
errStr.includes('23505') || errStr.includes('unique') || errStr.includes('duplicate');
if (isUniqueViolation && attempt < maxRetries - 1) {
continue;
}
throw error;
}
}
throw new Error('Failed to create task after max retries');
}
async findById(id: string): Promise<TaskItem | null> {
const result = await this.db
.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.createdByUserId, this.userId)))
.limit(1);
return result[0] || null;
}
async findByIds(ids: string[]): Promise<TaskItem[]> {
if (ids.length === 0) return [];
return this.db
.select()
.from(tasks)
.where(and(inArray(tasks.id, ids), eq(tasks.createdByUserId, this.userId)));
}
// Resolve id or identifier (e.g. 'TASK-1') to a task
async resolve(idOrIdentifier: string): Promise<TaskItem | null> {
if (idOrIdentifier.startsWith('task_')) return this.findById(idOrIdentifier);
return this.findByIdentifier(idOrIdentifier.toUpperCase());
}
async findByIdentifier(identifier: string): Promise<TaskItem | null> {
const result = await this.db
.select()
.from(tasks)
.where(and(eq(tasks.identifier, identifier), eq(tasks.createdByUserId, this.userId)))
.limit(1);
return result[0] || null;
}
async update(
id: string,
data: Partial<Omit<NewTask, 'id' | 'identifier' | 'seq' | 'createdByUserId'>>,
): Promise<TaskItem | null> {
const result = await this.db
.update(tasks)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(tasks.id, id), eq(tasks.createdByUserId, this.userId)))
.returning();
return result[0] || null;
}
async delete(id: string): Promise<boolean> {
const result = await this.db
.delete(tasks)
.where(and(eq(tasks.id, id), eq(tasks.createdByUserId, this.userId)))
.returning();
return result.length > 0;
}
async deleteAll(): Promise<number> {
const result = await this.db
.delete(tasks)
.where(eq(tasks.createdByUserId, this.userId))
.returning();
return result.length;
}
// ========== Query ==========
async list(options?: {
assigneeAgentId?: string;
limit?: number;
offset?: number;
parentTaskId?: string | null;
status?: string;
}): Promise<{ tasks: TaskItem[]; total: number }> {
const { status, parentTaskId, assigneeAgentId, limit = 50, offset = 0 } = options || {};
const conditions = [eq(tasks.createdByUserId, this.userId)];
if (status) conditions.push(eq(tasks.status, status));
if (assigneeAgentId) conditions.push(eq(tasks.assigneeAgentId, assigneeAgentId));
if (parentTaskId === null) {
conditions.push(isNull(tasks.parentTaskId));
} else if (parentTaskId) {
conditions.push(eq(tasks.parentTaskId, parentTaskId));
}
const where = and(...conditions);
const countResult = await this.db
.select({ count: sql<number>`count(*)` })
.from(tasks)
.where(where);
const taskList = await this.db
.select()
.from(tasks)
.where(where)
.orderBy(desc(tasks.createdAt))
.limit(limit)
.offset(offset);
return { tasks: taskList, total: Number(countResult[0].count) };
}
/**
* Batch update sortOrder for multiple tasks.
* @param order Array of { id, sortOrder } pairs
*/
async reorder(order: Array<{ id: string; sortOrder: number }>): Promise<void> {
for (const item of order) {
await this.db
.update(tasks)
.set({ sortOrder: item.sortOrder, updatedAt: new Date() })
.where(and(eq(tasks.id, item.id), eq(tasks.createdByUserId, this.userId)));
}
}
async findSubtasks(parentTaskId: string): Promise<TaskItem[]> {
return this.db
.select()
.from(tasks)
.where(and(eq(tasks.parentTaskId, parentTaskId), eq(tasks.createdByUserId, this.userId)))
.orderBy(tasks.sortOrder, tasks.seq);
}
// Recursive query to get full task tree
async getTaskTree(rootTaskId: string): Promise<TaskItem[]> {
const result = await this.db.execute(sql`
WITH RECURSIVE task_tree AS (
SELECT * FROM tasks WHERE id = ${rootTaskId} AND created_by_user_id = ${this.userId}
UNION ALL
SELECT t.* FROM tasks t
JOIN task_tree tt ON t.parent_task_id = tt.id
)
SELECT * FROM task_tree
`);
return result.rows as TaskItem[];
}
// ========== Status ==========
async updateStatus(
id: string,
status: string,
extra?: { completedAt?: Date; error?: string | null; startedAt?: Date },
): Promise<TaskItem | null> {
return this.update(id, { status, ...extra });
}
async batchUpdateStatus(ids: string[], status: string): Promise<number> {
const result = await this.db
.update(tasks)
.set({ status, updatedAt: new Date() })
.where(and(inArray(tasks.id, ids), eq(tasks.createdByUserId, this.userId)))
.returning();
return result.length;
}
// ========== Checkpoint ==========
getCheckpointConfig(task: TaskItem): CheckpointConfig {
return (task.config as Record<string, any>)?.checkpoint || {};
}
async updateCheckpointConfig(id: string, checkpoint: CheckpointConfig): Promise<TaskItem | null> {
const task = await this.findById(id);
if (!task) return null;
const config = { ...(task.config as Record<string, any>), checkpoint };
return this.update(id, { config });
}
// ========== Review Config ==========
getReviewConfig(task: TaskItem): Record<string, any> | undefined {
return (task.config as Record<string, any>)?.review;
}
async updateReviewConfig(id: string, review: Record<string, any>): Promise<TaskItem | null> {
const task = await this.findById(id);
if (!task) return null;
const config = { ...(task.config as Record<string, any>), review };
return this.update(id, { config });
}
// Check if a task should pause after a topic completes
// Default: pause (when no checkpoint config is set)
// Explicit: pause only if topic.after is true
shouldPauseOnTopicComplete(task: TaskItem): boolean {
const checkpoint = this.getCheckpointConfig(task);
const hasAnyConfig = Object.keys(checkpoint).length > 0;
return hasAnyConfig ? !!checkpoint.topic?.after : true;
}
// Check if a task should be paused before starting (parent's tasks.beforeIds)
shouldPauseBeforeStart(parentTask: TaskItem, childIdentifier: string): boolean {
const checkpoint = this.getCheckpointConfig(parentTask);
return checkpoint.tasks?.beforeIds?.includes(childIdentifier) ?? false;
}
// Check if a task should be paused after completing (parent's tasks.afterIds)
shouldPauseAfterComplete(parentTask: TaskItem, childIdentifier: string): boolean {
const checkpoint = this.getCheckpointConfig(parentTask);
return checkpoint.tasks?.afterIds?.includes(childIdentifier) ?? false;
}
// ========== Heartbeat ==========
async updateHeartbeat(id: string): Promise<void> {
await this.db
.update(tasks)
.set({ lastHeartbeatAt: new Date(), updatedAt: new Date() })
.where(eq(tasks.id, id));
}
// Find stuck tasks (running but heartbeat timed out)
// Only checks tasks that have both lastHeartbeatAt and heartbeatTimeout set
static async findStuckTasks(db: LobeChatDatabase): Promise<TaskItem[]> {
return db
.select()
.from(tasks)
.where(
and(
eq(tasks.status, 'running'),
isNotNull(tasks.lastHeartbeatAt),
isNotNull(tasks.heartbeatTimeout),
sql`${tasks.lastHeartbeatAt} < now() - make_interval(secs => ${tasks.heartbeatTimeout})`,
),
);
}
// ========== Dependencies ==========
async addDependency(taskId: string, dependsOnId: string, type: string = 'blocks'): Promise<void> {
await this.db
.insert(taskDependencies)
.values({ dependsOnId, taskId, type })
.onConflictDoNothing();
}
async removeDependency(taskId: string, dependsOnId: string): Promise<void> {
await this.db
.delete(taskDependencies)
.where(
and(eq(taskDependencies.taskId, taskId), eq(taskDependencies.dependsOnId, dependsOnId)),
);
}
async getDependencies(taskId: string) {
return this.db.select().from(taskDependencies).where(eq(taskDependencies.taskId, taskId));
}
async getDependenciesByTaskIds(taskIds: string[]) {
if (taskIds.length === 0) return [];
return this.db.select().from(taskDependencies).where(inArray(taskDependencies.taskId, taskIds));
}
async getDependents(taskId: string) {
return this.db.select().from(taskDependencies).where(eq(taskDependencies.dependsOnId, taskId));
}
// Check if all dependencies of a task are completed
async areAllDependenciesCompleted(taskId: string): Promise<boolean> {
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(taskDependencies)
.innerJoin(tasks, eq(taskDependencies.dependsOnId, tasks.id))
.where(
and(
eq(taskDependencies.taskId, taskId),
eq(taskDependencies.type, 'blocks'),
ne(tasks.status, 'completed'),
),
);
return Number(result[0].count) === 0;
}
// Find tasks that are now unblocked after a dependency completes
async getUnlockedTasks(completedTaskId: string): Promise<TaskItem[]> {
// Find all tasks that depend on the completed task
const dependents = await this.getDependents(completedTaskId);
const unlocked: TaskItem[] = [];
for (const dep of dependents) {
if (dep.type !== 'blocks') continue;
// Check if ALL dependencies of this task are now completed
const allDone = await this.areAllDependenciesCompleted(dep.taskId);
if (!allDone) continue;
// Get the task itself — only unlock if it's in backlog
const task = await this.findById(dep.taskId);
if (task && task.status === 'backlog') {
unlocked.push(task);
}
}
return unlocked;
}
// Check if all subtasks of a parent task are completed
async areAllSubtasksCompleted(parentTaskId: string): Promise<boolean> {
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(tasks)
.where(
and(
eq(tasks.parentTaskId, parentTaskId),
ne(tasks.status, 'completed'),
eq(tasks.createdByUserId, this.userId),
),
);
return Number(result[0].count) === 0;
}
// ========== Documents (MVP Workspace) ==========
async pinDocument(taskId: string, documentId: string, pinnedBy: string = 'agent'): Promise<void> {
await this.db
.insert(taskDocuments)
.values({ documentId, pinnedBy, taskId })
.onConflictDoNothing();
}
async unpinDocument(taskId: string, documentId: string): Promise<void> {
await this.db
.delete(taskDocuments)
.where(and(eq(taskDocuments.taskId, taskId), eq(taskDocuments.documentId, documentId)));
}
async getPinnedDocuments(taskId: string) {
return this.db
.select()
.from(taskDocuments)
.where(eq(taskDocuments.taskId, taskId))
.orderBy(taskDocuments.createdAt);
}
// Get all pinned docs from a task tree (recursive), returns nodeMap + tree structure
async getTreePinnedDocuments(rootTaskId: string): Promise<WorkspaceData> {
const result = await this.db.execute(sql`
WITH RECURSIVE task_tree AS (
SELECT id, identifier FROM tasks WHERE id = ${rootTaskId}
UNION ALL
SELECT t.id, t.identifier FROM tasks t
JOIN task_tree tt ON t.parent_task_id = tt.id
)
SELECT td.*, tt.id as source_task_id, tt.identifier as source_task_identifier,
d.title as document_title, d.file_type as document_file_type, d.parent_id as document_parent_id,
d.total_char_count as document_char_count, d.updated_at as document_updated_at
FROM task_documents td
JOIN task_tree tt ON td.task_id = tt.id
LEFT JOIN documents d ON td.document_id = d.id
ORDER BY td.created_at
`);
// Build nodeMap
const nodeMap: Record<string, WorkspaceDocNode> = {};
const docIds = new Set<string>();
for (const row of result.rows as any[]) {
const docId = row.document_id;
docIds.add(docId);
nodeMap[docId] = {
charCount: row.document_char_count,
createdAt: row.created_at,
fileType: row.document_file_type,
parentId: row.document_parent_id,
pinnedBy: row.pinned_by,
sourceTaskIdentifier: row.source_task_id !== rootTaskId ? row.source_task_identifier : null,
title: row.document_title || 'Untitled',
updatedAt: row.document_updated_at,
};
}
// Build tree (children as id references)
type TreeNode = WorkspaceTreeNode;
const childrenMap = new Map<string | null, TreeNode[]>();
for (const docId of docIds) {
const node = nodeMap[docId];
const parentId = node.parentId && docIds.has(node.parentId) ? node.parentId : null;
const list = childrenMap.get(parentId) || [];
list.push({ children: [], id: docId });
childrenMap.set(parentId, list);
}
const buildTree = (parentId: string | null): TreeNode[] => {
const nodes = childrenMap.get(parentId) || [];
for (const node of nodes) {
node.children = buildTree(node.id);
}
return nodes;
};
return { nodeMap, tree: buildTree(null) };
}
// ========== Topic Management ==========
async incrementTopicCount(id: string): Promise<void> {
await this.db
.update(tasks)
.set({
totalTopics: sql`${tasks.totalTopics} + 1`,
updatedAt: new Date(),
})
.where(eq(tasks.id, id));
}
async updateCurrentTopic(id: string, topicId: string): Promise<void> {
await this.db
.update(tasks)
.set({ currentTopicId: topicId, updatedAt: new Date() })
.where(eq(tasks.id, id));
}
// ========== Comments ==========
async addComment(data: Omit<NewTaskComment, 'id'>): Promise<TaskCommentItem> {
const [comment] = await this.db.insert(taskComments).values(data).returning();
return comment;
}
// ========== Comments ==========
async getComments(taskId: string): Promise<TaskCommentItem[]> {
return this.db
.select()
.from(taskComments)
.where(eq(taskComments.taskId, taskId))
.orderBy(taskComments.createdAt);
}
async deleteComment(id: string): Promise<boolean> {
const result = await this.db
.delete(taskComments)
.where(and(eq(taskComments.id, id), eq(taskComments.userId, this.userId)))
.returning();
return result.length > 0;
}
}
+203
View File
@@ -0,0 +1,203 @@
import { and, desc, eq, sql } from 'drizzle-orm';
import type { TaskTopicItem } from '../schemas/task';
import { tasks, taskTopics } from '../schemas/task';
import type { LobeChatDatabase } from '../type';
export class TaskTopicModel {
private readonly userId: string;
private readonly db: LobeChatDatabase;
constructor(db: LobeChatDatabase, userId: string) {
this.db = db;
this.userId = userId;
}
async add(
taskId: string,
topicId: string,
params: { operationId?: string; seq: number },
): Promise<void> {
await this.db
.insert(taskTopics)
.values({
operationId: params.operationId,
seq: params.seq,
taskId,
topicId,
userId: this.userId,
})
.onConflictDoNothing();
}
async updateStatus(taskId: string, topicId: string, status: string): Promise<void> {
await this.db
.update(taskTopics)
.set({ status })
.where(
and(
eq(taskTopics.taskId, taskId),
eq(taskTopics.topicId, topicId),
eq(taskTopics.userId, this.userId),
),
);
}
async updateHandoff(
taskId: string,
topicId: string,
handoff: {
keyFindings?: string[];
nextAction?: string;
summary?: string;
title?: string;
},
): Promise<void> {
await this.db
.update(taskTopics)
.set({
handoffKeyFindings: handoff.keyFindings,
handoffNextAction: handoff.nextAction,
handoffSummary: handoff.summary,
handoffTitle: handoff.title,
})
.where(
and(
eq(taskTopics.taskId, taskId),
eq(taskTopics.topicId, topicId),
eq(taskTopics.userId, this.userId),
),
);
}
async updateReview(
taskId: string,
topicId: string,
review: {
iteration: number;
passed: boolean;
score: number;
scores: any[];
},
): Promise<void> {
await this.db
.update(taskTopics)
.set({
reviewIteration: review.iteration,
reviewPassed: review.passed ? 1 : 0,
reviewScore: review.score,
reviewScores: review.scores,
reviewedAt: new Date(),
})
.where(
and(
eq(taskTopics.taskId, taskId),
eq(taskTopics.topicId, topicId),
eq(taskTopics.userId, this.userId),
),
);
}
async timeoutRunning(taskId: string): Promise<number> {
const result = await this.db
.update(taskTopics)
.set({ status: 'timeout' })
.where(
and(
eq(taskTopics.taskId, taskId),
eq(taskTopics.status, 'running'),
eq(taskTopics.userId, this.userId),
),
)
.returning();
return result.length;
}
async findByTopicId(topicId: string): Promise<TaskTopicItem | null> {
const result = await this.db
.select()
.from(taskTopics)
.where(and(eq(taskTopics.topicId, topicId), eq(taskTopics.userId, this.userId)))
.limit(1);
return result[0] || null;
}
async findByTaskId(taskId: string): Promise<TaskTopicItem[]> {
return this.db
.select()
.from(taskTopics)
.where(and(eq(taskTopics.taskId, taskId), eq(taskTopics.userId, this.userId)))
.orderBy(desc(taskTopics.seq));
}
async findWithDetails(taskId: string) {
const { topics } = await import('../schemas/topic');
return this.db
.select({
createdAt: topics.createdAt,
handoffKeyFindings: taskTopics.handoffKeyFindings,
handoffNextAction: taskTopics.handoffNextAction,
handoffSummary: taskTopics.handoffSummary,
handoffTitle: taskTopics.handoffTitle,
id: topics.id,
metadata: topics.metadata,
operationId: taskTopics.operationId,
reviewIteration: taskTopics.reviewIteration,
reviewPassed: taskTopics.reviewPassed,
reviewScore: taskTopics.reviewScore,
reviewScores: taskTopics.reviewScores,
reviewedAt: taskTopics.reviewedAt,
seq: taskTopics.seq,
status: taskTopics.status,
title: topics.title,
updatedAt: topics.updatedAt,
})
.from(taskTopics)
.innerJoin(topics, eq(taskTopics.topicId, topics.id))
.where(and(eq(taskTopics.taskId, taskId), eq(taskTopics.userId, this.userId)))
.orderBy(desc(taskTopics.seq));
}
async findWithHandoff(taskId: string, limit = 4) {
return this.db
.select({
createdAt: taskTopics.createdAt,
handoffKeyFindings: taskTopics.handoffKeyFindings,
handoffNextAction: taskTopics.handoffNextAction,
handoffSummary: taskTopics.handoffSummary,
handoffTitle: taskTopics.handoffTitle,
seq: taskTopics.seq,
status: taskTopics.status,
topicId: taskTopics.topicId,
})
.from(taskTopics)
.where(and(eq(taskTopics.taskId, taskId), eq(taskTopics.userId, this.userId)))
.orderBy(desc(taskTopics.seq))
.limit(limit);
}
async remove(taskId: string, topicId: string): Promise<boolean> {
const result = await this.db
.delete(taskTopics)
.where(
and(
eq(taskTopics.taskId, taskId),
eq(taskTopics.topicId, topicId),
eq(taskTopics.userId, this.userId),
),
)
.returning();
if (result.length > 0) {
await this.db
.update(tasks)
.set({
totalTopics: sql`GREATEST(${tasks.totalTopics} - 1, 0)`,
updatedAt: new Date(),
})
.where(eq(tasks.id, taskId));
}
return result.length > 0;
}
}
+51
View File
@@ -0,0 +1,51 @@
import { index, jsonb, pgTable, text, uuid } from 'drizzle-orm/pg-core';
import { createdAt, timestamptz } from './_helpers';
import { agentCronJobs } from './agentCronJob';
import { tasks } from './task';
import { users } from './user';
export const briefs = pgTable(
'briefs',
{
id: uuid('id').defaultRandom().primaryKey().notNull(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
// Source (polymorphic, fill as needed)
taskId: text('task_id').references(() => tasks.id, { onDelete: 'cascade' }),
cronJobId: text('cron_job_id').references(() => agentCronJobs.id, { onDelete: 'cascade' }),
topicId: text('topic_id'),
agentId: text('agent_id'),
// Content
type: text('type').notNull(), // 'decision' | 'result' | 'insight' | 'error'
priority: text('priority').default('info'), // 'urgent' | 'normal' | 'info'
title: text('title').notNull(),
summary: text('summary').notNull(),
artifacts: jsonb('artifacts'), // document ids
actions: jsonb('actions'), // BriefAction[]
commentType: text('comment_type'), // 'summary' | 'suggestion' | 'motion'
// Resolution
resolvedAction: text('resolved_action'), // action key chosen by user (e.g. 'approve', 'feedback')
resolvedComment: text('resolved_comment'), // user feedback text (for 'comment' type actions)
readAt: timestamptz('read_at'),
resolvedAt: timestamptz('resolved_at'),
createdAt: createdAt(),
},
(t) => [
index('briefs_user_id_idx').on(t.userId),
index('briefs_task_id_idx').on(t.taskId),
index('briefs_cron_job_id_idx').on(t.cronJobId),
index('briefs_agent_id_idx').on(t.agentId),
index('briefs_type_idx').on(t.type),
index('briefs_priority_idx').on(t.priority),
index('briefs_unresolved_idx').on(t.userId, t.resolvedAt),
],
);
export type NewBrief = typeof briefs.$inferInsert;
export type BriefItem = typeof briefs.$inferSelect;
+2
View File
@@ -8,6 +8,7 @@ export * from './aiInfra';
export * from './apiKey';
export * from './asyncTask';
export * from './betterAuth';
export * from './brief';
export * from './chatGroup';
export * from './file';
export * from './generation';
@@ -19,6 +20,7 @@ export * from './ragEvals';
export * from './rbac';
export * from './relations';
export * from './session';
export * from './task';
export * from './topic';
export * from './user';
export * from './userMemories';
+225
View File
@@ -0,0 +1,225 @@
import { index, integer, jsonb, pgTable, text, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { idGenerator } from '../utils/idGenerator';
import { createdAt, timestamps, timestamptz, varchar255 } from './_helpers';
import { documents } from './file';
import { users } from './user';
// ── Tasks ────────────────────────────────────────────────
export const tasks = pgTable(
'tasks',
{
id: text('id')
.primaryKey()
.$defaultFn(() => idGenerator('tasks'))
.notNull(),
// Workspace-level identifier (e.g. 'TASK-1', 'PROJ-42')
identifier: text('identifier').notNull(),
seq: integer('seq').notNull(),
// Creator (user or agent)
createdByUserId: text('created_by_user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
createdByAgentId: text('created_by_agent_id'),
// Assignee (user and agent can coexist, both nullable)
assigneeUserId: text('assignee_user_id'),
assigneeAgentId: text('assignee_agent_id'),
// Tree structure (self-referencing, no depth limit)
parentTaskId: text('parent_task_id'),
// Task definition
name: text('name'),
description: varchar255('description'),
instruction: text('instruction').notNull(),
// Lifecycle (same state machine for user and agent)
// 'backlog' | 'running' | 'paused' | 'completed' | 'failed' | 'canceled'
status: text('status').notNull().default('backlog'),
priority: integer('priority').default(0), // 'no' | 'urgent' | 'high' | 'normal' | 'low'
sortOrder: integer('sort_order').default(0), // manual sort within parent, lower = higher
// Heartbeat
heartbeatInterval: integer('heartbeat_interval').default(300), // seconds
heartbeatTimeout: integer('heartbeat_timeout'), // seconds, null = disabled (default off)
lastHeartbeatAt: timestamptz('last_heartbeat_at'),
// Schedule (optional)
schedulePattern: text('schedule_pattern'),
scheduleTimezone: text('schedule_timezone').default('UTC'),
// Topic management
totalTopics: integer('total_topics').default(0),
maxTopics: integer('max_topics'), // null = unlimited
currentTopicId: text('current_topic_id'),
// Context & config (each task independent, no inheritance from parent)
context: jsonb('context').default({}),
config: jsonb('config').default({}), // CheckpointConfig, ReviewConfig, etc.
error: text('error'),
// Timestamps
startedAt: timestamptz('started_at'),
completedAt: timestamptz('completed_at'),
...timestamps,
},
(t) => [
uniqueIndex('tasks_identifier_idx').on(t.identifier, t.createdByUserId),
index('tasks_created_by_user_id_idx').on(t.createdByUserId),
index('tasks_created_by_agent_id_idx').on(t.createdByAgentId),
index('tasks_assignee_user_id_idx').on(t.assigneeUserId),
index('tasks_assignee_agent_id_idx').on(t.assigneeAgentId),
index('tasks_parent_task_id_idx').on(t.parentTaskId),
index('tasks_status_idx').on(t.status),
index('tasks_priority_idx').on(t.priority),
index('tasks_heartbeat_idx').on(t.status, t.lastHeartbeatAt),
],
);
export type NewTask = typeof tasks.$inferInsert;
export type TaskItem = typeof tasks.$inferSelect;
// ── Task Dependencies ────────────────────────────────────
export const taskDependencies = pgTable(
'task_dependencies',
{
id: uuid('id').defaultRandom().primaryKey().notNull(),
taskId: text('task_id')
.references(() => tasks.id, { onDelete: 'cascade' })
.notNull(),
dependsOnId: text('depends_on_id')
.references(() => tasks.id, { onDelete: 'cascade' })
.notNull(),
// 'blocks' | 'relates'
type: text('type').notNull().default('blocks'),
// Reserved for conditional dependencies: {"on": "success"} / {"on": "failure"}
condition: jsonb('condition'),
createdAt: createdAt(),
},
(t) => [
uniqueIndex('task_deps_unique_idx').on(t.taskId, t.dependsOnId),
index('task_deps_task_id_idx').on(t.taskId),
index('task_deps_depends_on_id_idx').on(t.dependsOnId),
],
);
export type NewTaskDependency = typeof taskDependencies.$inferInsert;
export type TaskDependencyItem = typeof taskDependencies.$inferSelect;
// ── Task Documents (MVP Workspace) ───────────────────────
export const taskDocuments = pgTable(
'task_documents',
{
id: uuid('id').defaultRandom().primaryKey().notNull(),
taskId: text('task_id')
.references(() => tasks.id, { onDelete: 'cascade' })
.notNull(),
documentId: text('document_id')
.references(() => documents.id, { onDelete: 'cascade' })
.notNull(),
// 'agent' | 'user' | 'system'
pinnedBy: text('pinned_by').notNull().default('agent'),
createdAt: createdAt(),
},
(t) => [
uniqueIndex('task_docs_unique_idx').on(t.taskId, t.documentId),
index('task_docs_task_id_idx').on(t.taskId),
index('task_docs_document_id_idx').on(t.documentId),
],
);
export type NewTaskDocument = typeof taskDocuments.$inferInsert;
export type TaskDocumentItem = typeof taskDocuments.$inferSelect;
// ── Task Topics ─────────────────────────────────────────
export const taskTopics = pgTable(
'task_topics',
{
id: uuid('id').defaultRandom().primaryKey().notNull(),
taskId: text('task_id')
.references(() => tasks.id, { onDelete: 'cascade' })
.notNull(),
topicId: text('topic_id').notNull(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
seq: integer('seq').notNull(), // topic sequence within task (1, 2, 3...)
operationId: text('operation_id'), // agent execution operation ID
// 'running' | 'completed' | 'failed' | 'timeout' | 'canceled'
status: text('status').notNull().default('running'),
// Handoff (populated after topic completes via LLM summarization)
handoffTitle: text('handoff_title'),
handoffSummary: text('handoff_summary'),
handoffKeyFindings: jsonb('handoff_key_findings'), // string[]
handoffNextAction: text('handoff_next_action'),
// Review results (populated after topic completes + review runs)
reviewPassed: integer('review_passed'), // 1 = passed, 0 = failed, null = not reviewed
reviewScore: integer('review_score'), // overall score 0-100
reviewScores: jsonb('review_scores'), // [{rubricId, score, passed, reason}]
reviewIteration: integer('review_iteration'), // which iteration (1, 2, 3...)
reviewedAt: timestamptz('reviewed_at'),
createdAt: createdAt(),
},
(t) => [
uniqueIndex('task_topics_unique_idx').on(t.taskId, t.topicId),
index('task_topics_task_id_idx').on(t.taskId),
index('task_topics_topic_id_idx').on(t.topicId),
index('task_topics_user_id_idx').on(t.userId),
index('task_topics_status_idx').on(t.taskId, t.status),
],
);
export type NewTaskTopic = typeof taskTopics.$inferInsert;
export type TaskTopicItem = typeof taskTopics.$inferSelect;
// ── Task Comments ───────────────────────────────────────
export const taskComments = pgTable(
'task_comments',
{
id: uuid('id').defaultRandom().primaryKey().notNull(),
taskId: text('task_id')
.references(() => tasks.id, { onDelete: 'cascade' })
.notNull(),
// Author (one of)
userId: text('user_id'),
agentId: text('agent_id'),
// Content
content: text('content').notNull(),
editorData: jsonb('editor_data'),
// Optional references
briefId: uuid('brief_id'), // reply to a brief
topicId: text('topic_id'), // related topic
...timestamps,
},
(t) => [
index('task_comments_task_id_idx').on(t.taskId),
index('task_comments_user_id_idx').on(t.userId),
index('task_comments_agent_id_idx').on(t.agentId),
index('task_comments_brief_id_idx').on(t.briefId),
index('task_comments_topic_id_idx').on(t.topicId),
],
);
export type NewTaskComment = typeof taskComments.$inferInsert;
export type TaskCommentItem = typeof taskComments.$inferSelect;
@@ -8,6 +8,7 @@ export const createNanoId = (size = 8) =>
const prefixes = {
agentCronJobs: 'cron',
agentSkills: 'skl',
tasks: 'task',
agents: 'agt',
budget: 'bgt',
chatGroups: 'cg',
@@ -7,7 +7,6 @@ import { moonshotChatModels } from './moonshot';
import { openaiChatModels } from './openai';
import { xaiChatModels } from './xai';
import { xiaomimimoChatModels } from './xiaomimimo';
import { zhipuChatModels } from './zhipu';
export const lobehubChatModels: AIChatModelCard[] = [
...anthropicChatModels,
@@ -15,9 +14,8 @@ export const lobehubChatModels: AIChatModelCard[] = [
...openaiChatModels,
...deepseekChatModels,
...xaiChatModels,
...moonshotChatModels,
...minimaxChatModels,
...zhipuChatModels,
...moonshotChatModels,
...xiaomimimoChatModels,
];
@@ -29,4 +27,3 @@ export { moonshotChatModels } from './moonshot';
export { openaiChatModels } from './openai';
export { xaiChatModels } from './xai';
export { xiaomimimoChatModels } from './xiaomimimo';
export { zhipuChatModels } from './zhipu';
@@ -1,31 +0,0 @@
import type { AIChatModelCard } from '../../../types/aiModel';
export const zhipuChatModels: AIChatModelCard[] = [
{
abilities: {
functionCall: true,
reasoning: true,
search: true,
},
contextWindowTokens: 200_000,
description:
"Zai's new-generation flagship foundation model, designed for Agentic Engineering, capable of providing reliable productivity in complex system engineering and long-range Agent tasks.",
displayName: 'GLM-5',
enabled: true,
id: 'glm-5',
maxOutput: 131_072,
pricing: {
units: [
{ name: 'textInput', rate: 1, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textInput_cacheRead', rate: 0.2, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 3.2, strategy: 'fixed', unit: 'millionTokens' },
],
},
releasedAt: '2026-02-12',
settings: {
extendParams: ['enableReasoning'],
searchImpl: 'params',
},
type: 'chat',
},
];
@@ -15,7 +15,6 @@ export type ApiType =
| 'vertexai'
| 'volcengine'
| 'xai'
| 'xiaomimimo'
| 'zhipu';
| 'xiaomimimo';
export type RuntimeClass = new (options?: any) => LobeRuntimeAI;
@@ -13,7 +13,6 @@ import { LobeVertexAI } from '../../providers/vertexai';
import { LobeVolcengineAI } from '../../providers/volcengine';
import { LobeXAI } from '../../providers/xai';
import { LobeXiaomiMiMoAI } from '../../providers/xiaomimimo';
import { LobeZhipuAI } from '../../providers/zhipu';
import type { ApiType, RuntimeClass } from './apiTypes';
export const baseRuntimeMap = {
@@ -32,5 +31,4 @@ export const baseRuntimeMap = {
volcengine: LobeVolcengineAI,
xai: LobeXAI,
xiaomimimo: LobeXiaomiMiMoAI,
zhipu: LobeZhipuAI,
} satisfies Record<ApiType, RuntimeClass>;
@@ -32,5 +32,5 @@ export const resolveMaxTokens = async ({
const hasSmallContextWindow = smallContextWindowPatterns.some((pattern) => pattern.test(model));
return hasSmallContextWindow ? 4096 : 8192;
return hasSmallContextWindow ? 4096 : 16_384;
};
@@ -949,7 +949,7 @@ describe('LobeOpenAICompatibleFactory', () => {
it('should use custom stream handler when provided', async () => {
// Create a custom stream handler that handles both ReadableStream and OpenAI Stream
const customStreamHandler = vi.fn(
(stream: ReadableStream | Stream<OpenAI.ChatCompletionChunk>, _options?: any) => {
(stream: ReadableStream | Stream<OpenAI.ChatCompletionChunk>) => {
const readableStream =
stream instanceof ReadableStream ? stream : stream.toReadableStream();
return new ReadableStream({
@@ -1009,13 +1009,6 @@ describe('LobeOpenAICompatibleFactory', () => {
await instance.chat(payload);
expect(customStreamHandler).toHaveBeenCalled();
// Verify payload is passed to custom stream handler
const handlerOptions = customStreamHandler.mock.calls[0][1];
expect(handlerOptions.payload).toMatchObject({
model: 'test-model',
provider: ModelProvider.OpenAI,
});
});
it('should use custom transform handler for non-streaming response', async () => {
@@ -104,11 +104,7 @@ export interface OpenAICompatibleFactoryOptions<T extends Record<string, any> =
) => OpenAI.ChatCompletionCreateParamsStreaming;
handleStream?: (
stream: Stream<OpenAI.ChatCompletionChunk> | ReadableStream,
options: {
callbacks?: ChatStreamCallbacks;
inputStartAt?: number;
payload?: ChatPayloadForTransformStream;
},
{ callbacks, inputStartAt }: { callbacks?: ChatStreamCallbacks; inputStartAt?: number },
) => ReadableStream;
handleStreamBizErrorType?: (error: {
message: string;
@@ -511,7 +507,6 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
? chatCompletion.handleStream(prod, {
callbacks: streamOptions.callbacks,
inputStartAt,
payload: streamOptions.payload,
})
: OpenAIStream(prod, {
...streamOptions,
@@ -542,7 +537,6 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
? chatCompletion.handleStream(stream, {
callbacks: streamOptions.callbacks,
inputStartAt,
payload: streamOptions.payload,
})
: OpenAIStream(stream, { ...streamOptions, enableStreaming: false, inputStartAt }),
{
@@ -648,76 +648,6 @@ describe('LobeZhipuAI - custom features', () => {
expect(result).toBeDefined();
});
it('should filter out incomplete placeholder tool_call chunks from proxies', async () => {
// Some proxies (e.g., aihubmix) send empty placeholder chunks without
// id/function.name when tool_stream is enabled. These must be filtered
// out to prevent ZodError in parseToolCalls.
const mockStream = new ReadableStream({
start(controller) {
// Placeholder chunks (no id, no name, empty arguments)
controller.enqueue({
choices: [
{
delta: {
tool_calls: [{ type: 'function', function: { arguments: '' }, index: 0 }],
},
finish_reason: null,
index: 0,
},
],
created: 1234567890,
id: 'chatcmpl-123',
model: 'glm-5',
object: 'chat.completion.chunk',
});
// Real chunk with id and name
controller.enqueue({
choices: [
{
delta: {
tool_calls: [
{
id: 'tool-abc123',
type: 'function',
function: { name: 'calculator', arguments: '{"expression":"1+1"}' },
index: 0,
},
],
},
finish_reason: null,
index: 0,
},
],
created: 1234567890,
id: 'chatcmpl-123',
model: 'glm-5',
object: 'chat.completion.chunk',
});
controller.close();
},
});
(instance['client'].chat.completions.create as any).mockResolvedValue(mockStream);
// Should not throw ZodError from incomplete placeholder chunks
const result = await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'glm-5',
temperature: 0.5,
});
const reader = result.body?.getReader();
if (reader) {
let done = false;
while (!done) {
const { value, done: isDone } = await reader.read();
done = isDone;
}
}
expect(result).toBeDefined();
});
it('should handle multiple chunks with tool_calls', async () => {
const mockStream = new ReadableStream({
start(controller) {
@@ -83,7 +83,7 @@ export const params = {
tools: zhipuTools,
} as any;
},
handleStream: (stream, { callbacks, inputStartAt, payload }) => {
handleStream: (stream, { callbacks, inputStartAt }) => {
const readableStream =
stream instanceof ReadableStream ? stream : convertIterableToStream(stream);
@@ -97,33 +97,28 @@ export const params = {
const choice = chunk.choices[0];
if (choice.delta?.tool_calls && Array.isArray(choice.delta.tool_calls)) {
// Fix negative index, convert -1 to positive index based on array position
// With tool_stream enabled, some proxies (e.g., aihubmix) send
// incomplete tool_call chunks without id/function.name before the
// real chunk arrives. Filter them out to prevent ZodError in parseToolCalls.
const fixedToolCalls = choice.delta.tool_calls
.filter(
(toolCall: any) =>
// Keep chunks that have id/name (first real chunk) or
// non-empty arguments (subsequent incremental chunks)
toolCall.id || toolCall.function?.name || toolCall.function?.arguments,
)
.map((toolCall: any, globalIndex: number) => ({
const fixedToolCalls = choice.delta.tool_calls.map(
(toolCall: any, globalIndex: number) => ({
...toolCall,
// Fix negative index (-1 → array position)
index: toolCall.index < 0 ? globalIndex : toolCall.index,
}));
}),
);
if (fixedToolCalls.length === 0) {
// All tool_calls were incomplete placeholders, skip this chunk
controller.enqueue({ ...chunk, choices: [{ ...choice, delta: {} }] });
} else {
controller.enqueue({
...chunk,
choices: [
{ ...choice, delta: { ...choice.delta, tool_calls: fixedToolCalls } },
],
});
}
// Create fixed chunk
const fixedChunk = {
...chunk,
choices: [
{
...choice,
delta: {
...choice.delta,
tool_calls: fixedToolCalls,
},
},
],
};
controller.enqueue(fixedChunk);
} else {
controller.enqueue(chunk);
}
@@ -137,7 +132,9 @@ export const params = {
return OpenAIStream(preprocessedStream, {
callbacks,
inputStartAt,
payload,
payload: {
provider: 'zhipu',
},
});
},
},
+1
View File
@@ -10,4 +10,5 @@ export * from './summaryGenerationTitle';
export * from './summaryHistory';
export * from './summaryTags';
export * from './summaryTitle';
export * from './taskTopicHandoff';
export * from './translate';
@@ -0,0 +1,57 @@
import type { ChatStreamPayload } from '@lobechat/types';
/**
* Generate a handoff summary for a completed task topic.
* Returns both a title and structured handoff data for the next topic.
*
* Input: the last assistant message content from the topic.
* Output: JSON with { title, summary, keyFindings?, nextAction? }
*/
export const chainTaskTopicHandoff = (params: {
lastAssistantContent: string;
taskInstruction: string;
taskName: string;
}): Partial<ChatStreamPayload> => ({
messages: [
{
content: `You are a task execution summarizer. A topic (one round of agent execution) has just completed within a task. Generate a handoff summary for the next topic to read.
Output a JSON object with these fields:
- "title": A concise title summarizing what this topic accomplished (max 50 chars, same language as content)
- "summary": A 1-3 sentence summary of what was done and the key outcome
- "keyFindings": An array of key findings or decisions made (optional, max 5 items)
- "nextAction": What the next topic should do (optional, 1 sentence)
Rules:
- Focus on WHAT WAS ACCOMPLISHED, not what was asked
- Use the same language as the content
- Keep title short and specific (e.g. "制定8章书籍大纲" not "完成任务")
- summary should capture the essential outcome a new topic needs to know
- Output ONLY the JSON object, no markdown fences or explanations`,
role: 'system',
},
{
content: `Task: ${params.taskName}
Task instruction: ${params.taskInstruction}
Last assistant response:
${params.lastAssistantContent}`,
role: 'user',
},
],
});
export const TASK_TOPIC_HANDOFF_SCHEMA = {
additionalProperties: false,
properties: {
keyFindings: {
items: { type: 'string' },
type: 'array',
},
nextAction: { type: 'string' },
summary: { type: 'string' },
title: { type: 'string' },
},
required: ['title', 'summary'],
type: 'object',
};
+1
View File
@@ -15,5 +15,6 @@ export * from './search';
export * from './skills';
export * from './speaker';
export * from './systemRole';
export * from './task';
export * from './toolDiscovery';
export * from './userMemory';
@@ -0,0 +1,243 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`buildTaskRunPrompt > should build prompt with only task instruction 1`] = `
"<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-1 写一本书
Status: ? undefined Priority: -
Instruction: 帮我写一本 AI Agent 技术书籍
Review: (not configured)
</task>"
`;
exports[`buildTaskRunPrompt > should build prompt with task description + instruction 1`] = `
"<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-1 写一本书
Status: ? undefined Priority: -
Instruction: 帮我写一本 AI Agent 技术书籍,目标 8 章
Description: 面向开发者的技术书籍
Review: (not configured)
</task>"
`;
exports[`buildTaskRunPrompt > should handle empty activities gracefully 1`] = `
"<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-1 写一本书
Status: ? undefined Priority: -
Instruction: 写书
Review: (not configured)
</task>"
`;
exports[`buildTaskRunPrompt > should handle full scenario with all sections 1`] = `
"<high_priority_instruction>
这次直接开始写第1章
</high_priority_instruction>
<user_feedback>
<comment time="3h ago">第5章后移,增加评测章节</comment>
</user_feedback>
<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-1 写一本书
Status: ? undefined Priority: -
Instruction: 写一本 AI Agent 书,目标 8 章
Description: 面向开发者的 AI Agent 技术书籍
Topics: 1
Review: (not configured)
Activities:
💬 19h ago Topic #1 制定大纲 ✓ completed tpc_001
📋 18h ago Brief [decision] 大纲完成 ✅ approve brief_001
💭 3h ago 👤 user 第5章后移,增加评测章节
💭 2h ago 🤖 agent 已调整大纲
</task>"
`;
exports[`buildTaskRunPrompt > should include activity history with topics and briefs in CLI style 1`] = `
"<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-1 写一本书
Status: ? undefined Priority: -
Instruction: 写书
Topics: 2
Review: (not configured)
Activities:
💬 19h ago Topic #1 制定大纲 ✓ completed tpc_aaa
📋 18h ago Brief [decision] 大纲完成 [urgent] ✅ approve brief_abc123
💬 18h ago Topic #2 修订大纲 ✓ completed tpc_bbb
📋 18h ago Brief [decision] 建议拆分第4章 [normal] brief_def456
</task>"
`;
exports[`buildTaskRunPrompt > should include agent comments with author label and time 1`] = `
"<user_feedback>
<comment time="2h ago">确认,开始写</comment>
</user_feedback>
<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-1 写一本书
Status: ? undefined Priority: -
Instruction: 写书
Review: (not configured)
Activities:
💭 3h ago 🤖 agent 大纲已完成,请确认
💭 2h ago 👤 user 确认,开始写
</task>"
`;
exports[`buildTaskRunPrompt > should include parentTask context for subtasks 1`] = `
"<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-4 第2章 核心架构
Status: ● running Priority: -
Instruction: 撰写第2章
Parent: TASK-1
Review: (not configured)
<parentTask identifier="TASK-1" name="写一本书">
Instruction: 写一本 AI Agent 书,目标 8 章
Subtasks (3):
TASK-2 ✓ completed 第1章 概述
TASK-4 ● running 第2章 核心架构 ← blocks: TASK-2 ◀ current
TASK-6 ○ backlog 第3章 手写 Agent ← blocks: TASK-4
</parentTask>
</task>"
`;
exports[`buildTaskRunPrompt > should include subtasks in task section 1`] = `
"<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-1 写一本书
Status: ● running Priority: -
Instruction: 写书
Subtasks:
TASK-2 ✓ completed 第1章 Agent 概述
TASK-3 ○ backlog 第2章 快速上手 ← blocks: TASK-2
Review: (not configured)
</task>"
`;
exports[`buildTaskRunPrompt > should include subtasks in task tag when provided 1`] = `
"<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-1 写一本书
Status: ● running Priority: -
Instruction: 写一本 AI Agent 书,目标 8 章
Subtasks:
TASK-2 ● running 第1章 AI Agent 概述
TASK-3 ○ backlog 第2章 核心架构
TASK-4 ○ backlog 第3章 手写 Agent
Review: (not configured)
</task>"
`;
exports[`buildTaskRunPrompt > should only include user comments in user_feedback, not agent comments 1`] = `
"<user_feedback>
<comment time="2h ago">用户反馈</comment>
</user_feedback>
<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-1 写一本书
Status: ? undefined Priority: -
Instruction: 写书
Review: (not configured)
Activities:
💭 2h ago 👤 user 用户反馈
💭 1h ago 🤖 agent Agent 回复
</task>"
`;
exports[`buildTaskRunPrompt > should place high_priority_instruction first, then feedback 1`] = `
"<high_priority_instruction>
这次重点关注第3章
</high_priority_instruction>
<user_feedback>
<comment time="1h ago">用户反馈</comment>
</user_feedback>
<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-1 写一本书
Status: ? undefined Priority: -
Instruction: 写书
Review: (not configured)
Activities:
💭 1h ago 👤 user 用户反馈
</task>"
`;
exports[`buildTaskRunPrompt > should prioritize user feedback at the top 1`] = `
"<user_feedback>
<comment time="2h ago">第2章改为先上手再讲原理</comment>
<comment time="1h ago">增加评测章节</comment>
</user_feedback>
<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-1 写一本书
Status: ? undefined Priority: -
Instruction: 写书
Review: (not configured)
Activities:
💭 2h ago 👤 user 第2章改为先上手再讲原理
💭 1h ago 👤 user 增加评测章节
</task>"
`;
exports[`buildTaskRunPrompt > should show resolved action and comment on briefs 1`] = `
"<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-2 第2章
Status: ? undefined Priority: -
Instruction: 写第2章
Review: (not configured)
Activities:
✅ 19h ago Brief [result] 第2章完成 ✏️ 第2章需要更多实例
</task>"
`;
exports[`buildTaskRunPrompt > should truncate comments to 80 chars in activities but keep full in user_feedback 1`] = `
"<user_feedback>
<comment time="1h ago">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA — this part should be truncated in activities</comment>
</user_feedback>
<task>
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
TASK-1 写一本书
Status: ? undefined Priority: -
Instruction: 写书
Review: (not configured)
Activities:
💭 1h ago 👤 user AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...
</task>"
`;
@@ -0,0 +1,452 @@
import { describe, expect, it } from 'vitest';
import { buildTaskRunPrompt } from './index';
// Fixed reference time for stable timeAgo output
const NOW = new Date('2026-03-22T12:00:00Z');
describe('buildTaskRunPrompt', () => {
it('should build prompt with only task instruction', () => {
const result = buildTaskRunPrompt(
{
task: {
identifier: 'TASK-1',
instruction: '帮我写一本 AI Agent 技术书籍',
name: '写一本书',
},
},
NOW,
);
expect(result).toMatchSnapshot();
});
it('should build prompt with task description + instruction', () => {
const result = buildTaskRunPrompt(
{
task: {
description: '面向开发者的技术书籍',
identifier: 'TASK-1',
instruction: '帮我写一本 AI Agent 技术书籍,目标 8 章',
name: '写一本书',
},
},
NOW,
);
expect(result).toMatchSnapshot();
});
it('should prioritize user feedback at the top', () => {
const result = buildTaskRunPrompt(
{
activities: {
comments: [
{ content: '第2章改为先上手再讲原理', createdAt: '2026-03-22T10:00:00Z' },
{ content: '增加评测章节', createdAt: '2026-03-22T10:30:00Z' },
],
},
task: {
identifier: 'TASK-1',
instruction: '写书',
name: '写一本书',
},
},
NOW,
);
expect(result).toMatchSnapshot();
// Verify feedback comes before task
const feedbackIdx = result.indexOf('<user_feedback>');
const taskIdx = result.indexOf('<task');
expect(feedbackIdx).toBeLessThan(taskIdx);
});
it('should include agent comments with author label and time', () => {
const result = buildTaskRunPrompt(
{
activities: {
comments: [
{
agentId: 'agt_xxx',
content: '大纲已完成,请确认',
createdAt: '2026-03-22T09:00:00Z',
},
{ content: '确认,开始写', createdAt: '2026-03-22T10:00:00Z' },
],
},
task: {
identifier: 'TASK-1',
instruction: '写书',
name: '写一本书',
},
},
NOW,
);
expect(result).toMatchSnapshot();
expect(result).toContain('🤖 agent');
expect(result).toContain('👤 user');
expect(result).toContain('3h ago');
expect(result).toContain('2h ago');
});
it('should place high_priority_instruction first, then feedback', () => {
const result = buildTaskRunPrompt(
{
activities: {
comments: [{ content: '用户反馈', createdAt: '2026-03-22T11:00:00Z' }],
},
extraPrompt: '这次重点关注第3章',
task: {
identifier: 'TASK-1',
instruction: '写书',
name: '写一本书',
},
},
NOW,
);
expect(result).toMatchSnapshot();
const feedbackIdx = result.indexOf('<user_feedback>');
const extraIdx = result.indexOf('<high_priority_instruction>');
const taskIdx = result.indexOf('<task');
expect(extraIdx).toBeLessThan(feedbackIdx);
expect(feedbackIdx).toBeLessThan(taskIdx);
});
it('should include activity history with topics and briefs in CLI style', () => {
const result = buildTaskRunPrompt(
{
activities: {
briefs: [
{
createdAt: '2026-03-21T17:05:00Z',
id: 'brief_abc123',
priority: 'urgent',
resolvedAction: 'approve',
resolvedAt: '2026-03-21T17:30:00Z',
summary: '8章大纲已制定完成',
title: '大纲完成',
type: 'decision',
},
{
createdAt: '2026-03-21T18:00:00Z',
id: 'brief_def456',
priority: 'normal',
resolvedAt: null,
summary: '第4章内容过多,建议拆分',
title: '建议拆分第4章',
type: 'decision',
},
],
topics: [
{
createdAt: '2026-03-21T17:00:00Z',
id: 'tpc_aaa',
metadata: { handoff: { summary: '完成了大纲制定' } },
seq: 1,
status: 'completed',
title: '制定大纲',
},
{
createdAt: '2026-03-21T17:31:00Z',
id: 'tpc_bbb',
metadata: { handoff: { summary: '修订了大纲并拆分子任务' } },
seq: 2,
status: 'completed',
title: '修订大纲',
},
],
},
task: {
identifier: 'TASK-1',
instruction: '写书',
name: '写一本书',
},
},
NOW,
);
expect(result).toMatchSnapshot();
// Verify timeline is sorted chronologically (oldest first)
// Data: topic1(17:00), brief1(17:05), topic2(17:31), brief2(18:00)
const taskSection = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
const topic1Idx = taskSection.indexOf('Topic #1');
const brief1Idx = taskSection.indexOf('brief_abc123');
const topic2Idx = taskSection.indexOf('Topic #2');
const brief2Idx = taskSection.indexOf('brief_def456');
expect(topic1Idx).toBeLessThan(brief1Idx);
expect(brief1Idx).toBeLessThan(topic2Idx);
expect(topic2Idx).toBeLessThan(brief2Idx);
});
it('should show resolved action and comment on briefs', () => {
const result = buildTaskRunPrompt(
{
activities: {
briefs: [
{
createdAt: '2026-03-21T17:00:00Z',
resolvedAction: 'feedback',
resolvedAt: '2026-03-21T18:00:00Z',
resolvedComment: '第2章需要更多实例',
summary: '第2章初稿完成',
title: '第2章完成',
type: 'result',
},
],
},
task: {
identifier: 'TASK-2',
instruction: '写第2章',
name: '第2章',
},
},
NOW,
);
expect(result).toMatchSnapshot();
expect(result).toContain('第2章需要更多实例');
});
it('should handle full scenario with all sections', () => {
const result = buildTaskRunPrompt(
{
activities: {
briefs: [
{
createdAt: '2026-03-21T17:05:00Z',
id: 'brief_001',
resolvedAction: 'approve',
resolvedAt: '2026-03-21T17:30:00Z',
summary: '大纲已完成',
title: '大纲完成',
type: 'decision',
},
],
comments: [
{ content: '第5章后移,增加评测章节', createdAt: '2026-03-22T09:00:00Z' },
{ agentId: 'agt_inbox', content: '已调整大纲', createdAt: '2026-03-22T09:05:00Z' },
],
topics: [
{
createdAt: '2026-03-21T17:00:00Z',
id: 'tpc_001',
metadata: { handoff: { summary: '完成大纲' } },
seq: 1,
status: 'completed',
title: '制定大纲',
},
],
},
extraPrompt: '这次直接开始写第1章',
task: {
description: '面向开发者的 AI Agent 技术书籍',
identifier: 'TASK-1',
instruction: '写一本 AI Agent 书,目标 8 章',
name: '写一本书',
},
},
NOW,
);
expect(result).toMatchSnapshot();
// Verify order: instruction → feedback → task (activities now inside task)
const tags = ['<high_priority_instruction>', '<user_feedback>', '<task>'];
let lastIdx = -1;
for (const tag of tags) {
const idx = result.indexOf(tag);
expect(idx).toBeGreaterThan(lastIdx);
lastIdx = idx;
}
});
it('should handle empty activities gracefully', () => {
const result = buildTaskRunPrompt(
{
activities: {
briefs: [],
comments: [],
topics: [],
},
task: {
identifier: 'TASK-1',
instruction: '写书',
name: '写一本书',
},
},
NOW,
);
expect(result).toMatchSnapshot();
expect(result).not.toContain('<user_feedback>');
expect(result).not.toContain('<activities>');
});
it('should include subtasks in task section', () => {
const result = buildTaskRunPrompt(
{
task: {
id: 'task_root',
identifier: 'TASK-1',
instruction: '写书',
name: '写一本书',
status: 'running',
subtasks: [
{ identifier: 'TASK-2', name: '第1章 Agent 概述', priority: 3, status: 'completed' },
{
blockedBy: 'TASK-2',
identifier: 'TASK-3',
name: '第2章 快速上手',
priority: 3,
status: 'backlog',
},
],
},
},
NOW,
);
expect(result).toMatchSnapshot();
expect(result).toContain('TASK-2');
expect(result).toContain('TASK-3');
expect(result).toContain('← blocks: TASK-2');
});
it('should truncate comments to 80 chars in activities but keep full in user_feedback', () => {
const longContent = 'A'.repeat(90) + ' — this part should be truncated in activities';
const result = buildTaskRunPrompt(
{
activities: {
comments: [{ content: longContent, createdAt: '2026-03-22T11:00:00Z' }],
},
task: {
identifier: 'TASK-1',
instruction: '写书',
name: '写一本书',
},
},
NOW,
);
expect(result).toMatchSnapshot();
// user_feedback should have full content
const feedbackSection = result.split('<user_feedback>')[1]?.split('</user_feedback>')[0] || '';
expect(feedbackSection).toContain(longContent);
// activities comment should be truncated
const taskSection = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
expect(taskSection).toContain('...');
expect(taskSection).not.toContain(longContent);
});
it('should include subtasks in task tag when provided', () => {
const result = buildTaskRunPrompt(
{
task: {
id: 'task_001',
identifier: 'TASK-1',
instruction: '写一本 AI Agent 书,目标 8 章',
name: '写一本书',
status: 'running',
subtasks: [
{ identifier: 'TASK-2', name: '第1章 AI Agent 概述', priority: 3, status: 'running' },
{ identifier: 'TASK-3', name: '第2章 核心架构', priority: 3, status: 'backlog' },
{ identifier: 'TASK-4', name: '第3章 手写 Agent', priority: 2, status: 'backlog' },
],
},
},
NOW,
);
expect(result).toMatchSnapshot();
// Verify subtasks appear between <task> and </task>
const taskMatch = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
expect(taskMatch).toContain('Subtasks:');
expect(taskMatch).toContain('TASK-2');
expect(taskMatch).toContain('TASK-3');
expect(taskMatch).toContain('TASK-4');
// Verify hint is present
expect(taskMatch).toContain('Do NOT call viewTask');
});
it('should include parentTask context for subtasks', () => {
const result = buildTaskRunPrompt(
{
parentTask: {
identifier: 'TASK-1',
instruction: '写一本 AI Agent 书,目标 8 章',
name: '写一本书',
subtasks: [
{ identifier: 'TASK-2', name: '第1章 概述', priority: 3, status: 'completed' },
{
blockedBy: 'TASK-2',
identifier: 'TASK-4',
name: '第2章 核心架构',
priority: 3,
status: 'running',
},
{
blockedBy: 'TASK-4',
identifier: 'TASK-6',
name: '第3章 手写 Agent',
priority: 2,
status: 'backlog',
},
],
},
task: {
id: 'task_004',
identifier: 'TASK-4',
instruction: '撰写第2章',
name: '第2章 核心架构',
parentIdentifier: 'TASK-1',
status: 'running',
},
},
NOW,
);
expect(result).toMatchSnapshot();
// Verify parentTask block exists inside <task>
const taskSection = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
expect(taskSection).toContain('<parentTask');
expect(taskSection).toContain('TASK-1');
expect(taskSection).toContain('写一本 AI Agent 书');
// Current task should be marked
expect(taskSection).toContain('TASK-4');
expect(taskSection).toContain('◀ current');
// Dependency info
expect(taskSection).toContain('← blocks: TASK-2');
expect(taskSection).toContain('← blocks: TASK-4');
});
it('should only include user comments in user_feedback, not agent comments', () => {
const result = buildTaskRunPrompt(
{
activities: {
comments: [
{ content: '用户反馈', createdAt: '2026-03-22T10:00:00Z' },
{ agentId: 'agt_xxx', content: 'Agent 回复', createdAt: '2026-03-22T10:05:00Z' },
],
},
task: {
identifier: 'TASK-1',
instruction: '写书',
name: '写一本书',
},
},
NOW,
);
expect(result).toMatchSnapshot();
const feedbackSection = result.split('<user_feedback>')[1]?.split('</user_feedback>')[0] || '';
expect(feedbackSection).toContain('用户反馈');
expect(feedbackSection).not.toContain('Agent 回复');
// But activities should have both
const taskSection = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
expect(taskSection).toContain('👤 user');
expect(taskSection).toContain('🤖 agent');
});
});
+584
View File
@@ -0,0 +1,584 @@
import type { TaskDetailData, TaskDetailWorkspaceNode } from '@lobechat/types';
// ── Formatting helpers for Task tool responses ──
const priorityLabel = (p?: number | null): string => {
switch (p) {
case 1: {
return 'urgent';
}
case 2: {
return 'high';
}
case 3: {
return 'normal';
}
case 4: {
return 'low';
}
default: {
return '-';
}
}
};
const statusIcon = (s: string): string => {
switch (s) {
case 'backlog': {
return '○';
}
case 'running': {
return '●';
}
case 'paused': {
return '◐';
}
case 'completed': {
return '✓';
}
case 'failed': {
return '✗';
}
case 'canceled': {
return '⊘';
}
default: {
return '?';
}
}
};
export interface TaskSummary {
identifier: string;
name?: string | null;
priority?: number | null;
status: string;
}
// Re-export shared types from @lobechat/types for backward compatibility
export type {
TaskDetailBrief,
TaskDetailComment,
TaskDetailData,
TaskDetailSubtask,
TaskDetailTopic,
TaskDetailWorkspaceNode,
} from '@lobechat/types';
/**
* Format a single task as a one-line summary
*/
export const formatTaskLine = (t: TaskSummary): string =>
`${t.identifier} ${statusIcon(t.status)} ${t.status} ${t.name || '(unnamed)'} [${priorityLabel(t.priority)}]`;
/**
* Format createTask response
*/
export const formatTaskCreated = (
t: TaskSummary & { instruction: string; parentLabel?: string },
): string => {
const lines = [
`Task created: ${t.identifier} "${t.name}"`,
` Status: ${statusIcon(t.status)} ${t.status}`,
` Priority: ${priorityLabel(t.priority)}`,
];
if (t.parentLabel) lines.push(` Parent: ${t.parentLabel}`);
lines.push(` Instruction: ${t.instruction}`);
return lines.join('\n');
};
/**
* Format task list response
*/
export const formatTaskList = (
tasks: TaskSummary[],
parentLabel: string,
filter?: string,
): string => {
if (tasks.length === 0) {
const filterNote = filter ? ` with status "${filter}"` : '';
return `No subtasks found under ${parentLabel}${filterNote}.`;
}
return [
`${tasks.length} task(s) under ${parentLabel}:`,
...tasks.map((t) => ` ${formatTaskLine(t)}`),
].join('\n');
};
/**
* Format viewTask response
*/
export const formatTaskDetail = (t: TaskDetailData): string => {
const lines = [
`${t.identifier} ${t.name || '(unnamed)'}`,
`Status: ${statusIcon(t.status)} ${t.status} Priority: ${priorityLabel(t.priority)}`,
`Instruction: ${t.instruction}`,
];
if (t.agentId) lines.push(`Agent: ${t.agentId}`);
if (t.parent) lines.push(`Parent: ${t.parent.identifier}`);
if (t.topicCount) lines.push(`Topics: ${t.topicCount}`);
if (t.createdAt) lines.push(`Created: ${t.createdAt}`);
if (t.dependencies && t.dependencies.length > 0) {
lines.push(
`Dependencies: ${t.dependencies.map((d) => `${d.type}: ${d.dependsOn}`).join(', ')}`,
);
}
// Subtasks
if (t.subtasks && t.subtasks.length > 0) {
lines.push('');
lines.push('Subtasks:');
for (const s of t.subtasks) {
const dep = s.blockedBy ? ` ← blocks: ${s.blockedBy}` : '';
lines.push(
` ${s.identifier} ${statusIcon(s.status)} ${s.status} ${s.name || '(unnamed)'}${dep}`,
);
}
}
// Checkpoint
lines.push('');
if (t.checkpoint && Object.keys(t.checkpoint).length > 0) {
lines.push(`Checkpoint: ${JSON.stringify(t.checkpoint)}`);
} else {
lines.push('Checkpoint: (not configured, default: onAgentRequest=true)');
}
// Review
lines.push('');
if (t.review && Object.keys(t.review).length > 0) {
const rubrics = (t.review as any).rubrics as
| Array<{ name: string; threshold?: number; type: string }>
| undefined;
lines.push(`Review (maxIterations: ${(t.review as any).maxIterations || 3}):`);
if (rubrics) {
for (const r of rubrics) {
lines.push(
` - ${r.name} [${r.type}]${r.threshold ? `${Math.round(r.threshold * 100)}%` : ''}`,
);
}
}
} else {
lines.push('Review: (not configured)');
}
// Workspace
if (t.workspace && t.workspace.length > 0) {
const countNodes = (nodes: TaskDetailWorkspaceNode[]): number =>
nodes.reduce((sum, n) => sum + 1 + (n.children ? countNodes(n.children) : 0), 0);
const total = countNodes(t.workspace);
lines.push('');
lines.push(`Workspace (${total}):`);
const renderNodes = (nodes: TaskDetailWorkspaceNode[], indent: string) => {
for (const node of nodes) {
const isFolder = node.fileType === 'custom/folder';
const icon = isFolder ? '📁' : '📄';
const source = node.sourceTaskIdentifier ? `${node.sourceTaskIdentifier}` : '';
const sizeStr = !isFolder && node.size ? ` ${node.size} chars` : '';
lines.push(
`${indent}${icon} ${node.title || 'Untitled'} (${node.documentId})${source}${sizeStr}`,
);
if (node.children) {
renderNodes(node.children, indent + ' ');
}
}
};
renderNodes(t.workspace, ' ');
}
// Activities
const timelineEntries: { text: string; time: number }[] = [];
if (t.timeline?.topics) {
for (const tp of t.timeline.topics) {
const status = tp.status || 'completed';
const idSuffix = tp.id ? ` ${tp.id}` : '';
timelineEntries.push({
text: ` 💬 ${tp.time || ''} Topic #${tp.seq || '?'} ${tp.title || 'Untitled'} ${statusIcon(status)} ${status}${idSuffix}`,
time: 0,
});
}
}
if (t.timeline?.briefs) {
for (const b of t.timeline.briefs) {
let resolved = '';
if (b.resolvedAction) {
resolved = ` ✏️ ${b.resolvedAction}`;
}
const priStr = b.priority ? ` [${b.priority}]` : '';
const idSuffix = b.id ? ` ${b.id}` : '';
timelineEntries.push({
text: ` ${briefIcon(b.type)} ${b.time || ''} Brief [${b.type}] ${b.title}${priStr}${resolved}${idSuffix}`,
time: 0,
});
}
}
if (t.timeline?.comments) {
for (const c of t.timeline.comments) {
const author = c.agentId ? '🤖 agent' : '👤 user';
const truncated = c.content.length > 80 ? c.content.slice(0, 80) + '...' : c.content;
timelineEntries.push({
text: ` 💭 ${c.time || ''} ${author} ${truncated}`,
time: 0,
});
}
}
if (timelineEntries.length > 0) {
lines.push('');
lines.push('Activities:');
lines.push(...timelineEntries.map((e) => e.text));
}
return lines.join('\n');
};
/**
* Format editTask response
*/
export const formatTaskEdited = (identifier: string, changes: string[]): string =>
`Task ${identifier} updated:\n ${changes.join('\n ')}`;
/**
* Format dependency change response
*/
export const formatDependencyAdded = (task: string, dependsOn: string): string =>
`Dependency added: ${task} now blocks on ${dependsOn}.\n${task} will not start until ${dependsOn} is completed.`;
export const formatDependencyRemoved = (task: string, dependsOn: string): string =>
`Dependency removed: ${task} no longer blocks on ${dependsOn}.`;
/**
* Format brief created response
*/
export const formatBriefCreated = (args: {
id: string;
priority: string;
summary: string;
title: string;
type: string;
}): string =>
`Brief created (${args.type}, ${args.priority}):\n "${args.title}"\n ${args.summary}\n\nBrief ID: ${args.id}`;
/**
* Format checkpoint response
*/
export const formatCheckpointCreated = (reason: string): string =>
`Checkpoint created. Task is now paused and waiting for user review.\n\nReason: ${reason}\n\nThe user will see this as a "decision" brief and can resume the task after review.`;
// ── Task Run Prompt Builder ──
export interface TaskRunPromptComment {
agentId?: string | null;
content: string;
createdAt?: string;
id?: string;
}
export interface TaskRunPromptTopic {
createdAt: string;
handoff?: {
keyFindings?: string[];
nextAction?: string;
summary?: string;
title?: string;
} | null;
id?: string;
seq?: number | null;
status?: string | null;
title?: string | null;
}
export interface TaskRunPromptBrief {
createdAt: string;
id?: string;
priority?: string | null;
resolvedAction?: string | null;
resolvedAt?: string | null;
resolvedComment?: string | null;
summary: string;
title: string;
type: string;
}
export interface TaskRunPromptSubtask {
createdAt?: string;
id?: string;
identifier: string;
name?: string | null;
status: string;
}
export interface TaskRunPromptWorkspaceNode {
children?: TaskRunPromptWorkspaceNode[];
createdAt?: string;
documentId: string;
fileType?: string;
size?: number;
sourceTaskIdentifier?: string;
title?: string;
}
export interface TaskRunPromptInput {
/** Activity data (all optional) */
activities?: {
briefs?: TaskRunPromptBrief[];
comments?: TaskRunPromptComment[];
subtasks?: TaskRunPromptSubtask[];
topics?: TaskRunPromptTopic[];
};
/** --prompt flag content */
extraPrompt?: string;
/** Parent task context (when current task is a subtask) */
parentTask?: {
identifier: string;
instruction: string;
name?: string | null;
subtasks?: Array<TaskSummary & { blockedBy?: string }>;
};
/** Task data */
task: {
assigneeAgentId?: string | null;
dependencies?: Array<{ dependsOn: string; type: string }>;
description?: string | null;
id: string;
identifier: string;
instruction: string;
name?: string | null;
parentIdentifier?: string | null;
priority?: number | null;
review?: {
enabled?: boolean;
maxIterations?: number;
rubrics?: Array<{ name: string; threshold?: number; type: string }>;
} | null;
status: string;
subtasks?: Array<TaskSummary & { blockedBy?: string }>;
};
/** Pinned documents (workspace) */
workspace?: TaskRunPromptWorkspaceNode[];
}
// ── Relative time helper ──
const timeAgo = (dateStr: string, now?: Date): string => {
const date = new Date(dateStr);
const ref = now || new Date();
const diffMs = ref.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
const diffDay = Math.floor(diffHr / 24);
return `${diffDay}d ago`;
};
// ── Brief icon ──
const briefIcon = (type: string): string => {
switch (type) {
case 'decision': {
return '📋';
}
case 'result': {
return '✅';
}
case 'insight': {
return '💡';
}
case 'error': {
return '❌';
}
default: {
return '📌';
}
}
};
/**
* Build the prompt for task.run — injected as user message to the Agent.
*
* Priority order:
* 1. High Priority Instruction (--prompt) — the most important directive for this run
* 2. User Feedback (user comments only, full content) — what the user wants
* 3. Activities (topics + briefs + comments + subtasks, chronological) — full timeline
* 4. Original Task (instruction + description) — the base requirement
*/
export const buildTaskRunPrompt = (input: TaskRunPromptInput, now?: Date): string => {
const { task, activities, extraPrompt, workspace, parentTask } = input;
const sections: string[] = [];
// ── 1. High Priority Instruction ──
if (extraPrompt) {
sections.push(`<high_priority_instruction>\n${extraPrompt}\n</high_priority_instruction>`);
}
// ── 2. User Feedback (user comments only, full content) ──
const userComments = activities?.comments?.filter((c) => !c.agentId);
if (userComments && userComments.length > 0) {
const lines = userComments.map((c) => {
const ago = c.createdAt ? timeAgo(c.createdAt, now) : '';
const timeAttr = ago ? ` time="${ago}"` : '';
const idAttr = c.id ? ` id="${c.id}"` : '';
return `<comment${idAttr}${timeAttr}>${c.content}</comment>`;
});
sections.push(`<user_feedback>\n${lines.join('\n')}\n</user_feedback>`);
}
// ── 3. Task context (full detail so agent doesn't need to call viewTask) ──
const taskLines = [
`<task>`,
`<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>`,
`${task.identifier} ${task.name || task.identifier}`,
`Status: ${statusIcon(task.status)} ${task.status} Priority: ${priorityLabel(task.priority)}`,
`Instruction: ${task.instruction}`,
];
if (task.description) taskLines.push(`Description: ${task.description}`);
if (task.assigneeAgentId) taskLines.push(`Agent: ${task.assigneeAgentId}`);
if (task.parentIdentifier) taskLines.push(`Parent: ${task.parentIdentifier}`);
const topicCount = activities?.topics?.length ?? 0;
if (topicCount > 0) taskLines.push(`Topics: ${topicCount}`);
if (task.dependencies && task.dependencies.length > 0) {
taskLines.push(
`Dependencies: ${task.dependencies.map((d) => `${d.type}: ${d.dependsOn}`).join(', ')}`,
);
}
// Subtasks
if (task.subtasks && task.subtasks.length > 0) {
taskLines.push('');
taskLines.push('Subtasks:');
for (const s of task.subtasks) {
const dep = s.blockedBy ? ` ← blocks: ${s.blockedBy}` : '';
taskLines.push(
` ${s.identifier} ${statusIcon(s.status)} ${s.status} ${s.name || '(unnamed)'}${dep}`,
);
}
}
// Review
taskLines.push('');
if (task.review?.enabled && task.review.rubrics && task.review.rubrics.length > 0) {
taskLines.push(`Review (maxIterations: ${task.review.maxIterations || 3}):`);
for (const r of task.review.rubrics) {
taskLines.push(
` - ${r.name} [${r.type}]${r.threshold ? `${Math.round(r.threshold * 100)}%` : ''}`,
);
}
} else {
taskLines.push('Review: (not configured)');
}
// Workspace
if (workspace && workspace.length > 0) {
const countNodes = (nodes: TaskRunPromptWorkspaceNode[]): number =>
nodes.reduce((sum, n) => sum + 1 + (n.children ? countNodes(n.children) : 0), 0);
const total = countNodes(workspace);
taskLines.push('');
taskLines.push(`Workspace (${total}):`);
const renderNodes = (nodes: TaskRunPromptWorkspaceNode[], indent: string) => {
for (const node of nodes) {
const isFolder = node.fileType === 'custom/folder';
const icon = isFolder ? '📁' : '📄';
const source = node.sourceTaskIdentifier ? `${node.sourceTaskIdentifier}` : '';
const sizeStr = !isFolder && node.size ? ` ${node.size} chars` : '';
const ago = node.createdAt ? ` ${timeAgo(node.createdAt, now)}` : '';
taskLines.push(
`${indent}${icon} ${node.title || 'Untitled'} (${node.documentId})${source}${sizeStr}${ago}`,
);
if (node.children) {
renderNodes(node.children, indent + ' ');
}
}
};
renderNodes(workspace, ' ');
}
// Activities (chronological, flat list)
const timelineEntries: { text: string; time: number }[] = [];
if (activities?.topics) {
for (const t of activities.topics) {
const ago = timeAgo(t.createdAt, now);
const status = t.status || 'completed';
const title = t.title || t.handoff?.title || 'Untitled';
const idSuffix = t.id ? ` ${t.id}` : '';
timelineEntries.push({
text: ` 💬 ${ago} Topic #${t.seq || '?'} ${title} ${statusIcon(status)} ${status}${idSuffix}`,
time: new Date(t.createdAt).getTime(),
});
}
}
if (activities?.briefs) {
for (const b of activities.briefs) {
const ago = timeAgo(b.createdAt, now);
let resolved = '';
if (b.resolvedAt && b.resolvedAction) {
resolved = b.resolvedComment ? ` ✏️ ${b.resolvedComment}` : `${b.resolvedAction}`;
}
const priStr = b.priority ? ` [${b.priority}]` : '';
const idSuffix = b.id ? ` ${b.id}` : '';
timelineEntries.push({
text: ` ${briefIcon(b.type)} ${ago} Brief [${b.type}] ${b.title}${priStr}${resolved}${idSuffix}`,
time: new Date(b.createdAt).getTime(),
});
}
}
if (activities?.comments) {
for (const c of activities.comments) {
const author = c.agentId ? '🤖 agent' : '👤 user';
const ago = c.createdAt ? timeAgo(c.createdAt, now) : '';
const truncated = c.content.length > 80 ? c.content.slice(0, 80) + '...' : c.content;
timelineEntries.push({
text: ` 💭 ${ago} ${author} ${truncated}`,
time: c.createdAt ? new Date(c.createdAt).getTime() : 0,
});
}
}
if (timelineEntries.length > 0) {
timelineEntries.sort((a, b) => a.time - b.time);
taskLines.push('');
taskLines.push('Activities:');
taskLines.push(...timelineEntries.map((e) => e.text));
}
// Parent task context
if (parentTask) {
taskLines.push('');
taskLines.push(
`<parentTask identifier="${parentTask.identifier}" name="${parentTask.name || parentTask.identifier}">`,
);
taskLines.push(` Instruction: ${parentTask.instruction}`);
if (parentTask.subtasks && parentTask.subtasks.length > 0) {
taskLines.push(` Subtasks (${parentTask.subtasks.length}):`);
for (const s of parentTask.subtasks) {
const dep = s.blockedBy ? ` ← blocks: ${s.blockedBy}` : '';
const marker = s.identifier === task.identifier ? ' ◀ current' : '';
taskLines.push(
` ${s.identifier} ${statusIcon(s.status)} ${s.status} ${s.name || '(unnamed)'}${dep}${marker}`,
);
}
}
taskLines.push('</parentTask>');
}
taskLines.push('</task>');
sections.push(taskLines.join('\n'));
return sections.join('\n\n');
};
export { priorityLabel, statusIcon };
+1 -1
View File
@@ -203,7 +203,7 @@ export const AgentChatConfigSchema = z
thinkingLevel3: z.enum(['low', 'medium', 'high']).optional(),
thinkingLevel4: z.enum(['minimal', 'high']).optional(),
thinkingLevel5: z.enum(['minimal', 'low', 'medium', 'high']).optional(),
toolResultMaxLength: z.number().default(6000),
toolResultMaxLength: z.number().default(25000),
urlContext: z.boolean().optional(),
useModelBuiltinSearch: z.boolean().optional(),
})
+32
View File
@@ -0,0 +1,32 @@
export interface BriefAction {
/** Action identifier, e.g. 'approve', 'reject', 'feedback' */
key: string;
/** Display label, e.g. "✅ 确认开始", "💬 修改意见" */
label: string;
/**
* Action type:
* - 'resolve': directly mark brief as resolved
* - 'comment': prompt for text input, then resolve
* - 'link': navigate to a URL (no resolution)
*/
type: 'resolve' | 'comment' | 'link';
/** URL for 'link' type actions */
url?: string;
}
/** Default actions by brief type */
export const DEFAULT_BRIEF_ACTIONS: Record<string, BriefAction[]> = {
decision: [
{ key: 'approve', label: '✅ 确认', type: 'resolve' },
{ key: 'feedback', label: '💬 修改意见', type: 'comment' },
],
error: [
{ key: 'retry', label: '🔄 重试', type: 'resolve' },
{ key: 'feedback', label: '💬 反馈', type: 'comment' },
],
insight: [{ key: 'acknowledge', label: '👍 知悉', type: 'resolve' }],
result: [
{ key: 'approve', label: '✅ 通过', type: 'resolve' },
{ key: 'feedback', label: '💬 修改意见', type: 'comment' },
],
};
+2
View File
@@ -6,6 +6,7 @@ export * from './aiProvider';
export * from './artifact';
export * from './asyncTask';
export * from './auth';
export * from './brief';
export * from './chunk';
export * from './clientDB';
export * from './conversation';
@@ -31,6 +32,7 @@ export * from './service';
export * from './session';
export * from './skill';
export * from './stepContext';
export * from './task';
export * from './tool';
export * from './topic';
export * from './user';
+105
View File
@@ -0,0 +1,105 @@
export interface CheckpointConfig {
onAgentRequest?: boolean;
tasks?: {
afterIds?: string[];
beforeIds?: string[];
};
topic?: {
after?: boolean;
before?: boolean;
};
}
export interface WorkspaceDocNode {
charCount: number | null;
createdAt: string;
fileType: string;
parentId: string | null;
pinnedBy: string;
sourceTaskIdentifier: string | null;
title: string;
updatedAt: string | null;
}
export interface WorkspaceTreeNode {
children: WorkspaceTreeNode[];
id: string;
}
export interface WorkspaceData {
nodeMap: Record<string, WorkspaceDocNode>;
tree: WorkspaceTreeNode[];
}
// ── Task Detail (shared across CLI, viewTask tool, task.detail router) ──
export interface TaskDetailSubtask {
blockedBy?: string;
identifier: string;
name?: string | null;
priority?: number | null;
status: string;
}
export interface TaskDetailWorkspaceNode {
children?: TaskDetailWorkspaceNode[];
documentId: string;
fileType?: string;
size?: number | null;
sourceTaskIdentifier?: string | null;
title?: string;
}
export interface TaskDetailTopic {
id?: string;
seq?: number | null;
status?: string | null;
time?: string;
title?: string;
}
export interface TaskDetailBrief {
id?: string;
priority?: string | null;
resolvedAction?: string | null;
summary?: string;
time?: string;
title: string;
type: string;
}
export interface TaskDetailComment {
agentId?: string | null;
content: string;
time?: string;
}
export interface TaskDetailData {
agentId?: string | null;
checkpoint?: CheckpointConfig;
createdAt?: string;
dependencies?: Array<{ dependsOn: string; type: string }>;
description?: string | null;
error?: string | null;
heartbeat?: {
interval?: number | null;
lastAt?: string | null;
timeout?: number | null;
};
identifier: string;
instruction: string;
name?: string | null;
parent?: { identifier: string; name: string | null } | null;
priority?: number | null;
review?: Record<string, any> | null;
status: string;
subtasks?: TaskDetailSubtask[];
timeline?: {
briefs?: TaskDetailBrief[];
comments?: TaskDetailComment[];
topics?: TaskDetailTopic[];
};
topicCount?: number;
userId?: string | null;
workspace?: TaskDetailWorkspaceNode[];
}
@@ -4,7 +4,6 @@ import { after } from 'next/server';
import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { type BotProviderConfig, discord } from '@/server/services/bot/platforms';
import { BotConnectQueue } from '@/server/services/gateway/botConnectQueue';
@@ -23,10 +22,7 @@ function createDiscordBot(applicationId: string, credentials: Record<string, str
platform: 'discord',
settings: {},
};
return discord.clientFactory.createClient(config, {
appUrl: process.env.APP_URL,
redisClient: getAgentRuntimeRedisClient() as any,
});
return discord.clientFactory.createClient(config, { appUrl: process.env.APP_URL });
}
async function processConnectQueue(remainingMs: number): Promise<number> {
@@ -1,128 +0,0 @@
import debug from 'debug';
import type { NextRequest } from 'next/server';
import { after } from 'next/server';
import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { type BotProviderConfig, wechat } from '@/server/services/bot/platforms';
import { BotConnectQueue } from '@/server/services/gateway/botConnectQueue';
const log = debug('lobe-server:bot:gateway:cron:wechat');
const GATEWAY_DURATION_MS = 600_000; // 10 minutes
const POLL_INTERVAL_MS = 30_000; // 30 seconds
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function createWechatBot(applicationId: string, credentials: Record<string, string>) {
const config: BotProviderConfig = {
applicationId,
credentials,
platform: 'wechat',
settings: {},
};
return wechat.clientFactory.createClient(config, { appUrl: process.env.APP_URL });
}
async function processConnectQueue(remainingMs: number): Promise<number> {
const queue = new BotConnectQueue();
const items = await queue.popAll();
const wechatItems = items.filter((item) => item.platform === 'wechat');
if (wechatItems.length === 0) return 0;
log('Processing %d queued wechat connect requests', wechatItems.length);
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
let processed = 0;
for (const item of wechatItems) {
try {
const model = new AgentBotProviderModel(serverDB, item.userId, gateKeeper);
const provider = await model.findEnabledByApplicationId('wechat', item.applicationId);
if (!provider) {
log('No enabled provider found for queued appId=%s', item.applicationId);
await queue.remove('wechat', item.applicationId);
continue;
}
const bot = createWechatBot(provider.applicationId, provider.credentials);
await bot.start({
durationMs: remainingMs,
waitUntil: (task: Promise<any>) => {
after(() => task);
},
});
processed++;
log('Started queued bot appId=%s', item.applicationId);
} catch (err) {
log('Failed to start queued bot appId=%s: %O', item.applicationId, err);
}
await queue.remove('wechat', item.applicationId);
}
return processed;
}
export async function GET(request: NextRequest) {
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response('Unauthorized', { status: 401 });
}
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
const providers = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
'wechat',
gateKeeper,
);
log('Found %d enabled WeChat providers', providers.length);
let started = 0;
for (const provider of providers) {
const { applicationId, credentials } = provider;
try {
const bot = createWechatBot(applicationId, credentials);
await bot.start({
durationMs: GATEWAY_DURATION_MS,
waitUntil: (task: Promise<any>) => {
after(() => task);
},
});
started++;
log('Started gateway listener for appId=%s', applicationId);
} catch (err) {
log('Failed to start gateway listener for appId=%s: %O', applicationId, err);
}
}
// Process any queued connect requests immediately
const queued = await processConnectQueue(GATEWAY_DURATION_MS);
// Poll for new connect requests in background
after(async () => {
const pollEnd = Date.now() + GATEWAY_DURATION_MS;
while (Date.now() < pollEnd) {
await sleep(POLL_INTERVAL_MS);
if (Date.now() >= pollEnd) break;
const remaining = pollEnd - Date.now();
await processConnectQueue(remaining);
}
});
return Response.json({ queued, started, total: providers.length });
}
@@ -35,10 +35,10 @@ export async function POST(request: Request): Promise<Response> {
progressMessageId,
);
if (!type || !applicationId || !platformThreadId) {
if (!type || !applicationId || !platformThreadId || !progressMessageId) {
return NextResponse.json(
{
error: 'Missing required fields: type, applicationId, platformThreadId',
error: 'Missing required fields: type, applicationId, platformThreadId, progressMessageId',
},
{ status: 400 },
);
@@ -26,7 +26,7 @@ const FailedPage = () => {
<Flexbox gap={8}>
<Text fontSize={16} type="secondary">
{t('error.desc', {
reason: t(`error.reason.${reason}` as any, { defaultValue: reason ?? '' }),
reason: t(`error.reason.${reason}` as any, { defaultValue: reason }),
})}
</Text>
{!!errorMessage && <Highlighter language={'log'}>{errorMessage}</Highlighter>}
@@ -79,7 +79,7 @@ const ToolTitle = memo<ToolTitleProps>(
const pluginMeta = useToolStore(toolSelectors.getMetaById(identifier), isEqual);
const isBuiltinPlugin = builtinToolIdentifiers.includes(identifier);
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin');
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? identifier;
const params = useMemo(() => {
const argsToUse = args || partialArgs || {};
@@ -64,8 +64,7 @@ export const usePluginContext = (): PluginContext => {
return findTopicAcrossAllSessions(state.topicDataMap, topicId);
},
t: (key: string, options?: Record<string, unknown>) =>
t(key as any, options as any) as string,
t: (key: string, options?: Record<string, unknown>) => t(key as any, options) as string,
}),
[agentMap, topicDataMap, sessionGroups, documents, t],
);
+1 -1
View File
@@ -18,7 +18,7 @@ const PluginAvatar = memo<PluginAvatarProps>(({ identifier, size = 32 }) => {
const pluginMeta = useToolStore(toolSelectors.getMetaById(identifier), isEqual);
const pluginAvatar = pluginHelpers.getPluginAvatar(pluginMeta);
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin');
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? identifier;
return pluginAvatar ? (
<Avatar alt={pluginTitle} avatar={pluginAvatar} size={size} />
+1 -1
View File
@@ -29,7 +29,7 @@ const LocalForm = memo<{ form: FormInstance; mode?: 'edit' | 'create' }>(({ form
message: t('dev.meta.identifier.pattenErrorMessage'),
pattern: /^[\w-]+$/,
},
// In edit mode, skip duplicate validation
// 编辑模式下,不进行重复校验
isEditMode
? {}
: {
@@ -33,11 +33,11 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
}));
interface CollapsibleSectionProps {
/** Child component content */
/** 子组件内容 */
children: ReactNode;
/** Whether expanded by default */
/** 默认是否展开 */
defaultExpanded?: boolean;
/** Title text */
/** 标题文本 */
title: string;
}
@@ -28,10 +28,10 @@ const STDIO_ARGS = ['customParams', 'mcp', 'args'];
const STDIO_ENV = ['customParams', 'mcp', 'env'];
const MCP_TYPE = ['customParams', 'mcp', 'type'];
const DESC_TYPE = ['customParams', 'description'];
// Authentication-related constants
// 新增认证相关常量
const AUTH_TYPE = ['customParams', 'mcp', 'auth', 'type'];
const AUTH_TOKEN = ['customParams', 'mcp', 'auth', 'token'];
// Headers-related constants
// 新增 headers 相关常量
const HEADERS = ['customParams', 'mcp', 'headers'];
const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
@@ -43,7 +43,7 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
const [isTesting, setIsTesting] = useState(false);
const testMcpConnection = useToolStore((s) => s.testMcpConnection);
// Use identifier to track test state (if present in the form)
// 使用 identifier 来跟踪测试状态(如果表单中有的话)
const formValues = form.getFieldsValue();
const identifier = formValues?.identifier || 'temp-test-id';
const testState = useToolStore(mcpStoreSelectors.getMCPConnectionTestState(identifier), isEqual);
@@ -63,7 +63,7 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
...(mcpType === 'http' ? [HTTP_URL_KEY] : [STDIO_COMMAND, STDIO_ARGS]),
];
// For HTTP type, also validate authentication fields
// 如果是 HTTP 类型,还需要验证认证字段
if (mcpType === 'http') {
fieldsToValidate.push(AUTH_TYPE);
const currentAuthType = form.getFieldValue(AUTH_TYPE);
@@ -90,7 +90,7 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
const description = values.customParams?.description;
const avatar = values.customParams?.avatar;
// Use mcpStore's testMcpConnection method
// 使用 mcpStore testMcpConnection 方法
const result = await testMcpConnection({
connection: mcp,
identifier: id,
@@ -101,10 +101,10 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
// Optionally update form if manifest ID differs or to store the fetched manifest
// Be careful about overwriting user input if not desired
form.setFieldsValue({ manifest: result.manifest });
setConnectionError(null); // Clear local error state
setConnectionError(null); // 清除本地错误状态
setErrorMetadata(null);
} else if (result.error) {
// Store has already handled the error state; optionally show additional user-friendly messages here
// Store 已经处理了错误状态,这里可以选择显示额外的用户友好提示
const errorMessage = t('error.testConnectionFailed', {
error: result.error,
});
@@ -199,7 +199,7 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
validator: async (_, value) => {
if (!value) return true;
// Throws automatically if the value is not a valid URL
// 如果不是 URL 就会自动抛出错误
new URL(value);
},
},
@@ -2,7 +2,7 @@ import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { safeParseJSON } from '@/utils/safeParseJSON';
// (McpConfig, McpServers, ParsedMcpInput interface definitions remain unchanged)
// McpConfig, McpServers, ParsedMcpInput 接口定义保持不变)
interface McpConfig {
args?: string[];
command?: string;
@@ -18,7 +18,7 @@ interface ParsedMcpInput {
mcpServers?: McpServers;
}
// Removed DuplicateIdentifier
// 移除 DuplicateIdentifier
export enum McpParseErrorCode {
EmptyMcpServers = 'EmptyMcpServers',
InvalidJsonStructure = 'InvalidJsonStructure',
@@ -26,7 +26,7 @@ export enum McpParseErrorCode {
ManifestNotSupported = 'ManifestNotSupported',
}
// Removed isDuplicate
// 移除 isDuplicate
interface ParseSuccessResult {
identifier: string;
mcpConfig: McpConfig & { type: 'stdio' | 'http' };
@@ -35,7 +35,7 @@ interface ParseSuccessResult {
interface ParseErrorResult {
errorCode: McpParseErrorCode;
// identifier field may still be useful for displaying the user-input ID when structure errors occur
// identifier 字段仍然可能有用,用于在结构错误时也能显示用户输入的 ID
identifier?: string;
status: 'error';
}
@@ -60,7 +60,7 @@ export const parseMcpInput = (value: string): ParseResult => {
if (mcpKeys.length > 0) {
const identifier = mcpKeys[0];
// @ts-expect-error type mismatch
// @ts-expect-error type 不一样
const mcpConfig = parsedJson.mcpServers[identifier];
if (mcpConfig && typeof mcpConfig === 'object' && !Array.isArray(mcpConfig)) {
@@ -89,7 +89,7 @@ const UrlManifestForm = memo<{ form: FormInstance; isEditMode: boolean }>(
}
},
},
// In edit mode, skip duplicate validation
// 编辑模式下,不进行重复校验
isEditMode
? {}
: {
@@ -28,7 +28,7 @@ const ArtifactItem = memo<ArtifactItemProps>(({ payload, messageId, identifier =
const pluginMeta = useToolStore(toolSelectors.getMetaById(identifier), isEqual);
const isToolHasUI = useToolStore(toolSelectors.isToolHasUI(identifier));
const openToolUI = useChatStore((s) => s.openToolUI);
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin');
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? identifier;
return (
<Flexbox

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