mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddf058fb36 | |||
| 78dda79f81 | |||
| f6081c9914 | |||
| d6f11f80b6 | |||
| 1c75686b70 | |||
| 7e89fa782d | |||
| 18bc2716b2 | |||
| 636a3b77c3 | |||
| c70ac84da7 | |||
| 116495bd1e | |||
| 922f7ace41 | |||
| b369c53bda | |||
| 5ecccf4b9e | |||
| f9fbd45fee | |||
| 0b490a7268 | |||
| a9c5badb80 | |||
| cd0f65210c | |||
| 24be35fd84 | |||
| 46adf43453 | |||
| f0a811ef83 | |||
| 10914ff015 | |||
| b857ae6c57 | |||
| e11c89fc48 | |||
| b9a136f9f1 | |||
| 809e1e0716 | |||
| 7953cf5b5a | |||
| 012214205e | |||
| f0f2feb015 | |||
| f439fb913a | |||
| 6966d366d1 | |||
| f89adb36b3 |
+4
-2
@@ -413,7 +413,9 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# #### Message Gateway (IM Integration) ##
|
||||
# #######################################
|
||||
|
||||
# URL of the message-gateway Cloudflare Worker for unified IM platform connection management
|
||||
# When set, LobeHub delegates all platform connections to the external gateway
|
||||
# External message-gateway for unified IM platform connection management.
|
||||
# Set ENABLED=1 to activate. To migrate away, remove ENABLED first (keep URL/TOKEN)
|
||||
# so LobeHub can automatically disconnect leftover gateway connections.
|
||||
# MESSAGE_GATEWAY_ENABLED=1
|
||||
# MESSAGE_GATEWAY_URL=https://message-gateway.lobehub.com
|
||||
# MESSAGE_GATEWAY_SERVICE_TOKEN=your_service_token_here
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.4" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.6" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -37,7 +37,25 @@ export async function getAuthInfo(): Promise<AuthInfo> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers' | 'serverUrl'>> {
|
||||
export type AgentStreamTokenType = 'jwt' | 'apiKey';
|
||||
|
||||
export interface AgentStreamAuthInfo {
|
||||
headers: Record<string, string>;
|
||||
serverUrl: string;
|
||||
/**
|
||||
* Raw token value (without header prefix). Used for WebSocket auth messages
|
||||
* where header-based auth is not available.
|
||||
*/
|
||||
token: string;
|
||||
/**
|
||||
* How the token should be verified by downstream services (agent gateway WS).
|
||||
* jwt → validate with JWKS
|
||||
* apiKey → validate by calling /api/v1/users/me
|
||||
*/
|
||||
tokenType: AgentStreamTokenType;
|
||||
}
|
||||
|
||||
export async function getAgentStreamAuthInfo(): Promise<AgentStreamAuthInfo> {
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
const envJwt = process.env.LOBEHUB_JWT;
|
||||
@@ -45,6 +63,8 @@ export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers'
|
||||
return {
|
||||
headers: { 'Oidc-Auth': envJwt },
|
||||
serverUrl,
|
||||
token: envJwt,
|
||||
tokenType: 'jwt',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +73,8 @@ export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers'
|
||||
return {
|
||||
headers: { 'X-API-Key': envApiKey },
|
||||
serverUrl,
|
||||
token: envApiKey,
|
||||
tokenType: 'apiKey',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,11 +86,15 @@ export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers'
|
||||
return {
|
||||
headers: {},
|
||||
serverUrl,
|
||||
token: '',
|
||||
tokenType: 'jwt',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
headers: { 'Oidc-Auth': result.credentials.accessToken },
|
||||
serverUrl,
|
||||
token: result.credentials.accessToken,
|
||||
tokenType: 'jwt',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,6 +258,10 @@ export function registerAgentCommand(program: Command) {
|
||||
'--device <target>',
|
||||
'Target device ID, or use "local" for the current connected device',
|
||||
)
|
||||
.option(
|
||||
'--no-headless',
|
||||
"Disable headless mode and wait for human approval on tool calls (default: headless — tools auto-run, matching the CLI's non-interactive nature)",
|
||||
)
|
||||
.option('--json', 'Output full JSON event stream')
|
||||
.option('-v, --verbose', 'Show detailed tool call info')
|
||||
.option('--replay <file>', 'Replay events from a saved JSON file (offline)')
|
||||
@@ -267,6 +271,7 @@ export function registerAgentCommand(program: Command) {
|
||||
agentId?: string;
|
||||
autoStart?: boolean;
|
||||
device?: string;
|
||||
headless?: boolean;
|
||||
json?: boolean;
|
||||
prompt?: string;
|
||||
replay?: string;
|
||||
@@ -340,6 +345,11 @@ export function registerAgentCommand(program: Command) {
|
||||
if (options.slug) input.slug = options.slug;
|
||||
if (options.topicId) input.appContext = { topicId: options.topicId };
|
||||
if (options.autoStart === false) input.autoStart = false;
|
||||
// commander's --no-headless sets `headless` to false. Anything else
|
||||
// (undefined, true) → headless mode is on and tool calls auto-execute.
|
||||
if (options.headless !== false) {
|
||||
input.userInterventionConfig = { approvalMode: 'headless' };
|
||||
}
|
||||
|
||||
const result = await client.aiAgent.execAgent.mutate(input as any);
|
||||
const r = result as any;
|
||||
@@ -355,16 +365,17 @@ export function registerAgentCommand(program: Command) {
|
||||
}
|
||||
|
||||
// 2. Connect to stream (WebSocket via Gateway, or fallback to SSE)
|
||||
const { serverUrl, headers } = await getAgentStreamAuthInfo();
|
||||
const { serverUrl, headers, token, tokenType } = await getAgentStreamAuthInfo();
|
||||
const agentGatewayUrl = options.sse ? undefined : resolveAgentGatewayUrl();
|
||||
|
||||
if (agentGatewayUrl) {
|
||||
const token = headers['Oidc-Auth'] || headers['X-API-Key'] || '';
|
||||
await streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: agentGatewayUrl,
|
||||
json: options.json,
|
||||
operationId,
|
||||
serverUrl,
|
||||
token,
|
||||
tokenType,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -270,6 +270,48 @@ describe('generate command', () => {
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Video generation started'));
|
||||
});
|
||||
|
||||
it('should pass image-to-video params', async () => {
|
||||
mockTrpcClient.generationTopic.createTopic.mutate.mockResolvedValue('topic-3');
|
||||
mockTrpcClient.video.createVideo.mutate.mockResolvedValue({
|
||||
data: { generationId: 'gen-v2' },
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'video',
|
||||
'a cat waving',
|
||||
'--model',
|
||||
'cogvideox',
|
||||
'--provider',
|
||||
'zhipu',
|
||||
'--image',
|
||||
'https://example.com/first.png',
|
||||
'--end-image',
|
||||
'https://example.com/last.png',
|
||||
'--images',
|
||||
'https://example.com/a.png',
|
||||
'https://example.com/b.png',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.video.createVideo.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
generationTopicId: 'topic-3',
|
||||
model: 'cogvideox',
|
||||
params: {
|
||||
endImageUrl: 'https://example.com/last.png',
|
||||
imageUrl: 'https://example.com/first.png',
|
||||
imageUrls: ['https://example.com/a.png', 'https://example.com/b.png'],
|
||||
prompt: 'a cat waving',
|
||||
},
|
||||
provider: 'zhipu',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tts', () => {
|
||||
|
||||
@@ -6,13 +6,16 @@ import { getTrpcClient } from '../../api/client';
|
||||
export function registerVideoCommand(parent: Command) {
|
||||
parent
|
||||
.command('video <prompt>')
|
||||
.description('Generate a video from text')
|
||||
.description('Generate a video from text or image(s)')
|
||||
.requiredOption('-m, --model <model>', 'Model ID')
|
||||
.requiredOption('-p, --provider <provider>', 'Provider name')
|
||||
.option('--aspect-ratio <ratio>', 'Aspect ratio (e.g. 16:9)')
|
||||
.option('--duration <sec>', 'Duration in seconds')
|
||||
.option('--resolution <res>', 'Resolution (e.g. 720p, 1080p)')
|
||||
.option('--seed <n>', 'Random seed')
|
||||
.option('--image <url>', 'First-frame image URL (image-to-video)')
|
||||
.option('--images <urls...>', 'Multiple reference image URLs')
|
||||
.option('--end-image <url>', 'Last-frame image URL')
|
||||
.option('--json', 'Output raw JSON')
|
||||
.action(
|
||||
async (
|
||||
@@ -20,6 +23,9 @@ export function registerVideoCommand(parent: Command) {
|
||||
options: {
|
||||
aspectRatio?: string;
|
||||
duration?: string;
|
||||
endImage?: string;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
json?: boolean;
|
||||
model: string;
|
||||
provider: string;
|
||||
@@ -35,6 +41,9 @@ export function registerVideoCommand(parent: Command) {
|
||||
if (options.duration) params.duration = Number.parseInt(options.duration, 10);
|
||||
if (options.resolution) params.resolution = options.resolution;
|
||||
if (options.seed) params.seed = Number.parseInt(options.seed, 10);
|
||||
if (options.image) params.imageUrl = options.image;
|
||||
if (options.images && options.images.length > 0) params.imageUrls = options.images;
|
||||
if (options.endImage) params.endImageUrl = options.endImage;
|
||||
|
||||
const result = await client.video.createVideo.mutate({
|
||||
generationTopicId: topicId as string,
|
||||
|
||||
@@ -279,8 +279,10 @@ describe('streamAgentEventsViaWebSocket', () => {
|
||||
await flush();
|
||||
|
||||
const ws = capturedWs!;
|
||||
// Note: serverUrl is not set here, and JSON.stringify drops undefined keys,
|
||||
// so the parsed auth message will not contain a `serverUrl` field.
|
||||
expect(ws.sent.map((s) => JSON.parse(s))).toEqual([
|
||||
{ token: 'test-token', type: 'auth' },
|
||||
{ token: 'test-token', tokenType: 'jwt', type: 'auth' },
|
||||
{ lastEventId: '', type: 'resume' },
|
||||
]);
|
||||
|
||||
@@ -288,6 +290,31 @@ describe('streamAgentEventsViaWebSocket', () => {
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should send tokenType=apiKey and serverUrl when the caller uses an API key', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'lh_sk_abc',
|
||||
tokenType: 'apiKey',
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const ws = capturedWs!;
|
||||
// serverUrl is forwarded so the gateway can call back to /api/v1/users/me
|
||||
// to verify the API key.
|
||||
expect(ws.sent.map((s) => JSON.parse(s))[0]).toEqual({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'lh_sk_abc',
|
||||
tokenType: 'apiKey',
|
||||
type: 'auth',
|
||||
});
|
||||
|
||||
ws.simulateMessage({ id: '1', type: 'session_complete' });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should render agent_event messages using existing renderEvent', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
|
||||
@@ -20,7 +20,18 @@ interface StreamOptions {
|
||||
interface WebSocketStreamOptions extends StreamOptions {
|
||||
gatewayUrl: string;
|
||||
operationId: string;
|
||||
/**
|
||||
* LobeHub server URL the gateway should call back to when verifying
|
||||
* an apiKey token (via `/api/v1/users/me`). Required when
|
||||
* `tokenType === 'apiKey'`; ignored for JWT.
|
||||
*/
|
||||
serverUrl?: string;
|
||||
token: string;
|
||||
/**
|
||||
* How the gateway should verify `token`. `jwt` is the default for
|
||||
* backwards compatibility with existing callers.
|
||||
*/
|
||||
tokenType?: 'jwt' | 'apiKey';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,13 +179,13 @@ const HEARTBEAT_INTERVAL = 30_000;
|
||||
export async function streamAgentEventsViaWebSocket(
|
||||
options: WebSocketStreamOptions,
|
||||
): Promise<void> {
|
||||
const { gatewayUrl, operationId, token, ...streamOpts } = options;
|
||||
const { gatewayUrl, operationId, serverUrl, token, tokenType = 'jwt', ...streamOpts } = options;
|
||||
const wsUrl = urlJoin(
|
||||
gatewayUrl.replace(/^http/, 'ws'),
|
||||
`/ws?operationId=${encodeURIComponent(operationId)}`,
|
||||
);
|
||||
|
||||
log.debug(`Connecting to gateway: ${wsUrl}`);
|
||||
log.debug(`Connecting to gateway: ${wsUrl} (auth: ${tokenType})`);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
@@ -192,7 +203,10 @@ export async function streamAgentEventsViaWebSocket(
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ token, type: 'auth' }));
|
||||
// `serverUrl` is required so the gateway can call back to verify an
|
||||
// apiKey token. Harmless (but unused) for JWT, so we always include it
|
||||
// when available to match the device-gateway-client contract.
|
||||
ws.send(JSON.stringify({ serverUrl, token, tokenType, type: 'auth' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BrowserWindow, shell } from 'electron';
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
import { appendVercelCookie } from '@/utils/http-headers';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
import { netFetch } from '@/utils/net-fetch';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
@@ -360,10 +361,10 @@ export default class AuthCtr extends ControllerModule {
|
||||
|
||||
logger.debug(`Polling for credentials: ${url.toString()}`);
|
||||
|
||||
// Send HTTP request directly
|
||||
// Use Electron net.fetch to respect system CA store (self-signed/private CA certs)
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
appendVercelCookie(headers);
|
||||
const response = await fetch(url.toString(), { headers, method: 'GET' });
|
||||
const response = await netFetch(url.toString(), { headers, method: 'GET' });
|
||||
|
||||
// Check response status
|
||||
if (response.status === 404) {
|
||||
@@ -481,7 +482,7 @@ export default class AuthCtr extends ControllerModule {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
};
|
||||
appendVercelCookie(tokenHeaders);
|
||||
const response = await fetch(tokenUrl.toString(), {
|
||||
const response = await netFetch(tokenUrl.toString(), {
|
||||
body,
|
||||
headers: tokenHeaders,
|
||||
method: 'POST',
|
||||
|
||||
@@ -48,6 +48,7 @@ import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
|
||||
import ContentSearchService from '@/services/contentSearchSrv';
|
||||
import FileSearchService from '@/services/fileSearchSrv';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
import { netFetch } from '@/utils/net-fetch';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
@@ -341,7 +342,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const response = await netFetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to download skill package: ${response.status} ${response.statusText}`,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
ShowDesktopNotificationParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { app, Notification } from 'electron';
|
||||
import { macOS, windows } from 'electron-is';
|
||||
import { linux, macOS, windows } from 'electron-is';
|
||||
|
||||
import { getIpcContext } from '@/utils/ipc';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
@@ -131,7 +131,12 @@ export default class NotificationCtr extends ControllerModule {
|
||||
silent: params.silent || false,
|
||||
timeoutType: 'default',
|
||||
title: params.title,
|
||||
urgency: 'normal',
|
||||
// On Linux/GNOME Shell, urgency 'normal' causes notifications to appear as banners.
|
||||
// Clicking the dismiss (X) button on such banners can freeze the system for 30-45 seconds
|
||||
// due to heavy gnome-shell processing. Using 'low' urgency routes notifications to the
|
||||
// message tray instead, preventing the banner's X button from being shown.
|
||||
// The urgency option is ignored on macOS and Windows.
|
||||
urgency: linux() ? 'low' : 'normal',
|
||||
});
|
||||
|
||||
// Add more event listeners for debugging
|
||||
|
||||
@@ -9,6 +9,7 @@ import { OFFICIAL_CLOUD_SERVER } from '@/const/env';
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
import { appendVercelCookie } from '@/utils/http-headers';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
import { netFetch } from '@/utils/net-fetch';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
@@ -485,7 +486,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
};
|
||||
appendVercelCookie(headers);
|
||||
const response = await fetch(tokenUrl.toString(), { body, headers, method: 'POST' });
|
||||
const response = await netFetch(tokenUrl.toString(), { body, headers, method: 'POST' });
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to parse error response
|
||||
|
||||
@@ -29,6 +29,11 @@ vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
net: {
|
||||
fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
global.fetch(input as any, init as any),
|
||||
),
|
||||
},
|
||||
shell: {
|
||||
openExternal: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
|
||||
@@ -5,11 +5,14 @@ import { type App } from '@/core/App';
|
||||
|
||||
import LocalFileCtr from '../LocalFileCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
const { ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.mock('@/utils/net-fetch', () => ({
|
||||
netFetch: fetchMock,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
@@ -37,8 +40,6 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
// Mock node:fs/promises and node:fs
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: vi.fn(),
|
||||
|
||||
@@ -41,6 +41,7 @@ vi.mock('electron', () => {
|
||||
|
||||
// Mock electron-is
|
||||
vi.mock('electron-is', () => ({
|
||||
linux: vi.fn(() => false),
|
||||
macOS: vi.fn(() => false),
|
||||
windows: vi.fn(() => false),
|
||||
}));
|
||||
@@ -180,6 +181,26 @@ describe('NotificationCtr', () => {
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should use low urgency on Linux to prevent GNOME Shell freeze', async () => {
|
||||
const { linux } = await import('electron-is');
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(linux).mockReturnValue(true);
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
mockBrowserWindow.isVisible.mockReturnValue(false);
|
||||
|
||||
const promise = controller.showDesktopNotification(params);
|
||||
vi.advanceTimersByTime(100);
|
||||
await promise;
|
||||
|
||||
expect(Notification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
urgency: 'low',
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(linux).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should show notification when window is minimized', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
|
||||
@@ -5,8 +5,13 @@ import type { App } from '@/core/App';
|
||||
|
||||
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
const { ipcMainHandleMock, mockFetch } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
mockFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/net-fetch', () => ({
|
||||
netFetch: mockFetch,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
@@ -420,13 +425,6 @@ describe('RemoteServerConfigCtr', () => {
|
||||
});
|
||||
|
||||
describe('refreshAccessToken', () => {
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
it('should return error when remote server is not active', async () => {
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'dataSyncConfig') {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BrowserWindow, type Session } from 'electron';
|
||||
import { isDev } from '@/const/env';
|
||||
import { appendVercelCookie } from '@/utils/http-headers';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
import { netFetch } from '@/utils/net-fetch';
|
||||
|
||||
interface BackendProxyProtocolManagerOptions {
|
||||
getAccessToken: () => Promise<string | undefined | null>;
|
||||
@@ -137,7 +138,7 @@ export class BackendProxyProtocolManager {
|
||||
|
||||
let upstreamResponse: Response;
|
||||
try {
|
||||
upstreamResponse = await fetch(rewrittenUrl, requestInit);
|
||||
upstreamResponse = await netFetch(rewrittenUrl, requestInit);
|
||||
} catch (error) {
|
||||
this.logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error);
|
||||
|
||||
|
||||
+5
@@ -43,6 +43,11 @@ vi.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
getAllWindows: vi.fn(),
|
||||
},
|
||||
net: {
|
||||
fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
global.fetch(input as any, init as any),
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BackendProxyProtocolManager', () => {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { net } from 'electron';
|
||||
|
||||
/**
|
||||
* Fetch using Electron's net module (Chromium networking stack).
|
||||
*
|
||||
* Unlike Node.js `fetch`, `net.fetch` respects the OS certificate store
|
||||
* (e.g. macOS Keychain, Windows Certificate Store), so self-signed or
|
||||
* private-CA certificates trusted at the system level work automatically.
|
||||
*
|
||||
* This must be called only after `app.whenReady()` has resolved.
|
||||
*/
|
||||
export const netFetch: typeof globalThis.fetch = (input, init?) => {
|
||||
return net.fetch(input as any, init as any);
|
||||
};
|
||||
@@ -466,5 +466,6 @@
|
||||
"https://github.com/user-attachments/assets/facdc83c-e789-4649-8060-7f7a10a1b1dd": "/blog/assets05b20e40c03ced0ec8707fed2e8e0f25.webp",
|
||||
"https://github.com/user-attachments/assets/fcdfb9c5-819a-488f-b28d-0857fe861219": "/blog/assets8477415ecec1f37e38ab38ff1217d0a7.webp",
|
||||
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
|
||||
"https://file.rene.wang/clipboard-1775701725582-123f8f8cf73f8.png": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp"
|
||||
"https://file.rene.wang/clipboard-1775701725582-123f8f8cf73f8.png": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"https://file.rene.wang/changlog-04-14.png": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: Agent Gateway & Customizable Sidebar
|
||||
description: >-
|
||||
Server-side agent execution via Gateway mode, customizable sidebar layout,
|
||||
agent workspace with document management, and new model support.
|
||||
tags:
|
||||
- Gateway
|
||||
- Sidebar
|
||||
- Agent Workspace
|
||||
- Task Manager
|
||||
---
|
||||
|
||||
# Agent Gateway & Customizable Sidebar
|
||||
|
||||
Server-side agent execution over WebSocket, a fully customizable sidebar, and a new agent workspace for managing documents and tasks.
|
||||
|
||||
## Key Updates
|
||||
|
||||
- Gateway mode: agents now execute server-side and stream results back over WebSocket, with auto-reconnect when switching topics and seamless resume after disconnects
|
||||
- Customizable sidebar: choose which items appear in the sidebar and reorder them through a new customize modal, plus a recents section with search, rename, and quick actions
|
||||
- Agent workspace: a right-side panel for managing agent documents — browse, rename, delete files, and view document history all in one place
|
||||
- Task manager: a dedicated task manager view with its own topic state, so running tasks no longer interfere with your main conversations
|
||||
- Prompt rewrite & translate: rewrite or translate your prompt directly in the chat input before sending
|
||||
- Desktop CLI: the LobeHub CLI is now embedded in the desktop app and can be installed to your PATH from settings
|
||||
- Screen capture: capture your screen with an overlay picker and attach it directly to a conversation
|
||||
- New models: GLM-5.1 from Zhipu, Seedance 2.0 video generation, and a new StreamLake provider
|
||||
|
||||
## Experience Improvements
|
||||
|
||||
- Desktop app now uses Electron's native fetch for remote requests, improving connection reliability
|
||||
- Loading states during optimistic updates prevent flickering when the assistant is thinking
|
||||
- Agent details pages load correctly on refresh instead of showing a perpetual spinner
|
||||
- Improved error classification for insufficient balance and deactivated accounts shows clearer messages
|
||||
- Fixed a context engine crash when non-string content was passed to document injection
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
title: Agent 网关与可自定义侧边栏
|
||||
description: 通过网关模式实现服务端智能体执行、可自定义侧边栏布局、带文档管理的智能体工作区,以及新模型支持。
|
||||
tags:
|
||||
- 网关
|
||||
- 侧边栏
|
||||
- 智能体工作区
|
||||
- 任务管理器
|
||||
---
|
||||
|
||||
# Agent 网关与可自定义侧边栏
|
||||
|
||||
通过 WebSocket 实现服务端智能体执行、完全可自定义的侧边栏,以及用于管理文档和任务的全新智能体工作区。
|
||||
|
||||
## 重要更新
|
||||
|
||||
- 网关模式:智能体现在在服务端执行并通过 WebSocket 实时推送结果,切换话题时自动重连,断线后无缝恢复
|
||||
- 可自定义侧边栏:通过新的自定义弹窗选择侧边栏显示哪些项目并调整排序,还新增了支持搜索、重命名和快捷操作的「最近」板块
|
||||
- 智能体工作区:右侧面板用于管理智能体文档 —— 在同一界面中浏览、重命名、删除文件并查看文档历史
|
||||
- 任务管理器:专属的任务管理视图拥有独立的话题状态,运行中的任务不再干扰你的主要对话
|
||||
- 提示词改写与翻译:发送前可直接在聊天输入框中改写或翻译你的提示词
|
||||
- 桌面端 CLI:LobeHub CLI 现已内嵌在桌面应用中,可从设置中安装到系统 PATH
|
||||
- 屏幕截图:使用覆盖层选择器截取屏幕内容,直接附加到对话中
|
||||
- 新模型:智谱 GLM-5.1、Seedance 2.0 视频生成,以及新的 StreamLake 提供商
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 桌面应用现使用 Electron 原生 fetch 进行远程请求,提升连接稳定性
|
||||
- 乐观更新时的加载状态防止了助手思考时的界面闪烁
|
||||
- 智能体详情页在刷新后正确加载,不再显示无限加载动画
|
||||
- 改进了余额不足和账户停用的错误分类,展示更清晰的提示信息
|
||||
- 修复了非字符串内容传入文档注入时的上下文引擎崩溃问题
|
||||
@@ -2,6 +2,14 @@
|
||||
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
|
||||
"cloud": [],
|
||||
"community": [
|
||||
{
|
||||
"image": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
|
||||
"id": "2026-04-13-gateway-sidebar",
|
||||
"date": "2026-04-13",
|
||||
"versionRange": [
|
||||
"2.1.46"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"id": "2026-04-06-auto-completion",
|
||||
|
||||
@@ -56,6 +56,13 @@ LobeHub supports two connection modes for Slack:
|
||||
bot_user:
|
||||
display_name: LobeHub Assistant
|
||||
always_online: true
|
||||
slash_commands:
|
||||
- command: /new
|
||||
description: Start a new conversation
|
||||
should_escape: false
|
||||
- command: /stop
|
||||
description: Stop the current execution
|
||||
should_escape: false
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
@@ -63,6 +70,7 @@ LobeHub supports two connection modes for Slack:
|
||||
- channels:history
|
||||
- channels:read
|
||||
- chat:write
|
||||
- commands
|
||||
- groups:history
|
||||
- groups:read
|
||||
- im:history
|
||||
@@ -84,12 +92,14 @@ LobeHub supports two connection modes for Slack:
|
||||
- member_joined_channel
|
||||
- assistant_thread_started
|
||||
- assistant_thread_context_changed
|
||||
interactivity:
|
||||
is_enabled: true
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
> **Note:** `socket_mode_enabled: true` means no Request URL is needed. Events are delivered via WebSocket.
|
||||
> **Note:** `socket_mode_enabled: true` means no Request URL is needed. Events (including Slash Commands) are delivered via WebSocket.
|
||||
|
||||
### Create the App
|
||||
|
||||
@@ -154,6 +164,8 @@ LobeHub supports two connection modes for Slack:
|
||||
|
||||
Click **Test Connection** in LobeHub, then go to Slack, invite the bot to a channel, and mention it with `@LobeHub Assistant` to confirm it responds.
|
||||
|
||||
> **Slash Commands:** If you used the manifest template above, the `/new` and `/stop` commands are automatically configured. Type `/new` in Slack to reset the conversation, or `/stop` to stop the current execution. You can also use these commands via `@bot /new`.
|
||||
|
||||
---
|
||||
|
||||
## Webhook Setup (Alternative)
|
||||
@@ -177,11 +189,28 @@ Use this method if your Slack app already has Event Subscriptions configured wit
|
||||
|
||||
Enter **Application ID**, **Bot Token**, and **Signing Secret** in LobeHub's Slack channel settings. Set **Connection Mode** to **Webhook** in Advanced Settings. Save and copy the displayed **Webhook URL**.
|
||||
|
||||
### Enable App Home Messaging
|
||||
|
||||
In the Slack API Dashboard → **App Home**, find the **Show Tabs** section, enable **Messages Tab**, and make sure **"Allow users to send Slash commands and messages from the messages tab"** is checked. This allows users to chat with the bot via direct messages.
|
||||
|
||||
### Configure Event Subscriptions
|
||||
|
||||
In the Slack API Dashboard → **Event Subscriptions**, enable events, paste the Webhook URL as the **Request URL**, and subscribe to bot events: `app_mention`, `message.channels`, `message.groups`, `message.im`, `message.mpim`, `member_joined_channel`.
|
||||
|
||||

|
||||
|
||||
### Configure Slash Commands (Optional)
|
||||
|
||||
In the Slack API Dashboard → **Slash Commands**, click **Create New Command** and add the following commands:
|
||||
|
||||
| Command | Request URL | Short Description |
|
||||
| ------- | ------------------------- | -------------------------- |
|
||||
| `/new` | Same Webhook URL as above | Start a new conversation |
|
||||
| `/stop` | Same Webhook URL as above | Stop the current execution |
|
||||
|
||||
> **Note:** The Request URL is required for Webhook mode. If you are using Socket Mode, we recommend creating the app from the Manifest template above, which automatically configures Slash Commands without manual setup.
|
||||
|
||||
Also ensure you add the `commands` scope under **OAuth & Permissions** → **Bot Token Scopes**, and enable **Interactivity & Shortcuts** with the same Webhook URL as the Request URL.
|
||||
</Steps>
|
||||
|
||||
## Configuration Reference
|
||||
@@ -196,6 +225,7 @@ Use this method if your Slack app already has Event Subscriptions configured wit
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **DM shows "Sending messages to this app has been turned off":** In the Slack API Dashboard → **App Home** → **Show Tabs**, make sure **Messages Tab** is enabled and "Allow users to send Slash commands and messages from the messages tab" is checked. This is already enabled if you created the app using the Manifest template.
|
||||
- **Bot not responding:** Confirm the bot has been invited to the channel. For Socket Mode, ensure the App-Level Token is correct and Socket Mode is enabled in Slack app settings.
|
||||
- **Test Connection failed:** Double-check the Application ID and Bot Token. Ensure the app is installed to the workspace.
|
||||
- **Webhook verification failed (Webhook mode):** Make sure the Signing Secret matches and the Webhook URL is correct.
|
||||
|
||||
@@ -53,6 +53,13 @@ LobeHub 支持两种 Slack 连接模式:
|
||||
bot_user:
|
||||
display_name: LobeHub Assistant
|
||||
always_online: true
|
||||
slash_commands:
|
||||
- command: /new
|
||||
description: Start a new conversation
|
||||
should_escape: false
|
||||
- command: /stop
|
||||
description: Stop the current execution
|
||||
should_escape: false
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
@@ -60,6 +67,7 @@ LobeHub 支持两种 Slack 连接模式:
|
||||
- channels:history
|
||||
- channels:read
|
||||
- chat:write
|
||||
- commands
|
||||
- groups:history
|
||||
- groups:read
|
||||
- im:history
|
||||
@@ -81,12 +89,14 @@ LobeHub 支持两种 Slack 连接模式:
|
||||
- member_joined_channel
|
||||
- assistant_thread_started
|
||||
- assistant_thread_context_changed
|
||||
interactivity:
|
||||
is_enabled: true
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: true
|
||||
token_rotation_enabled: false
|
||||
```
|
||||
|
||||
> **注意:** `socket_mode_enabled: true` 表示无需配置 Request URL。事件通过 WebSocket 推送。
|
||||
> **注意:** `socket_mode_enabled: true` 表示无需配置 Request URL。事件(包括 Slash Commands)通过 WebSocket 推送。
|
||||
|
||||
### 创建应用
|
||||
|
||||
@@ -151,6 +161,8 @@ LobeHub 支持两种 Slack 连接模式:
|
||||
|
||||
在 LobeHub 点击 **测试连接**,然后进入 Slack,将机器人邀请到频道,通过 `@LobeHub Assistant` 提及它,确认是否正常响应。
|
||||
|
||||
> **Slash Commands:** 如果您使用了上方的 Manifest 模板,`/new` 和 `/stop` 命令已自动配置。在 Slack 输入 `/new` 可以重置对话,输入 `/stop` 可以停止当前执行。您也可以通过 `@bot /new` 的方式使用这些命令。
|
||||
|
||||
---
|
||||
|
||||
## Webhook 设置(备选方案)
|
||||
@@ -174,11 +186,28 @@ LobeHub 支持两种 Slack 连接模式:
|
||||
|
||||
在 LobeHub 的 Slack 渠道设置中输入 **应用 ID**、**Bot Token** 和 **签名密钥**。在高级设置中将 **连接模式** 设为 **Webhook**。保存后复制显示的 **Webhook URL**。
|
||||
|
||||
### 启用 App Home 消息功能
|
||||
|
||||
在 Slack API 控制台 → **App Home** 中,找到 **Show Tabs** 区域,勾选 **Messages Tab**,并确保 **"Allow users to send Slash commands and messages from the messages tab"** 已启用。这样用户才能在私信中与机器人对话。
|
||||
|
||||
### 配置事件订阅
|
||||
|
||||
在 Slack API 控制台 → **Event Subscriptions** 中,启用事件,将 Webhook URL 粘贴为 **Request URL**,订阅事件:`app_mention`、`message.channels`、`message.groups`、`message.im`、`message.mpim`、`member_joined_channel`。
|
||||
|
||||

|
||||
|
||||
### 配置 Slash Commands(可选)
|
||||
|
||||
在 Slack API 控制台 → **Slash Commands** 中,点击 **Create New Command**,添加以下命令:
|
||||
|
||||
| Command | Request URL | Short Description |
|
||||
| ------- | ------------------ | -------------------------- |
|
||||
| `/new` | 与上方相同的 Webhook URL | Start a new conversation |
|
||||
| `/stop` | 与上方相同的 Webhook URL | Stop the current execution |
|
||||
|
||||
> **注意:** Webhook 模式下 Request URL 为必填项。如果您使用 Socket Mode,推荐通过 Manifest 模板创建应用,Slash Commands 会自动配置,无需手动添加。
|
||||
|
||||
同时确保在 **OAuth & Permissions** → **Bot Token Scopes** 中添加 `commands` 权限,并在 **Interactivity & Shortcuts** 中启用 Interactivity,将 Request URL 设为相同的 Webhook URL。
|
||||
</Steps>
|
||||
|
||||
## 配置参考
|
||||
@@ -193,6 +222,7 @@ LobeHub 支持两种 Slack 连接模式:
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **私信显示 "Sending messages to this app has been turned off":** 在 Slack API 控制台 → **App Home** → **Show Tabs** 中,确保 **Messages Tab** 已启用,并勾选 "Allow users to send Slash commands and messages from the messages tab"。如果使用 Manifest 模板创建应用则默认已开启。
|
||||
- **机器人未响应:** 确认机器人已被邀请到频道。Socket Mode 下请确保应用级别 Token 正确且 Socket Mode 已在 Slack 应用设置中启用。
|
||||
- **测试连接失败:** 仔细检查应用 ID 和 Bot Token 是否正确。确保应用已安装到工作区。
|
||||
- **Webhook 验证失败(Webhook 模式):** 确保签名密钥匹配且 Webhook URL 正确。
|
||||
|
||||
+1
-1
@@ -265,7 +265,7 @@
|
||||
"@lobehub/analytics": "^1.6.0",
|
||||
"@lobehub/charts": "^5.0.0",
|
||||
"@lobehub/desktop-ipc-typings": "workspace:*",
|
||||
"@lobehub/editor": "^4.5.0",
|
||||
"@lobehub/editor": "^4.8.1",
|
||||
"@lobehub/icons": "^5.0.0",
|
||||
"@lobehub/market-sdk": "0.32.2",
|
||||
"@lobehub/tts": "^5.1.2",
|
||||
|
||||
@@ -20,37 +20,40 @@ export const systemPrompt = `You have access to a Cloud Sandbox that provides a
|
||||
**IMPORTANT: Prefer Pre-installed Software**
|
||||
The sandbox comes with pre-installed software and libraries. **Always prioritize using these pre-installed tools** when they can solve the user's problem, rather than installing additional packages.
|
||||
|
||||
**Operating System:**
|
||||
- Debian 12
|
||||
**Base Image:** lobehubbot/python-node:latest (Debian-based)
|
||||
|
||||
**Programming Languages & Runtimes:**
|
||||
- Python (with pip)
|
||||
- Node.js (with npm)
|
||||
- Bun
|
||||
- Bash/Shell
|
||||
|
||||
**Build Tools:**
|
||||
- build-essential 12.9
|
||||
- gcc/g++ 12.2.0
|
||||
**Package Managers:**
|
||||
- pip (Python)
|
||||
- npm / pnpm (Node.js)
|
||||
|
||||
**Python Libraries (Pre-installed):**
|
||||
- numpy 2.4.1 - Numerical computing
|
||||
- scipy 1.17.0 - Scientific computing
|
||||
- pandas 2.3.3 - Data analysis
|
||||
- matplotlib 3.10.8 - Static visualization
|
||||
- plotly 6.5.2 - Interactive visualization
|
||||
- scikit-learn 1.8.0 - Machine learning
|
||||
- opencv-python 4.13.0.90 - Computer vision
|
||||
- Pillow 12.1.0 - Image processing
|
||||
- wheel 0.45.1 - Python package installer
|
||||
|
||||
**Document & Media Tools:**
|
||||
**System Tools (apt):**
|
||||
- curl, wget, unzip, jq - Common utilities
|
||||
- build-essential - gcc/g++/make compilation toolchain
|
||||
- FFmpeg - Audio/video processing
|
||||
- LibreOffice - Office document processing
|
||||
- Pandoc - Document format conversion
|
||||
- pdftoppm - PDF to image conversion
|
||||
- FFmpeg 5.1.8-0+deb12u1 - Audio/video processing
|
||||
- poppler-utils - PDF tools (pdftotext, pdftoppm, etc.)
|
||||
- GitHub CLI (gh)
|
||||
|
||||
**Browser Automation:**
|
||||
**JS/TS Tools:**
|
||||
- marp-cli - Markdown to PPT/PDF presentation
|
||||
- Chromium (installed via Playwright, also used by marp-cli)
|
||||
- Playwright - Browser automation
|
||||
- marpc-cli - Browser-based PPTX generation
|
||||
|
||||
**Python Libraries (Pre-installed):**
|
||||
- Data Science/ML: numpy, pandas, scipy, scikit-learn
|
||||
- Visualization: matplotlib, plotly
|
||||
- Data Processing: pyyaml, toml, python-dotenv, Pillow, opencv-python-headless
|
||||
- File Processing: openpyxl, xlrd, python-docx, PyPDF2, reportlab
|
||||
- Async: aiofiles, anyio
|
||||
- Testing: pytest
|
||||
- Server: fastapi, uvicorn, pydantic
|
||||
|
||||
**Fonts:**
|
||||
- Noto Sans CJK - Chinese/Japanese/Korean sans-serif font
|
||||
@@ -60,6 +63,7 @@ The sandbox comes with pre-installed software and libraries. **Always prioritize
|
||||
- Tesseract (OCR) - Not installed
|
||||
- Puppeteer - Not installed, use Playwright instead
|
||||
- mermaid-cli - Not installed
|
||||
- seaborn - Not installed
|
||||
|
||||
**Installation Guidelines:**
|
||||
- Only install additional packages when pre-installed software cannot fulfill the requirement
|
||||
@@ -173,7 +177,9 @@ When executing Python code:
|
||||
|
||||
**Using Pre-installed Libraries:**
|
||||
- **Always check if required libraries are pre-installed** (see preinstalled_software section)
|
||||
- numpy, scipy, pandas, matplotlib, plotly, scikit-learn, opencv-python, Pillow are already available
|
||||
- Data Science/ML: numpy, pandas, scipy, scikit-learn, matplotlib, plotly are already available
|
||||
- Data Processing: pyyaml, toml, python-dotenv, Pillow, opencv-python-headless are already available
|
||||
- File Processing: openpyxl, xlrd, python-docx, PyPDF2, reportlab are already available
|
||||
- **Skip pip install** for pre-installed libraries - use them directly
|
||||
- Only use \`pip install\` for libraries NOT in the pre-installed list
|
||||
|
||||
@@ -188,12 +194,12 @@ When executing Python code:
|
||||
|
||||
**Generating Document Files:**
|
||||
You MUST use the following libraries for each supported file format:
|
||||
- **PDF**: Use \`reportlab\` - prioritize \`reportlab.platypus\` over canvas for text content
|
||||
- **DOCX**: Use \`python-docx\`
|
||||
- **XLSX**: Use \`openpyxl\`
|
||||
- **PPTX**: Use \`python-pptx\`
|
||||
- **CSV**: Use pre-installed \`pandas\` (no installation needed)
|
||||
- **ODS/ODT/ODP**: Use \`odfpy\`
|
||||
- **PDF**: Use \`reportlab\` (pre-installed) - prioritize \`reportlab.platypus\` over canvas for text content
|
||||
- **DOCX**: Use \`python-docx\` (pre-installed)
|
||||
- **XLSX**: Use \`openpyxl\` (pre-installed)
|
||||
- **PPTX**: Use \`python-pptx\` (requires pip install)
|
||||
- **CSV**: Use \`pandas\` (pre-installed)
|
||||
- **ODS/ODT/ODP**: Use \`odfpy\` (requires pip install)
|
||||
|
||||
For libraries NOT pre-installed: Install with \`pip install <package-name>\` before use.
|
||||
**After successful generation, automatically export the document file.**
|
||||
|
||||
@@ -370,21 +370,49 @@ class CredsExecutor extends BaseExecutor<typeof CredsApiName> {
|
||||
_ctx?: BuiltinToolContext,
|
||||
): Promise<BuiltinToolResult> => {
|
||||
try {
|
||||
log('[CredsExecutor] saveCreds - key:', params.key, 'name:', params.name);
|
||||
// Normalize params: AI may send `displayName` instead of `name`,
|
||||
// or `value` (env-style string) instead of `values` (Record)
|
||||
const raw = params as any;
|
||||
const name: string = params.name || raw.displayName || params.key;
|
||||
|
||||
let values: Record<string, string> = params.values;
|
||||
if (!values && typeof raw.value === 'string') {
|
||||
values = {};
|
||||
for (const line of (raw.value as string).split('\n')) {
|
||||
const idx = line.indexOf('=');
|
||||
if (idx > 0) {
|
||||
values[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!values || Object.keys(values).length === 0) {
|
||||
return {
|
||||
content:
|
||||
'Failed to save credential: values must be a non-empty object of key-value pairs (e.g., { "API_KEY": "sk-xxx" }).',
|
||||
error: {
|
||||
message: 'values is empty or missing. Provide key-value pairs, not a raw string.',
|
||||
type: 'InvalidParams',
|
||||
},
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
log('[CredsExecutor] saveCreds - key:', params.key, 'name:', name);
|
||||
|
||||
await lambdaClient.market.creds.createKV.mutate({
|
||||
description: params.description,
|
||||
key: params.key,
|
||||
name: params.name,
|
||||
name,
|
||||
type: params.type as 'kv-env' | 'kv-header',
|
||||
values: params.values,
|
||||
values,
|
||||
});
|
||||
|
||||
return {
|
||||
content: `Credential "${params.name}" saved successfully with key "${params.key}"`,
|
||||
content: `Credential "${name}" saved successfully with key "${params.key}"`,
|
||||
state: {
|
||||
key: params.key,
|
||||
message: `Credential "${params.name}" saved successfully`,
|
||||
message: `Credential "${name}" saved successfully`,
|
||||
success: true,
|
||||
},
|
||||
success: true,
|
||||
|
||||
@@ -29,6 +29,10 @@ Sandbox mode: {{sandbox_enabled}}
|
||||
- **getPlaintextCred**: Retrieve the plaintext value of a credential by key. Only use when you need to actually use the credential.
|
||||
- **injectCredsToSandbox**: Inject credentials into the sandbox environment. Only available when sandbox mode is enabled.
|
||||
- **saveCreds**: Save new credentials securely. Use when user wants to store sensitive information.
|
||||
- Parameters: \`key\` (unique identifier, lowercase with hyphens), \`name\` (display name), \`type\` ("kv-env" or "kv-header"), \`values\` (object of key-value pairs, NOT a string), \`description\` (optional)
|
||||
- Example: \`saveCreds({ key: "openai", name: "OpenAI API Key", type: "kv-env", values: { "OPENAI_API_KEY": "sk-xxx" } })\`
|
||||
- For multiple env vars: \`saveCreds({ key: "my-config", name: "My Config", type: "kv-env", values: { "APP_URL": "http://localhost:3000", "DB_URL": "postgres://..." } })\`
|
||||
- IMPORTANT: \`values\` must be a JSON object (Record<string, string>), NOT a raw string. Each environment variable should be a separate key-value pair in the object.
|
||||
</tooling>
|
||||
|
||||
<oauth_providers>
|
||||
@@ -61,7 +65,7 @@ Proactively suggest saving credentials when you detect:
|
||||
When suggesting to save, always:
|
||||
1. Explain that the credential will be encrypted and stored securely
|
||||
2. Ask the user for a meaningful name and optional description
|
||||
3. Use the \`saveCreds\` tool to store it
|
||||
3. Use the \`saveCreds\` tool to store it with \`values\` as a JSON object (e.g., \`{ "API_KEY": "sk-xxx" }\`), NOT a raw string
|
||||
</credential_saving_triggers>
|
||||
|
||||
<sandbox_integration>
|
||||
|
||||
@@ -101,7 +101,10 @@ export class ToolResolver {
|
||||
tools.push(...newTools);
|
||||
enabledToolIds.push(activation.id);
|
||||
|
||||
if (activation.source) {
|
||||
// Only set source if not already present — the operation-level sourceMap
|
||||
// may already have the correct routing source (e.g., 'lobehubSkill', 'klavis')
|
||||
// and the activation source ('discovery') should not overwrite it.
|
||||
if (activation.source && !sourceMap[activation.id]) {
|
||||
sourceMap[activation.id] = this.mapSource(activation.source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,17 +69,17 @@ describe('ChunkModel', () => {
|
||||
expect(createdChunks[1]).toMatchObject(params[1]);
|
||||
});
|
||||
|
||||
// 测试空参数场景
|
||||
// Test empty params scenario
|
||||
it('should handle empty params array', async () => {
|
||||
const result = await chunkModel.bulkCreate([], '1');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
// 测试事务回滚
|
||||
// Test transaction rollback
|
||||
it('should rollback transaction on error', async () => {
|
||||
const invalidParams = [
|
||||
{ text: 'Chunk 1', userId },
|
||||
{ index: 'abc', userId }, // 这会导致错误
|
||||
{ index: 'abc', userId }, // This will cause an error
|
||||
] as any;
|
||||
|
||||
await expect(chunkModel.bulkCreate(invalidParams, '1')).rejects.toThrow();
|
||||
@@ -203,7 +203,7 @@ describe('ChunkModel', () => {
|
||||
expect(result[1].id).toBe(chunk2.id);
|
||||
expect(result[0].similarity).toBeGreaterThan(result[1].similarity);
|
||||
});
|
||||
// 补充无文件 ID 的搜索场景
|
||||
// Additional search scenario without file ID
|
||||
it('should perform semantic search without fileIds', async () => {
|
||||
const [chunk1, chunk2] = await serverDB
|
||||
.insert(chunks)
|
||||
@@ -228,7 +228,7 @@ describe('ChunkModel', () => {
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
// 测试空结果场景
|
||||
// Test empty result scenario
|
||||
it('should return empty array when no matches found', async () => {
|
||||
const result = await chunkModel.semanticSearch({
|
||||
embedding: designThinkingQuery,
|
||||
@@ -524,7 +524,7 @@ content in Table html is below:
|
||||
});
|
||||
|
||||
describe('semanticSearchForChat', () => {
|
||||
// 测试空文件 ID 列表场景
|
||||
// Test empty file ID list scenario
|
||||
it('should return empty array when fileIds is empty', async () => {
|
||||
const result = await chunkModel.semanticSearchForChat({
|
||||
embedding: designThinkingQuery,
|
||||
@@ -535,7 +535,7 @@ content in Table html is below:
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
// 测试结果限制
|
||||
// Test result limit
|
||||
it('should limit results to 15 items', async () => {
|
||||
const fileId = '1';
|
||||
// Create 24 chunks
|
||||
|
||||
@@ -631,7 +631,7 @@ describe('FileModel', () => {
|
||||
|
||||
describe('findByNames', () => {
|
||||
it('should find files by names', async () => {
|
||||
// 准备测试数据
|
||||
// Prepare test data
|
||||
const fileList = [
|
||||
{
|
||||
name: 'test1.txt',
|
||||
@@ -658,7 +658,7 @@ describe('FileModel', () => {
|
||||
|
||||
await serverDB.insert(files).values(fileList);
|
||||
|
||||
// 测试查找文件
|
||||
// Test finding files
|
||||
const result = await fileModel.findByNames(['test1', 'test2']);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((f) => f.name)).toContain('test1.txt');
|
||||
@@ -671,7 +671,7 @@ describe('FileModel', () => {
|
||||
});
|
||||
|
||||
it('should only find files belonging to current user', async () => {
|
||||
// 准备测试数据
|
||||
// Prepare test data
|
||||
await serverDB.insert(files).values([
|
||||
{
|
||||
name: 'test1.txt',
|
||||
@@ -685,7 +685,7 @@ describe('FileModel', () => {
|
||||
url: 'https://example.com/test2.txt',
|
||||
size: 200,
|
||||
fileType: 'text/plain',
|
||||
userId: 'user2', // 不同用户的文件
|
||||
userId: 'user2', // file from a different user
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -697,7 +697,7 @@ describe('FileModel', () => {
|
||||
|
||||
describe('deleteGlobalFile', () => {
|
||||
it('should delete global file by hashId', async () => {
|
||||
// 准备测试数据
|
||||
// Prepare test data
|
||||
const globalFile = {
|
||||
hashId: 'test-hash',
|
||||
fileType: 'text/plain',
|
||||
@@ -709,10 +709,10 @@ describe('FileModel', () => {
|
||||
|
||||
await serverDB.insert(globalFiles).values(globalFile);
|
||||
|
||||
// 执行删除操作
|
||||
// Execute delete operation
|
||||
await fileModel.deleteGlobalFile('test-hash');
|
||||
|
||||
// 验证文件已被删除
|
||||
// Verify file has been deleted
|
||||
const result = await serverDB.query.globalFiles.findFirst({
|
||||
where: eq(globalFiles.hashId, 'test-hash'),
|
||||
});
|
||||
@@ -720,12 +720,12 @@ describe('FileModel', () => {
|
||||
});
|
||||
|
||||
it('should not throw error when deleting non-existent global file', async () => {
|
||||
// 删除不存在的文件不应抛出错误
|
||||
// Deleting a non-existent file should not throw an error
|
||||
await expect(fileModel.deleteGlobalFile('non-existent-hash')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should only delete specified global file', async () => {
|
||||
// 准备测试数据
|
||||
// Prepare test data
|
||||
const globalFiles1 = {
|
||||
hashId: 'hash1',
|
||||
fileType: 'text/plain',
|
||||
@@ -743,10 +743,10 @@ describe('FileModel', () => {
|
||||
|
||||
await serverDB.insert(globalFiles).values([globalFiles1, globalFiles2]);
|
||||
|
||||
// 删除一个文件
|
||||
// Delete one file
|
||||
await fileModel.deleteGlobalFile('hash1');
|
||||
|
||||
// 验证只有指定文件被删除
|
||||
// Verify only the specified file was deleted
|
||||
const remainingFiles = await serverDB.query.globalFiles.findMany();
|
||||
expect(remainingFiles).toHaveLength(1);
|
||||
expect(remainingFiles[0].hashId).toBe('hash2');
|
||||
@@ -764,22 +764,22 @@ describe('FileModel', () => {
|
||||
fileHash: 'test-hash-txn',
|
||||
};
|
||||
|
||||
// 在事务中创建文件
|
||||
// Create file in transaction
|
||||
const result = await serverDB.transaction(async (trx) => {
|
||||
const { id } = await fileModel.create(params, true, trx);
|
||||
|
||||
// 在事务内验证文件已创建
|
||||
// Verify file was created inside the transaction
|
||||
const file = await trx.query.files.findFirst({ where: eq(files.id, id) });
|
||||
expect(file).toMatchObject({ ...params, userId });
|
||||
|
||||
return { id };
|
||||
});
|
||||
|
||||
// 事务提交后,验证文件仍然存在
|
||||
// After transaction commit, verify file still exists
|
||||
const file = await serverDB.query.files.findFirst({ where: eq(files.id, result.id) });
|
||||
expect(file).toMatchObject({ ...params, userId });
|
||||
|
||||
// 验证全局文件也被创建
|
||||
// Verify global file was also created
|
||||
const globalFile = await serverDB.query.globalFiles.findFirst({
|
||||
where: eq(globalFiles.hashId, params.fileHash),
|
||||
});
|
||||
@@ -797,22 +797,22 @@ describe('FileModel', () => {
|
||||
|
||||
let createdFileId: string | undefined;
|
||||
|
||||
// 故意让事务失败
|
||||
// Intentionally fail the transaction
|
||||
await expect(
|
||||
serverDB.transaction(async (trx) => {
|
||||
const { id } = await fileModel.create(params, true, trx);
|
||||
createdFileId = id;
|
||||
|
||||
// 在事务内验证文件已创建
|
||||
// Verify file was created inside the transaction
|
||||
const file = await trx.query.files.findFirst({ where: eq(files.id, id) });
|
||||
expect(file).toMatchObject({ ...params, userId });
|
||||
|
||||
// 抛出错误导致事务回滚
|
||||
// Throw an error to cause transaction rollback
|
||||
throw new Error('Intentional rollback');
|
||||
}),
|
||||
).rejects.toThrow('Intentional rollback');
|
||||
|
||||
// 验证文件创建被回滚
|
||||
// Verify file creation was rolled back
|
||||
if (createdFileId) {
|
||||
const file = await serverDB.query.files.findFirst({
|
||||
where: eq(files.id, createdFileId),
|
||||
@@ -820,7 +820,7 @@ describe('FileModel', () => {
|
||||
expect(file).toBeUndefined();
|
||||
}
|
||||
|
||||
// 验证全局文件创建也被回滚
|
||||
// Verify global file creation was also rolled back
|
||||
const globalFile = await serverDB.query.globalFiles.findFirst({
|
||||
where: eq(globalFiles.hashId, params.fileHash),
|
||||
});
|
||||
@@ -839,7 +839,7 @@ describe('FileModel', () => {
|
||||
const result = await serverDB.transaction(async (trx) => {
|
||||
const { id } = await fileModel.create(params, false, trx);
|
||||
|
||||
// 验证知识库文件关联已创建
|
||||
// Verify knowledge base file association was created
|
||||
const kbFile = await trx.query.knowledgeBaseFiles.findFirst({
|
||||
where: eq(knowledgeBaseFiles.fileId, id),
|
||||
});
|
||||
@@ -848,7 +848,7 @@ describe('FileModel', () => {
|
||||
return { id };
|
||||
});
|
||||
|
||||
// 事务提交后验证
|
||||
// Verify after transaction commit
|
||||
const kbFile = await serverDB.query.knowledgeBaseFiles.findFirst({
|
||||
where: eq(knowledgeBaseFiles.fileId, result.id),
|
||||
});
|
||||
@@ -862,7 +862,7 @@ describe('FileModel', () => {
|
||||
|
||||
describe('delete with transaction', () => {
|
||||
it('should delete file within provided transaction', async () => {
|
||||
// 先创建文件和全局文件
|
||||
// First create the file and global file
|
||||
await fileModel.createGlobalFile({
|
||||
hashId: 'delete-txn-hash',
|
||||
url: 'https://example.com/delete-txn.txt',
|
||||
@@ -879,20 +879,20 @@ describe('FileModel', () => {
|
||||
fileHash: 'delete-txn-hash',
|
||||
});
|
||||
|
||||
// 在事务中删除文件
|
||||
// Delete file in transaction
|
||||
await serverDB.transaction(async (trx) => {
|
||||
await fileModel.delete(id, true, trx);
|
||||
|
||||
// 在事务内验证文件已删除
|
||||
// Verify file was deleted inside the transaction
|
||||
const file = await trx.query.files.findFirst({ where: eq(files.id, id) });
|
||||
expect(file).toBeUndefined();
|
||||
});
|
||||
|
||||
// 事务提交后验证文件仍然被删除
|
||||
// After transaction commit, verify file is still deleted
|
||||
const file = await serverDB.query.files.findFirst({ where: eq(files.id, id) });
|
||||
expect(file).toBeUndefined();
|
||||
|
||||
// 验证全局文件也被删除(因为没有其他引用)
|
||||
// Verify global file was also deleted (no other references)
|
||||
const globalFile = await serverDB.query.globalFiles.findFirst({
|
||||
where: eq(globalFiles.hashId, 'delete-txn-hash'),
|
||||
});
|
||||
@@ -900,7 +900,7 @@ describe('FileModel', () => {
|
||||
});
|
||||
|
||||
it('should rollback file deletion when transaction fails', async () => {
|
||||
// 先创建文件和全局文件
|
||||
// First create the file and global file
|
||||
await fileModel.createGlobalFile({
|
||||
hashId: 'rollback-delete-hash',
|
||||
url: 'https://example.com/rollback-delete.txt',
|
||||
@@ -917,26 +917,26 @@ describe('FileModel', () => {
|
||||
fileHash: 'rollback-delete-hash',
|
||||
});
|
||||
|
||||
// 故意让事务失败
|
||||
// Intentionally fail the transaction
|
||||
await expect(
|
||||
serverDB.transaction(async (trx) => {
|
||||
await fileModel.delete(id, true, trx);
|
||||
|
||||
// 在事务内验证文件已删除
|
||||
// Verify file was deleted inside the transaction
|
||||
const file = await trx.query.files.findFirst({ where: eq(files.id, id) });
|
||||
expect(file).toBeUndefined();
|
||||
|
||||
// 抛出错误导致事务回滚
|
||||
// Throw an error to cause transaction rollback
|
||||
throw new Error('Intentional rollback for delete');
|
||||
}),
|
||||
).rejects.toThrow('Intentional rollback for delete');
|
||||
|
||||
// 验证文件删除被回滚,文件仍然存在
|
||||
// Verify file deletion was rolled back, file still exists
|
||||
const file = await serverDB.query.files.findFirst({ where: eq(files.id, id) });
|
||||
expect(file).toBeDefined();
|
||||
expect(file?.name).toBe('rollback-delete-file.txt');
|
||||
|
||||
// 验证全局文件也被回滚,仍然存在
|
||||
// Verify global file was also rolled back, still exists
|
||||
const globalFile = await serverDB.query.globalFiles.findFirst({
|
||||
where: eq(globalFiles.hashId, 'rollback-delete-hash'),
|
||||
});
|
||||
@@ -944,7 +944,7 @@ describe('FileModel', () => {
|
||||
});
|
||||
|
||||
it('should delete file but preserve global file when removeGlobalFile=false in transaction', async () => {
|
||||
// 先创建文件和全局文件
|
||||
// First create the file and global file
|
||||
await fileModel.createGlobalFile({
|
||||
hashId: 'preserve-global-hash',
|
||||
url: 'https://example.com/preserve-global.txt',
|
||||
@@ -961,16 +961,16 @@ describe('FileModel', () => {
|
||||
fileHash: 'preserve-global-hash',
|
||||
});
|
||||
|
||||
// 在事务中删除文件,但不删除全局文件
|
||||
// Delete file in transaction, but keep global file
|
||||
await serverDB.transaction(async (trx) => {
|
||||
await fileModel.delete(id, false, trx);
|
||||
});
|
||||
|
||||
// 验证文件被删除
|
||||
// Verify file was deleted
|
||||
const file = await serverDB.query.files.findFirst({ where: eq(files.id, id) });
|
||||
expect(file).toBeUndefined();
|
||||
|
||||
// 验证全局文件被保留
|
||||
// Verify global file was retained
|
||||
const globalFile = await serverDB.query.globalFiles.findFirst({
|
||||
where: eq(globalFiles.hashId, 'preserve-global-hash'),
|
||||
});
|
||||
@@ -980,7 +980,7 @@ describe('FileModel', () => {
|
||||
|
||||
describe('mixed operations in transaction', () => {
|
||||
it('should support create and delete operations in same transaction', async () => {
|
||||
// 先创建一个要删除的文件
|
||||
// First create a file to be deleted
|
||||
await fileModel.createGlobalFile({
|
||||
hashId: 'mixed-delete-hash',
|
||||
url: 'https://example.com/mixed-delete.txt',
|
||||
@@ -997,12 +997,12 @@ describe('FileModel', () => {
|
||||
fileHash: 'mixed-delete-hash',
|
||||
});
|
||||
|
||||
// 在同一个事务中删除旧文件并创建新文件
|
||||
// Delete old file and create new file in the same transaction
|
||||
const result = await serverDB.transaction(async (trx) => {
|
||||
// 删除旧文件
|
||||
// Delete old file
|
||||
await fileModel.delete(deleteFileId, true, trx);
|
||||
|
||||
// 创建新文件
|
||||
// Create new file
|
||||
const { id: newFileId } = await fileModel.create(
|
||||
{
|
||||
name: 'mixed-create-file.txt',
|
||||
@@ -1018,20 +1018,20 @@ describe('FileModel', () => {
|
||||
return { newFileId };
|
||||
});
|
||||
|
||||
// 验证旧文件被删除
|
||||
// Verify old file was deleted
|
||||
const deletedFile = await serverDB.query.files.findFirst({
|
||||
where: eq(files.id, deleteFileId),
|
||||
});
|
||||
expect(deletedFile).toBeUndefined();
|
||||
|
||||
// 验证新文件被创建
|
||||
// Verify new file was created
|
||||
const newFile = await serverDB.query.files.findFirst({
|
||||
where: eq(files.id, result.newFileId),
|
||||
});
|
||||
expect(newFile).toBeDefined();
|
||||
expect(newFile?.name).toBe('mixed-create-file.txt');
|
||||
|
||||
// 验证新的全局文件被创建
|
||||
// Verify new global file was created
|
||||
const newGlobalFile = await serverDB.query.globalFiles.findFirst({
|
||||
where: eq(globalFiles.hashId, 'mixed-create-hash'),
|
||||
});
|
||||
@@ -1152,7 +1152,7 @@ describe('FileModel', () => {
|
||||
});
|
||||
|
||||
it('should delete file even when chunks deletion fails', async () => {
|
||||
// 创建测试文件
|
||||
// Create test file
|
||||
const testFile = {
|
||||
name: 'error-test-file.txt',
|
||||
url: 'https://example.com/error-test-file.txt',
|
||||
@@ -1163,52 +1163,52 @@ describe('FileModel', () => {
|
||||
|
||||
const { id: fileId } = await fileModel.create(testFile, true);
|
||||
|
||||
// 创建一些测试数据来模拟chunks关联
|
||||
// Create some test data to simulate chunk associations
|
||||
const chunkId1 = '550e8400-e29b-41d4-a716-446655440001';
|
||||
const chunkId2 = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
// 插入chunks
|
||||
// Insert chunks
|
||||
await serverDB.insert(chunks).values([
|
||||
{ id: chunkId1, text: 'chunk 1', userId, type: 'text' },
|
||||
{ id: chunkId2, text: 'chunk 2', userId, type: 'text' },
|
||||
]);
|
||||
|
||||
// 插入fileChunks关联
|
||||
// Insert fileChunks associations
|
||||
await serverDB.insert(fileChunks).values([
|
||||
{ fileId, chunkId: chunkId1, userId },
|
||||
{ fileId, chunkId: chunkId2, userId },
|
||||
]);
|
||||
|
||||
// 插入embeddings (1024维向量)
|
||||
// Insert embeddings (1024-dimensional vectors)
|
||||
const testEmbedding = Array.from({ length: 1024 }).fill(0.1) as number[];
|
||||
await serverDB
|
||||
.insert(embeddings)
|
||||
.values([{ chunkId: chunkId1, embeddings: testEmbedding, model: 'test-model', userId }]);
|
||||
|
||||
// 跳过 documentChunks 测试,因为需要先创建 documents 记录
|
||||
// Skip documentChunks test, requires creating documents records first
|
||||
|
||||
// 删除文件,应该会清理所有相关数据
|
||||
// Delete file, should clean up all related data
|
||||
const result = await fileModel.delete(fileId, true);
|
||||
|
||||
// 验证文件被删除
|
||||
// Verify file was deleted
|
||||
const deletedFile = await serverDB.query.files.findFirst({
|
||||
where: eq(files.id, fileId),
|
||||
});
|
||||
expect(deletedFile).toBeUndefined();
|
||||
|
||||
// 验证chunks被删除
|
||||
// Verify chunks were deleted
|
||||
const remainingChunks = await serverDB.query.chunks.findMany({
|
||||
where: inArray(chunks.id, [chunkId1, chunkId2]),
|
||||
});
|
||||
expect(remainingChunks).toHaveLength(0);
|
||||
|
||||
// 验证embeddings被删除
|
||||
// Verify embeddings were deleted
|
||||
const remainingEmbeddings = await serverDB.query.embeddings.findMany({
|
||||
where: inArray(embeddings.chunkId, [chunkId1, chunkId2]),
|
||||
});
|
||||
expect(remainingEmbeddings).toHaveLength(0);
|
||||
|
||||
// 验证fileChunks被删除
|
||||
// Verify fileChunks were deleted
|
||||
const remainingFileChunks = await serverDB.query.fileChunks.findMany({
|
||||
where: eq(fileChunks.fileId, fileId),
|
||||
});
|
||||
@@ -1218,7 +1218,7 @@ describe('FileModel', () => {
|
||||
});
|
||||
|
||||
it('should successfully delete file with all related chunks and embeddings', async () => {
|
||||
// 简化测试:只验证正常的完整删除流程(移除知识库保护后)
|
||||
// Simplified test: only verify the normal full deletion flow (after removing knowledge base protection)
|
||||
const testFile = {
|
||||
name: 'complete-deletion-test.txt',
|
||||
url: 'https://example.com/complete-deletion-test.txt',
|
||||
@@ -1231,42 +1231,42 @@ describe('FileModel', () => {
|
||||
|
||||
const chunkId = '550e8400-e29b-41d4-a716-446655440003';
|
||||
|
||||
// 插入chunk
|
||||
// Insert chunk
|
||||
await serverDB
|
||||
.insert(chunks)
|
||||
.values([{ id: chunkId, text: 'complete test chunk', userId, type: 'text' }]);
|
||||
|
||||
// 插入fileChunks关联
|
||||
// Insert fileChunks associations
|
||||
await serverDB.insert(fileChunks).values([{ fileId, chunkId, userId }]);
|
||||
|
||||
// 插入embeddings
|
||||
// Insert embeddings
|
||||
const testEmbedding = Array.from({ length: 1024 }).fill(0.1) as number[];
|
||||
await serverDB
|
||||
.insert(embeddings)
|
||||
.values([{ chunkId, embeddings: testEmbedding, model: 'test-model', userId }]);
|
||||
|
||||
// 删除文件
|
||||
// Delete file
|
||||
await fileModel.delete(fileId, true);
|
||||
|
||||
// 验证文件被删除
|
||||
// Verify file was deleted
|
||||
const deletedFile = await serverDB.query.files.findFirst({
|
||||
where: eq(files.id, fileId),
|
||||
});
|
||||
expect(deletedFile).toBeUndefined();
|
||||
|
||||
// 验证chunks被删除
|
||||
// Verify chunks were deleted
|
||||
const remainingChunks = await serverDB.query.chunks.findMany({
|
||||
where: eq(chunks.id, chunkId),
|
||||
});
|
||||
expect(remainingChunks).toHaveLength(0);
|
||||
|
||||
// 验证embeddings被删除
|
||||
// Verify embeddings were deleted
|
||||
const remainingEmbeddings = await serverDB.query.embeddings.findMany({
|
||||
where: eq(embeddings.chunkId, chunkId),
|
||||
});
|
||||
expect(remainingEmbeddings).toHaveLength(0);
|
||||
|
||||
// 验证fileChunks被删除
|
||||
// Verify fileChunks were deleted
|
||||
const remainingFileChunks = await serverDB.query.fileChunks.findMany({
|
||||
where: eq(fileChunks.fileId, fileId),
|
||||
});
|
||||
@@ -1274,7 +1274,7 @@ describe('FileModel', () => {
|
||||
});
|
||||
|
||||
it('should delete files that are in knowledge bases (removed protection)', async () => {
|
||||
// 测试修复后的逻辑:知识库中的文件也应该被删除
|
||||
// Test the fixed logic: files in knowledge bases should also be deleted
|
||||
const testFile = {
|
||||
name: 'knowledge-base-file.txt',
|
||||
url: 'https://example.com/knowledge-base-file.txt',
|
||||
@@ -1288,47 +1288,47 @@ describe('FileModel', () => {
|
||||
|
||||
const chunkId = '550e8400-e29b-41d4-a716-446655440007';
|
||||
|
||||
// 插入chunk和关联数据
|
||||
// Insert chunk and association data
|
||||
await serverDB
|
||||
.insert(chunks)
|
||||
.values([{ id: chunkId, text: 'knowledge base chunk', userId, type: 'text' }]);
|
||||
|
||||
await serverDB.insert(fileChunks).values([{ fileId, chunkId, userId }]);
|
||||
|
||||
// 插入embeddings (1024维向量)
|
||||
// Insert embeddings (1024-dimensional vectors)
|
||||
const testEmbedding = Array.from({ length: 1024 }).fill(0.1) as number[];
|
||||
await serverDB
|
||||
.insert(embeddings)
|
||||
.values([{ chunkId, embeddings: testEmbedding, model: 'test-model', userId }]);
|
||||
|
||||
// 验证文件确实在知识库中
|
||||
// Verify file is indeed in the knowledge base
|
||||
const kbFile = await serverDB.query.knowledgeBaseFiles.findFirst({
|
||||
where: eq(knowledgeBaseFiles.fileId, fileId),
|
||||
});
|
||||
expect(kbFile).toBeDefined();
|
||||
|
||||
// 删除文件
|
||||
// Delete file
|
||||
await fileModel.delete(fileId, true);
|
||||
|
||||
// 验证知识库中的文件也被完全删除
|
||||
// Verify files in knowledge base were also completely deleted
|
||||
const deletedFile = await serverDB.query.files.findFirst({
|
||||
where: eq(files.id, fileId),
|
||||
});
|
||||
expect(deletedFile).toBeUndefined();
|
||||
|
||||
// 验证chunks被删除(这是修复的核心:之前知识库文件的chunks不会被删除)
|
||||
// Verify chunks were deleted (this is the core of the fix: previously chunks of knowledge base files would not be deleted)
|
||||
const remainingChunks = await serverDB.query.chunks.findMany({
|
||||
where: eq(chunks.id, chunkId),
|
||||
});
|
||||
expect(remainingChunks).toHaveLength(0);
|
||||
|
||||
// 验证embeddings被删除
|
||||
// Verify embeddings were deleted
|
||||
const remainingEmbeddings = await serverDB.query.embeddings.findMany({
|
||||
where: eq(embeddings.chunkId, chunkId),
|
||||
});
|
||||
expect(remainingEmbeddings).toHaveLength(0);
|
||||
|
||||
// 验证fileChunks被删除
|
||||
// Verify fileChunks were deleted
|
||||
const remainingFileChunks = await serverDB.query.fileChunks.findMany({
|
||||
where: eq(fileChunks.fileId, fileId),
|
||||
});
|
||||
|
||||
@@ -25,19 +25,19 @@ const sessionModel = new SessionModel(serverDB, userId);
|
||||
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
// 并创建初始用户
|
||||
// and create the initial user
|
||||
await serverDB.insert(users).values({ id: userId });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// 在每个测试用例之后, 清空用户表 (应该会自动级联删除所有数据)
|
||||
// After each test case, clear the users table (should auto-cascade delete all data)
|
||||
await serverDB.delete(users);
|
||||
});
|
||||
|
||||
describe('SessionModel', () => {
|
||||
describe('query', () => {
|
||||
it('should query sessions by user ID', async () => {
|
||||
// 创建一些测试数据
|
||||
// Create some test data
|
||||
await serverDB.insert(users).values([{ id: '456' }]);
|
||||
|
||||
await serverDB.insert(sessions).values([
|
||||
@@ -46,10 +46,10 @@ describe('SessionModel', () => {
|
||||
{ id: '3', userId: '456', updatedAt: new Date('2023-03-01') },
|
||||
]);
|
||||
|
||||
// 调用 query 方法
|
||||
// Call the query method
|
||||
const result = await sessionModel.query();
|
||||
|
||||
// 断言结果
|
||||
// Assert results
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('2');
|
||||
expect(result[1].id).toBe('1');
|
||||
@@ -76,7 +76,7 @@ describe('SessionModel', () => {
|
||||
|
||||
describe('queryWithGroups', () => {
|
||||
it('should return sessions grouped by group', async () => {
|
||||
// 创建测试数据
|
||||
// Create test data
|
||||
await serverDB.transaction(async (trx) => {
|
||||
await trx.insert(users).values([{ id: '456' }]);
|
||||
await trx.insert(sessionGroups).values([
|
||||
@@ -94,10 +94,10 @@ describe('SessionModel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// 调用 queryWithGroups 方法
|
||||
// Call the queryWithGroups method
|
||||
const result = await sessionModel.queryWithGroups();
|
||||
|
||||
// 断言结果
|
||||
// Assert results
|
||||
expect(result.sessions).toHaveLength(6);
|
||||
expect(result.sessionGroups).toHaveLength(2);
|
||||
expect(result.sessionGroups[0].id).toBe('group1');
|
||||
@@ -107,10 +107,10 @@ describe('SessionModel', () => {
|
||||
});
|
||||
|
||||
it('should return empty groups if no sessions', async () => {
|
||||
// 调用 queryWithGroups 方法
|
||||
// Call the queryWithGroups method
|
||||
const result = await sessionModel.queryWithGroups();
|
||||
|
||||
// 断言结果
|
||||
// Assert results
|
||||
expect(result.sessions).toHaveLength(0);
|
||||
expect(result.sessionGroups).toHaveLength(0);
|
||||
});
|
||||
@@ -236,7 +236,7 @@ describe('SessionModel', () => {
|
||||
|
||||
describe('count', () => {
|
||||
it('should return the count of sessions for the user', async () => {
|
||||
// 创建测试数据
|
||||
// Create test data
|
||||
await serverDB.insert(users).values([{ id: '456' }]);
|
||||
await serverDB.insert(sessions).values([
|
||||
{ id: '1', userId },
|
||||
@@ -244,22 +244,22 @@ describe('SessionModel', () => {
|
||||
{ id: '3', userId: '456' },
|
||||
]);
|
||||
|
||||
// 调用 count 方法
|
||||
// Call the count method
|
||||
const result = await sessionModel.count();
|
||||
|
||||
// 断言结果
|
||||
// Assert results
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 0 if no sessions exist for the user', async () => {
|
||||
// 创建测试数据
|
||||
// Create test data
|
||||
await serverDB.insert(users).values([{ id: '456' }]);
|
||||
await serverDB.insert(sessions).values([{ id: '3', userId: '456' }]);
|
||||
|
||||
// 调用 count 方法
|
||||
// Call the count method
|
||||
const result = await sessionModel.count();
|
||||
|
||||
// 断言结果
|
||||
// Assert results
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
@@ -366,7 +366,7 @@ describe('SessionModel', () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new session', async () => {
|
||||
// 调用 create 方法
|
||||
// Call the create method
|
||||
const result = await sessionModel.create({
|
||||
type: 'agent',
|
||||
session: {
|
||||
@@ -375,7 +375,7 @@ describe('SessionModel', () => {
|
||||
config: { model: 'gpt-3.5-turbo' },
|
||||
});
|
||||
|
||||
// 断言结果
|
||||
// Assert results
|
||||
const sessionId = result.id;
|
||||
expect(sessionId).toBeDefined();
|
||||
expect(sessionId.startsWith('ssn_')).toBeTruthy();
|
||||
@@ -390,7 +390,7 @@ describe('SessionModel', () => {
|
||||
});
|
||||
|
||||
it('should create a new session with custom ID', async () => {
|
||||
// 调用 create 方法,传入自定义 ID
|
||||
// Call the create method with a custom ID
|
||||
const customId = 'custom-id';
|
||||
const result = await sessionModel.create({
|
||||
type: 'agent',
|
||||
@@ -399,7 +399,7 @@ describe('SessionModel', () => {
|
||||
id: customId,
|
||||
});
|
||||
|
||||
// 断言结果
|
||||
// Assert results
|
||||
expect(result.id).toBe(customId);
|
||||
});
|
||||
|
||||
@@ -471,7 +471,7 @@ describe('SessionModel', () => {
|
||||
|
||||
describe('batchCreate', () => {
|
||||
it('should batch create sessions', async () => {
|
||||
// 调用 batchCreate 方法
|
||||
// Call the batchCreate method
|
||||
const sessions: NewSession[] = [
|
||||
{
|
||||
id: '1',
|
||||
@@ -490,13 +490,13 @@ describe('SessionModel', () => {
|
||||
];
|
||||
const result = await sessionModel.batchCreate(sessions);
|
||||
|
||||
// 断言结果
|
||||
// Assert results
|
||||
// pglite return affectedRows while postgres return rowCount
|
||||
expect((result as any).affectedRows || result.rowCount).toEqual(2);
|
||||
});
|
||||
|
||||
it.skip('should set group to default if group does not exist', async () => {
|
||||
// 调用 batchCreate 方法,传入不存在的 group
|
||||
// Call the batchCreate method with a non-existent group
|
||||
const sessions: NewSession[] = [
|
||||
{
|
||||
id: '1',
|
||||
@@ -509,14 +509,14 @@ describe('SessionModel', () => {
|
||||
];
|
||||
const result = await sessionModel.batchCreate(sessions);
|
||||
|
||||
// 断言结果
|
||||
// Assert results
|
||||
// expect(result[0].group).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate', () => {
|
||||
it('should duplicate a session', async () => {
|
||||
// 创建一个用户和一个 session
|
||||
// Create a user and a session
|
||||
await serverDB.transaction(async (trx) => {
|
||||
await trx
|
||||
.insert(sessions)
|
||||
@@ -525,10 +525,10 @@ describe('SessionModel', () => {
|
||||
await trx.insert(agentsToSessions).values({ agentId: 'agent-1', sessionId: '1', userId });
|
||||
});
|
||||
|
||||
// 调用 duplicate 方法
|
||||
// Call the duplicate method
|
||||
const result = (await sessionModel.duplicate('1', 'Duplicated Session')) as SessionItem;
|
||||
|
||||
// 断言结果
|
||||
// Assert results
|
||||
expect(result.id).not.toBe('1');
|
||||
expect(result.userId).toBe(userId);
|
||||
expect(result.type).toBe('agent');
|
||||
@@ -542,34 +542,34 @@ describe('SessionModel', () => {
|
||||
});
|
||||
|
||||
it('should return undefined if session does not exist', async () => {
|
||||
// 调用 duplicate 方法,传入不存在的 session ID
|
||||
// Call the duplicate method with a non-existent session ID
|
||||
const result = await sessionModel.duplicate('non-existent-id');
|
||||
|
||||
// 断言结果
|
||||
// Assert results
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update a session', async () => {
|
||||
// 创建一个测试 session
|
||||
// Create a test session
|
||||
const sessionId = '123';
|
||||
await serverDB.insert(sessions).values({ userId, id: sessionId, title: 'Test Session' });
|
||||
|
||||
// 调用 update 方法更新 session
|
||||
// Call the update method to update the session
|
||||
const updatedSessions = await sessionModel.update(sessionId, {
|
||||
title: 'Updated Test Session',
|
||||
description: 'This is an updated test session',
|
||||
});
|
||||
|
||||
// 断言更新后的结果
|
||||
// Assert the updated results
|
||||
expect(updatedSessions).toHaveLength(1);
|
||||
expect(updatedSessions[0].title).toBe('Updated Test Session');
|
||||
expect(updatedSessions[0].description).toBe('This is an updated test session');
|
||||
});
|
||||
|
||||
it('should not update a session if user ID does not match', async () => {
|
||||
// 创建一个测试 session,但使用不同的 user ID
|
||||
// Create a test session with a different user ID
|
||||
await serverDB.insert(users).values([{ id: '777' }]);
|
||||
|
||||
const sessionId = '123';
|
||||
@@ -578,7 +578,7 @@ describe('SessionModel', () => {
|
||||
.insert(sessions)
|
||||
.values({ userId: '777', id: sessionId, title: 'Test Session' });
|
||||
|
||||
// 尝试更新这个 session,应该不会有任何更新
|
||||
// Attempt to update this session — should produce no updates
|
||||
const updatedSessions = await sessionModel.update(sessionId, {
|
||||
title: 'Updated Test Session',
|
||||
});
|
||||
@@ -589,26 +589,26 @@ describe('SessionModel', () => {
|
||||
|
||||
describe('delete', () => {
|
||||
it('should handle deleting a session with no associated messages or topics', async () => {
|
||||
// 创建测试数据
|
||||
// Create test data
|
||||
await serverDB.insert(sessions).values({ id: '1', userId });
|
||||
|
||||
// 调用 delete 方法
|
||||
// Call the delete method
|
||||
await sessionModel.delete('1');
|
||||
|
||||
// 断言删除结果
|
||||
// Assert deletion results
|
||||
const result = await serverDB.select({ id: sessions.id }).from(sessions);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle concurrent deletions gracefully', async () => {
|
||||
// 创建测试数据
|
||||
// Create test data
|
||||
await serverDB.insert(sessions).values({ id: '1', userId });
|
||||
|
||||
// 并发调用 delete 方法
|
||||
// Concurrently call the delete method
|
||||
await Promise.all([sessionModel.delete('1'), sessionModel.delete('1')]);
|
||||
|
||||
// 断言删除结果
|
||||
// Assert deletion results
|
||||
const result = await serverDB.select({ id: sessions.id }).from(sessions);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
@@ -673,35 +673,35 @@ describe('SessionModel', () => {
|
||||
|
||||
describe('batchDelete', () => {
|
||||
it('should handle deleting sessions with no associated messages or topics', async () => {
|
||||
// 创建测试数据
|
||||
// Create test data
|
||||
await serverDB.insert(sessions).values([
|
||||
{ id: '1', userId },
|
||||
{ id: '2', userId },
|
||||
]);
|
||||
|
||||
// 调用 batchDelete 方法
|
||||
// Call the batchDelete method
|
||||
await sessionModel.batchDelete(['1', '2']);
|
||||
|
||||
// 断言删除结果
|
||||
// Assert deletion results
|
||||
const result = await serverDB.select({ id: sessions.id }).from(sessions);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle concurrent batch deletions gracefully', async () => {
|
||||
// 创建测试数据
|
||||
// Create test data
|
||||
await serverDB.insert(sessions).values([
|
||||
{ id: '1', userId },
|
||||
{ id: '2', userId },
|
||||
]);
|
||||
|
||||
// 并发调用 batchDelete 方法
|
||||
// Concurrently call the batchDelete method
|
||||
await Promise.all([
|
||||
sessionModel.batchDelete(['1', '2']),
|
||||
sessionModel.batchDelete(['1', '2']),
|
||||
]);
|
||||
|
||||
// 断言删除结果
|
||||
// Assert deletion results
|
||||
const result = await serverDB.select({ id: sessions.id }).from(sessions);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
@@ -1519,7 +1519,7 @@ describe('SessionModel', () => {
|
||||
|
||||
describe('findSessionsByKeywords', () => {
|
||||
it('should handle errors gracefully and return empty array', async () => {
|
||||
// 这个测试旨在覆盖 findSessionsByKeywords 中的错误处理逻辑
|
||||
// This test aims to cover the error-handling logic in findSessionsByKeywords
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Mock the database query to throw an error
|
||||
@@ -1528,7 +1528,7 @@ describe('SessionModel', () => {
|
||||
|
||||
const result = await sessionModel.findSessionsByKeywords({ keyword: 'test' });
|
||||
|
||||
// 即使发生错误,方法也应该返回一个空数组
|
||||
// Even when an error occurs, the method should return an empty array
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toEqual([]);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('findSessionsByKeywords error:', expect.any(Error), {
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('AgentEvalBenchmarkModel', () => {
|
||||
it('should order by createdAt descending', async () => {
|
||||
const results = await benchmarkModel.query(true);
|
||||
|
||||
// 最新的应该在前面
|
||||
// The newest should come first
|
||||
// Order may vary in PGlite due to timing
|
||||
expect(results.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ describe('AgentEvalDatasetModel', () => {
|
||||
it('should order by createdAt descending', async () => {
|
||||
const results = await datasetModel.query();
|
||||
|
||||
// 最新的应该在前面
|
||||
// The newest should come first
|
||||
// Order may vary, just check we got results
|
||||
expect(results.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
CheckpointConfig,
|
||||
NewTask,
|
||||
TaskItem,
|
||||
WorkspaceData,
|
||||
WorkspaceDocNode,
|
||||
WorkspaceTreeNode,
|
||||
@@ -8,7 +10,7 @@ import { and, desc, eq, inArray, isNotNull, isNull, ne, sql } from 'drizzle-orm'
|
||||
|
||||
import { merge } from '@/utils/merge';
|
||||
|
||||
import type { NewTask, NewTaskComment, TaskCommentItem, TaskItem } from '../schemas/task';
|
||||
import type { NewTaskComment, TaskCommentItem } from '../schemas/task';
|
||||
import { taskComments, taskDependencies, taskDocuments, tasks } from '../schemas/task';
|
||||
import type { LobeChatDatabase } from '../type';
|
||||
|
||||
@@ -307,7 +309,7 @@ export class TaskModel {
|
||||
SELECT * FROM task_tree
|
||||
`);
|
||||
|
||||
return result.rows as TaskItem[];
|
||||
return result.rows as unknown as TaskItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ import { and, asc, desc, eq, inArray, or, sql } from 'drizzle-orm';
|
||||
import type { NewUserMemoryActivity, UserMemoryActivity } from '../../schemas';
|
||||
import { userMemories, userMemoriesActivities } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { sanitizeBm25Query } from '../../utils/bm25';
|
||||
import { SAFE_BM25_QUERY_OPTIONS, sanitizeBm25Query } from '../../utils/bm25';
|
||||
|
||||
export class UserMemoryActivityModel {
|
||||
private userId: string;
|
||||
@@ -69,7 +69,9 @@ export class UserMemoryActivityModel {
|
||||
const normalizedPageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||
const offset = (normalizedPage - 1) * normalizedPageSize;
|
||||
const normalizedQuery = typeof q === 'string' ? q.trim() : '';
|
||||
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
|
||||
const bm25Query = normalizedQuery
|
||||
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
|
||||
: '';
|
||||
|
||||
const conditions: Array<SQL | undefined> = [
|
||||
eq(userMemoriesActivities.userId, this.userId),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { and, asc, desc, eq, inArray, or, sql } from 'drizzle-orm';
|
||||
import type { NewUserMemoryExperience, UserMemoryExperience } from '../../schemas';
|
||||
import { userMemories, userMemoriesExperiences } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { sanitizeBm25Query } from '../../utils/bm25';
|
||||
import { SAFE_BM25_QUERY_OPTIONS, sanitizeBm25Query } from '../../utils/bm25';
|
||||
|
||||
export class UserMemoryExperienceModel {
|
||||
private userId: string;
|
||||
@@ -74,7 +74,9 @@ export class UserMemoryExperienceModel {
|
||||
const normalizedPageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||
const offset = (normalizedPage - 1) * normalizedPageSize;
|
||||
const normalizedQuery = typeof q === 'string' ? q.trim() : '';
|
||||
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
|
||||
const bm25Query = normalizedQuery
|
||||
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
|
||||
: '';
|
||||
|
||||
// Build WHERE conditions
|
||||
const conditions: Array<SQL | undefined> = [
|
||||
|
||||
@@ -6,7 +6,7 @@ import { and, asc, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
import type { NewUserMemoryIdentity, UserMemoryIdentity } from '../../schemas';
|
||||
import { userMemories, userMemoriesIdentities } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { sanitizeBm25Query } from '../../utils/bm25';
|
||||
import { SAFE_BM25_QUERY_OPTIONS, sanitizeBm25Query } from '../../utils/bm25';
|
||||
|
||||
export class UserMemoryIdentityModel {
|
||||
private userId: string;
|
||||
@@ -75,7 +75,9 @@ export class UserMemoryIdentityModel {
|
||||
const normalizedPageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||
const offset = (normalizedPage - 1) * normalizedPageSize;
|
||||
const normalizedQuery = typeof q === 'string' ? q.trim() : '';
|
||||
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
|
||||
const bm25Query = normalizedQuery
|
||||
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
|
||||
: '';
|
||||
|
||||
// Build WHERE conditions
|
||||
const conditions: Array<SQL | undefined> = [
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
userMemoriesPreferences,
|
||||
} from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { sanitizeBm25Query } from '../../utils/bm25';
|
||||
import { SAFE_BM25_QUERY_OPTIONS, sanitizeBm25Query } from '../../utils/bm25';
|
||||
import { selectNonVectorColumns } from '../../utils/columns';
|
||||
import { TopicModel } from '../topic';
|
||||
import type { UserMemoryHybridSearchAggregatedResult } from './query';
|
||||
@@ -900,7 +900,9 @@ export class UserMemoryModel {
|
||||
|
||||
const normalizedQuery = typeof q === 'string' ? q.trim() : '';
|
||||
const resolvedLayer = layer ?? LayersEnum.Context;
|
||||
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
|
||||
const bm25Query = normalizedQuery
|
||||
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
|
||||
: '';
|
||||
|
||||
const conditions: Array<SQL | undefined> = [
|
||||
eq(userMemories.userId, this.userId),
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
userMemoriesPreferences,
|
||||
} from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { sanitizeBm25Query } from '../../utils/bm25';
|
||||
import { SAFE_BM25_QUERY_OPTIONS, sanitizeBm25Query } from '../../utils/bm25';
|
||||
|
||||
const DEFAULT_HYBRID_SEARCH_LIMIT = 5;
|
||||
const HYBRID_SEARCH_OVERFETCH_MULTIPLIER = 3;
|
||||
@@ -2059,7 +2059,9 @@ export class UserMemoryQueryModel {
|
||||
params: SearchMemoryParams,
|
||||
) {
|
||||
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
|
||||
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
|
||||
const bm25Query = normalizedQuery
|
||||
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
|
||||
: '';
|
||||
const conditions = [
|
||||
eq(userMemoriesActivities.userId, this.userId),
|
||||
eq(userMemories.userId, this.userId),
|
||||
@@ -2122,7 +2124,9 @@ export class UserMemoryQueryModel {
|
||||
params: SearchMemoryParams,
|
||||
) {
|
||||
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
|
||||
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
|
||||
const bm25Query = normalizedQuery
|
||||
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
|
||||
: '';
|
||||
const conditions = [
|
||||
eq(userMemoriesContexts.userId, this.userId),
|
||||
eq(userMemories.userId, this.userId),
|
||||
@@ -2221,7 +2225,9 @@ export class UserMemoryQueryModel {
|
||||
params: SearchMemoryParams,
|
||||
) {
|
||||
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
|
||||
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
|
||||
const bm25Query = normalizedQuery
|
||||
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
|
||||
: '';
|
||||
const conditions = [
|
||||
eq(userMemoriesExperiences.userId, this.userId),
|
||||
eq(userMemories.userId, this.userId),
|
||||
@@ -2277,7 +2283,9 @@ export class UserMemoryQueryModel {
|
||||
params: SearchMemoryParams,
|
||||
) {
|
||||
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
|
||||
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
|
||||
const bm25Query = normalizedQuery
|
||||
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
|
||||
: '';
|
||||
const conditions = [
|
||||
eq(userMemoriesPreferences.userId, this.userId),
|
||||
eq(userMemories.userId, this.userId),
|
||||
@@ -2330,7 +2338,9 @@ export class UserMemoryQueryModel {
|
||||
params: SearchMemoryParams,
|
||||
) {
|
||||
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
|
||||
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
|
||||
const bm25Query = normalizedQuery
|
||||
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
|
||||
: '';
|
||||
const conditions = [
|
||||
eq(userMemoriesIdentities.userId, this.userId),
|
||||
eq(userMemories.userId, this.userId),
|
||||
|
||||
+37
-37
@@ -267,7 +267,7 @@ describe('AiInfraRepos', () => {
|
||||
expect(merged.settings).toEqual({ searchImpl: 'params' });
|
||||
});
|
||||
|
||||
// 测试场景:用户模型 abilitie 为空(Empty),而基础模型有搜索能力和设置
|
||||
// Test scenario: user model abilities is empty (Empty) while the base model has search capability and settings
|
||||
it('should retain builtin abilities and settings when user model has no abilities (empty) and builtin has settings', async () => {
|
||||
const providerId = 'openai';
|
||||
|
||||
@@ -285,7 +285,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
abilities: { search: false }, // 使用 builtin abilities
|
||||
abilities: { search: false }, // Use builtin abilities
|
||||
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
|
||||
},
|
||||
];
|
||||
@@ -297,9 +297,9 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// 使用 builtin abilities
|
||||
// Use builtin abilities
|
||||
expect(merged?.abilities?.search).toEqual(false);
|
||||
// 保留 builtin settings
|
||||
// Retain builtin settings
|
||||
expect(merged?.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -320,7 +320,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
abilities: { search: true }, // 使用 builtin abilities
|
||||
abilities: { search: true }, // Use builtin abilities
|
||||
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
|
||||
},
|
||||
];
|
||||
@@ -332,13 +332,13 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// 使用 builtin abilities
|
||||
// Use builtin abilities
|
||||
expect(merged?.abilities?.search).toEqual(true);
|
||||
// 保留 builtin settings
|
||||
// Retain builtin settings
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
|
||||
});
|
||||
|
||||
// 测试场景:用户模型未启用搜索(abilities.search 为 undefined),而基础模型有搜索能力和设置
|
||||
// Test scenario: user model has search disabled (abilities.search is undefined) while the base model has search capability and settings
|
||||
it('should retain builtin settings when user model has no abilities (empty) and builtin has settings', async () => {
|
||||
const providerId = 'openai';
|
||||
|
||||
@@ -347,7 +347,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
abilities: { vision: true }, // 启用 vision 能力, no search
|
||||
abilities: { vision: true }, // Enable vision ability, no search
|
||||
},
|
||||
];
|
||||
|
||||
@@ -368,9 +368,9 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// abilities.search 会被 merge 为 false,此处和 getEnabledAiModel 不同
|
||||
// abilities.search will be merged as false, differs from getEnabledAiModel
|
||||
expect(merged?.abilities?.search).toEqual(false);
|
||||
// 删去 builtin settings
|
||||
// Remove builtin settings
|
||||
expect(merged?.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -382,7 +382,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
abilities: { vision: true }, // 启用 vision 能力, no search
|
||||
abilities: { vision: true }, // Enable vision ability, no search
|
||||
},
|
||||
];
|
||||
|
||||
@@ -391,7 +391,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
abilities: { search: true }, // builtin abilities 会被 merge
|
||||
abilities: { search: true }, // builtin abilities will be merged
|
||||
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
|
||||
},
|
||||
];
|
||||
@@ -403,13 +403,13 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// abilities.search 会被 merge 为 true,此处和 getEnabledAiModel 不同
|
||||
// abilities.search will be merged as true, differs from getEnabledAiModel
|
||||
expect(merged?.abilities?.search).toEqual(true);
|
||||
// 保留 builtin settings
|
||||
// Retain builtin settings
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
|
||||
});
|
||||
|
||||
// 测试:用户模型无 abilities.search(undefined),保留 builtin settings(mergeArrayById 优先用户,但用户无则 builtin)
|
||||
// Test: user model has no abilities.search (undefined), retains builtin settings (mergeArrayById prefers user, falls back to builtin when absent)
|
||||
it('should retain builtin settings when user model has no abilities.search (undefined) and builtin has settings', async () => {
|
||||
const providerId = 'openai';
|
||||
|
||||
@@ -418,7 +418,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
abilities: {}, // 无 search
|
||||
abilities: {}, // no search
|
||||
},
|
||||
];
|
||||
|
||||
@@ -440,7 +440,7 @@ describe('AiInfraRepos', () => {
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
expect(merged?.abilities?.search).toBeUndefined();
|
||||
// 保留 builtin settings
|
||||
// Retain builtin settings
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
|
||||
});
|
||||
|
||||
@@ -452,7 +452,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
abilities: {}, // 无 search
|
||||
abilities: {}, // no search
|
||||
},
|
||||
];
|
||||
|
||||
@@ -461,7 +461,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
// 无 settings
|
||||
// no settings
|
||||
},
|
||||
];
|
||||
|
||||
@@ -473,11 +473,11 @@ describe('AiInfraRepos', () => {
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
expect(merged?.abilities?.search).toBeUndefined();
|
||||
// 无 settings
|
||||
// no settings
|
||||
expect(merged?.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
// 测试:用户模型有 abilities.search: true
|
||||
// Test: user model has abilities.search: true
|
||||
it('should inject defaults when user has search: true, no existing settings (builtin none)', async () => {
|
||||
const providerId = 'openai';
|
||||
|
||||
@@ -486,7 +486,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
abilities: { search: true }, // 用户启用
|
||||
abilities: { search: true }, // user-enabled
|
||||
},
|
||||
];
|
||||
|
||||
@@ -495,7 +495,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
// 无 settings
|
||||
// no settings
|
||||
},
|
||||
];
|
||||
|
||||
@@ -507,7 +507,7 @@ describe('AiInfraRepos', () => {
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
expect(merged?.abilities).toEqual({ search: true });
|
||||
// 注入 defaults
|
||||
// Inject defaults
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'params' });
|
||||
});
|
||||
|
||||
@@ -540,11 +540,11 @@ describe('AiInfraRepos', () => {
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
expect(merged?.abilities).toEqual({ search: true });
|
||||
// 使用 builtin settings
|
||||
// Use builtin settings
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'tool' });
|
||||
});
|
||||
|
||||
// 测试:用户模型有 abilities.search: false
|
||||
// Test: user model has abilities.search: false
|
||||
it('should remove settings when user has search: false and builtin has settings', async () => {
|
||||
const providerId = 'openai';
|
||||
|
||||
@@ -553,7 +553,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
abilities: { search: false }, // 用户禁用
|
||||
abilities: { search: false }, // user-disabled
|
||||
},
|
||||
];
|
||||
|
||||
@@ -574,7 +574,7 @@ describe('AiInfraRepos', () => {
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
expect(merged?.abilities).toEqual({ search: false });
|
||||
// 移除 search 相关,保留其他
|
||||
// Remove search-related settings, retain others
|
||||
expect(merged?.settings).toEqual({ extendParams: [] });
|
||||
});
|
||||
|
||||
@@ -595,7 +595,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
// 无 settings
|
||||
// no settings
|
||||
},
|
||||
];
|
||||
|
||||
@@ -607,7 +607,7 @@ describe('AiInfraRepos', () => {
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
expect(merged?.abilities).toEqual({ search: false });
|
||||
// 无 settings
|
||||
// no settings
|
||||
expect(merged?.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -640,7 +640,7 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// 应该使用用户的 settings
|
||||
// Should use user settings
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'user-provider' });
|
||||
});
|
||||
|
||||
@@ -653,7 +653,7 @@ describe('AiInfraRepos', () => {
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
abilities: { vision: true },
|
||||
// 用户未设置 settings
|
||||
// user has not set settings
|
||||
},
|
||||
];
|
||||
|
||||
@@ -673,7 +673,7 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// 应该使用内置的 settings
|
||||
// Should use builtin settings
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'tool', searchProvider: 'google' });
|
||||
});
|
||||
|
||||
@@ -686,7 +686,7 @@ describe('AiInfraRepos', () => {
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
abilities: { vision: true },
|
||||
// 用户未设置 settings
|
||||
// user has not set settings
|
||||
},
|
||||
];
|
||||
|
||||
@@ -695,7 +695,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
type: 'chat',
|
||||
enabled: true,
|
||||
// 内置也无 settings
|
||||
// builtin also has no settings
|
||||
},
|
||||
];
|
||||
|
||||
@@ -706,7 +706,7 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// 无 settings
|
||||
// no settings
|
||||
expect(merged?.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@ describe('AiInfraRepos', () => {
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'params' });
|
||||
});
|
||||
|
||||
// 测试场景:用户模型 abilitie 为空(Empty),而基础模型有搜索能力和设置
|
||||
// Test scenario: user model abilities is empty (Empty) while the base model has search capability and settings
|
||||
it('should retain builtin abilities and settings when user model has no abilities (empty) and builtin has settings', async () => {
|
||||
const mockProviders = [
|
||||
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
|
||||
@@ -346,7 +346,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
enabled: true,
|
||||
type: 'chat' as const,
|
||||
abilities: { search: false }, // 使用 builtin abilities
|
||||
abilities: { search: false }, // Use builtin abilities
|
||||
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
|
||||
};
|
||||
|
||||
@@ -358,9 +358,9 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// 使用 builtin abilities
|
||||
// Use builtin abilities
|
||||
expect(merged?.abilities?.search).toEqual(false);
|
||||
// 删去 builtin settings
|
||||
// Remove builtin settings
|
||||
expect(merged?.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -381,7 +381,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
enabled: true,
|
||||
type: 'chat' as const,
|
||||
abilities: { search: true }, // 使用 builtin abilities
|
||||
abilities: { search: true }, // Use builtin abilities
|
||||
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
|
||||
};
|
||||
|
||||
@@ -393,13 +393,13 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// 使用 builtin abilities
|
||||
// Use builtin abilities
|
||||
expect(merged?.abilities?.search).toEqual(true);
|
||||
// 保留 builtin settings
|
||||
// Retain builtin settings
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
|
||||
});
|
||||
|
||||
// 测试场景:用户模型未启用搜索(abilities.search 为 undefined),而基础模型有搜索能力和设置
|
||||
// Test scenario: user model has search disabled (abilities.search is undefined) while the base model has search capability and settings
|
||||
it('should retain builtin settings when user model has no abilities.search (undefined) and builtin has settings', async () => {
|
||||
const mockProviders = [
|
||||
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
|
||||
@@ -410,14 +410,14 @@ describe('AiInfraRepos', () => {
|
||||
providerId: 'openai',
|
||||
enabled: true,
|
||||
type: 'chat',
|
||||
abilities: { vision: true }, // 启用 vision 能力, no search
|
||||
abilities: { vision: true }, // Enable vision ability, no search
|
||||
};
|
||||
|
||||
const builtinModel = {
|
||||
id: 'gpt-4',
|
||||
enabled: true,
|
||||
type: 'chat' as const,
|
||||
abilities: { search: false }, // builtin abilities 不生效
|
||||
abilities: { search: false }, // builtin abilities have no effect
|
||||
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
|
||||
};
|
||||
|
||||
@@ -429,9 +429,9 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// abilities.search 仍 undefined(兼容老版本)
|
||||
// abilities.search remains undefined (backward compatible)
|
||||
expect(merged?.abilities?.search).toBeUndefined();
|
||||
// 保留 builtin settings
|
||||
// Retain builtin settings
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
|
||||
});
|
||||
|
||||
@@ -445,14 +445,14 @@ describe('AiInfraRepos', () => {
|
||||
providerId: 'openai',
|
||||
enabled: true,
|
||||
type: 'chat',
|
||||
abilities: { vision: true }, // 启用 vision 能力, no search
|
||||
abilities: { vision: true }, // Enable vision ability, no search
|
||||
};
|
||||
|
||||
const builtinModel = {
|
||||
id: 'gpt-4',
|
||||
enabled: true,
|
||||
type: 'chat' as const,
|
||||
abilities: { search: true }, // builtin abilities 不生效
|
||||
abilities: { search: true }, // builtin abilities have no effect
|
||||
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
|
||||
};
|
||||
|
||||
@@ -464,13 +464,13 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// abilities.search 仍 undefined(兼容老版本)
|
||||
// abilities.search remains undefined (backward compatible)
|
||||
expect(merged?.abilities?.search).toBeUndefined();
|
||||
// 保留 builtin settings
|
||||
// Retain builtin settings
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
|
||||
});
|
||||
|
||||
// 测试场景:用户模型未启用搜索(abilities.search 为 undefined),而基础模型也无搜索能力和设置
|
||||
// Test scenario: user model has search disabled (abilities.search is undefined) and the base model also has no search capability or settings
|
||||
it('should retain no settings when user model has no abilities.search (undefined) and builtin has no settings', async () => {
|
||||
const mockProviders = [
|
||||
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
|
||||
@@ -481,7 +481,7 @@ describe('AiInfraRepos', () => {
|
||||
providerId: 'openai',
|
||||
enabled: true,
|
||||
type: 'chat',
|
||||
abilities: {}, // 无 search
|
||||
abilities: {}, // no search
|
||||
};
|
||||
|
||||
const builtinModel = {
|
||||
@@ -489,7 +489,7 @@ describe('AiInfraRepos', () => {
|
||||
enabled: true,
|
||||
type: 'chat' as const,
|
||||
abilities: {},
|
||||
// builtin 无 settings
|
||||
// builtin has no settings
|
||||
};
|
||||
|
||||
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
|
||||
@@ -501,11 +501,11 @@ describe('AiInfraRepos', () => {
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
expect(merged?.abilities?.search).toBeUndefined();
|
||||
// 无 settings
|
||||
// no settings
|
||||
expect(merged?.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
// 测试:用户模型有 abilities.search: true
|
||||
// Test: user model has abilities.search: true
|
||||
it('should inject defaults when user has search: true, no existing settings (builtin none)', async () => {
|
||||
const mockProviders = [
|
||||
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
|
||||
@@ -516,7 +516,7 @@ describe('AiInfraRepos', () => {
|
||||
providerId: 'openai',
|
||||
enabled: true,
|
||||
type: 'chat',
|
||||
abilities: { search: true }, // 用户启用 search
|
||||
abilities: { search: true }, // user-enabled search
|
||||
};
|
||||
|
||||
const builtinModel = {
|
||||
@@ -524,7 +524,7 @@ describe('AiInfraRepos', () => {
|
||||
enabled: true,
|
||||
type: 'chat' as const,
|
||||
abilities: {},
|
||||
// 无 settings
|
||||
// no settings
|
||||
};
|
||||
|
||||
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
|
||||
@@ -536,7 +536,7 @@ describe('AiInfraRepos', () => {
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
expect(merged?.abilities).toEqual({ search: true });
|
||||
// 注入 defaults (openai: params)
|
||||
// Inject defaults (openai: params)
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'params' });
|
||||
});
|
||||
|
||||
@@ -557,7 +557,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
enabled: true,
|
||||
type: 'chat' as const,
|
||||
settings: { searchImpl: 'tool' }, // builtin 有 settings
|
||||
settings: { searchImpl: 'tool' }, // builtin has settings
|
||||
};
|
||||
|
||||
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
|
||||
@@ -569,11 +569,11 @@ describe('AiInfraRepos', () => {
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
expect(merged?.abilities).toEqual({ search: true });
|
||||
// 使用 builtin settings
|
||||
// Use builtin settings
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'tool' });
|
||||
});
|
||||
|
||||
// 测试:用户模型有 abilities.search: false
|
||||
// Test: user model has abilities.search: false
|
||||
it('should remove settings when user has search: false and builtin has settings', async () => {
|
||||
const mockProviders = [
|
||||
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
|
||||
@@ -584,14 +584,14 @@ describe('AiInfraRepos', () => {
|
||||
providerId: 'openai',
|
||||
enabled: true,
|
||||
type: 'chat',
|
||||
abilities: { search: false }, // 用户禁用 search
|
||||
abilities: { search: false }, // user-disabled search
|
||||
};
|
||||
|
||||
const builtinModel = {
|
||||
id: 'gpt-4',
|
||||
enabled: true,
|
||||
type: 'chat' as const,
|
||||
settings: { searchImpl: 'tool', extendParams: [] }, // builtin 有 settings
|
||||
settings: { searchImpl: 'tool', extendParams: [] }, // builtin has settings
|
||||
};
|
||||
|
||||
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
|
||||
@@ -603,7 +603,7 @@ describe('AiInfraRepos', () => {
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
expect(merged?.abilities).toEqual({ search: false });
|
||||
// 移除 search 相关,保留其他
|
||||
// Remove search-related settings, retain others
|
||||
expect(merged?.settings).toEqual({ extendParams: [] });
|
||||
});
|
||||
|
||||
@@ -624,7 +624,7 @@ describe('AiInfraRepos', () => {
|
||||
id: 'gpt-4',
|
||||
enabled: true,
|
||||
type: 'chat' as const,
|
||||
// 无 settings
|
||||
// no settings
|
||||
};
|
||||
|
||||
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
|
||||
@@ -636,7 +636,7 @@ describe('AiInfraRepos', () => {
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
expect(merged?.abilities).toEqual({ search: false });
|
||||
// 无 settings
|
||||
// no settings
|
||||
expect(merged?.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -669,7 +669,7 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// 应该使用用户的 settings,不是内置的
|
||||
// Should use user settings, not builtin
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'user-provider' });
|
||||
});
|
||||
|
||||
@@ -684,7 +684,7 @@ describe('AiInfraRepos', () => {
|
||||
enabled: true,
|
||||
type: 'chat',
|
||||
abilities: { vision: true },
|
||||
// 用户未设置 settings
|
||||
// user has not set settings
|
||||
};
|
||||
|
||||
const builtinModel = {
|
||||
@@ -702,7 +702,7 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// 应该使用内置的 settings
|
||||
// Should use builtin settings
|
||||
expect(merged?.settings).toEqual({ searchImpl: 'tool', searchProvider: 'google' });
|
||||
});
|
||||
|
||||
@@ -757,14 +757,14 @@ describe('AiInfraRepos', () => {
|
||||
enabled: true,
|
||||
type: 'chat',
|
||||
abilities: { vision: true },
|
||||
// 用户未设置 settings
|
||||
// user has not set settings
|
||||
};
|
||||
|
||||
const builtinModel = {
|
||||
id: 'gpt-4',
|
||||
enabled: true,
|
||||
type: 'chat' as const,
|
||||
// 内置也无 settings
|
||||
// builtin also has no settings
|
||||
};
|
||||
|
||||
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
|
||||
@@ -775,7 +775,7 @@ describe('AiInfraRepos', () => {
|
||||
|
||||
const merged = result.find((m) => m.id === 'gpt-4');
|
||||
expect(merged).toBeDefined();
|
||||
// 无 settings
|
||||
// no settings
|
||||
expect(merged?.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ import { DATA_EXPORT_CONFIG, DataExporterRepos } from './index';
|
||||
|
||||
let db: LobeChatDatabase;
|
||||
|
||||
// 设置测试数据
|
||||
// Set up test data
|
||||
describe('DataExporterRepos', () => {
|
||||
// 测试数据 ID
|
||||
// Test data IDs
|
||||
const testIds = {
|
||||
userId: 'test-user-id',
|
||||
fileId: 'test-file-id',
|
||||
@@ -36,7 +36,7 @@ describe('DataExporterRepos', () => {
|
||||
knowledgeBaseId: 'test-kb-id',
|
||||
};
|
||||
|
||||
// 设置测试环境
|
||||
// Set up test environment
|
||||
const userId: string = testIds.userId;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -45,20 +45,20 @@ describe('DataExporterRepos', () => {
|
||||
|
||||
const setupTestData = async () => {
|
||||
await db.transaction(async (trx) => {
|
||||
// 用户数据
|
||||
// User data
|
||||
await trx.insert(users).values({
|
||||
id: testIds.userId,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
// 用户设置
|
||||
// User settings
|
||||
await trx.insert(userSettings).values({
|
||||
id: testIds.userId,
|
||||
general: { theme: 'light' },
|
||||
});
|
||||
|
||||
// 全局文件
|
||||
// Global files
|
||||
await trx.insert(globalFiles).values({
|
||||
hashId: testIds.fileHash,
|
||||
fileType: 'text/plain',
|
||||
@@ -67,7 +67,7 @@ describe('DataExporterRepos', () => {
|
||||
creator: testIds.userId,
|
||||
});
|
||||
|
||||
// 文件数据
|
||||
// File data
|
||||
await trx.insert(files).values({
|
||||
id: testIds.fileId,
|
||||
userId: testIds.userId,
|
||||
@@ -78,13 +78,13 @@ describe('DataExporterRepos', () => {
|
||||
url: 'https://example.com/test-file.txt',
|
||||
});
|
||||
|
||||
// 会话组
|
||||
// Session groups
|
||||
await trx.insert(sessionGroups).values({
|
||||
name: 'Test Group',
|
||||
userId: testIds.userId,
|
||||
});
|
||||
|
||||
// 会话
|
||||
// Sessions
|
||||
await trx.insert(sessions).values({
|
||||
id: testIds.sessionId,
|
||||
slug: 'test-session',
|
||||
@@ -92,7 +92,7 @@ describe('DataExporterRepos', () => {
|
||||
userId: testIds.userId,
|
||||
});
|
||||
|
||||
// 主题
|
||||
// Topics
|
||||
await trx.insert(topics).values({
|
||||
id: testIds.topicId,
|
||||
title: 'Test Topic',
|
||||
@@ -100,7 +100,7 @@ describe('DataExporterRepos', () => {
|
||||
userId: testIds.userId,
|
||||
});
|
||||
|
||||
// 消息
|
||||
// Messages
|
||||
await trx.insert(messages).values({
|
||||
id: testIds.messageId,
|
||||
role: 'user',
|
||||
@@ -110,42 +110,42 @@ describe('DataExporterRepos', () => {
|
||||
topicId: testIds.topicId,
|
||||
});
|
||||
|
||||
// 代理
|
||||
// Agents
|
||||
await trx.insert(agents).values({
|
||||
id: testIds.agentId,
|
||||
title: 'Test Agent',
|
||||
userId: testIds.userId,
|
||||
});
|
||||
|
||||
// 代理到会话的关联
|
||||
// Agent-to-session associations
|
||||
await trx.insert(agentsToSessions).values({
|
||||
agentId: testIds.agentId,
|
||||
sessionId: testIds.sessionId,
|
||||
userId: testIds.userId,
|
||||
});
|
||||
|
||||
// 文件到会话的关联
|
||||
// File-to-session associations
|
||||
await trx.insert(filesToSessions).values({
|
||||
fileId: testIds.fileId,
|
||||
sessionId: testIds.sessionId,
|
||||
userId: testIds.userId,
|
||||
});
|
||||
|
||||
// 知识库
|
||||
// Knowledge bases
|
||||
await trx.insert(knowledgeBases).values({
|
||||
id: testIds.knowledgeBaseId,
|
||||
name: 'Test Knowledge Base',
|
||||
userId: testIds.userId,
|
||||
});
|
||||
|
||||
// 知识库文件
|
||||
// Knowledge base files
|
||||
await trx.insert(knowledgeBaseFiles).values({
|
||||
knowledgeBaseId: testIds.knowledgeBaseId,
|
||||
fileId: testIds.fileId,
|
||||
userId: testIds.userId,
|
||||
});
|
||||
|
||||
// 代理知识库
|
||||
// Agent knowledge bases
|
||||
await trx.insert(agentsKnowledgeBases).values({
|
||||
agentId: testIds.agentId,
|
||||
knowledgeBaseId: testIds.knowledgeBaseId,
|
||||
@@ -155,7 +155,7 @@ describe('DataExporterRepos', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// 清理并插入测试数据
|
||||
// Clean up and insert test data
|
||||
await db.delete(users);
|
||||
await db.delete(globalFiles);
|
||||
await setupTestData();
|
||||
@@ -170,17 +170,17 @@ describe('DataExporterRepos', () => {
|
||||
|
||||
describe('export', () => {
|
||||
it('should export all user data correctly', async () => {
|
||||
// 创建导出器实例
|
||||
// Create exporter instance
|
||||
const dataExporter = new DataExporterRepos(db, userId);
|
||||
|
||||
// 执行导出
|
||||
// Execute export
|
||||
const result = await dataExporter.export();
|
||||
|
||||
// 验证基础表导出结果
|
||||
// Verify base table export results
|
||||
// expect(result).toHaveProperty('users');
|
||||
// expect(result.users).toHaveLength(1);
|
||||
// expect(result.users[0]).toHaveProperty('id', testIds.userId);
|
||||
// expect(result.users[0]).not.toHaveProperty('userId'); // userId 字段应该被移除
|
||||
// expect(result.users[0]).not.toHaveProperty('userId'); // the userId field should be removed
|
||||
|
||||
expect(result).toHaveProperty('userSettings');
|
||||
expect(result.userSettings).toHaveLength(1);
|
||||
@@ -212,7 +212,7 @@ describe('DataExporterRepos', () => {
|
||||
// expect(result.knowledgeBases).toHaveLength(1);
|
||||
// expect(result.knowledgeBases[0]).toHaveProperty('id', testIds.knowledgeBaseId);
|
||||
|
||||
// 验证关联表导出结果
|
||||
// Verify relation table export results
|
||||
// expect(result).toHaveProperty('globalFiles');
|
||||
// expect(result.globalFiles).toHaveLength(1);
|
||||
// expect(result.globalFiles[0]).toHaveProperty('hashId', testIds.fileHash);
|
||||
@@ -237,18 +237,18 @@ describe('DataExporterRepos', () => {
|
||||
});
|
||||
|
||||
it('should handle empty database gracefully', async () => {
|
||||
// 清空数据库
|
||||
// Clear the database
|
||||
|
||||
await db.delete(users);
|
||||
await db.delete(globalFiles);
|
||||
|
||||
// 创建导出器实例
|
||||
// Create exporter instance
|
||||
const dataExporter = new DataExporterRepos(db, userId);
|
||||
|
||||
// 执行导出
|
||||
// Execute export
|
||||
const result = await dataExporter.export();
|
||||
|
||||
// 验证所有表都返回空数组
|
||||
// Verify all tables return empty arrays
|
||||
DATA_EXPORT_CONFIG.baseTables.forEach(({ table }) => {
|
||||
expect(result).toHaveProperty(table);
|
||||
expect(result[table]).toEqual([]);
|
||||
@@ -261,17 +261,17 @@ describe('DataExporterRepos', () => {
|
||||
});
|
||||
|
||||
it('should handle database query errors', async () => {
|
||||
// 模拟查询错误
|
||||
// Simulate a query error
|
||||
// @ts-ignore
|
||||
vi.spyOn(db.query.users, 'findMany').mockRejectedValueOnce(new Error('Database error'));
|
||||
|
||||
// 创建导出器实例
|
||||
// Create exporter instance
|
||||
const dataExporter = new DataExporterRepos(db, userId);
|
||||
|
||||
// 执行导出
|
||||
// Execute export
|
||||
const result = await dataExporter.export();
|
||||
|
||||
// 验证其他表仍然被导出
|
||||
// Verify other tables are still exported
|
||||
expect(result).toHaveProperty('sessions');
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
});
|
||||
@@ -329,7 +329,7 @@ describe('DataExporterRepos', () => {
|
||||
});
|
||||
|
||||
it('should export data for a different user', async () => {
|
||||
// 创建另一个用户
|
||||
// Create another user
|
||||
const anotherUserId = 'another-user-id';
|
||||
await db.transaction(async (trx) => {
|
||||
await trx.insert(users).values({
|
||||
@@ -345,13 +345,13 @@ describe('DataExporterRepos', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 创建导出器实例,使用另一个用户 ID
|
||||
// Create exporter instance using another user ID
|
||||
const dataExporter = new DataExporterRepos(db, anotherUserId);
|
||||
|
||||
// 执行导出
|
||||
// Execute export
|
||||
const result = await dataExporter.export();
|
||||
|
||||
// 验证只导出了另一个用户的数据
|
||||
// Verify only the other user's data was exported
|
||||
// expect(result).toHaveProperty('users');
|
||||
// expect(result.users).toHaveLength(1);
|
||||
// expect(result.users[0]).toHaveProperty('id', anotherUserId);
|
||||
|
||||
@@ -18,7 +18,7 @@ let importer: DataImporterRepos;
|
||||
beforeEach(async () => {
|
||||
await clientDB.delete(Schema.users);
|
||||
|
||||
// 创建测试数据
|
||||
// Create test data
|
||||
await clientDB.transaction(async (tx) => {
|
||||
await tx.insert(Schema.users).values({ id: userId });
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ let importer: DataImporterRepos;
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
|
||||
// 创建测试数据
|
||||
// Create test data
|
||||
await serverDB.transaction(async (tx) => {
|
||||
await tx.insert(users).values({ id: userId });
|
||||
});
|
||||
@@ -323,13 +323,13 @@ describe('DataImporter', () => {
|
||||
|
||||
await importer.importData(data);
|
||||
|
||||
// 验证是否为每个 session 创建了对应的 agent
|
||||
// Verify that a corresponding agent was created for each session
|
||||
const agentCount = await serverDB.query.agents.findMany({
|
||||
where: eq(agents.userId, userId),
|
||||
});
|
||||
expect(agentCount).toHaveLength(2);
|
||||
|
||||
// 验证 agent 的属性是否正确设置
|
||||
// Verify that agent attributes are correctly set
|
||||
const agent1 = await serverDB.query.agents.findFirst({
|
||||
where: eq(agents.systemRole, 'Test Agent 1'),
|
||||
});
|
||||
@@ -340,7 +340,7 @@ describe('DataImporter', () => {
|
||||
});
|
||||
expect(agent2?.model).toBe('def');
|
||||
|
||||
// 验证 agentsToSessions 关联是否正确建立
|
||||
// Verify that the agentsToSessions association is correctly established
|
||||
const session1 = await serverDB.query.sessions.findFirst({
|
||||
where: eq(sessions.clientId, 'session1'),
|
||||
});
|
||||
@@ -363,7 +363,7 @@ describe('DataImporter', () => {
|
||||
});
|
||||
|
||||
it('should not create duplicate agents for existing sessions', async () => {
|
||||
// 先导入一些 sessions
|
||||
// First import some sessions
|
||||
await importer.importData({
|
||||
sessions: [
|
||||
{
|
||||
@@ -387,7 +387,7 @@ describe('DataImporter', () => {
|
||||
version: CURRENT_CONFIG_VERSION,
|
||||
});
|
||||
|
||||
// 再次导入相同的 sessions
|
||||
// Import the same sessions again
|
||||
await importer.importData({
|
||||
sessions: [
|
||||
{
|
||||
@@ -411,7 +411,7 @@ describe('DataImporter', () => {
|
||||
version: CURRENT_CONFIG_VERSION,
|
||||
});
|
||||
|
||||
// 验证只创建了一个 agent
|
||||
// Verify that only one agent was created
|
||||
const agentCount = await serverDB.query.agents.findMany({
|
||||
where: eq(agents.userId, userId),
|
||||
});
|
||||
|
||||
@@ -101,9 +101,6 @@ export const tasks = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export type NewTask = typeof tasks.$inferInsert;
|
||||
export type TaskItem = typeof tasks.$inferSelect;
|
||||
|
||||
// ── Task Dependencies ────────────────────────────────────
|
||||
|
||||
export const taskDependencies = pgTable(
|
||||
|
||||
@@ -254,7 +254,7 @@ describe('UserModel', () => {
|
||||
|
||||
it('should handle decrypt failure and return empty object', async () => {
|
||||
const userId = 'user-api-test-id';
|
||||
// 模拟解密失败的情况
|
||||
// Simulate decrypt failure scenario
|
||||
const invalidEncryptedData = 'invalid:-encrypted-:data';
|
||||
await serverDB.insert(users).values({ id: userId });
|
||||
await serverDB.insert(userSettings).values({
|
||||
@@ -306,7 +306,7 @@ describe('UserModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 补充一些边界情况的测试
|
||||
// Additional edge case tests
|
||||
describe('edge cases', () => {
|
||||
describe('updatePreference', () => {
|
||||
it('should handle undefined preference', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { sanitizeBm25Query } from './bm25';
|
||||
import { SAFE_BM25_QUERY_OPTIONS, sanitizeBm25Query } from './bm25';
|
||||
|
||||
describe('sanitizeBm25Query', () => {
|
||||
it('should join multiple words with AND', () => {
|
||||
@@ -52,4 +52,61 @@ describe('sanitizeBm25Query', () => {
|
||||
expect(sanitizeBm25Query('你好世界')).toBe('你好世界');
|
||||
expect(sanitizeBm25Query('こんにちは')).toBe('こんにちは');
|
||||
});
|
||||
|
||||
// NOTICE:
|
||||
// These safeguards document the production failure mode where lexical search
|
||||
// received extremely long, tool-injected text and generated parser-hostile
|
||||
// BM25 query expressions.
|
||||
it('should keep boolean-like tokens by default', () => {
|
||||
expect(sanitizeBm25Query('alpha AND beta')).toBe('alpha AND AND AND beta');
|
||||
});
|
||||
|
||||
it('should drop boolean operator tokens from user input', () => {
|
||||
expect(sanitizeBm25Query('alpha AND beta or NOT gamma', SAFE_BM25_QUERY_OPTIONS)).toBe(
|
||||
'alpha AND beta AND gamma',
|
||||
);
|
||||
});
|
||||
|
||||
it('should cap the number of terms to avoid pathological long queries', () => {
|
||||
const longQuery = Array.from({ length: 80 }, (_, index) => `term${index + 1}`).join(' ');
|
||||
const result = sanitizeBm25Query(longQuery, SAFE_BM25_QUERY_OPTIONS);
|
||||
|
||||
expect(result.split(' AND ')).toHaveLength(48);
|
||||
});
|
||||
|
||||
// NOTICE:
|
||||
// Public util behavior is configurable by design; these tests protect the
|
||||
// options contract for call sites with different needs.
|
||||
it('should allow overriding safe behavior when configured', () => {
|
||||
expect(sanitizeBm25Query('alpha AND beta', { ...SAFE_BM25_QUERY_OPTIONS, maxTerms: 2 })).toBe(
|
||||
'alpha AND beta',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow overriding max terms', () => {
|
||||
expect(sanitizeBm25Query('a b c d', { maxTerms: 2 })).toBe('a AND b');
|
||||
});
|
||||
|
||||
it('should sanitize long tool-result-like payloads safely', () => {
|
||||
const payload = `
|
||||
TOOL: <searchResults>
|
||||
<item title="No. 12 Gonzaga carves up Maryland defense in 100-61 rout"
|
||||
url="https://gozags.com/news/2025/11/25/mens-basketball-no-12-gonzaga-carves-up-maryland.aspx" />
|
||||
<item title="Men's Basketball Falls in Tight Matchup Against Brown"
|
||||
url="https://goblackbears.com/news/2025/11/23/mens-basketball-falls-in-tight-matchup-against-brown.aspx" />
|
||||
</searchResults>
|
||||
ASSISTANT: Let me search for "lost by 30 points" "November 2023" "college basketball"
|
||||
AND OR NOT AND OR NOT AND OR NOT
|
||||
`
|
||||
.repeat(30)
|
||||
.trim();
|
||||
|
||||
const result = sanitizeBm25Query(payload, SAFE_BM25_QUERY_OPTIONS);
|
||||
const terms = result.split(' AND ');
|
||||
|
||||
expect(terms).toHaveLength(48);
|
||||
expect(terms).not.toContain('AND');
|
||||
expect(terms).not.toContain('OR');
|
||||
expect(terms).not.toContain('NOT');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
const BM25_BOOLEAN_OPERATORS = new Set(['AND', 'OR', 'NOT']);
|
||||
const BM25_MAX_TERMS = 48;
|
||||
|
||||
// NOTICE:
|
||||
// This utility is used by multiple lexical search paths. We keep safe defaults
|
||||
// to prevent parser-hostile queries (for example, huge tool-output payloads
|
||||
// containing many boolean-like tokens), while exposing options so specific
|
||||
// call sites can tune behavior if they have stricter/looser requirements.
|
||||
export interface SanitizeBm25QueryOptions {
|
||||
dropBooleanOperators?: boolean;
|
||||
maxTerms?: number;
|
||||
}
|
||||
|
||||
export const SAFE_BM25_QUERY_OPTIONS: Required<SanitizeBm25QueryOptions> = {
|
||||
dropBooleanOperators: true,
|
||||
maxTerms: BM25_MAX_TERMS,
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape special tantivy query syntax characters and join terms with AND
|
||||
* so all words must match (instead of Tantivy's default OR behavior).
|
||||
*/
|
||||
export function sanitizeBm25Query(query: string): string {
|
||||
export function sanitizeBm25Query(query: string, options: SanitizeBm25QueryOptions = {}): string {
|
||||
const { dropBooleanOperators = false, maxTerms } = options;
|
||||
const terms = query
|
||||
.trim()
|
||||
.replaceAll('-', ' ') // treat hyphens as word separators (ICU tokenizer does the same)
|
||||
.split(/\s+/)
|
||||
.map((word) => word.trim())
|
||||
.filter((word) => !dropBooleanOperators || !BM25_BOOLEAN_OPERATORS.has(word.toUpperCase()))
|
||||
.map((word) => word.replaceAll(/[+&|!(){}[\]^"~*?:\\/]/g, '\\$&'))
|
||||
.filter(Boolean);
|
||||
|
||||
if (terms.length === 0) throw new Error('Query is empty after sanitization');
|
||||
|
||||
if (typeof maxTerms === 'number') {
|
||||
return terms.slice(0, Math.max(1, maxTerms)).join(' AND ');
|
||||
}
|
||||
|
||||
return terms.join(' AND ');
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export const xiaomimimoChatModels: AIChatModelCard[] = [
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
search: false,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
@@ -51,7 +51,6 @@ export const xiaomimimoChatModels: AIChatModelCard[] = [
|
||||
releasedAt: '2026-03-18',
|
||||
settings: {
|
||||
extendParams: ['enableReasoning'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -59,7 +58,7 @@ export const xiaomimimoChatModels: AIChatModelCard[] = [
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
search: false,
|
||||
video: true,
|
||||
vision: true,
|
||||
},
|
||||
@@ -83,7 +82,6 @@ export const xiaomimimoChatModels: AIChatModelCard[] = [
|
||||
releasedAt: '2026-03-18',
|
||||
settings: {
|
||||
extendParams: ['enableReasoning'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
@@ -91,7 +89,7 @@ export const xiaomimimoChatModels: AIChatModelCard[] = [
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
search: false,
|
||||
},
|
||||
contextWindowTokens: 262_144,
|
||||
description:
|
||||
@@ -113,7 +111,6 @@ export const xiaomimimoChatModels: AIChatModelCard[] = [
|
||||
releasedAt: '2026-03-03',
|
||||
settings: {
|
||||
extendParams: ['enableReasoning'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ export const zhipuChatModels: AIChatModelCard[] = [
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
search: false,
|
||||
},
|
||||
contextWindowTokens: 200_000,
|
||||
description:
|
||||
@@ -27,7 +27,6 @@ export const zhipuChatModels: AIChatModelCard[] = [
|
||||
releasedAt: '2026-03-27',
|
||||
settings: {
|
||||
extendParams: ['enableReasoning'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface ExecAgentParams {
|
||||
appContext?: ExecAgentAppContext;
|
||||
/** Whether to auto-start execution after creating operation (default: true) */
|
||||
autoStart?: boolean;
|
||||
/**
|
||||
* Runtime of the client initiating this request. Used by the server to
|
||||
* enable `executor: 'client'` tools (e.g. local-system) when the caller
|
||||
* is a desktop Electron client that will receive `tool_execute` events
|
||||
* over the same Agent Gateway WebSocket.
|
||||
*/
|
||||
clientRuntime?: 'desktop' | 'web';
|
||||
/** Explicit device ID to bind to the topic and activate for this run */
|
||||
deviceId?: string;
|
||||
/** Optional existing message IDs to include in context */
|
||||
|
||||
@@ -38,6 +38,85 @@ export interface TaskTopicHandoff {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// ── Task list item (shared between router response and client) ──
|
||||
|
||||
export interface TaskParticipant {
|
||||
avatar: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'user' | 'agent';
|
||||
}
|
||||
|
||||
export interface TaskItem {
|
||||
accessedAt: Date;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
completedAt: Date | null;
|
||||
config: unknown;
|
||||
context: unknown;
|
||||
createdAt: Date;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string;
|
||||
currentTopicId: string | null;
|
||||
description: string | null;
|
||||
error: string | null;
|
||||
heartbeatInterval: number | null;
|
||||
heartbeatTimeout: number | null;
|
||||
id: string;
|
||||
identifier: string;
|
||||
instruction: string;
|
||||
lastHeartbeatAt: Date | null;
|
||||
maxTopics: number | null;
|
||||
name: string | null;
|
||||
parentTaskId: string | null;
|
||||
priority: number | null;
|
||||
schedulePattern: string | null;
|
||||
scheduleTimezone: string | null;
|
||||
seq: number;
|
||||
sortOrder: number | null;
|
||||
startedAt: Date | null;
|
||||
status: string;
|
||||
totalTopics: number | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type TaskListItem = TaskItem & {
|
||||
participants: TaskParticipant[];
|
||||
};
|
||||
|
||||
export interface NewTask {
|
||||
accessedAt?: Date;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
completedAt?: Date | null;
|
||||
config?: unknown;
|
||||
context?: unknown;
|
||||
createdAt?: Date;
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId: string;
|
||||
currentTopicId?: string | null;
|
||||
description?: string | null;
|
||||
error?: string | null;
|
||||
heartbeatInterval?: number | null;
|
||||
heartbeatTimeout?: number | null;
|
||||
id?: string;
|
||||
identifier: string;
|
||||
instruction: string;
|
||||
lastHeartbeatAt?: Date | null;
|
||||
maxTopics?: number | null;
|
||||
name?: string | null;
|
||||
parentTaskId?: string | null;
|
||||
priority?: number | null;
|
||||
schedulePattern?: string | null;
|
||||
scheduleTimezone?: string | null;
|
||||
seq: number;
|
||||
sortOrder?: number | null;
|
||||
startedAt?: Date | null;
|
||||
status?: string;
|
||||
totalTopics?: number | null;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
// ── Task Detail (shared across CLI, viewTask tool, task.detail router) ──
|
||||
|
||||
export interface TaskDetailSubtask {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { detectTruncatedJSON } from './detectTruncatedJSON';
|
||||
|
||||
describe('detectTruncatedJSON', () => {
|
||||
it('returns null for a balanced object', () => {
|
||||
expect(detectTruncatedJSON('{"a": 1}')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a balanced nested structure', () => {
|
||||
expect(detectTruncatedJSON('{"a": {"b": [1, 2]}}')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(detectTruncatedJSON('')).toBeNull();
|
||||
});
|
||||
|
||||
it('flags an object with an unclosed brace (typical LLM cutoff)', () => {
|
||||
const truncated = '{"title": "foo", "description": "bar", "type": "report"';
|
||||
expect(detectTruncatedJSON(truncated)).toMatch(/unclosed '\{'/);
|
||||
});
|
||||
|
||||
it('flags an unterminated string value', () => {
|
||||
const truncated = '{"title": "foo", "content": "this got cut';
|
||||
expect(detectTruncatedJSON(truncated)).toBe('unterminated string');
|
||||
});
|
||||
|
||||
it('flags an unclosed array', () => {
|
||||
const truncated = '[1, 2, 3';
|
||||
expect(detectTruncatedJSON(truncated)).toMatch(/unclosed '\['/);
|
||||
});
|
||||
|
||||
it('flags structure with both unclosed braces and brackets', () => {
|
||||
const truncated = '{"items": [1, 2, 3';
|
||||
// Any of the unclosed-bracket/brace reasons is acceptable — both are present.
|
||||
expect(detectTruncatedJSON(truncated)).toMatch(/unclosed/);
|
||||
});
|
||||
|
||||
it('returns null for malformed-but-balanced JSON (not a truncation signal)', () => {
|
||||
// invalid JSON but brackets balanced — should NOT be flagged as truncated
|
||||
expect(detectTruncatedJSON('{name: "foo"}')).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores braces and quotes inside string values', () => {
|
||||
expect(detectTruncatedJSON('{"code": "if (a) { return \\"x\\"; }"}')).toBeNull();
|
||||
});
|
||||
|
||||
it('flags deeply nested object truncation', () => {
|
||||
const truncated = '{"a": {"b": {"c": "d"';
|
||||
expect(detectTruncatedJSON(truncated)).toMatch(/unclosed '\{'/);
|
||||
});
|
||||
|
||||
it('flags truncation mid-string inside nested objects', () => {
|
||||
const truncated = '{"a": {"b": {"c": "still writing';
|
||||
expect(detectTruncatedJSON(truncated)).toBe('unterminated string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Detect whether a JSON string looks structurally truncated — typical when an
|
||||
* LLM's `max_tokens` budget runs out mid-generation of a tool call payload.
|
||||
*
|
||||
* Returns a short reason string when truncation is suspected, or `null` when
|
||||
* the structure looks balanced (in which case any parse failure is more likely
|
||||
* a plain syntax error rather than truncation).
|
||||
*
|
||||
* Intended to be called AFTER `JSON.parse` has already failed, to distinguish
|
||||
* "truncated by max_tokens" from "malformed but complete".
|
||||
*/
|
||||
export const detectTruncatedJSON = (text: string): string | null => {
|
||||
if (!text) return null;
|
||||
|
||||
let braces = 0;
|
||||
let brackets = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
|
||||
for (const ch of text) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (inString) {
|
||||
if (ch === '\\') escape = true;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = true;
|
||||
else if (ch === '{') braces++;
|
||||
else if (ch === '}') braces--;
|
||||
else if (ch === '[') brackets++;
|
||||
else if (ch === ']') brackets--;
|
||||
}
|
||||
|
||||
if (inString) return 'unterminated string';
|
||||
if (braces > 0) return `${braces} unclosed '{'`;
|
||||
if (brackets > 0) return `${brackets} unclosed '['`;
|
||||
return null;
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
export * from './base64';
|
||||
export * from './dedupeBy';
|
||||
export * from './chunkers';
|
||||
export * from './client/cookie';
|
||||
export * from './dedupeBy';
|
||||
export * from './detectChinese';
|
||||
export * from './detectTruncatedJSON';
|
||||
export * from './env';
|
||||
export * from './error';
|
||||
export * from './folderStructure';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import debug from 'debug';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { gatewayEnv } from '@/envs/gateway';
|
||||
import {
|
||||
BOT_RUNTIME_STATUSES,
|
||||
type BotRuntimeStatus,
|
||||
updateBotRuntimeStatus,
|
||||
} from '@/server/services/gateway/runtimeStatus';
|
||||
|
||||
const log = debug('api-route:agent:gateway:callback');
|
||||
|
||||
const StateChangeSchema = z.object({
|
||||
applicationId: z.string().optional(),
|
||||
connectionId: z.string(),
|
||||
platform: z.string(),
|
||||
state: z.object({
|
||||
error: z.string().optional(),
|
||||
status: z.enum(['connected', 'connecting', 'disconnected', 'error']),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Receive connection state-change callbacks from the external message gateway.
|
||||
* When a persistent connection (e.g. Discord WebSocket) transitions to
|
||||
* "connected" or "error" asynchronously, the gateway POSTs here so LobeHub
|
||||
* can update the bot runtime status visible to users.
|
||||
*
|
||||
* Authenticated with MESSAGE_GATEWAY_SERVICE_TOKEN.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
// Ignore callbacks when gateway is disabled — connections are managed locally,
|
||||
// and stale gateway callbacks (e.g. from disconnectAll during migration) could
|
||||
// overwrite locally-managed status.
|
||||
if (gatewayEnv.MESSAGE_GATEWAY_ENABLED !== '1') {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
const serviceToken = gatewayEnv.MESSAGE_GATEWAY_SERVICE_TOKEN;
|
||||
if (!serviceToken) {
|
||||
return NextResponse.json({ error: 'Service not configured' }, { status: 503 });
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (authHeader !== `Bearer ${serviceToken}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
const body = await request.json();
|
||||
parsed = StateChangeSchema.safeParse(body);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid body', issues: parsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { applicationId, platform, state } = parsed.data;
|
||||
|
||||
if (!applicationId) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
const statusMap: Partial<Record<string, BotRuntimeStatus>> = {
|
||||
connected: BOT_RUNTIME_STATUSES.connected,
|
||||
disconnected: BOT_RUNTIME_STATUSES.disconnected,
|
||||
error: BOT_RUNTIME_STATUSES.failed,
|
||||
};
|
||||
|
||||
const runtimeStatus = statusMap[state.status];
|
||||
if (!runtimeStatus) {
|
||||
// "connecting" — no status update needed
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
await updateBotRuntimeStatus({
|
||||
applicationId,
|
||||
errorMessage: state.error,
|
||||
platform,
|
||||
status: runtimeStatus,
|
||||
});
|
||||
|
||||
log('Updated %s:%s → %s', platform, applicationId, runtimeStatus);
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
@@ -123,11 +123,15 @@ export async function GET(request: NextRequest) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// When an external message gateway is fully configured (both URL and service
|
||||
// token), it manages all persistent connections. Skip in-process bot startup
|
||||
// to avoid duplicate connections.
|
||||
// When the external message gateway is enabled, sync connections via gateway.
|
||||
if (process.env.MESSAGE_GATEWAY_URL && process.env.MESSAGE_GATEWAY_SERVICE_TOKEN) {
|
||||
return Response.json({ skipped: true, reason: 'using external message gateway' });
|
||||
const { GatewayService } = await import('@/server/services/gateway');
|
||||
const service = new GatewayService();
|
||||
|
||||
if (service.useMessageGateway) {
|
||||
await service.ensureRunning();
|
||||
return Response.json({ ensureRunning: true });
|
||||
}
|
||||
}
|
||||
|
||||
const platforms = platformRegistry.listPlatforms();
|
||||
|
||||
@@ -6,6 +6,7 @@ export const getGatewayConfig = () => {
|
||||
runtimeEnv: {
|
||||
DEVICE_GATEWAY_SERVICE_TOKEN: process.env.DEVICE_GATEWAY_SERVICE_TOKEN,
|
||||
DEVICE_GATEWAY_URL: process.env.DEVICE_GATEWAY_URL,
|
||||
MESSAGE_GATEWAY_ENABLED: process.env.MESSAGE_GATEWAY_ENABLED,
|
||||
MESSAGE_GATEWAY_SERVICE_TOKEN: process.env.MESSAGE_GATEWAY_SERVICE_TOKEN,
|
||||
MESSAGE_GATEWAY_URL: process.env.MESSAGE_GATEWAY_URL,
|
||||
},
|
||||
@@ -13,6 +14,7 @@ export const getGatewayConfig = () => {
|
||||
server: {
|
||||
DEVICE_GATEWAY_SERVICE_TOKEN: z.string().optional(),
|
||||
DEVICE_GATEWAY_URL: z.string().url().optional(),
|
||||
MESSAGE_GATEWAY_ENABLED: z.string().optional(),
|
||||
MESSAGE_GATEWAY_SERVICE_TOKEN: z.string().optional(),
|
||||
MESSAGE_GATEWAY_URL: z.string().url().optional(),
|
||||
},
|
||||
|
||||
@@ -18,7 +18,8 @@ export const useInitAgentConfig = (agentId?: string) => {
|
||||
|
||||
const params = useParams<{ aid?: string }>();
|
||||
|
||||
const id = agentId || activeAgentId || params.aid || '';
|
||||
// Prioritize URL params over store's activeAgentId to avoid stale ID from previous navigation
|
||||
const id = agentId || params.aid || activeAgentId || '';
|
||||
|
||||
const data = useFetchAgentConfig(isLogin, id);
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
import { getStableNavigate } from '@/utils/stableNavigate';
|
||||
|
||||
/**
|
||||
* Stable `navigate` that forwards to the live ref on each call (see `NavigatorRegistrar`).
|
||||
* Prefer over subscribing to `navigationRef` from `useGlobalStore` in components.
|
||||
*/
|
||||
export function useStableNavigate(): NavigateFunction {
|
||||
return useCallback(
|
||||
((to, options) => {
|
||||
const navigate = getStableNavigate();
|
||||
if (!navigate) return;
|
||||
if (typeof to === 'number') {
|
||||
navigate(to);
|
||||
} else {
|
||||
navigate(to, options);
|
||||
}
|
||||
}) as NavigateFunction,
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,13 @@ export async function register() {
|
||||
await import('./libs/debug-file-logger');
|
||||
}
|
||||
|
||||
// Auto-start GatewayManager / sync message-gateway connections on server start.
|
||||
// - Non-Vercel (Docker, local): always run — persistent bots need reconnection after restart.
|
||||
// - Vercel: only run when an external message gateway is configured, to sync connection state.
|
||||
// Auto-start GatewayManager on server start for non-Vercel environments (Docker, local).
|
||||
// Persistent bots need reconnection after restart.
|
||||
// On Vercel, the cron job at /api/agent/gateway handles this reliably instead.
|
||||
if (
|
||||
process.env.NEXT_RUNTIME === 'nodejs' &&
|
||||
process.env.DATABASE_URL &&
|
||||
(!process.env.VERCEL_ENV || process.env.MESSAGE_GATEWAY_URL)
|
||||
!process.env.VERCEL_ENV
|
||||
) {
|
||||
const { GatewayService } = await import('./server/services/gateway');
|
||||
const service = new GatewayService();
|
||||
|
||||
@@ -401,6 +401,60 @@ describe('AgentStreamClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendToolResult', () => {
|
||||
it('should send a successful tool_result message', async () => {
|
||||
const client = createClient();
|
||||
const ws = await connectAndAuth(client);
|
||||
|
||||
const ok = client.sendToolResult({
|
||||
content: '{"files":["a.txt"]}',
|
||||
success: true,
|
||||
toolCallId: 'call_1',
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
const toolResult = ws.sent.find((s) => JSON.parse(s).type === 'tool_result');
|
||||
expect(toolResult).toBeDefined();
|
||||
expect(JSON.parse(toolResult!)).toEqual({
|
||||
content: '{"files":["a.txt"]}',
|
||||
success: true,
|
||||
toolCallId: 'call_1',
|
||||
type: 'tool_result',
|
||||
});
|
||||
});
|
||||
|
||||
it('should send an error tool_result message', async () => {
|
||||
const client = createClient();
|
||||
const ws = await connectAndAuth(client);
|
||||
|
||||
client.sendToolResult({
|
||||
content: null,
|
||||
error: { message: 'ipc failed', type: 'ipc_error' },
|
||||
success: false,
|
||||
toolCallId: 'call_2',
|
||||
});
|
||||
|
||||
const toolResult = ws.sent.find((s) => JSON.parse(s).type === 'tool_result');
|
||||
expect(JSON.parse(toolResult!)).toEqual({
|
||||
content: null,
|
||||
error: { message: 'ipc failed', type: 'ipc_error' },
|
||||
success: false,
|
||||
toolCallId: 'call_2',
|
||||
type: 'tool_result',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false when socket is not open', () => {
|
||||
const client = createClient();
|
||||
const ok = client.sendToolResult({
|
||||
content: null,
|
||||
success: false,
|
||||
toolCallId: 'call_3',
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('should clean up timers on disconnect', async () => {
|
||||
const client = createClient();
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ClientMessage,
|
||||
ConnectionStatus,
|
||||
ServerMessage,
|
||||
ToolResultMessage,
|
||||
} from './types';
|
||||
|
||||
// ─── Constants ───
|
||||
@@ -146,6 +147,16 @@ export class AgentStreamClient extends TypedEmitter {
|
||||
this.sendMessage({ type: 'interrupt' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a tool execution result back to the server.
|
||||
* Correlated by toolCallId; the server's agent loop is blocked on BLPOP until this arrives.
|
||||
* Returns true when the payload was handed off to the WebSocket, false when no live socket
|
||||
* is available (caller should fall back to server-side BLPOP timeout).
|
||||
*/
|
||||
sendToolResult(result: Omit<ToolResultMessage, 'type'>): boolean {
|
||||
return this.sendMessage({ ...result, type: 'tool_result' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the auth token (e.g. after JWT refresh). Call connect() or wait for auto-reconnect.
|
||||
*/
|
||||
@@ -418,10 +429,12 @@ export class AgentStreamClient extends TypedEmitter {
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
private sendMessage(data: ClientMessage): void {
|
||||
private sendMessage(data: ClientMessage): boolean {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private closeWebSocket(): void {
|
||||
|
||||
@@ -10,5 +10,7 @@ export type {
|
||||
StreamChunkType,
|
||||
StreamStartData,
|
||||
ToolEndData,
|
||||
ToolExecuteData,
|
||||
ToolResultMessage,
|
||||
ToolStartData,
|
||||
} from './types';
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { useTheme } from 'antd-style';
|
||||
import { type FC, type ReactNode } from 'react';
|
||||
import { Activity, useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Activity, type FC, type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import { useIsDark } from '@/hooks/useIsDark';
|
||||
import { useHomeStore } from '@/store/home';
|
||||
|
||||
import HomeAgentIdSync from './HomeAgentIdSync';
|
||||
import RecentHydration from './RecentHydration';
|
||||
@@ -19,17 +17,11 @@ interface LayoutProps {
|
||||
const Layout: FC<LayoutProps> = ({ children }) => {
|
||||
const isDarkMode = useIsDark();
|
||||
const theme = useTheme(); // Keep for colorBgContainerSecondary (not in cssVar)
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const isHomeRoute = pathname === '/';
|
||||
const [hasActivated, setHasActivated] = useState(isHomeRoute);
|
||||
const setNavigate = useHomeStore((s) => s.setNavigate);
|
||||
const content = children ?? <Outlet />;
|
||||
|
||||
useEffect(() => {
|
||||
setNavigate(navigate);
|
||||
}, [navigate, setNavigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHomeRoute) setHasActivated(true);
|
||||
}, [isHomeRoute]);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useInitBuiltinAgent } from '@/hooks/useInitBuiltinAgent';
|
||||
import { useStableNavigate } from '@/hooks/useStableNavigate';
|
||||
import { type StarterMode } from '@/store/home';
|
||||
import { useHomeStore } from '@/store/home';
|
||||
|
||||
@@ -52,10 +53,10 @@ const StarterList = memo(() => {
|
||||
useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.groupAgentBuilder);
|
||||
useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.pageAgent);
|
||||
|
||||
const [inputActiveMode, setInputActiveMode, navigate] = useHomeStore((s) => [
|
||||
const navigate = useStableNavigate();
|
||||
const [inputActiveMode, setInputActiveMode] = useHomeStore((s) => [
|
||||
s.inputActiveMode,
|
||||
s.setInputActiveMode,
|
||||
s.navigate,
|
||||
]);
|
||||
|
||||
const items: StarterItem[] = useMemo(
|
||||
@@ -99,12 +100,12 @@ const StarterList = memo(() => {
|
||||
const handleClick = useCallback(
|
||||
(key: StarterMode) => {
|
||||
if (key === 'video') {
|
||||
navigate?.('/video?model=doubao-seedance-2-0-260128');
|
||||
navigate('/video?model=doubao-seedance-2-0-260128');
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'image') {
|
||||
navigate?.('/image');
|
||||
navigate('/image');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,7 +116,7 @@ const StarterList = memo(() => {
|
||||
setInputActiveMode(key);
|
||||
}
|
||||
},
|
||||
[inputActiveMode, setInputActiveMode, navigate],
|
||||
[inputActiveMode, navigate, setInputActiveMode],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,6 @@ import Loading from '@/components/Loading/BrandTextLoading';
|
||||
import { MarketAuthProvider } from '@/layout/AuthProvider/MarketAuth';
|
||||
import dynamic from '@/libs/next/dynamic';
|
||||
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
import { NavigatorRegistrar } from '@/utils/router';
|
||||
|
||||
import NavBar from './NavBar';
|
||||
|
||||
@@ -31,7 +30,6 @@ const MobileMainLayout: FC = () => {
|
||||
const showNav = MOBILE_NAV_ROUTES.has(pathname);
|
||||
return (
|
||||
<>
|
||||
<NavigatorRegistrar />
|
||||
<Suspense fallback={null}>{showCloudPromotion && <CloudBanner mobile />}</Suspense>
|
||||
<MarketAuthProvider isDesktop={false}>
|
||||
<Suspense fallback={<Loading debugId="MobileMainLayout > Outlet" />}>
|
||||
|
||||
@@ -39,6 +39,7 @@ import { serverMessagesEngine } from '@/server/modules/Mecha/ContextEngineering'
|
||||
import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/types';
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
import { AgentDocumentsService } from '@/server/services/agentDocuments';
|
||||
import { FileService } from '@/server/services/file';
|
||||
import { MessageService } from '@/server/services/message';
|
||||
import { OnboardingService } from '@/server/services/onboarding';
|
||||
import {
|
||||
@@ -89,6 +90,26 @@ const getToolFailureKind = (result: ToolExecutionResultResponse): ToolFailureKin
|
||||
const shouldRetryTool = (kind: ToolFailureKind | undefined, attempt: number, maxRetries: number) =>
|
||||
kind === 'retry' && attempt <= maxRetries;
|
||||
|
||||
// Builds a postProcessUrl callback that resolves S3 keys in file-backed fields
|
||||
// (imageList, videoList, fileList) to absolute URLs. Must be passed to every
|
||||
// messageModel.query() call whose output is later fed to the LLM — otherwise
|
||||
// the provider layer receives raw keys like `files/user_xxx/icon.png` and
|
||||
// rejects them (see anthropic contextBuilder `Invalid image URL`).
|
||||
//
|
||||
// FileService is constructed lazily so environments without S3 config (unit
|
||||
// tests) don't fail at context-build time; failure returns undefined, which
|
||||
// leaves URLs as raw keys — same behavior as before this helper existed.
|
||||
const buildPostProcessUrl = (ctx: Pick<RuntimeExecutorContext, 'serverDB' | 'userId'>) => {
|
||||
if (!ctx.userId || !ctx.serverDB) return undefined;
|
||||
let fileService: FileService | undefined;
|
||||
try {
|
||||
fileService = new FileService(ctx.serverDB, ctx.userId);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return (path: string | null) => fileService!.getFullFileUrl(path);
|
||||
};
|
||||
|
||||
const shouldRetryLLM = (kind: LLMErrorKind, attempt: number, maxRetries: number) =>
|
||||
kind === 'retry' && attempt <= maxRetries;
|
||||
|
||||
@@ -217,7 +238,6 @@ export interface RuntimeExecutorContext {
|
||||
botPlatformContext?: any;
|
||||
discordContext?: any;
|
||||
evalContext?: EvalContext;
|
||||
fileService?: any;
|
||||
loadAgentState?: (operationId: string) => Promise<AgentState | null>;
|
||||
messageModel: MessageModel;
|
||||
operationId: string;
|
||||
@@ -373,11 +393,14 @@ export const createRuntimeExecutors = (
|
||||
async (topicId) => topicModel.findById(topicId),
|
||||
async (topicId) => {
|
||||
const topic = await topicModel.findById(topicId);
|
||||
return messageModel.query({
|
||||
agentId: topic?.agentId ?? undefined,
|
||||
groupId: topic?.groupId ?? undefined,
|
||||
topicId,
|
||||
});
|
||||
return messageModel.query(
|
||||
{
|
||||
agentId: topic?.agentId ?? undefined,
|
||||
groupId: topic?.groupId ?? undefined,
|
||||
topicId,
|
||||
},
|
||||
{ postProcessUrl: buildPostProcessUrl(ctx) },
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1060,11 +1083,14 @@ export const createRuntimeExecutors = (
|
||||
}
|
||||
|
||||
try {
|
||||
const dbMessages = await ctx.messageModel.query({
|
||||
agentId: state.metadata?.agentId,
|
||||
threadId: state.metadata?.threadId,
|
||||
topicId,
|
||||
});
|
||||
const dbMessages = await ctx.messageModel.query(
|
||||
{
|
||||
agentId: state.metadata?.agentId,
|
||||
threadId: state.metadata?.threadId,
|
||||
topicId,
|
||||
},
|
||||
{ postProcessUrl: buildPostProcessUrl(ctx) },
|
||||
);
|
||||
|
||||
const messageIds = dbMessages
|
||||
.filter(
|
||||
@@ -1363,6 +1389,12 @@ export const createRuntimeExecutors = (
|
||||
});
|
||||
execution = { attempts: 1, result: dispatchResult };
|
||||
} else {
|
||||
// Inject source from sourceMap so BuiltinToolsExecutor can route
|
||||
// lobehubSkill / klavis tools correctly (LLM responses don't carry source)
|
||||
if (toolSource && !chatToolPayload.source) {
|
||||
chatToolPayload.source = toolSource;
|
||||
}
|
||||
|
||||
// Execute tool using ToolExecutionService
|
||||
log(`[${operationLogId}] Executing tool ${toolName} ...`);
|
||||
execution = await executeToolWithRetry(
|
||||
@@ -1657,6 +1689,15 @@ export const createRuntimeExecutors = (
|
||||
});
|
||||
execution = { attempts: 1, result: dispatchResult };
|
||||
} else {
|
||||
// Inject source from sourceMap so BuiltinToolsExecutor can route
|
||||
// lobehubSkill / klavis tools correctly (LLM responses don't carry source)
|
||||
const batchToolSource =
|
||||
state.operationToolSet?.sourceMap?.[chatToolPayload.identifier] ??
|
||||
state.toolSourceMap?.[chatToolPayload.identifier];
|
||||
if (batchToolSource && !chatToolPayload.source) {
|
||||
chatToolPayload.source = batchToolSource;
|
||||
}
|
||||
|
||||
execution = await executeToolWithRetry(
|
||||
() =>
|
||||
toolExecutionService.executeTool(chatToolPayload, {
|
||||
@@ -1816,11 +1857,17 @@ export const createRuntimeExecutors = (
|
||||
// Query latest messages from database
|
||||
// Must pass agentId to ensure correct query scope, otherwise when topicId is undefined,
|
||||
// the query will use isNull(topicId) condition which won't find messages with actual topicId
|
||||
const latestMessages = await ctx.messageModel.query({
|
||||
agentId: state.metadata?.agentId,
|
||||
threadId: state.metadata?.threadId,
|
||||
topicId: state.metadata?.topicId,
|
||||
});
|
||||
//
|
||||
// postProcessUrl resolves S3 keys in imageList/videoList/fileList to absolute URLs;
|
||||
// without it the next LLM call sees raw keys and providers reject them.
|
||||
const latestMessages = await ctx.messageModel.query(
|
||||
{
|
||||
agentId: state.metadata?.agentId,
|
||||
threadId: state.metadata?.threadId,
|
||||
topicId: state.metadata?.topicId,
|
||||
},
|
||||
{ postProcessUrl: buildPostProcessUrl(ctx) },
|
||||
);
|
||||
|
||||
// Use conversation-flow parse to resolve branching into linear flat list
|
||||
// parse() handles assistantGroup, compare, supervisor, etc. virtual message types
|
||||
|
||||
@@ -1771,11 +1771,14 @@ describe('RuntimeExecutors', () => {
|
||||
const result = await executors.call_tools_batch!(instruction, state);
|
||||
|
||||
// Should query messages from database with agentId, threadId, and topicId
|
||||
expect(mockMessageModel.query).toHaveBeenCalledWith({
|
||||
agentId: 'agent-123',
|
||||
threadId: 'thread-123',
|
||||
topicId: 'topic-123',
|
||||
});
|
||||
expect(mockMessageModel.query).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: 'agent-123',
|
||||
threadId: 'thread-123',
|
||||
topicId: 'topic-123',
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
// Messages should be refreshed from database (4 messages from mock)
|
||||
expect(result.newState.messages).toHaveLength(4);
|
||||
@@ -2099,11 +2102,14 @@ describe('RuntimeExecutors', () => {
|
||||
await executors.call_tools_batch!(instruction, state);
|
||||
|
||||
// Should query messages with agentId, threadId, and topicId from state.metadata
|
||||
expect(mockMessageModel.query).toHaveBeenCalledWith({
|
||||
agentId: 'agent-abc',
|
||||
threadId: 'thread-xyz',
|
||||
topicId: 'topic-abc-123',
|
||||
});
|
||||
expect(mockMessageModel.query).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: 'agent-abc',
|
||||
threadId: 'thread-xyz',
|
||||
topicId: 'topic-abc-123',
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
// LOBE-5143: After DB refresh, state.messages stores raw UIChatMessage[]
|
||||
@@ -2235,11 +2241,14 @@ describe('RuntimeExecutors', () => {
|
||||
const result = await executors.call_tools_batch!(instruction, state);
|
||||
|
||||
// Verify agentId is passed in the query
|
||||
expect(mockMessageModel.query).toHaveBeenCalledWith({
|
||||
agentId: 'agent-123',
|
||||
threadId: 'thread-123',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(mockMessageModel.query).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: 'agent-123',
|
||||
threadId: 'thread-123',
|
||||
topicId: undefined,
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
// Expected: newState.messages should NOT be empty
|
||||
// The next call_llm step needs messages to work properly
|
||||
|
||||
@@ -49,4 +49,86 @@ describe('classifyLLMError', () => {
|
||||
it('should default unknown errors to retry', () => {
|
||||
expect(classifyLLMError(new Error('unexpected upstream issue')).kind).toBe('retry');
|
||||
});
|
||||
|
||||
describe('non-string code / errorType defensive handling', () => {
|
||||
// Regression: real-world provider errors sometimes carry numeric `code`
|
||||
// (HTTP status) or a structured object in the error fields. Earlier versions
|
||||
// called `.trim()` on these and threw TypeError, which masked the original
|
||||
// provider error behind "e.trim is not a function".
|
||||
|
||||
it('does not throw when error.code is a number', () => {
|
||||
const result = classifyLLMError({ code: 429, message: 'rate limit' });
|
||||
expect(result.message).toBe('rate limit');
|
||||
// Classifier should still land on a valid kind, not crash.
|
||||
expect(['retry', 'stop']).toContain(result.kind);
|
||||
});
|
||||
|
||||
it('does not throw when errorType is an object', () => {
|
||||
const result = classifyLLMError({
|
||||
errorType: { nested: 'structured error' },
|
||||
message: 'upstream returned structured type',
|
||||
});
|
||||
expect(result.message).toBe('upstream returned structured type');
|
||||
expect(['retry', 'stop']).toContain(result.kind);
|
||||
});
|
||||
|
||||
it('does not throw when nested error.code is a number (OpenAI SDK shape)', () => {
|
||||
const result = classifyLLMError({
|
||||
error: { error: { code: 402, message: 'payment required' } },
|
||||
errorType: 'ProviderBizError',
|
||||
});
|
||||
expect(result.message).toBe('payment required');
|
||||
expect(['retry', 'stop']).toContain(result.kind);
|
||||
});
|
||||
|
||||
// Regression: some third-party proxies surface HTTP status ONLY as a
|
||||
// numeric `code` (no `status`/`statusCode`, no status digits in the
|
||||
// message). Previously these fell through to `retry`, causing wasteful
|
||||
// retry loops on permanent auth/permission failures.
|
||||
|
||||
it('treats numeric code=401 as stop when no status field is present', () => {
|
||||
const result = classifyLLMError({ code: 401, message: 'upstream refused' });
|
||||
expect(result.kind).toBe('stop');
|
||||
});
|
||||
|
||||
it('treats numeric code=403 as stop when no status field is present', () => {
|
||||
const result = classifyLLMError({ code: 403, message: 'upstream refused' });
|
||||
expect(result.kind).toBe('stop');
|
||||
});
|
||||
|
||||
it('treats numeric code=429 as retry when no status field is present', () => {
|
||||
const result = classifyLLMError({ code: 429, message: 'upstream refused' });
|
||||
expect(result.kind).toBe('retry');
|
||||
});
|
||||
|
||||
it('treats nested numeric code as stop (proxy-wrapped auth failure)', () => {
|
||||
const result = classifyLLMError({
|
||||
error: { error: { code: 401, message: 'proxy refused upstream' } },
|
||||
});
|
||||
expect(result.kind).toBe('stop');
|
||||
});
|
||||
|
||||
it('prefers explicit status over numeric code fallback', () => {
|
||||
// status says 500 (retry), code says 401 (stop) — status wins.
|
||||
const result = classifyLLMError({ code: 401, message: 'oops', status: 500 });
|
||||
expect(result.kind).toBe('retry');
|
||||
});
|
||||
|
||||
it('preserves the original error message when normalizeSignal itself would throw', () => {
|
||||
// Force a normalizeSignal crash by making `.toLowerCase()` blow up on a
|
||||
// non-string message (via a Proxy that throws on Symbol.toPrimitive).
|
||||
const hostile = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => {
|
||||
throw new Error('property access explodes');
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = classifyLLMError(hostile);
|
||||
// Falls back to 'stop' when classifier throws; message is best-effort.
|
||||
expect(result.kind).toBe('stop');
|
||||
expect(typeof result.message).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,8 +64,8 @@ const STOP_KEYWORDS = [
|
||||
const hasAnyKeyword = (text: string, keywords: string[]) =>
|
||||
keywords.some((keyword) => text.includes(keyword));
|
||||
|
||||
const normalizeCode = (value?: string) => {
|
||||
if (!value) return;
|
||||
const normalizeCode = (value?: unknown): string | undefined => {
|
||||
if (typeof value !== 'string' || !value) return;
|
||||
|
||||
return value
|
||||
.trim()
|
||||
@@ -73,7 +73,11 @@ const normalizeCode = (value?: string) => {
|
||||
.replaceAll(/[\s-]+/g, '_');
|
||||
};
|
||||
|
||||
const normalizeErrorType = (value?: string) => value?.trim();
|
||||
const normalizeErrorType = (value?: unknown): string | undefined => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
};
|
||||
|
||||
const tryExtractStatus = (message: string) => {
|
||||
const matches = message.match(/\b([45]\d{2})\b/);
|
||||
@@ -83,6 +87,17 @@ const tryExtractStatus = (message: string) => {
|
||||
return Number.isNaN(status) ? undefined : status;
|
||||
};
|
||||
|
||||
// Some providers (notably bare HTTP proxies) only surface the HTTP status as a
|
||||
// numeric `code` on the error object, with no `status`/`statusCode`. Treat
|
||||
// those numeric codes as status so classifyKind can still map 401/403 to stop
|
||||
// and 429/5xx to retry without falling through to message-keyword matching.
|
||||
const numericStatusFromCode = (...codes: unknown[]): number | undefined => {
|
||||
for (const code of codes) {
|
||||
if (typeof code === 'number' && Number.isFinite(code)) return code;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeSignal = (error: unknown): LLMErrorSignal => {
|
||||
if (typeof error === 'string') {
|
||||
const message = error.toLowerCase();
|
||||
@@ -91,11 +106,11 @@ const normalizeSignal = (error: unknown): LLMErrorSignal => {
|
||||
|
||||
if (error instanceof Error) {
|
||||
const raw = error as Error & {
|
||||
code?: string;
|
||||
errorType?: string;
|
||||
code?: unknown;
|
||||
errorType?: unknown;
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
type?: string;
|
||||
type?: unknown;
|
||||
};
|
||||
const message = (raw.message || raw.name || 'unknown error').toLowerCase();
|
||||
|
||||
@@ -108,26 +123,26 @@ const normalizeSignal = (error: unknown): LLMErrorSignal => {
|
||||
? raw.status
|
||||
: typeof raw.statusCode === 'number'
|
||||
? raw.statusCode
|
||||
: tryExtractStatus(message),
|
||||
: (numericStatusFromCode(raw.code) ?? tryExtractStatus(message)),
|
||||
};
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object') {
|
||||
const raw = error as {
|
||||
code?: string;
|
||||
code?: unknown;
|
||||
error?: {
|
||||
code?: string;
|
||||
error?: { code?: string; message?: string; status?: number; type?: string };
|
||||
errorType?: string;
|
||||
code?: unknown;
|
||||
error?: { code?: unknown; message?: string; status?: number; type?: unknown };
|
||||
errorType?: unknown;
|
||||
message?: string;
|
||||
status?: number;
|
||||
type?: string;
|
||||
type?: unknown;
|
||||
};
|
||||
errorType?: string;
|
||||
errorType?: unknown;
|
||||
message?: string;
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
type?: string;
|
||||
type?: unknown;
|
||||
};
|
||||
const nested = raw.error;
|
||||
const nestedError = nested?.error;
|
||||
@@ -153,7 +168,8 @@ const normalizeSignal = (error: unknown): LLMErrorSignal => {
|
||||
? nested.status
|
||||
: typeof nestedError?.status === 'number'
|
||||
? nestedError.status
|
||||
: tryExtractStatus(message),
|
||||
: (numericStatusFromCode(raw.code, nested?.code, nestedError?.code) ??
|
||||
tryExtractStatus(message)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,14 +214,47 @@ const classifyKind = ({ code, errorType, message, status }: LLMErrorSignal): LLM
|
||||
return 'retry';
|
||||
};
|
||||
|
||||
export const classifyLLMError = (error: unknown): ClassifiedLLMError => {
|
||||
const signal = normalizeSignal(error);
|
||||
/**
|
||||
* Extract a human-readable message for the fallback path without relying on
|
||||
* normalizeSignal (which might be the thing that just threw).
|
||||
*/
|
||||
const bestEffortMessage = (error: unknown): string => {
|
||||
try {
|
||||
if (error instanceof Error && typeof error.message === 'string' && error.message.length > 0) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string' && error.length > 0) return error;
|
||||
if (error && typeof error === 'object') {
|
||||
const e = error as { message?: unknown; error?: { message?: unknown } };
|
||||
if (typeof e.message === 'string' && e.message.length > 0) return e.message;
|
||||
const nested = e.error?.message;
|
||||
if (typeof nested === 'string' && nested.length > 0) return nested;
|
||||
}
|
||||
} catch {
|
||||
// Property access itself can throw (e.g. hostile Proxy). Fall through to default.
|
||||
}
|
||||
return 'unknown error';
|
||||
};
|
||||
|
||||
return {
|
||||
code: signal.code || signal.errorType,
|
||||
kind: classifyKind(signal),
|
||||
message: signal.message,
|
||||
};
|
||||
export const classifyLLMError = (error: unknown): ClassifiedLLMError => {
|
||||
// Defensive: a classifier that throws would mask the original provider error
|
||||
// behind the classifier's own TypeError (e.g. `e.trim is not a function`),
|
||||
// making prod debugging impossible. If anything below throws, fall back to a
|
||||
// conservative "stop" decision that preserves the original error message.
|
||||
try {
|
||||
const signal = normalizeSignal(error);
|
||||
|
||||
return {
|
||||
code: signal.code || signal.errorType,
|
||||
kind: classifyKind(signal),
|
||||
message: signal.message,
|
||||
};
|
||||
} catch (classificationError) {
|
||||
return {
|
||||
kind: 'stop',
|
||||
message: bestEffortMessage(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export type { ClassifiedLLMError, LLMErrorKind };
|
||||
|
||||
@@ -520,4 +520,97 @@ describe('createServerAgentToolsEngine', () => {
|
||||
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clientRuntime === "desktop" (Phase 6.4)', () => {
|
||||
it('enables LocalSystem when caller is desktop, regardless of device-proxy config', () => {
|
||||
// The Agent Gateway WS used to push `tool_execute` is orthogonal to
|
||||
// the legacy device-proxy. A desktop Electron caller is already the
|
||||
// execution target — no device-proxy prerequisite required.
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: { plugins: [LocalSystemManifest.identifier] },
|
||||
clientRuntime: 'desktop',
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
const result = engine.generateToolsDetailed({
|
||||
toolIds: [LocalSystemManifest.identifier],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toContain(LocalSystemManifest.identifier);
|
||||
});
|
||||
|
||||
it('respects agent-level runtimeMode opt-out for desktop callers', () => {
|
||||
// User has configured the agent to NOT use local runtime on desktop.
|
||||
// Even though the caller is a desktop client, local-system stays off.
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: {
|
||||
chatConfig: {
|
||||
runtimeEnv: { runtimeMode: { desktop: 'none' } },
|
||||
},
|
||||
plugins: [LocalSystemManifest.identifier],
|
||||
},
|
||||
clientRuntime: 'desktop',
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
const result = engine.generateToolsDetailed({
|
||||
toolIds: [LocalSystemManifest.identifier],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).not.toContain(LocalSystemManifest.identifier);
|
||||
});
|
||||
|
||||
it('does not enable LocalSystem for web callers even when gateway is configured', () => {
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: { plugins: [LocalSystemManifest.identifier] },
|
||||
clientRuntime: 'web',
|
||||
deviceContext: { gatewayConfigured: true },
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
const result = engine.generateToolsDetailed({
|
||||
toolIds: [LocalSystemManifest.identifier],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).not.toContain(LocalSystemManifest.identifier);
|
||||
});
|
||||
|
||||
it('suppresses RemoteDevice when caller is a desktop client', () => {
|
||||
// Even when device-proxy is configured, a desktop caller has local IPC
|
||||
// so the proxy is redundant. Otherwise the LLM might pick RemoteDevice
|
||||
// first (via `listOnlineDevices` / `activateDevice`) and route tool calls
|
||||
// to a *different* registered device instead of back to the caller.
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: {
|
||||
plugins: [LocalSystemManifest.identifier, RemoteDeviceManifest.identifier],
|
||||
},
|
||||
clientRuntime: 'desktop',
|
||||
deviceContext: { gatewayConfigured: true },
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
const result = engine.generateToolsDetailed({
|
||||
toolIds: [LocalSystemManifest.identifier, RemoteDeviceManifest.identifier],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toContain(LocalSystemManifest.identifier);
|
||||
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing';
|
||||
import { alwaysOnToolIds, builtinTools, defaultToolIds } from '@lobechat/builtin-tools';
|
||||
import { createEnableChecker, type LobeToolManifest } from '@lobechat/context-engine';
|
||||
import { ToolsEngine } from '@lobechat/context-engine';
|
||||
import { type RuntimeEnvMode, type RuntimePlatform } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import {
|
||||
@@ -94,6 +95,7 @@ export const createServerAgentToolsEngine = (
|
||||
const {
|
||||
additionalManifests,
|
||||
agentConfig,
|
||||
clientRuntime,
|
||||
deviceContext,
|
||||
globalMemoryEnabled = false,
|
||||
hasAgentDocuments = false,
|
||||
@@ -102,24 +104,49 @@ export const createServerAgentToolsEngine = (
|
||||
model,
|
||||
provider,
|
||||
} = params;
|
||||
|
||||
// ─── Tool-dispatch capability flags ───
|
||||
//
|
||||
// Two orthogonal signals control whether client-side tools can run.
|
||||
//
|
||||
// 1. `hasClientExecutor` — the caller itself is an Electron desktop
|
||||
// client and can receive `tool_execute` events over the Agent
|
||||
// Gateway WebSocket (Phase 6.4).
|
||||
// 2. `hasDeviceProxy` — the server has a device-proxy configured that
|
||||
// can tunnel commands to a *separately registered* desktop device
|
||||
// (legacy Remote Device flow).
|
||||
//
|
||||
// Either, both, or neither can be true independently.
|
||||
const hasClientExecutor = clientRuntime === 'desktop';
|
||||
const hasDeviceProxy = !!deviceContext?.gatewayConfigured;
|
||||
|
||||
// ─── Platform / runtime mode ───
|
||||
//
|
||||
// `platform` is a property of the caller, not of the server. Prefer the
|
||||
// explicit `clientRuntime` signal; fall back to treating a server with
|
||||
// a configured device-proxy as desktop for callers that don't yet send
|
||||
// `clientRuntime` (backwards compat).
|
||||
const platform: RuntimePlatform = clientRuntime ?? (hasDeviceProxy ? 'desktop' : 'web');
|
||||
|
||||
// User-configured runtime mode for the current platform, with a
|
||||
// platform-appropriate default when unset.
|
||||
const runtimeMode: RuntimeEnvMode =
|
||||
agentConfig.chatConfig?.runtimeEnv?.runtimeMode?.[platform] ??
|
||||
(platform === 'desktop' ? 'local' : 'none');
|
||||
|
||||
const searchMode = agentConfig.chatConfig?.searchMode ?? 'auto';
|
||||
const isSearchEnabled = searchMode !== 'off';
|
||||
|
||||
// Determine runtime mode based on platform
|
||||
const isDesktopClient = !!deviceContext?.gatewayConfigured;
|
||||
const platform = isDesktopClient ? 'desktop' : 'web';
|
||||
const runtimeMode =
|
||||
agentConfig.chatConfig?.runtimeEnv?.runtimeMode?.[platform] ??
|
||||
(isDesktopClient ? 'local' : 'none');
|
||||
|
||||
log(
|
||||
'Creating agent tools engine for model=%s, provider=%s, searchMode=%s, runtimeMode=%s, additionalManifests=%d, deviceGateway=%s',
|
||||
'Creating agent tools engine model=%s provider=%s searchMode=%s platform=%s runtimeMode=%s additionalManifests=%d hasClientExecutor=%s hasDeviceProxy=%s',
|
||||
model,
|
||||
provider,
|
||||
searchMode,
|
||||
platform,
|
||||
runtimeMode,
|
||||
additionalManifests?.length ?? 0,
|
||||
!!deviceContext?.gatewayConfigured,
|
||||
hasClientExecutor,
|
||||
hasDeviceProxy,
|
||||
);
|
||||
|
||||
return createServerToolsEngine(context, {
|
||||
@@ -128,6 +155,8 @@ export const createServerAgentToolsEngine = (
|
||||
// Add default tools based on configuration
|
||||
defaultToolIds,
|
||||
enableChecker: createEnableChecker({
|
||||
// Allow lobe-activator to dynamically enable tools at runtime (e.g., lobe-creds, lobe-cron)
|
||||
allowExplicitActivation: true,
|
||||
rules: {
|
||||
// User-selected plugins
|
||||
...Object.fromEntries((agentConfig.plugins ?? []).map((id) => [id, true])),
|
||||
@@ -136,16 +165,26 @@ export const createServerAgentToolsEngine = (
|
||||
// System-level rules (may override user selection for specific tools)
|
||||
[CloudSandboxManifest.identifier]: runtimeMode === 'cloud',
|
||||
[KnowledgeBaseManifest.identifier]: hasEnabledKnowledgeBases,
|
||||
// Local-system: user must have opted into local runtime on this
|
||||
// platform (`runtimeMode === 'local'`), AND one execution channel
|
||||
// must exist:
|
||||
// - `hasClientExecutor` — Phase 6.4 dispatch over the Agent Gateway
|
||||
// WS that this request is already riding on; no extra server-side
|
||||
// prerequisite needed;
|
||||
// - legacy device-proxy with an online & auto-activated device.
|
||||
[LocalSystemManifest.identifier]:
|
||||
runtimeMode === 'local' &&
|
||||
!!deviceContext?.gatewayConfigured &&
|
||||
!!deviceContext?.deviceOnline &&
|
||||
!!deviceContext?.autoActivated,
|
||||
(hasClientExecutor ||
|
||||
(hasDeviceProxy && !!deviceContext?.deviceOnline && !!deviceContext?.autoActivated)),
|
||||
[MemoryManifest.identifier]: globalMemoryEnabled,
|
||||
// Only auto-enable in bot conversations; otherwise let user's plugin selection take effect
|
||||
...(isBotConversation && { [MessageManifest.identifier]: true }),
|
||||
// Remote-device proxy: shown only when the server has a proxy but
|
||||
// no specific device is auto-activated yet (user must pick). When
|
||||
// the caller itself can execute `executor: 'client'` tools, the
|
||||
// proxy is redundant — local-system goes directly to the caller.
|
||||
[RemoteDeviceManifest.identifier]:
|
||||
!!deviceContext?.gatewayConfigured && !deviceContext?.autoActivated,
|
||||
hasDeviceProxy && !deviceContext?.autoActivated && !hasClientExecutor,
|
||||
[AgentDocumentsManifest.identifier]: hasAgentDocuments,
|
||||
[WebBrowsingManifest.identifier]: isSearchEnabled,
|
||||
},
|
||||
|
||||
@@ -44,6 +44,13 @@ export interface ServerCreateAgentToolsEngineParams {
|
||||
/** Plugin IDs enabled for this agent */
|
||||
plugins?: string[];
|
||||
};
|
||||
/**
|
||||
* Runtime of the client initiating this request. When `'desktop'`, the
|
||||
* caller itself is an Electron client connected via the Agent Gateway WS,
|
||||
* so tools with `executor: 'client'` (e.g. local-system, stdio MCP) can be
|
||||
* dispatched back to it via `tool_execute` — no remote-device proxy needed.
|
||||
*/
|
||||
clientRuntime?: 'desktop' | 'web';
|
||||
/** Device gateway context for remote tool calling */
|
||||
deviceContext?: {
|
||||
/** When true, a device has been auto-activated — Remote Device tool is unnecessary */
|
||||
|
||||
@@ -419,6 +419,31 @@ describe('Task Router Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('list participants', () => {
|
||||
it('should populate participants from assignee agent', async () => {
|
||||
const { agents } = await import('@/database/schemas');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
await serverDB
|
||||
.update(agents)
|
||||
.set({ avatar: 'avatar.png', title: 'Agent One' })
|
||||
.where(eq(agents.id, testAgentId));
|
||||
|
||||
await caller.create({ assigneeAgentId: testAgentId, instruction: 'Task A' });
|
||||
await caller.create({ instruction: 'Task without assignee' });
|
||||
|
||||
const list = await caller.list({});
|
||||
expect(list.data).toHaveLength(2);
|
||||
|
||||
const assigned = list.data.find((t) => t.assigneeAgentId === testAgentId)!;
|
||||
expect(assigned.participants).toEqual([
|
||||
{ avatar: 'avatar.png', id: testAgentId, name: 'Agent One', type: 'agent' },
|
||||
]);
|
||||
|
||||
const unassigned = list.data.find((t) => !t.assigneeAgentId)!;
|
||||
expect(unassigned.participants).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('heartbeat timeout detection', () => {
|
||||
it('should auto-detect timeout on detail and pause task', async () => {
|
||||
const task = await caller.create({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type AgentRuntimeContext } from '@lobechat/agent-runtime';
|
||||
import { parse } from '@lobechat/conversation-flow';
|
||||
import { type TaskCurrentActivity, type TaskStatusResult } from '@lobechat/types';
|
||||
import { ThreadStatus, ThreadType } from '@lobechat/types';
|
||||
import { ThreadStatus, ThreadType, UserInterventionConfigSchema } from '@lobechat/types';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import debug from 'debug';
|
||||
import pMap from 'p-map';
|
||||
@@ -91,6 +91,12 @@ const ExecAgentSchema = z
|
||||
.optional(),
|
||||
/** Whether to auto-start execution after creating operation */
|
||||
autoStart: z.boolean().optional().default(true),
|
||||
/**
|
||||
* Runtime of the client initiating this request.
|
||||
* 'desktop' enables `executor: 'client'` tools (local-system, stdio MCP)
|
||||
* to be dispatched over the Agent Gateway WS.
|
||||
*/
|
||||
clientRuntime: z.enum(['desktop', 'web']).optional(),
|
||||
/** Explicit device ID to bind to the topic and activate for this run */
|
||||
deviceId: z.string().optional(),
|
||||
/** Optional existing message IDs to include in context */
|
||||
@@ -103,6 +109,12 @@ const ExecAgentSchema = z
|
||||
prompt: z.string(),
|
||||
/** The agent slug to run (either agentId or slug is required) */
|
||||
slug: z.string().optional(),
|
||||
/**
|
||||
* User intervention configuration for tool approvals.
|
||||
* Pass `{ approvalMode: 'headless' }` from headless clients (CLI, cron, bots)
|
||||
* so tool calls auto-execute without waiting for human approval.
|
||||
*/
|
||||
userInterventionConfig: UserInterventionConfigSchema.optional(),
|
||||
})
|
||||
.refine((data) => data.agentId || data.slug, {
|
||||
message: 'Either agentId or slug must be provided',
|
||||
@@ -530,10 +542,12 @@ export const aiAgentRouter = router({
|
||||
prompt,
|
||||
appContext,
|
||||
autoStart = true,
|
||||
clientRuntime,
|
||||
deviceId,
|
||||
existingMessageIds = [],
|
||||
fileIds,
|
||||
parentMessageId,
|
||||
userInterventionConfig,
|
||||
} = input;
|
||||
|
||||
log('execAgent: identifier=%s, prompt=%s', agentId || slug, prompt.slice(0, 50));
|
||||
@@ -543,6 +557,7 @@ export const aiAgentRouter = router({
|
||||
agentId,
|
||||
appContext,
|
||||
autoStart,
|
||||
clientRuntime,
|
||||
deviceId,
|
||||
existingMessageIds,
|
||||
fileIds,
|
||||
@@ -551,6 +566,7 @@ export const aiAgentRouter = router({
|
||||
// When parentMessageId is provided, this is a regeneration/continue — skip user message creation
|
||||
resume: !!parentMessageId,
|
||||
slug,
|
||||
userInterventionConfig,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('execAgent failed: %O', error);
|
||||
|
||||
@@ -2,10 +2,16 @@ import { TaskIdentifier as TaskSkillIdentifier } from '@lobechat/builtin-skills'
|
||||
import { BriefIdentifier } from '@lobechat/builtin-tool-brief';
|
||||
import { NotebookIdentifier } from '@lobechat/builtin-tool-notebook';
|
||||
import { buildTaskRunPrompt } from '@lobechat/prompts';
|
||||
import type { TaskTopicHandoff, WorkspaceData } from '@lobechat/types';
|
||||
import type {
|
||||
TaskListItem,
|
||||
TaskParticipant,
|
||||
TaskTopicHandoff,
|
||||
WorkspaceData,
|
||||
} from '@lobechat/types';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AgentModel } from '@/database/models/agent';
|
||||
import { BriefModel } from '@/database/models/brief';
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { TaskTopicModel } from '@/database/models/taskTopic';
|
||||
@@ -21,6 +27,7 @@ const taskProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
return opts.next({
|
||||
ctx: {
|
||||
agentModel: new AgentModel(ctx.serverDB, ctx.userId),
|
||||
briefModel: new BriefModel(ctx.serverDB, ctx.userId),
|
||||
taskLifecycle: new TaskLifecycleService(ctx.serverDB, ctx.userId),
|
||||
taskModel: new TaskModel(ctx.serverDB, ctx.userId),
|
||||
@@ -721,7 +728,31 @@ export const taskRouter = router({
|
||||
try {
|
||||
const model = ctx.taskModel;
|
||||
const result = await model.list(input);
|
||||
return { data: result.tasks, success: true, total: result.total };
|
||||
|
||||
const assigneeIds = [
|
||||
...new Set(result.tasks.map((t) => t.assigneeAgentId).filter((id): id is string => !!id)),
|
||||
];
|
||||
const agents =
|
||||
assigneeIds.length > 0 ? await ctx.agentModel.getAgentAvatarsByIds(assigneeIds) : [];
|
||||
const agentMap = new Map(agents.map((a) => [a.id, a]));
|
||||
|
||||
const data: TaskListItem[] = result.tasks.map((task) => {
|
||||
const participants: TaskParticipant[] = [];
|
||||
if (task.assigneeAgentId) {
|
||||
const agent = agentMap.get(task.assigneeAgentId);
|
||||
if (agent) {
|
||||
participants.push({
|
||||
avatar: agent.avatar,
|
||||
id: agent.id,
|
||||
name: agent.title ?? '',
|
||||
type: 'agent',
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ...task, participants };
|
||||
});
|
||||
|
||||
return { data, success: true, total: result.total };
|
||||
} catch (error) {
|
||||
console.error('[task:list]', error);
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
|
||||
import { RemoteDeviceManifest } from '@lobechat/builtin-tool-remote-device';
|
||||
import type * as ModelBankModule from 'model-bank';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -11,6 +12,7 @@ const {
|
||||
mockGetAgentConfig,
|
||||
mockGetEnabledPluginManifests,
|
||||
mockMessageCreate,
|
||||
mockPluginQuery,
|
||||
mockQueryDeviceList,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateOperation: vi.fn(),
|
||||
@@ -19,6 +21,7 @@ const {
|
||||
mockGetAgentConfig: vi.fn(),
|
||||
mockGetEnabledPluginManifests: vi.fn(),
|
||||
mockMessageCreate: vi.fn(),
|
||||
mockPluginQuery: vi.fn(),
|
||||
mockQueryDeviceList: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -51,7 +54,7 @@ vi.mock('@/server/services/agent', () => ({
|
||||
|
||||
vi.mock('@/database/models/plugin', () => ({
|
||||
PluginModel: vi.fn().mockImplementation(() => ({
|
||||
query: vi.fn().mockResolvedValue([]),
|
||||
query: mockPluginQuery,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -160,6 +163,7 @@ describe('AiAgentService.execAgent - device tool pipeline (LOBE-5636)', () => {
|
||||
success: true,
|
||||
});
|
||||
mockQueryDeviceList.mockResolvedValue([]);
|
||||
mockPluginQuery.mockResolvedValue([]);
|
||||
mockGenerateToolsDetailed.mockReturnValue({ enabledToolIds: [], tools: [] });
|
||||
mockGetEnabledPluginManifests.mockReturnValue(new Map());
|
||||
service = new AiAgentService(mockDb, userId);
|
||||
@@ -224,6 +228,44 @@ describe('AiAgentService.execAgent - device tool pipeline (LOBE-5636)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('clientRuntime forwarded to createServerAgentToolsEngine', () => {
|
||||
it('forwards clientRuntime="desktop" so the engine enables local-system for Electron callers', async () => {
|
||||
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
clientRuntime: 'desktop',
|
||||
prompt: 'Hello',
|
||||
});
|
||||
|
||||
expect(mockCreateServerAgentToolsEngine).toHaveBeenCalledTimes(1);
|
||||
const params = mockCreateServerAgentToolsEngine.mock.calls[0][1];
|
||||
expect(params.clientRuntime).toBe('desktop');
|
||||
});
|
||||
|
||||
it('forwards clientRuntime="web" verbatim', async () => {
|
||||
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
clientRuntime: 'web',
|
||||
prompt: 'Hello',
|
||||
});
|
||||
|
||||
const params = mockCreateServerAgentToolsEngine.mock.calls[0][1];
|
||||
expect(params.clientRuntime).toBe('web');
|
||||
});
|
||||
|
||||
it('omits clientRuntime when the caller does not specify one', async () => {
|
||||
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
|
||||
|
||||
await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' });
|
||||
|
||||
const params = mockCreateServerAgentToolsEngine.mock.calls[0][1];
|
||||
expect(params.clientRuntime).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemoteDevice systemRole override', () => {
|
||||
it('should override RemoteDevice systemRole with dynamic prompt when enabled by ToolsEngine', async () => {
|
||||
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
|
||||
@@ -273,8 +315,164 @@ describe('AiAgentService.execAgent - device tool pipeline (LOBE-5636)', () => {
|
||||
const callArgs = mockCreateOperation.mock.calls[0][0];
|
||||
const manifestMap = callArgs.toolSet.manifestMap;
|
||||
|
||||
// RemoteDevice should NOT be in manifestMap — no manual injection
|
||||
expect(manifestMap[RemoteDeviceManifest.identifier]).toBeUndefined();
|
||||
// RemoteDevice is present in manifestMap (discoverable builtin),
|
||||
// but should NOT be in enabledToolIds when gateway is not configured
|
||||
const enabledToolIds = callArgs.toolSet.enabledToolIds;
|
||||
expect(enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toolExecutorMap gating on gatewayConfigured (regression for #13769)', () => {
|
||||
it('should mark local-system as client when gateway is NOT configured (standalone Electron)', async () => {
|
||||
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
|
||||
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(false);
|
||||
|
||||
mockGetEnabledPluginManifests.mockReturnValue(
|
||||
new Map([[LocalSystemManifest.identifier, LocalSystemManifest]]),
|
||||
);
|
||||
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
|
||||
|
||||
await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' });
|
||||
|
||||
const executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
|
||||
expect(executorMap[LocalSystemManifest.identifier]).toBe('client');
|
||||
});
|
||||
|
||||
it('should NOT mark local-system as client when gateway IS configured (cloud)', async () => {
|
||||
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
|
||||
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(true);
|
||||
mockQueryDeviceList.mockResolvedValue([
|
||||
{ deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' },
|
||||
]);
|
||||
|
||||
mockGetEnabledPluginManifests.mockReturnValue(
|
||||
new Map([[LocalSystemManifest.identifier, LocalSystemManifest]]),
|
||||
);
|
||||
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
|
||||
|
||||
await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' });
|
||||
|
||||
const executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
|
||||
expect(executorMap[LocalSystemManifest.identifier]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should mark stdio MCP plugin as client only when gateway is NOT configured', async () => {
|
||||
const stdioPlugin = {
|
||||
customParams: { mcp: { type: 'stdio' } },
|
||||
identifier: 'my-stdio-mcp',
|
||||
} as any;
|
||||
const stdioManifest = {
|
||||
api: [{ description: 't', name: 'a', parameters: {} }],
|
||||
identifier: 'my-stdio-mcp',
|
||||
meta: { title: 'Stdio' },
|
||||
};
|
||||
|
||||
mockPluginQuery.mockResolvedValue([stdioPlugin]);
|
||||
mockGetEnabledPluginManifests.mockReturnValue(new Map([['my-stdio-mcp', stdioManifest]]));
|
||||
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig({ plugins: ['my-stdio-mcp'] }));
|
||||
|
||||
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
|
||||
|
||||
// Gateway NOT configured → should mark as client
|
||||
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(false);
|
||||
await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' });
|
||||
let executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
|
||||
expect(executorMap['my-stdio-mcp']).toBe('client');
|
||||
|
||||
// Gateway configured → should NOT mark as client
|
||||
mockCreateOperation.mockClear();
|
||||
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(true);
|
||||
mockQueryDeviceList.mockResolvedValue([
|
||||
{ deviceId: 'dev-1', deviceName: 'PC', platform: 'win32' },
|
||||
]);
|
||||
await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' });
|
||||
executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
|
||||
expect(executorMap['my-stdio-mcp']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clientRuntime="desktop" bypasses the DEVICE_GATEWAY gate (Phase 6.4)', () => {
|
||||
it('marks local-system as client when caller is desktop, even with DEVICE_GATEWAY configured', async () => {
|
||||
// On cloud canary, DEVICE_GATEWAY is configured AND a remote Linux VM
|
||||
// may be registered. Before this fix, `!gatewayConfigured` was false, so
|
||||
// local-system was never stamped `executor='client'` — and dispatch fell
|
||||
// through to the Remote Device proxy (which then tried to read the file
|
||||
// on the wrong host). When clientRuntime='desktop', the caller itself is
|
||||
// the execution target and wins.
|
||||
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
|
||||
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(true);
|
||||
mockQueryDeviceList.mockResolvedValue([
|
||||
{ deviceId: 'dev-1', deviceName: 'Remote VM', platform: 'linux' },
|
||||
]);
|
||||
|
||||
mockGetEnabledPluginManifests.mockReturnValue(
|
||||
new Map([[LocalSystemManifest.identifier, LocalSystemManifest]]),
|
||||
);
|
||||
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
clientRuntime: 'desktop',
|
||||
prompt: 'Hello',
|
||||
});
|
||||
|
||||
const executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
|
||||
expect(executorMap[LocalSystemManifest.identifier]).toBe('client');
|
||||
});
|
||||
|
||||
it('marks stdio MCP as client when caller is desktop, even with DEVICE_GATEWAY configured', async () => {
|
||||
const stdioPlugin = {
|
||||
customParams: { mcp: { type: 'stdio' } },
|
||||
identifier: 'my-stdio-mcp',
|
||||
} as any;
|
||||
const stdioManifest = {
|
||||
api: [{ description: 't', name: 'a', parameters: {} }],
|
||||
identifier: 'my-stdio-mcp',
|
||||
meta: { title: 'Stdio' },
|
||||
};
|
||||
|
||||
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
|
||||
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(true);
|
||||
mockQueryDeviceList.mockResolvedValue([
|
||||
{ deviceId: 'dev-1', deviceName: 'Remote VM', platform: 'linux' },
|
||||
]);
|
||||
|
||||
mockPluginQuery.mockResolvedValue([stdioPlugin]);
|
||||
mockGetEnabledPluginManifests.mockReturnValue(new Map([['my-stdio-mcp', stdioManifest]]));
|
||||
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig({ plugins: ['my-stdio-mcp'] }));
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
clientRuntime: 'desktop',
|
||||
prompt: 'Hello',
|
||||
});
|
||||
|
||||
const executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
|
||||
expect(executorMap['my-stdio-mcp']).toBe('client');
|
||||
});
|
||||
|
||||
it('keeps legacy routing for web callers with DEVICE_GATEWAY configured', async () => {
|
||||
// Web client + DEVICE_GATEWAY configured → tools still route through
|
||||
// Remote Device proxy; executor stays unset (legacy behaviour).
|
||||
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
|
||||
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(true);
|
||||
mockQueryDeviceList.mockResolvedValue([
|
||||
{ deviceId: 'dev-1', deviceName: 'Remote VM', platform: 'linux' },
|
||||
]);
|
||||
|
||||
mockGetEnabledPluginManifests.mockReturnValue(
|
||||
new Map([[LocalSystemManifest.identifier, LocalSystemManifest]]),
|
||||
);
|
||||
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
clientRuntime: 'web',
|
||||
prompt: 'Hello',
|
||||
});
|
||||
|
||||
const executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
|
||||
expect(executorMap[LocalSystemManifest.identifier]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -295,8 +493,8 @@ describe('AiAgentService.execAgent - device tool pipeline (LOBE-5636)', () => {
|
||||
const manifestMap = callArgs.toolSet.manifestMap;
|
||||
|
||||
expect(manifestMap['test-tool']).toBe(mockManifest);
|
||||
// No extra manifests added manually
|
||||
expect(Object.keys(manifestMap)).toEqual(['test-tool']);
|
||||
// manifestMap also includes discoverable builtin tools for activator discovery
|
||||
expect(Object.keys(manifestMap)).toContain('test-tool');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,6 +223,7 @@ export class AiAgentService {
|
||||
appContext,
|
||||
autoStart = true,
|
||||
botContext,
|
||||
clientRuntime,
|
||||
deviceId: requestedDeviceId,
|
||||
botPlatformContext,
|
||||
discordContext,
|
||||
@@ -565,6 +566,7 @@ export class AiAgentService {
|
||||
chatConfig: agentConfig.chatConfig ?? undefined,
|
||||
plugins: agentPlugins,
|
||||
},
|
||||
clientRuntime,
|
||||
deviceContext: gatewayConfigured
|
||||
? {
|
||||
autoActivated: activeDeviceId ? true : undefined,
|
||||
@@ -588,6 +590,9 @@ export class AiAgentService {
|
||||
LocalSystemManifest.identifier,
|
||||
RemoteDeviceManifest.identifier,
|
||||
...(isBotConversation ? [MessageToolIdentifier] : []),
|
||||
// Include LobeHub Skills and Klavis tools so they are passed to generateToolsDetailed
|
||||
...lobehubSkillManifests.map((m) => m.identifier),
|
||||
...klavisManifests.map((m) => m.identifier),
|
||||
];
|
||||
log('execAgent: agent configured plugins: %O', pluginIds);
|
||||
|
||||
@@ -604,11 +609,34 @@ export class AiAgentService {
|
||||
|
||||
log('execAgent: enabled tool ids: %O', toolsResult.enabledToolIds);
|
||||
|
||||
// Start with the scoped manifest map (pluginIds + defaultToolIds)
|
||||
const manifestMap = toolsEngine.getEnabledPluginManifests(pluginIds);
|
||||
manifestMap.forEach((manifest, id) => {
|
||||
toolManifestMap[id] = manifest;
|
||||
});
|
||||
|
||||
// Also include discoverable builtin tools that are not yet in the map,
|
||||
// so the activator can find their manifests when dynamically enabling them
|
||||
// (e.g., lobe-creds, lobe-cron). Exclude discoverable:false tools to prevent
|
||||
// internal infrastructure tools from being surfaced to the activator.
|
||||
for (const tool of builtinTools) {
|
||||
if (tool.discoverable !== false && !toolManifestMap[tool.identifier]) {
|
||||
toolManifestMap[tool.identifier] = tool.manifest as LobeToolManifest;
|
||||
}
|
||||
}
|
||||
|
||||
// Include lobehub skill and klavis manifests for activator discovery
|
||||
for (const manifest of lobehubSkillManifests) {
|
||||
if (!toolManifestMap[manifest.identifier]) {
|
||||
toolManifestMap[manifest.identifier] = manifest;
|
||||
}
|
||||
}
|
||||
for (const manifest of klavisManifests) {
|
||||
if (!toolManifestMap[manifest.identifier]) {
|
||||
toolManifestMap[manifest.identifier] = manifest;
|
||||
}
|
||||
}
|
||||
|
||||
for (const manifest of lobehubSkillManifests) {
|
||||
toolSourceMap[manifest.identifier] = 'lobehubSkill';
|
||||
}
|
||||
@@ -620,13 +648,28 @@ export class AiAgentService {
|
||||
// require local IPC / subprocess capabilities:
|
||||
// - local-system builtin: Electron IPC for file + command execution
|
||||
// - stdio MCP plugins: subprocess lives on the user's machine
|
||||
// Dispatcher in RuntimeExecutors reads this to route via Agent Gateway WS.
|
||||
if (manifestMap.has(LocalSystemManifest.identifier)) {
|
||||
toolExecutorMap[LocalSystemManifest.identifier] = 'client';
|
||||
}
|
||||
for (const plugin of installedPlugins) {
|
||||
if (plugin.customParams?.mcp?.type === 'stdio' && manifestMap.has(plugin.identifier)) {
|
||||
toolExecutorMap[plugin.identifier] = 'client';
|
||||
//
|
||||
// Two triggers, in priority order:
|
||||
// (a) `clientRuntime === 'desktop'` — the caller itself is an Electron
|
||||
// client on the Agent Gateway WS and is ready to receive
|
||||
// `tool_execute`. This is the Phase 6.4 path and is authoritative
|
||||
// regardless of whether DEVICE_GATEWAY (the legacy device-proxy) is
|
||||
// also configured.
|
||||
// (b) `!gatewayConfigured` — no DEVICE_GATEWAY configured on the server,
|
||||
// so legacy Remote Device proxy isn't an option and any client
|
||||
// tooling falls through to the Gateway WS (standalone Electron).
|
||||
//
|
||||
// When DEVICE_GATEWAY is configured AND the caller is a web client, we
|
||||
// leave executor unset so tools route via RemoteDevice proxy.
|
||||
const shouldDispatchToClient = clientRuntime === 'desktop' || !gatewayConfigured;
|
||||
if (shouldDispatchToClient) {
|
||||
if (manifestMap.has(LocalSystemManifest.identifier)) {
|
||||
toolExecutorMap[LocalSystemManifest.identifier] = 'client';
|
||||
}
|
||||
for (const plugin of installedPlugins) {
|
||||
if (plugin.customParams?.mcp?.type === 'stdio' && manifestMap.has(plugin.identifier)) {
|
||||
toolExecutorMap[plugin.identifier] = 'client';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -546,7 +546,7 @@ export class AgentBridgeService {
|
||||
client && botContext?.platformThreadId
|
||||
? !!client.getMessenger(botContext.platformThreadId).triggerTyping
|
||||
: true;
|
||||
const useGatewayTyping = gwClient.isConfigured && platformSupportsTyping;
|
||||
const useGatewayTyping = gwClient.isEnabled && platformSupportsTyping;
|
||||
|
||||
let progressMessage: SentMessage | undefined;
|
||||
if (useGatewayTyping) {
|
||||
|
||||
@@ -316,7 +316,7 @@ export class BotCallbackService {
|
||||
*/
|
||||
private renewGatewayTyping(connectionId: string, platformThreadId: string): void {
|
||||
const client = getMessageGatewayClient();
|
||||
if (!client.isConfigured) return;
|
||||
if (!client.isEnabled) return;
|
||||
|
||||
client.startTyping(connectionId, platformThreadId).catch((err) => {
|
||||
log('renewGatewayTyping failed: %O', err);
|
||||
@@ -325,7 +325,7 @@ export class BotCallbackService {
|
||||
|
||||
private stopGatewayTyping(connectionId: string, platformThreadId: string): void {
|
||||
const client = getMessageGatewayClient();
|
||||
if (!client.isConfigured) return;
|
||||
if (!client.isEnabled) return;
|
||||
|
||||
client.stopTyping(connectionId, platformThreadId).catch((err) => {
|
||||
log('stopGatewayTyping failed: %O', err);
|
||||
|
||||
@@ -408,7 +408,9 @@ export class BotMessageRouter {
|
||||
const charLimit = (info.settings?.charLimit as number) || undefined;
|
||||
const displayToolCalls = info.settings?.displayToolCalls !== false;
|
||||
|
||||
/** Try dispatching a text command. Returns true if handled. */
|
||||
/** Try dispatching a text command. Returns true if handled.
|
||||
* Strips platform mention artifacts (e.g. Slack's `<@U123>`) before
|
||||
* checking so that "@bot /new" correctly resolves to the /new command. */
|
||||
const tryDispatch = async (
|
||||
thread: {
|
||||
id: string;
|
||||
@@ -417,7 +419,8 @@ export class BotMessageRouter {
|
||||
},
|
||||
text: string | undefined,
|
||||
): Promise<boolean> => {
|
||||
const result = BotMessageRouter.dispatchTextCommand(text, commands);
|
||||
const sanitized = client.sanitizeUserInput?.(text ?? '') ?? text;
|
||||
const result = BotMessageRouter.dispatchTextCommand(sanitized, commands);
|
||||
if (!result) return false;
|
||||
await result.command.handler({
|
||||
args: result.args,
|
||||
@@ -559,7 +562,7 @@ export class BotMessageRouter {
|
||||
});
|
||||
|
||||
// Register slash command handlers (native + text-based)
|
||||
this.registerCommands(bot, commands);
|
||||
this.registerCommands(bot, commands, client);
|
||||
|
||||
// Register onNewMessage handler based on platform config
|
||||
const dmEnabled = info.settings?.dm?.enabled ?? false;
|
||||
@@ -712,7 +715,7 @@ export class BotMessageRouter {
|
||||
* To add a new command, add an entry to `buildCommands()` — it will be
|
||||
* automatically registered on all platforms.
|
||||
*/
|
||||
private registerCommands(bot: Chat<any>, commands: BotCommand[]): void {
|
||||
private registerCommands(bot: Chat<any>, commands: BotCommand[], client: PlatformClient): void {
|
||||
// --- Native slash commands (Slack, Discord) ---
|
||||
for (const cmd of commands) {
|
||||
bot.onSlashCommand(`/${cmd.name}`, async (event) => {
|
||||
@@ -729,11 +732,14 @@ export class BotMessageRouter {
|
||||
// Platforms that don't support native onSlashCommand send /commands as
|
||||
// regular text messages. This handler intercepts them in unsubscribed
|
||||
// threads (e.g. first command in a group chat or DM).
|
||||
// The regex also matches mention-prefixed messages (e.g. "<@U123> /new")
|
||||
// so that platforms like Slack can dispatch commands from @-mentions.
|
||||
const namePattern = commands.map((c) => c.name).join('|');
|
||||
const regex = new RegExp(`^\\/(?:${namePattern})(?:\\s|$|@)`);
|
||||
const regex = new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?:\\s|$|@)`);
|
||||
bot.onNewMessage(regex, async (thread, message) => {
|
||||
if (message.author.isBot === true) return;
|
||||
const result = BotMessageRouter.dispatchTextCommand(message.text, commands);
|
||||
const sanitized = client.sanitizeUserInput?.(message.text ?? '') ?? message.text;
|
||||
const result = BotMessageRouter.dispatchTextCommand(sanitized, commands);
|
||||
if (!result) return;
|
||||
await result.command.handler({
|
||||
args: result.args,
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock('@/server/services/aiAgent', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/gateway/MessageGatewayClient', () => ({
|
||||
getMessageGatewayClient: vi.fn().mockReturnValue({ isConfigured: false }),
|
||||
getMessageGatewayClient: vi.fn().mockReturnValue({ isConfigured: false, isEnabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/queue/impls', () => ({
|
||||
|
||||
@@ -77,6 +77,7 @@ vi.mock('../AgentBridgeService', () => ({
|
||||
vi.mock('@/server/services/gateway/MessageGatewayClient', () => ({
|
||||
getMessageGatewayClient: vi.fn().mockReturnValue({
|
||||
isConfigured: false,
|
||||
isEnabled: false,
|
||||
startTyping: vi.fn().mockResolvedValue(undefined),
|
||||
stopTyping: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
|
||||
@@ -215,10 +215,6 @@ class SlackWebhookClient implements PlatformClient {
|
||||
parseMessageId(compositeId: string): string {
|
||||
return compositeId;
|
||||
}
|
||||
|
||||
sanitizeUserInput(text: string): string {
|
||||
return text.replaceAll(/<@[A-Z\d]+>\s*/g, '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Socket Mode Client (persistent) ----------
|
||||
@@ -408,10 +404,6 @@ class SlackSocketModeClient implements PlatformClient {
|
||||
parseMessageId(compositeId: string): string {
|
||||
return compositeId;
|
||||
}
|
||||
|
||||
sanitizeUserInput(text: string): string {
|
||||
return text.replaceAll(/<@[A-Z\d]+>\s*/g, '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Factory ----------
|
||||
@@ -428,7 +420,10 @@ export class SlackClientFactory extends ClientFactory {
|
||||
return new SlackWebhookClient(config, context);
|
||||
}
|
||||
|
||||
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
|
||||
async validateCredentials(
|
||||
credentials: Record<string, string>,
|
||||
settings?: Record<string, unknown>,
|
||||
): Promise<ValidationResult> {
|
||||
if (!credentials.botToken) {
|
||||
return { errors: [{ field: 'botToken', message: 'Bot Token is required' }], valid: false };
|
||||
}
|
||||
@@ -438,6 +433,17 @@ export class SlackClientFactory extends ClientFactory {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
if (settings?.connectionMode === 'websocket' && !credentials.appToken) {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
field: 'appToken',
|
||||
message: 'App-Level Token is required for WebSocket (Socket Mode)',
|
||||
},
|
||||
],
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${SLACK_API_BASE}/auth.test`, {
|
||||
|
||||
@@ -67,6 +67,16 @@ export class MessageGatewayClient {
|
||||
return !!(this.baseUrl && this.serviceToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the gateway should be used for active flows (typing, connect, etc.).
|
||||
* Requires MESSAGE_GATEWAY_ENABLED=1 in addition to URL/token. This lets us
|
||||
* disable the gateway during migration while keeping the client reachable
|
||||
* for cleanup (via isConfigured).
|
||||
*/
|
||||
get isEnabled(): boolean {
|
||||
return gatewayEnv.MESSAGE_GATEWAY_ENABLED === '1' && this.isConfigured;
|
||||
}
|
||||
|
||||
// ─── Connection Management ───
|
||||
|
||||
async connect(config: MessageGatewayConnectionConfig): Promise<{ status: string }> {
|
||||
@@ -83,6 +93,19 @@ export class MessageGatewayClient {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async disconnectAll(): Promise<{ total: number }> {
|
||||
log('Disconnecting all connections');
|
||||
|
||||
const res = await this.fetch('/api/connections', { method: 'DELETE' });
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(`message-gateway disconnect-all failed (${res.status}): ${error}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async disconnect(connectionId: string): Promise<{ status: string }> {
|
||||
log('Disconnecting %s', connectionId);
|
||||
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// ─── Import after mocks ───
|
||||
import { GatewayService } from '../index';
|
||||
|
||||
// ─── Hoisted mocks ───
|
||||
|
||||
const mockGatewayClient = vi.hoisted(() => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
disconnectAll: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
isConfigured: false,
|
||||
isEnabled: false,
|
||||
}));
|
||||
|
||||
const mockGatewayEnv = vi.hoisted(() => ({
|
||||
MESSAGE_GATEWAY_ENABLED: undefined as string | undefined,
|
||||
}));
|
||||
|
||||
const mockGatewayManager = vi.hoisted(() => ({
|
||||
isRunning: false,
|
||||
start: vi.fn(),
|
||||
startClient: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
stopClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFindEnabledByPlatform = vi.hoisted(() => vi.fn());
|
||||
const mockGetServerDB = vi.hoisted(() => vi.fn());
|
||||
const mockInitWithEnvKey = vi.hoisted(() => vi.fn());
|
||||
const mockUpdateBotRuntimeStatus = vi.hoisted(() => vi.fn());
|
||||
const mockGetEffectiveConnectionMode = vi.hoisted(() => vi.fn());
|
||||
|
||||
// ─── Module mocks ───
|
||||
|
||||
vi.mock('@/envs/gateway', () => ({
|
||||
gatewayEnv: mockGatewayEnv,
|
||||
}));
|
||||
|
||||
vi.mock('../MessageGatewayClient', () => ({
|
||||
getMessageGatewayClient: () => mockGatewayClient,
|
||||
}));
|
||||
|
||||
vi.mock('../GatewayManager', () => ({
|
||||
createGatewayManager: () => mockGatewayManager,
|
||||
getGatewayManager: () => (mockGatewayManager.isRunning ? mockGatewayManager : null),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/core/db-adaptor', () => ({
|
||||
getServerDB: mockGetServerDB,
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/agentBotProvider', () => ({
|
||||
AgentBotProviderModel: {
|
||||
findEnabledByPlatform: mockFindEnabledByPlatform,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({
|
||||
KeyVaultsGateKeeper: { initWithEnvKey: mockInitWithEnvKey },
|
||||
}));
|
||||
|
||||
vi.mock('../runtimeStatus', () => ({
|
||||
BOT_RUNTIME_STATUSES: {
|
||||
connected: 'connected',
|
||||
disconnected: 'disconnected',
|
||||
failed: 'failed',
|
||||
queued: 'queued',
|
||||
starting: 'starting',
|
||||
},
|
||||
updateBotRuntimeStatus: mockUpdateBotRuntimeStatus,
|
||||
}));
|
||||
|
||||
vi.mock('../../bot/platforms', () => ({
|
||||
getEffectiveConnectionMode: mockGetEffectiveConnectionMode,
|
||||
platformRegistry: {
|
||||
getPlatform: () => ({ id: 'discord' }),
|
||||
listPlatforms: () => [{ id: 'discord' }, { id: 'telegram' }],
|
||||
},
|
||||
}));
|
||||
|
||||
describe('GatewayService', () => {
|
||||
let service: GatewayService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGatewayClient.isConfigured = false;
|
||||
mockGatewayClient.isEnabled = false;
|
||||
mockGatewayEnv.MESSAGE_GATEWAY_ENABLED = undefined;
|
||||
mockGatewayManager.isRunning = false;
|
||||
mockGetServerDB.mockResolvedValue({});
|
||||
mockInitWithEnvKey.mockResolvedValue({});
|
||||
mockFindEnabledByPlatform.mockResolvedValue([]);
|
||||
mockUpdateBotRuntimeStatus.mockResolvedValue({});
|
||||
service = new GatewayService();
|
||||
});
|
||||
|
||||
// ─── useMessageGateway ───
|
||||
|
||||
describe('useMessageGateway', () => {
|
||||
it('returns false when client is not enabled', () => {
|
||||
mockGatewayClient.isEnabled = false;
|
||||
expect(service.useMessageGateway).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when client is enabled', () => {
|
||||
mockGatewayClient.isEnabled = true;
|
||||
expect(service.useMessageGateway).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ensureRunning ───
|
||||
|
||||
describe('ensureRunning', () => {
|
||||
describe('in-process mode (gateway disabled)', () => {
|
||||
it('starts local GatewayManager', async () => {
|
||||
await service.ensureRunning();
|
||||
|
||||
expect(mockGatewayManager.start).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips start if GatewayManager already running', async () => {
|
||||
mockGatewayManager.isRunning = true;
|
||||
|
||||
await service.ensureRunning();
|
||||
|
||||
expect(mockGatewayManager.start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gateway mode (ENABLED=1)', () => {
|
||||
beforeEach(() => {
|
||||
mockGatewayEnv.MESSAGE_GATEWAY_ENABLED = '1';
|
||||
mockGatewayClient.isConfigured = true;
|
||||
mockGatewayClient.isEnabled = true;
|
||||
});
|
||||
|
||||
it('calls syncGatewayConnections instead of starting local manager', async () => {
|
||||
await service.ensureRunning();
|
||||
|
||||
expect(mockGatewayManager.start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── syncGatewayConnections ───
|
||||
|
||||
describe('syncGatewayConnections (via ensureRunning)', () => {
|
||||
beforeEach(() => {
|
||||
mockGatewayEnv.MESSAGE_GATEWAY_ENABLED = '1';
|
||||
mockGatewayClient.isConfigured = true;
|
||||
mockGatewayClient.isEnabled = true;
|
||||
});
|
||||
|
||||
it('skips webhook-mode providers', async () => {
|
||||
mockFindEnabledByPlatform.mockResolvedValue([
|
||||
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
|
||||
]);
|
||||
mockGetEffectiveConnectionMode.mockReturnValue('webhook');
|
||||
|
||||
await service.ensureRunning();
|
||||
|
||||
expect(mockGatewayClient.connect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips already connected providers', async () => {
|
||||
mockFindEnabledByPlatform.mockResolvedValue([
|
||||
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
|
||||
]);
|
||||
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
|
||||
mockGatewayClient.getStatus.mockResolvedValue({
|
||||
state: { status: 'connected' },
|
||||
});
|
||||
|
||||
await service.ensureRunning();
|
||||
|
||||
expect(mockGatewayClient.connect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips connecting providers', async () => {
|
||||
mockFindEnabledByPlatform.mockResolvedValue([
|
||||
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
|
||||
]);
|
||||
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
|
||||
mockGatewayClient.getStatus.mockResolvedValue({
|
||||
state: { status: 'connecting' },
|
||||
});
|
||||
|
||||
await service.ensureRunning();
|
||||
|
||||
expect(mockGatewayClient.connect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips providers in error state', async () => {
|
||||
mockFindEnabledByPlatform.mockResolvedValue([
|
||||
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
|
||||
]);
|
||||
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
|
||||
mockGatewayClient.getStatus.mockResolvedValue({
|
||||
state: { error: 'Session expired (errcode -14)', status: 'error' },
|
||||
});
|
||||
|
||||
await service.ensureRunning();
|
||||
|
||||
expect(mockGatewayClient.connect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('connects disconnected providers', async () => {
|
||||
mockFindEnabledByPlatform.mockResolvedValue([
|
||||
{
|
||||
applicationId: 'app-1',
|
||||
credentials: { token: 'x' },
|
||||
id: 'prov-1',
|
||||
settings: {},
|
||||
userId: 'u1',
|
||||
},
|
||||
]);
|
||||
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
|
||||
mockGatewayClient.getStatus.mockResolvedValue({
|
||||
state: { status: 'disconnected' },
|
||||
});
|
||||
mockGatewayClient.connect.mockResolvedValue({ status: 'connecting' });
|
||||
|
||||
await service.ensureRunning();
|
||||
|
||||
expect(mockGatewayClient.connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationId: 'app-1',
|
||||
connectionId: 'prov-1',
|
||||
platform: 'discord',
|
||||
}),
|
||||
);
|
||||
expect(mockUpdateBotRuntimeStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'starting' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('sets connected status for sync connect result', async () => {
|
||||
mockFindEnabledByPlatform.mockResolvedValue([
|
||||
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
|
||||
]);
|
||||
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
|
||||
mockGatewayClient.getStatus.mockRejectedValue(new Error('not found'));
|
||||
mockGatewayClient.connect.mockResolvedValue({ status: 'connected' });
|
||||
|
||||
await service.ensureRunning();
|
||||
|
||||
expect(mockUpdateBotRuntimeStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'connected' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('tries to connect when status check fails', async () => {
|
||||
mockFindEnabledByPlatform.mockResolvedValue([
|
||||
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
|
||||
]);
|
||||
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
|
||||
mockGatewayClient.getStatus.mockRejectedValue(new Error('DO not found'));
|
||||
mockGatewayClient.connect.mockResolvedValue({ status: 'connecting' });
|
||||
|
||||
await service.ensureRunning();
|
||||
|
||||
expect(mockGatewayClient.connect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles connect failure gracefully', async () => {
|
||||
mockFindEnabledByPlatform.mockResolvedValue([
|
||||
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
|
||||
]);
|
||||
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
|
||||
mockGatewayClient.getStatus.mockResolvedValue({
|
||||
state: { status: 'disconnected' },
|
||||
});
|
||||
mockGatewayClient.connect.mockRejectedValue(new Error('timeout'));
|
||||
|
||||
// Should not throw
|
||||
await expect(service.ensureRunning()).resolves.toBeUndefined();
|
||||
expect(mockUpdateBotRuntimeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MessageGatewayClient } from '../MessageGatewayClient';
|
||||
|
||||
const mockGatewayEnv = vi.hoisted(() => ({
|
||||
MESSAGE_GATEWAY_ENABLED: undefined as string | undefined,
|
||||
}));
|
||||
|
||||
vi.mock('@/envs/gateway', () => ({
|
||||
gatewayEnv: mockGatewayEnv,
|
||||
}));
|
||||
|
||||
describe('MessageGatewayClient', () => {
|
||||
let client: MessageGatewayClient;
|
||||
|
||||
@@ -25,6 +33,24 @@ describe('MessageGatewayClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnabled', () => {
|
||||
it('returns false when configured but MESSAGE_GATEWAY_ENABLED is not 1', () => {
|
||||
mockGatewayEnv.MESSAGE_GATEWAY_ENABLED = undefined;
|
||||
expect(client.isEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when MESSAGE_GATEWAY_ENABLED=1 but not configured', () => {
|
||||
mockGatewayEnv.MESSAGE_GATEWAY_ENABLED = '1';
|
||||
const c = new MessageGatewayClient('', '');
|
||||
expect(c.isEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when MESSAGE_GATEWAY_ENABLED=1 and configured', () => {
|
||||
mockGatewayEnv.MESSAGE_GATEWAY_ENABLED = '1';
|
||||
expect(client.isEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connect', () => {
|
||||
it('calls POST /api/connections with config', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -17,12 +17,13 @@ const isVercel = !!process.env.VERCEL_ENV;
|
||||
|
||||
export class GatewayService {
|
||||
/**
|
||||
* Check if the external message-gateway is configured.
|
||||
* When enabled, all platforms are registered on the gateway for
|
||||
* connection management and typing persistence.
|
||||
* Whether to use the external message-gateway for connection management.
|
||||
* Requires MESSAGE_GATEWAY_ENABLED=1 plus URL/TOKEN to be configured.
|
||||
* This allows disabling the gateway (for migration) while keeping
|
||||
* the client reachable for cleanup.
|
||||
*/
|
||||
get useMessageGateway(): boolean {
|
||||
return getMessageGatewayClient().isConfigured;
|
||||
return getMessageGatewayClient().isEnabled;
|
||||
}
|
||||
|
||||
async ensureRunning(): Promise<void> {
|
||||
@@ -37,10 +38,24 @@ export class GatewayService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start local connections first, then clean up gateway —
|
||||
// brief overlap is better than a gap where messages are lost.
|
||||
const manager = createGatewayManager({ definitions: platformRegistry.listPlatforms() });
|
||||
await manager.start();
|
||||
|
||||
log('GatewayManager started');
|
||||
|
||||
// Clean up leftover gateway connections to prevent duplicates.
|
||||
const client = getMessageGatewayClient();
|
||||
if (client.isConfigured) {
|
||||
try {
|
||||
const result = await client.disconnectAll();
|
||||
if (result.total > 0) {
|
||||
log('Cleaned up %d gateway connections', result.total);
|
||||
}
|
||||
} catch (err) {
|
||||
log('Gateway cleanup skipped (non-critical): %O', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +71,10 @@ export class GatewayService {
|
||||
const serverDB = await getServerDB();
|
||||
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
|
||||
|
||||
let totalSynced = 0;
|
||||
let totalSkipped = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
// Sync all registered platforms
|
||||
for (const definition of platformRegistry.listPlatforms()) {
|
||||
const platform = definition.id;
|
||||
@@ -66,7 +85,10 @@ export class GatewayService {
|
||||
gateKeeper,
|
||||
);
|
||||
|
||||
log('Gateway sync: found %d enabled providers for %s', providers.length, platform);
|
||||
let synced = 0;
|
||||
let skippedWebhook = 0;
|
||||
let skippedConnected = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
@@ -74,26 +96,26 @@ export class GatewayService {
|
||||
const connectionMode = getEffectiveConnectionMode(definition, provider.settings);
|
||||
|
||||
// Webhook-mode platforms don't need persistent gateway connections.
|
||||
// Run the platform client locally via GatewayManager instead
|
||||
// (e.g. Telegram setWebhook, QQ credential verification).
|
||||
// The webhook URL is set once when the user saves the bot config
|
||||
// (via startClientViaGateway). No action needed during periodic sync.
|
||||
if (connectionMode === 'webhook') {
|
||||
const manager = createGatewayManager({
|
||||
definitions: platformRegistry.listPlatforms(),
|
||||
});
|
||||
await manager.startClient(platform, provider.applicationId, provider.userId);
|
||||
log(
|
||||
'Gateway sync: started webhook-mode %s:%s locally',
|
||||
platform,
|
||||
provider.applicationId,
|
||||
);
|
||||
skippedWebhook++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// For persistent connections, check gateway status before reconnecting
|
||||
try {
|
||||
const status = await client.getStatus(provider.id);
|
||||
if (status.state.status === 'connected') {
|
||||
log('Gateway sync: %s already connected, skipping', provider.id);
|
||||
if (status.state.status === 'connected' || status.state.status === 'connecting') {
|
||||
skippedConnected++;
|
||||
log('Gateway sync: %s already %s, skipping', provider.id, status.state.status);
|
||||
continue;
|
||||
}
|
||||
// "error" means credential/config issue (e.g. session expired, unauthorized).
|
||||
// Auto-retry is pointless — only user action (saving new credentials) can fix it.
|
||||
if (status.state.status === 'error') {
|
||||
skippedConnected++;
|
||||
log('Gateway sync: %s in error (%s), skipping', provider.id, status.state.error);
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
@@ -101,7 +123,7 @@ export class GatewayService {
|
||||
}
|
||||
|
||||
const webhookPath = `/api/agent/webhooks/${platform}/${provider.applicationId}`;
|
||||
await client.connect({
|
||||
const result = await client.connect({
|
||||
applicationId: provider.applicationId,
|
||||
connectionId: provider.id,
|
||||
connectionMode,
|
||||
@@ -111,21 +133,51 @@ export class GatewayService {
|
||||
webhookPath,
|
||||
});
|
||||
|
||||
// Gateway returns "connecting" for async persistent connections
|
||||
// (e.g. Discord WebSocket), "connected" for sync webhook-mode.
|
||||
const runtimeStatus =
|
||||
result.status === 'connected'
|
||||
? BOT_RUNTIME_STATUSES.connected
|
||||
: BOT_RUNTIME_STATUSES.starting;
|
||||
|
||||
await updateBotRuntimeStatus({
|
||||
applicationId: provider.applicationId,
|
||||
platform,
|
||||
status: BOT_RUNTIME_STATUSES.connected,
|
||||
status: runtimeStatus,
|
||||
});
|
||||
|
||||
log('Gateway sync: connected %s:%s', platform, provider.applicationId);
|
||||
synced++;
|
||||
log('Gateway sync: %s %s:%s', result.status, platform, provider.applicationId);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
log('Gateway sync: failed to connect %s:%s: %O', platform, provider.applicationId, err);
|
||||
}
|
||||
}
|
||||
|
||||
log(
|
||||
'Gateway sync: %s — total=%d synced=%d skippedWebhook=%d skippedConnected=%d failed=%d',
|
||||
platform,
|
||||
providers.length,
|
||||
synced,
|
||||
skippedWebhook,
|
||||
skippedConnected,
|
||||
failed,
|
||||
);
|
||||
|
||||
totalSynced += synced;
|
||||
totalSkipped += skippedWebhook + skippedConnected;
|
||||
totalFailed += failed;
|
||||
} catch (err) {
|
||||
log('Gateway sync: error syncing platform %s: %O', platform, err);
|
||||
}
|
||||
}
|
||||
|
||||
log(
|
||||
'Gateway sync complete: synced=%d skipped=%d failed=%d',
|
||||
totalSynced,
|
||||
totalSkipped,
|
||||
totalFailed,
|
||||
);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { ChatToolPayload } from '@lobechat/types';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BuiltinToolsExecutor } from '../builtin';
|
||||
import type { ToolExecutionContext } from '../types';
|
||||
|
||||
const mockApiHandler = vi.fn();
|
||||
|
||||
vi.mock('../serverRuntimes', () => ({
|
||||
hasServerRuntime: vi.fn().mockReturnValue(true),
|
||||
getServerRuntime: vi.fn(async () => ({ createDocument: mockApiHandler })),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
vi.mock('@/server/services/market', () => ({
|
||||
MarketService: vi.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
const buildPayload = (argsStr: string): ChatToolPayload => ({
|
||||
apiName: 'createDocument',
|
||||
arguments: argsStr,
|
||||
id: 't1',
|
||||
identifier: 'lobe-notebook',
|
||||
type: 'default' as any,
|
||||
});
|
||||
|
||||
const context: ToolExecutionContext = {
|
||||
toolManifestMap: {},
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
describe('BuiltinToolsExecutor truncated arguments', () => {
|
||||
const executor = new BuiltinToolsExecutor({} as any, 'user-1');
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiHandler.mockReset();
|
||||
});
|
||||
|
||||
it('short-circuits with TRUNCATED_ARGUMENTS when JSON is cut mid-object', async () => {
|
||||
const truncated = '{"title": "Report", "description": "foo", "type": "report"';
|
||||
|
||||
const result = await executor.execute(buildPayload(truncated), context);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe('TRUNCATED_ARGUMENTS');
|
||||
expect(result.content).toMatch(/truncated/i);
|
||||
expect(result.content).toMatch(/max_tokens/);
|
||||
// The raw truncated payload is echoed back so the model sees exactly what
|
||||
// it produced and cannot blame upstream for a different payload.
|
||||
expect(result.content).toContain(truncated);
|
||||
expect(mockApiHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('short-circuits with TRUNCATED_ARGUMENTS when a string value is unterminated', async () => {
|
||||
const truncated = '{"title": "Report", "content": "this is cut';
|
||||
|
||||
const result = await executor.execute(buildPayload(truncated), context);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe('TRUNCATED_ARGUMENTS');
|
||||
expect(result.content).toMatch(/unterminated string/);
|
||||
expect(result.content).toContain(truncated);
|
||||
expect(mockApiHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still dispatches to the runtime for valid JSON missing required fields', async () => {
|
||||
mockApiHandler.mockResolvedValueOnce({
|
||||
content: 'Error: Missing content. The document content is required.',
|
||||
success: false,
|
||||
});
|
||||
|
||||
const result = await executor.execute(
|
||||
buildPayload('{"title": "Report", "type": "report"}'),
|
||||
context,
|
||||
);
|
||||
|
||||
expect(mockApiHandler).toHaveBeenCalledWith({ title: 'Report', type: 'report' }, context);
|
||||
// The schema-level error from the runtime passes through untouched.
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.content).toMatch(/Missing content/);
|
||||
});
|
||||
|
||||
it('returns INVALID_JSON_ARGUMENTS for balanced-but-invalid JSON (not truncated)', async () => {
|
||||
// Balanced brackets but invalid syntax (unquoted key). Not a truncation,
|
||||
// but still unparseable — reject with a non-truncation error rather than
|
||||
// silently passing `{}` to the tool.
|
||||
const invalid = '{title: "Report"}';
|
||||
|
||||
const result = await executor.execute(buildPayload(invalid), context);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe('INVALID_JSON_ARGUMENTS');
|
||||
expect(result.content).toMatch(/not valid JSON/);
|
||||
expect(result.content).toContain(invalid);
|
||||
expect(mockApiHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still dispatches normally when argsStr is empty', async () => {
|
||||
mockApiHandler.mockResolvedValueOnce({ content: 'ok', success: true });
|
||||
|
||||
// Empty arguments are legitimate for tools that take no params —
|
||||
// parse falls through to `{}` without triggering the invalid-JSON guard.
|
||||
const result = await executor.execute(buildPayload(''), context);
|
||||
|
||||
expect(mockApiHandler).toHaveBeenCalledWith({}, context);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type LobeChatDatabase } from '@lobechat/database';
|
||||
import { type ChatToolPayload } from '@lobechat/types';
|
||||
import { safeParseJSON } from '@lobechat/utils';
|
||||
import { detectTruncatedJSON, safeParseJSON } from '@lobechat/utils';
|
||||
import debug from 'debug';
|
||||
|
||||
import { KlavisService } from '@/server/services/klavis';
|
||||
@@ -25,7 +25,38 @@ export class BuiltinToolsExecutor implements IToolExecutor {
|
||||
context: ToolExecutionContext,
|
||||
): Promise<ToolExecutionResult> {
|
||||
const { identifier, apiName, arguments: argsStr, source } = payload;
|
||||
const args = safeParseJSON(argsStr) || {};
|
||||
const parsed = safeParseJSON(argsStr);
|
||||
|
||||
// When JSON.parse fails, return a dedicated error rather than silently
|
||||
// falling back to `{}`. Passing `{}` to the tool produced generic
|
||||
// "required field missing" errors, which led the model to retry with the
|
||||
// same broken payload. Distinguish a truncated payload (typical when
|
||||
// max_tokens is exhausted mid-tool-call) from plain malformed JSON, and
|
||||
// echo the raw arguments string so the model can verify it is exactly
|
||||
// what it produced.
|
||||
if (parsed === undefined && argsStr) {
|
||||
const truncationReason = detectTruncatedJSON(argsStr);
|
||||
const explanation = truncationReason
|
||||
? `The tool call arguments JSON appears to be truncated (${truncationReason}), ` +
|
||||
`likely because the model's max_tokens budget was exhausted ` +
|
||||
`(possibly by extended-thinking tokens). ` +
|
||||
`Either reduce the size of the content you are about to write, ` +
|
||||
`or ask the user to increase the model's max_tokens ` +
|
||||
`(and/or disable extended thinking or set a separate thinking budget). ` +
|
||||
`Do not retry with the same payload.`
|
||||
: `The tool call arguments string is not valid JSON and could not be parsed, ` +
|
||||
`so the tool was not invoked. Fix the JSON syntax and try again.`;
|
||||
const content = `${explanation}\n\nThe received arguments string was:\n${argsStr}`;
|
||||
const code = truncationReason ? 'TRUNCATED_ARGUMENTS' : 'INVALID_JSON_ARGUMENTS';
|
||||
log('Rejected invalid arguments for %s:%s (%s): %s', identifier, apiName, code, argsStr);
|
||||
return {
|
||||
content,
|
||||
error: { code, message: explanation },
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const args = parsed || {};
|
||||
|
||||
log(
|
||||
'Executing builtin tool: %s:%s (source: %s) with args: %O',
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface ExecAgentTaskParams {
|
||||
topicId?: string | null;
|
||||
};
|
||||
autoStart?: boolean;
|
||||
/**
|
||||
* Runtime of the client initiating this request. When 'desktop', server
|
||||
* enables `executor: 'client'` tools (local-system, stdio MCP) and
|
||||
* dispatches them over the Agent Gateway WS back to this client.
|
||||
*/
|
||||
clientRuntime?: 'desktop' | 'web';
|
||||
deviceId?: string;
|
||||
existingMessageIds?: string[];
|
||||
/** File IDs of already-uploaded attachments to attach to the new user message */
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { SWRResponse } from 'swr';
|
||||
import type { PartialDeep } from 'type-fest';
|
||||
|
||||
import { MESSAGE_CANCEL_FLAT } from '@/const/message';
|
||||
import { mutate, useClientDataSWR, useClientDataSWRWithSync } from '@/libs/swr';
|
||||
import { mutate, useClientDataSWRWithSync } from '@/libs/swr';
|
||||
import type { CreateAgentParams, CreateAgentResult } from '@/services/agent';
|
||||
import { agentService } from '@/services/agent';
|
||||
import {
|
||||
@@ -270,19 +270,21 @@ export class AgentSliceActionImpl {
|
||||
isLogin: boolean | undefined,
|
||||
agentId: string,
|
||||
): SWRResponse<LobeAgentConfig> => {
|
||||
return useClientDataSWR<LobeAgentConfig>(
|
||||
// Only fetch when login status is explicitly true (not null/undefined)
|
||||
const swrKey =
|
||||
isLogin === true && agentId && !isChatGroupSessionId(agentId)
|
||||
? ([FETCH_AGENT_CONFIG_KEY, agentId] as const)
|
||||
: null,
|
||||
async ([, id]: readonly [string, string]) => {
|
||||
const data = await agentService.getAgentConfigById(id);
|
||||
: null;
|
||||
|
||||
return useClientDataSWRWithSync<LobeAgentConfig>(
|
||||
swrKey,
|
||||
async () => {
|
||||
const data = await agentService.getAgentConfigById(agentId);
|
||||
return data as LobeAgentConfig;
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onData: (data) => {
|
||||
if (!data) return;
|
||||
this.#get().internal_dispatchAgentMap(agentId, data);
|
||||
|
||||
this.#set({ activeAgentId: data.id }, false, 'fetchAgentConfig');
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ToolExecuteData } from '@/libs/agent-stream';
|
||||
|
||||
import { ClientToolExecutionActionImpl } from '../clientToolExecution';
|
||||
|
||||
// ─── Hoisted mocks ───
|
||||
|
||||
const { hasExecutorMock, invokeExecutorMock, invokeMcpToolCallMock } = vi.hoisted(() => ({
|
||||
hasExecutorMock: vi.fn(),
|
||||
invokeExecutorMock: vi.fn(),
|
||||
invokeMcpToolCallMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/tool/slices/builtin/executors', () => ({
|
||||
hasExecutor: hasExecutorMock,
|
||||
invokeExecutor: invokeExecutorMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/mcp', () => ({
|
||||
mcpService: {
|
||||
invokeMcpToolCall: invokeMcpToolCallMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Shared harness ───
|
||||
|
||||
function makeData(overrides: Partial<ToolExecuteData> = {}): ToolExecuteData {
|
||||
return {
|
||||
apiName: 'readFile',
|
||||
arguments: '{"path":"/tmp/a.txt"}',
|
||||
executionTimeoutMs: 60_000,
|
||||
identifier: 'local-system',
|
||||
toolCallId: 'call_1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(options: { hasConnection?: boolean; sendReturns?: boolean } = {}) {
|
||||
const { hasConnection = true, sendReturns = true } = options;
|
||||
|
||||
const sendToolResult = vi.fn(() => sendReturns);
|
||||
|
||||
const state: any = {
|
||||
gatewayConnections: hasConnection
|
||||
? {
|
||||
'op-1': {
|
||||
client: {
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
on: vi.fn(),
|
||||
sendInterrupt: vi.fn(),
|
||||
sendToolResult,
|
||||
},
|
||||
status: 'connected',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
operations: {
|
||||
'op-1': {
|
||||
abortController: { signal: { aborted: false } },
|
||||
context: { agentId: 'agent-1', topicId: 'topic-1' },
|
||||
},
|
||||
},
|
||||
pendingClientToolExecutions: {},
|
||||
};
|
||||
|
||||
const set = vi.fn((updater: any) => {
|
||||
const patch = typeof updater === 'function' ? updater(state) : updater;
|
||||
Object.assign(state, patch);
|
||||
});
|
||||
const get = vi.fn(() => state);
|
||||
|
||||
const action = new ClientToolExecutionActionImpl(set, get);
|
||||
return { action, sendToolResult, state, set, get };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hasExecutorMock.mockReset();
|
||||
invokeExecutorMock.mockReset();
|
||||
invokeMcpToolCallMock.mockReset();
|
||||
});
|
||||
|
||||
// ─── Tests ───
|
||||
|
||||
describe('internal_executeClientTool', () => {
|
||||
describe('builtin dispatch', () => {
|
||||
it('sends a successful tool_result when the executor returns content', async () => {
|
||||
hasExecutorMock.mockReturnValue(true);
|
||||
invokeExecutorMock.mockResolvedValue({
|
||||
content: 'files: a.txt',
|
||||
state: { lastDir: '/tmp' },
|
||||
success: true,
|
||||
});
|
||||
const { action, sendToolResult } = setup();
|
||||
|
||||
await action.internal_executeClientTool(makeData(), { operationId: 'op-1' });
|
||||
|
||||
expect(invokeExecutorMock).toHaveBeenCalledWith(
|
||||
'local-system',
|
||||
'readFile',
|
||||
{ path: '/tmp/a.txt' },
|
||||
expect.objectContaining({
|
||||
agentId: 'agent-1',
|
||||
messageId: 'call_1',
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
}),
|
||||
);
|
||||
expect(sendToolResult).toHaveBeenCalledWith({
|
||||
content: 'files: a.txt',
|
||||
state: { lastDir: '/tmp' },
|
||||
success: true,
|
||||
toolCallId: 'call_1',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends a failure tool_result when the executor reports error', async () => {
|
||||
hasExecutorMock.mockReturnValue(true);
|
||||
invokeExecutorMock.mockResolvedValue({
|
||||
error: { message: 'ENOENT', type: 'fs_error' },
|
||||
success: false,
|
||||
});
|
||||
const { action, sendToolResult } = setup();
|
||||
|
||||
await action.internal_executeClientTool(makeData(), { operationId: 'op-1' });
|
||||
|
||||
expect(sendToolResult).toHaveBeenCalledWith({
|
||||
content: 'ENOENT',
|
||||
error: { message: 'ENOENT', type: 'fs_error' },
|
||||
state: undefined,
|
||||
success: false,
|
||||
toolCallId: 'call_1',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes {} to executor when arguments is empty', async () => {
|
||||
hasExecutorMock.mockReturnValue(true);
|
||||
invokeExecutorMock.mockResolvedValue({ content: 'ok', success: true });
|
||||
const { action } = setup();
|
||||
|
||||
await action.internal_executeClientTool(makeData({ arguments: '' }), {
|
||||
operationId: 'op-1',
|
||||
});
|
||||
|
||||
expect(invokeExecutorMock).toHaveBeenCalledWith(
|
||||
'local-system',
|
||||
'readFile',
|
||||
{},
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error paths never block the server', () => {
|
||||
it('sends tool_result with parse error when arguments are malformed', async () => {
|
||||
hasExecutorMock.mockReturnValue(true);
|
||||
const { action, sendToolResult } = setup();
|
||||
|
||||
await action.internal_executeClientTool(makeData({ arguments: '{not-json' }), {
|
||||
operationId: 'op-1',
|
||||
});
|
||||
|
||||
expect(invokeExecutorMock).not.toHaveBeenCalled();
|
||||
expect(sendToolResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: null,
|
||||
error: expect.objectContaining({ type: 'arguments_parse_error' }),
|
||||
success: false,
|
||||
toolCallId: 'call_1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends tool_result when invokeExecutor throws', async () => {
|
||||
hasExecutorMock.mockReturnValue(true);
|
||||
invokeExecutorMock.mockRejectedValue(new Error('ipc died'));
|
||||
const { action, sendToolResult } = setup();
|
||||
|
||||
await action.internal_executeClientTool(makeData(), { operationId: 'op-1' });
|
||||
|
||||
expect(sendToolResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: 'ipc died',
|
||||
type: 'client_tool_execution_error',
|
||||
}),
|
||||
success: false,
|
||||
toolCallId: 'call_1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a not-found tool_result when no executor matches and MCP returns nothing', async () => {
|
||||
hasExecutorMock.mockReturnValue(false);
|
||||
invokeMcpToolCallMock.mockResolvedValue(undefined);
|
||||
const { action, sendToolResult } = setup();
|
||||
|
||||
await action.internal_executeClientTool(makeData({ identifier: 'unknown-tool' }), {
|
||||
operationId: 'op-1',
|
||||
});
|
||||
|
||||
expect(sendToolResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ type: 'executor_not_found' }),
|
||||
success: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when gateway connection is missing (server will timeout)', async () => {
|
||||
hasExecutorMock.mockReturnValue(true);
|
||||
invokeExecutorMock.mockResolvedValue({ content: 'ok', success: true });
|
||||
const { action, state } = setup({ hasConnection: false });
|
||||
|
||||
await expect(
|
||||
action.internal_executeClientTool(makeData(), { operationId: 'op-1' }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// Pending state was cleared even when send couldn't happen
|
||||
expect(state.pendingClientToolExecutions).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP fallback', () => {
|
||||
it('routes to mcpService when no builtin executor registered', async () => {
|
||||
hasExecutorMock.mockReturnValue(false);
|
||||
invokeMcpToolCallMock.mockResolvedValue({
|
||||
content: 'mcp-ok',
|
||||
state: { cursor: 1 },
|
||||
success: true,
|
||||
});
|
||||
const { action, sendToolResult } = setup();
|
||||
|
||||
await action.internal_executeClientTool(
|
||||
makeData({ apiName: 'echo', identifier: 'mcp-demo' }),
|
||||
{ operationId: 'op-1' },
|
||||
);
|
||||
|
||||
expect(invokeMcpToolCallMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiName: 'echo',
|
||||
arguments: '{"path":"/tmp/a.txt"}',
|
||||
identifier: 'mcp-demo',
|
||||
}),
|
||||
expect.objectContaining({ topicId: 'topic-1' }),
|
||||
);
|
||||
expect(sendToolResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: 'mcp-ok',
|
||||
state: { cursor: 1 },
|
||||
success: true,
|
||||
toolCallId: 'call_1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends failure tool_result when MCP returns an error result', async () => {
|
||||
hasExecutorMock.mockReturnValue(false);
|
||||
invokeMcpToolCallMock.mockResolvedValue({
|
||||
content: null,
|
||||
error: { message: 'mcp boom', type: 'mcp_error' },
|
||||
success: false,
|
||||
});
|
||||
const { action, sendToolResult } = setup();
|
||||
|
||||
await action.internal_executeClientTool(makeData({ identifier: 'mcp-demo' }), {
|
||||
operationId: 'op-1',
|
||||
});
|
||||
|
||||
expect(sendToolResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: null,
|
||||
error: expect.objectContaining({ message: 'mcp boom', type: 'mcp_error' }),
|
||||
success: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending state', () => {
|
||||
it('marks the call pending during execution and clears it afterwards', async () => {
|
||||
hasExecutorMock.mockReturnValue(true);
|
||||
let resolver: (v: any) => void = () => {};
|
||||
invokeExecutorMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolver = resolve;
|
||||
}),
|
||||
);
|
||||
const { action, state } = setup();
|
||||
|
||||
const promise = action.internal_executeClientTool(makeData(), { operationId: 'op-1' });
|
||||
|
||||
// Between start and resolve: pending map has the id
|
||||
await Promise.resolve();
|
||||
expect(state.pendingClientToolExecutions).toEqual({ call_1: true });
|
||||
|
||||
resolver({ content: 'done', success: true });
|
||||
await promise;
|
||||
|
||||
expect(state.pendingClientToolExecutions).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,49 @@ describe('ConversationControl actions', () => {
|
||||
|
||||
expect(result.current.operations[operationId!].status).toBe('running');
|
||||
});
|
||||
|
||||
it('cancels Gateway-mode execServerAgentRuntime ops and invokes their cancel handler', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
||||
act(() => {
|
||||
useChatStore.setState({
|
||||
activeAgentId: TEST_IDS.SESSION_ID,
|
||||
activeTopicId: TEST_IDS.TOPIC_ID,
|
||||
});
|
||||
});
|
||||
|
||||
let operationId!: string;
|
||||
act(() => {
|
||||
const res = result.current.startOperation({
|
||||
type: 'execServerAgentRuntime',
|
||||
context: { agentId: TEST_IDS.SESSION_ID, topicId: TEST_IDS.TOPIC_ID },
|
||||
});
|
||||
operationId = res.operationId;
|
||||
});
|
||||
|
||||
const cancelHandler = vi.fn();
|
||||
act(() => {
|
||||
result.current.onOperationCancel(operationId, cancelHandler);
|
||||
});
|
||||
|
||||
expect(result.current.operations[operationId].status).toBe('running');
|
||||
|
||||
act(() => {
|
||||
result.current.stopGenerateMessage();
|
||||
});
|
||||
|
||||
// Operation gets cancelled and the handler (which would fire the WS interrupt
|
||||
// in real code) is invoked with the operation context.
|
||||
expect(result.current.operations[operationId].status).toBe('cancelled');
|
||||
expect(cancelHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operationId,
|
||||
type: 'execServerAgentRuntime',
|
||||
}),
|
||||
);
|
||||
// isAborting flag is also flipped so the UI loading state clears immediately.
|
||||
expect(result.current.operations[operationId].metadata.isAborting).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelSendMessageInServer', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { GatewayActionImpl } from '../gateway';
|
||||
vi.mock('@/services/aiAgent', () => ({
|
||||
aiAgentService: {
|
||||
execAgentTask: vi.fn(),
|
||||
interruptTask: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -52,6 +53,7 @@ function createMockClient(): GatewayConnection['client'] & {
|
||||
set.add(listener);
|
||||
}),
|
||||
sendInterrupt: vi.fn(),
|
||||
sendToolResult: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -280,6 +282,7 @@ describe('GatewayActionImpl', () => {
|
||||
associateMessageWithOperation: vi.fn(),
|
||||
connectToGateway: vi.fn(),
|
||||
internal_updateTopicLoading: vi.fn(),
|
||||
onOperationCancel: vi.fn(),
|
||||
replaceMessages: vi.fn(),
|
||||
switchTopic: vi.fn(),
|
||||
})) as any;
|
||||
@@ -397,5 +400,67 @@ describe('GatewayActionImpl', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('registers a cancel handler that calls aiAgentService.interruptTask with the server operationId', async () => {
|
||||
const onOperationCancel = vi.fn();
|
||||
const startOperation = vi.fn(() => ({ operationId: 'gw-op-local' }));
|
||||
|
||||
const mockClient = createMockClient();
|
||||
const state: Record<string, any> = { gatewayConnections: {} };
|
||||
const set = vi.fn((updater: any) => {
|
||||
if (typeof updater === 'function') Object.assign(state, updater(state));
|
||||
else Object.assign(state, updater);
|
||||
});
|
||||
const get = vi.fn(() => ({
|
||||
...state,
|
||||
associateMessageWithOperation: vi.fn(),
|
||||
connectToGateway: vi.fn(),
|
||||
internal_updateTopicLoading: vi.fn(),
|
||||
onOperationCancel,
|
||||
replaceMessages: vi.fn(),
|
||||
startOperation,
|
||||
switchTopic: vi.fn(),
|
||||
})) as any;
|
||||
|
||||
(globalThis as any).window = {
|
||||
global_serverConfigStore: {
|
||||
getState: () => ({ serverConfig: { agentGatewayUrl: 'https://gateway.test.com' } }),
|
||||
},
|
||||
};
|
||||
|
||||
const action = new GatewayActionImpl(set as any, get, undefined);
|
||||
action.createClient = vi.fn(() => mockClient);
|
||||
const interruptTaskSpy = vi
|
||||
.mocked(aiAgentService.interruptTask)
|
||||
.mockResolvedValue({ operationId: 'server-op-xyz', success: true });
|
||||
|
||||
vi.mocked(aiAgentService.execAgentTask).mockResolvedValue({
|
||||
agentId: 'agent-1',
|
||||
assistantMessageId: 'ast-1',
|
||||
autoStarted: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
message: 'ok',
|
||||
operationId: 'server-op-xyz',
|
||||
status: 'created',
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
token: 'test-token',
|
||||
topicId: 'topic-1',
|
||||
userMessageId: 'usr-1',
|
||||
});
|
||||
|
||||
await action.executeGatewayAgent({
|
||||
context: { agentId: 'agent-1', topicId: 'topic-1', threadId: null, scope: 'main' },
|
||||
message: 'Hello',
|
||||
});
|
||||
|
||||
// Handler was registered against the local operation id...
|
||||
expect(onOperationCancel).toHaveBeenCalledWith('gw-op-local', expect.any(Function));
|
||||
|
||||
// ...and, when invoked, fires tRPC interruptTask with the *server-side* operation id
|
||||
const [, handler] = onOperationCancel.mock.calls[0];
|
||||
await handler();
|
||||
expect(interruptTaskSpy).toHaveBeenCalledWith({ operationId: 'server-op-xyz' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ function createMockStore() {
|
||||
associateMessageWithOperation: vi.fn(),
|
||||
completeOperation: vi.fn(),
|
||||
internal_dispatchMessage: vi.fn(),
|
||||
internal_executeClientTool: vi.fn().mockResolvedValue(undefined),
|
||||
internal_toggleToolCallingStreaming: vi.fn(),
|
||||
replaceMessages: vi.fn(),
|
||||
};
|
||||
@@ -22,12 +23,13 @@ function createMockStore() {
|
||||
|
||||
function createHandler(
|
||||
store: ReturnType<typeof createMockStore>,
|
||||
overrides?: { assistantMessageId?: string },
|
||||
overrides?: { assistantMessageId?: string; gatewayOperationId?: string },
|
||||
) {
|
||||
const get = vi.fn(() => store) as any;
|
||||
return createGatewayEventHandler(get, {
|
||||
assistantMessageId: overrides?.assistantMessageId ?? 'msg-initial',
|
||||
context: { agentId: 'agent-1', scope: 'session', topicId: 'topic-1' } as any,
|
||||
gatewayOperationId: overrides?.gatewayOperationId,
|
||||
operationId: 'op-1',
|
||||
});
|
||||
}
|
||||
@@ -197,6 +199,72 @@ describe('createGatewayEventHandler', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool_execute', () => {
|
||||
const toolExecuteData = {
|
||||
apiName: 'readFile',
|
||||
arguments: '{"path":"/tmp/a.txt"}',
|
||||
executionTimeoutMs: 60_000,
|
||||
identifier: 'local-system',
|
||||
toolCallId: 'call_1',
|
||||
};
|
||||
|
||||
it('forwards the payload to internal_executeClientTool with operationId', async () => {
|
||||
const store = createMockStore();
|
||||
const handler = createHandler(store);
|
||||
|
||||
handler(makeEvent('tool_execute', toolExecuteData));
|
||||
await flush();
|
||||
|
||||
expect(store.internal_executeClientTool).toHaveBeenCalledWith(toolExecuteData, {
|
||||
operationId: 'op-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses gatewayOperationId (WS key) when distinct from local operationId', async () => {
|
||||
// Locally the handler tracks `op-1` (used for message dispatch), but
|
||||
// the Agent Gateway WS is keyed on the server-side id `gw-op-server`.
|
||||
// The action must receive the latter so it can look up the live
|
||||
// AgentStreamClient in `gatewayConnections` and reply with tool_result.
|
||||
const store = createMockStore();
|
||||
const handler = createHandler(store, { gatewayOperationId: 'gw-op-server' });
|
||||
|
||||
handler(makeEvent('tool_execute', toolExecuteData));
|
||||
await flush();
|
||||
|
||||
expect(store.internal_executeClientTool).toHaveBeenCalledWith(toolExecuteData, {
|
||||
operationId: 'gw-op-server',
|
||||
});
|
||||
});
|
||||
|
||||
it('is fire-and-forget — does not block the event pipeline', async () => {
|
||||
const store = createMockStore();
|
||||
// Simulate a slow tool execution that never resolves
|
||||
store.internal_executeClientTool.mockImplementation(() => new Promise(() => {}));
|
||||
const handler = createHandler(store);
|
||||
|
||||
handler(makeEvent('tool_execute', toolExecuteData));
|
||||
// If the handler awaited the action, this subsequent stream_chunk would
|
||||
// be queued behind the pending promise forever. We assert it still runs.
|
||||
handler(makeEvent('stream_chunk', { chunkType: 'text', content: 'hi' }));
|
||||
await flush();
|
||||
|
||||
expect(store.internal_dispatchMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: { content: 'hi' } }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores tool_execute events without data', async () => {
|
||||
const store = createMockStore();
|
||||
const handler = createHandler(store);
|
||||
|
||||
handler(makeEvent('tool_execute'));
|
||||
await flush();
|
||||
|
||||
expect(store.internal_executeClientTool).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool_end', () => {
|
||||
it('should refresh messages to pull tool results', async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user