🐛 fix: fix input cannot send markdown (#9674)

* fix claude json output

* refactor to remove langchain in file-loaders

* support deepseek tools calling

* use peer

* use peer

* move files

* fix test

* add local-system placeholder

* fix markdown editing

* fix markdown editing

* refactor doc parse
This commit is contained in:
Arvin Xu
2025-10-12 10:11:02 +02:00
committed by GitHub
parent 0038a64819
commit 2518d7eabf
42 changed files with 985 additions and 226 deletions
+1 -1
View File
@@ -160,7 +160,7 @@
"@lobehub/charts": "^2.1.2",
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@lobehub/chat-plugins-gateway": "^1.9.0",
"@lobehub/editor": "^1.16.0",
"@lobehub/editor": "^1.16.1",
"@lobehub/icons": "^2.42.0",
"@lobehub/market-sdk": "^0.22.7",
"@lobehub/tts": "^2.0.1",
+1
View File
@@ -12,6 +12,7 @@ export * from './agent';
export * from './common';
export * from './group';
export * from './hotkey';
export * from './knowledge';
export * from './llm';
export * from './systemAgent';
export * from './tool';
+7 -5
View File
@@ -13,16 +13,18 @@
"test:server-db": "vitest run --config vitest.config.server.mts"
},
"dependencies": {
"@electric-sql/pglite": "^0.2.17",
"@lobechat/const": "workspace:*",
"@lobechat/types": "workspace:*",
"@lobechat/utils": "workspace:*",
"dayjs": "^1.11.18",
"drizzle-orm": "^0.44.5",
"nanoid": "^5.1.5",
"pg": "^8.16.3",
"random-words": "^2.0.1",
"ts-md5": "^2.0.1",
"ws": "^8.18.3"
},
"peerDependencies": {
"@electric-sql/pglite": "^0.2.17",
"dayjs": ">=1.11.18",
"drizzle-orm": ">=0.44.6",
"nanoid": ">=5.1.5",
"pg": ">=8.16.3"
}
}
@@ -1,4 +1,4 @@
import { LocalFilesDispatchEvents } from './localFile';
import { LocalSystemDispatchEvents } from './localSystem';
import { MenuDispatchEvents } from './menu';
import { NotificationDispatchEvents } from './notification';
import { ProtocolBroadcastEvents, ProtocolDispatchEvents } from './protocol';
@@ -19,7 +19,7 @@ export interface ClientDispatchEvents
extends WindowsDispatchEvents,
SystemDispatchEvents,
MenuDispatchEvents,
LocalFilesDispatchEvents,
LocalSystemDispatchEvents,
AutoUpdateDispatchEvents,
ShortcutDispatchEvents,
RemoteServerDispatchEvents,
@@ -1,4 +1,14 @@
import {
EditLocalFileParams,
EditLocalFileResult,
GetCommandOutputParams,
GetCommandOutputResult,
GlobFilesParams,
GlobFilesResult,
GrepContentParams,
GrepContentResult,
KillCommandParams,
KillCommandResult,
ListLocalFileParams,
LocalFileItem,
LocalMoveFilesResultItem,
@@ -11,20 +21,29 @@ import {
OpenLocalFolderParams,
RenameLocalFileParams,
RenameLocalFileResult,
RunCommandParams,
RunCommandResult,
WriteLocalFileParams,
} from '../types';
export interface LocalFilesDispatchEvents {
// Local Files API Events
listLocalFiles: (params: ListLocalFileParams) => LocalFileItem[];
/* eslint-disable typescript-sort-keys/interface */
export interface LocalSystemDispatchEvents {
// File Operations
editLocalFile: (params: EditLocalFileParams) => EditLocalFileResult;
moveLocalFiles: (params: MoveLocalFilesParams) => LocalMoveFilesResultItem[];
openLocalFile: (params: OpenLocalFileParams) => void;
openLocalFolder: (params: OpenLocalFolderParams) => void;
readLocalFile: (params: LocalReadFileParams) => LocalReadFileResult;
readLocalFiles: (params: LocalReadFilesParams) => LocalReadFileResult[];
renameLocalFile: (params: RenameLocalFileParams) => RenameLocalFileResult;
searchLocalFiles: (params: LocalSearchFilesParams) => LocalFileItem[];
writeLocalFile: (params: WriteLocalFileParams) => RenameLocalFileResult;
// Shell Commands
runCommand: (params: RunCommandParams) => RunCommandResult;
getCommandOutput: (params: GetCommandOutputParams) => GetCommandOutputResult;
killCommand: (params: KillCommandParams) => KillCommandResult;
// Search & Find
listLocalFiles: (params: ListLocalFileParams) => LocalFileItem[];
grepContent: (params: GrepContentParams) => GrepContentResult;
globLocalFiles: (params: GlobFilesParams) => GlobFilesResult;
searchLocalFiles: (params: LocalSearchFilesParams) => LocalFileItem[];
}
@@ -1,6 +1,6 @@
export * from './dataSync';
export * from './dispatch';
export * from './localFile';
export * from './localSystem';
export * from './mcpInstall';
export * from './notification';
export * from './proxy';
@@ -67,10 +67,6 @@ export interface WriteLocalFileParams {
path: string;
}
export interface RunCommandParams {
command: string;
}
export interface LocalReadFileResult {
/**
* Character count of the content within the specified `loc` range.
@@ -112,3 +108,92 @@ export interface OpenLocalFolderParams {
isDirectory?: boolean;
path: string;
}
// Shell command types
export interface RunCommandParams {
command: string;
description?: string;
run_in_background?: boolean;
timeout?: number;
}
export interface RunCommandResult {
error?: string;
exit_code?: number;
output?: string;
shell_id?: string;
stderr?: string;
stdout?: string;
success: boolean;
}
export interface GetCommandOutputParams {
filter?: string;
shell_id: string;
}
export interface GetCommandOutputResult {
error?: string;
output: string;
running: boolean;
stderr: string;
stdout: string;
success: boolean;
}
export interface KillCommandParams {
shell_id: string;
}
export interface KillCommandResult {
error?: string;
success: boolean;
}
// Grep types
export interface GrepContentParams {
'-A'?: number;
'-B'?: number;
'-C'?: number;
'-i'?: boolean;
'-n'?: boolean;
'glob'?: string;
'head_limit'?: number;
'multiline'?: boolean;
'output_mode'?: 'content' | 'files_with_matches' | 'count';
'path'?: string;
'pattern': string;
'type'?: string;
}
export interface GrepContentResult {
matches: string[];
success: boolean;
total_matches: number;
}
// Glob types
export interface GlobFilesParams {
path?: string;
pattern: string;
}
export interface GlobFilesResult {
files: string[];
success: boolean;
total_files: number;
}
// Edit types
export interface EditLocalFileParams {
file_path: string;
new_string: string;
old_string: string;
replace_all?: boolean;
}
export interface EditLocalFileResult {
error?: string;
replacements: number;
success: boolean;
}
+1 -2
View File
@@ -25,14 +25,13 @@
"test:coverage": "vitest --coverage --silent='passed-only'"
},
"dependencies": {
"@langchain/community": "^0.3.41",
"@langchain/core": "^0.3.45",
"@napi-rs/canvas": "^0.1.70",
"@xmldom/xmldom": "^0.9.8",
"concat-stream": "^2.0.0",
"mammoth": "^1.8.0",
"officeparser": "5.1.1",
"pdfjs-dist": "4.10.38",
"word-extractor": "^1.0.4",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yauzl": "^3.2.0"
},
+4 -1
View File
@@ -37,7 +37,10 @@ const getFileType = (filePath: string): SupportedFileType | undefined => {
log('File type identified as pdf');
return 'pdf';
}
case 'doc':
case 'doc': {
log('File type identified as doc');
return 'doc';
}
case 'docx': {
log('File type identified as docx');
return 'docx';
@@ -0,0 +1,46 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DocLoader > should aggregate content correctly > aggregated_content 1`] = `
"简单报告
副标题
轻点或点按此占位符文本并开始键入即可开始。你可以在 Mac、iPad、iPhone 或 iCloud.com 上查看和编辑此文稿。
轻松编辑文本、更改字体以及添加精美的图形。使用段落样式来使整篇文稿保持一致的风格。例如,此段落使用"正文"样式。你可以在"格式"控制的"文本"标签页中更改样式。
若要添加照片、图像画廊、音频片段、视频、图表或任意 700 多种可自定义形状,请在工具栏中轻点或点按其中一个插入按钮,或者将对象拖放到页面中。你可以分层放置对象、调整其大小以及将其放在页面中的任意位置。若要更改对象随文本移动的方式,请选择对象并随后轻点或点按"格式"控制中的"排列"标签页。
小标题
Pages 文稿可用于文字处理和页面布局。此"简单报告"模板为文字处理而设置,如此一来,文本便会随着你的键入而从某一页流向下一页,到达页面末尾时会自动创建新的页面。
在页面布局文稿中,你可以手动重新排列页面并随意调整页面中的文本框、图像和其他对象的位置。若要创建页面布局文稿,请在模板选取器中选取一种页面布局模板。你也可以在 Mac、iPad 或 iPhone 上将此文稿改为页面布局,方法是在"文稿"控制中关闭"文稿正文"。
"这是一个引用(报告中的关键短语)的例子。轻点或点按此文本添加你自己的内容。"
这是第二页的内容
"
`;
exports[`DocLoader > should load pages correctly from a DOC file 1`] = `
[
{
"charCount": 573,
"lineCount": 15,
"metadata": {
"pageNumber": 1,
},
"pageContent": "简单报告
副标题
轻点或点按此占位符文本并开始键入即可开始。你可以在 Mac、iPad、iPhone 或 iCloud.com 上查看和编辑此文稿。
轻松编辑文本、更改字体以及添加精美的图形。使用段落样式来使整篇文稿保持一致的风格。例如,此段落使用"正文"样式。你可以在"格式"控制的"文本"标签页中更改样式。
若要添加照片、图像画廊、音频片段、视频、图表或任意 700 多种可自定义形状,请在工具栏中轻点或点按其中一个插入按钮,或者将对象拖放到页面中。你可以分层放置对象、调整其大小以及将其放在页面中的任意位置。若要更改对象随文本移动的方式,请选择对象并随后轻点或点按"格式"控制中的"排列"标签页。
小标题
Pages 文稿可用于文字处理和页面布局。此"简单报告"模板为文字处理而设置,如此一来,文本便会随着你的键入而从某一页流向下一页,到达页面末尾时会自动创建新的页面。
在页面布局文稿中,你可以手动重新排列页面并随意调整页面中的文本框、图像和其他对象的位置。若要创建页面布局文稿,请在模板选取器中选取一种页面布局模板。你也可以在 Mac、iPad 或 iPhone 上将此文稿改为页面布局,方法是在"文稿"控制中关闭"文稿正文"。
"这是一个引用(报告中的关键短语)的例子。轻点或点按此文本添加你自己的内容。"
这是第二页的内容
",
},
]
`;
@@ -0,0 +1,38 @@
import path from 'node:path';
import { beforeEach, describe, expect, it } from 'vitest';
import type { FileLoaderInterface } from '../../types';
import { DocLoader } from './index';
const fixturePath = (filename: string) => path.join(__dirname, `./fixtures/${filename}`);
let loader: FileLoaderInterface;
const testFile = fixturePath('test.doc');
const nonExistentFile = fixturePath('nonexistent.doc');
beforeEach(() => {
loader = new DocLoader();
});
describe('DocLoader', () => {
it('should load pages correctly from a DOC file', async () => {
const pages = await loader.loadPages(testFile);
expect(pages).toHaveLength(1);
expect(pages).toMatchSnapshot();
});
it('should aggregate content correctly', async () => {
const pages = await loader.loadPages(testFile);
const content = await loader.aggregateContent(pages);
expect(content).toEqual(pages[0].pageContent);
expect(content).toMatchSnapshot('aggregated_content');
});
it('should handle file read errors in loadPages', async () => {
const pages = await loader.loadPages(nonExistentFile);
expect(pages).toHaveLength(1);
expect(pages[0].pageContent).toBe('');
expect(pages[0].metadata.error).toContain('Failed to load DOC file');
});
});
@@ -0,0 +1,57 @@
import debug from 'debug';
import WordExtractor from 'word-extractor';
import type { DocumentPage, FileLoaderInterface } from '../../types';
const log = debug('file-loaders:doc');
/**
* Loads legacy Word documents (.doc) using word-extractor.
* Extracts plain text content and basic metadata from DOC files.
*/
export class DocLoader implements FileLoaderInterface {
async loadPages(filePath: string): Promise<DocumentPage[]> {
log('Loading DOC file:', filePath);
try {
const extractor = new WordExtractor();
const extracted: any = await extractor.extract(filePath);
// Prefer getBody() if available; fallback to common fields
const pageContent: string =
extracted && typeof extracted.getBody === 'function'
? extracted.getBody()
: ((extracted?.text as string) ?? '');
const lines = pageContent.split('\n');
const lineCount = lines.length;
const charCount = pageContent.length;
const page: DocumentPage = {
charCount,
lineCount,
metadata: { pageNumber: 1 },
pageContent,
};
log('DOC loading completed');
return [page];
} catch (e) {
const error = e as Error;
log('Error encountered while loading DOC file');
console.error(`Error loading DOC file ${filePath}: ${error.message}`);
const errorPage: DocumentPage = {
charCount: 0,
lineCount: 0,
metadata: { error: `Failed to load DOC file: ${error.message}` },
pageContent: '',
};
return [errorPage];
}
}
async aggregateContent(pages: DocumentPage[]): Promise<string> {
log('Aggregating content from', pages.length, 'DOC pages');
return pages.map((p) => p.pageContent).join('\n\n');
}
}
+37 -46
View File
@@ -1,70 +1,61 @@
import { DocxLoader as LangchainDocxLoader } from '@langchain/community/document_loaders/fs/docx';
import debug from 'debug';
import fs from 'node:fs/promises';
import mammoth from 'mammoth';
import type { DocumentPage, FileLoaderInterface } from '../../types';
const log = debug('file-loaders:docx');
/**
* Loads Word documents (.docx) using the LangChain Community DocxLoader.
* Loads Word documents (.docx) using mammoth library.
* Extracts text content and basic metadata from DOCX files.
*/
export class DocxLoader implements FileLoaderInterface {
async loadPages(filePath: string): Promise<DocumentPage[]> {
log('Loading DOCX file:', filePath);
try {
let loader: LangchainDocxLoader;
if (filePath.endsWith('.doc')) {
loader = new LangchainDocxLoader(filePath, { type: 'doc' });
} else {
loader = new LangchainDocxLoader(filePath, { type: 'docx' });
}
log('LangChain DocxLoader created');
const docs = await loader.load(); // Langchain DocxLoader typically loads the whole doc as one
log('DOCX document loaded, parts:', docs.length);
// Read file as buffer
const buffer = await fs.readFile(filePath);
log('File buffer read, size:', buffer.length);
const pages: DocumentPage[] = docs.map((doc) => {
const pageContent = doc.pageContent || '';
const lines = pageContent.split('\n');
const lineCount = lines.length;
const charCount = pageContent.length;
// Extract text using mammoth
const result = await mammoth.extractRawText({ buffer });
const pageContent = result.value;
log('Text extracted, length:', pageContent.length);
// Langchain DocxLoader doesn't usually provide page numbers in metadata
// We treat it as a single page
const metadata = {
...doc.metadata, // Include any other metadata Langchain provides
// Count lines and characters
const lines = pageContent.split('\n');
const lineCount = lines.length;
const charCount = pageContent.length;
log('DOCX document processed, lines:', lineCount, 'chars:', charCount);
// Create single page with extracted content
const page: DocumentPage = {
charCount,
lineCount,
metadata: {
pageNumber: 1,
};
},
pageContent,
};
// @ts-expect-error Remove source if present, as it's handled at the FileDocument level
delete metadata.source;
log('DOCX document processed, lines:', lineCount, 'chars:', charCount);
return {
charCount,
lineCount,
metadata,
pageContent,
};
});
// If docs array is empty (e.g., empty file), create an empty page
if (pages.length === 0) {
log('No content in DOCX document, creating empty page');
pages.push({
charCount: 0,
lineCount: 0,
metadata: { pageNumber: 1 },
pageContent: '',
});
// Handle warnings if any
if (result.messages.length > 0) {
const warnings = result.messages.filter((msg) => msg.type === 'warning');
if (warnings.length > 0) {
log('Extraction warnings:', warnings.length);
warnings.forEach((warning) => log('Warning:', warning.message));
}
}
log('DOCX loading completed, total pages:', pages.length);
return pages;
log('DOCX loading completed');
return [page];
} catch (e) {
const error = e as Error;
log('Error encountered while loading DOCX file');
console.error(`Error loading DOCX file ${filePath} using LangChain loader: ${error.message}`);
console.error(`Error loading DOCX file ${filePath}: ${error.message}`);
const errorPage: DocumentPage = {
charCount: 0,
lineCount: 0,
@@ -1,4 +1,5 @@
import { FileLoaderInterface, SupportedFileType } from '../types';
import { DocLoader } from './doc';
import { DocxLoader } from './docx';
// import { EpubLoader } from './epub';
import { ExcelLoader } from './excel';
@@ -10,6 +11,7 @@ import { TextLoader } from './text';
// Key: file extension (lowercase, without leading dot) or specific type name
// Value: Loader Class implementing FileLoaderInterface
export const fileLoaders: Record<SupportedFileType, new () => FileLoaderInterface> = {
doc: DocLoader,
docx: DocxLoader,
// epub: EpubLoader,
excel: ExcelLoader,
+1 -1
View File
@@ -1,5 +1,5 @@
// Define supported file types - consider using an enum or const assertion
export type SupportedFileType = 'pdf' | 'docx' | 'txt' | 'excel' | 'pptx'; // | 'pptx' | 'latex' | 'epub' | 'code' | 'markdown';
export type SupportedFileType = 'pdf' | 'doc' | 'docx' | 'txt' | 'excel' | 'pptx'; // | 'pptx' | 'latex' | 'epub' | 'code' | 'markdown';
/**
* 代表一个完整的已加载文件,包含文件级信息和其所有页面/块。
+9
View File
@@ -0,0 +1,9 @@
declare module 'word-extractor' {
export default class WordExtractor {
extract(filePath: string): Promise<{
getBody: () => string;
getHeaders?: () => Record<string, string> | undefined;
text?: string;
}>;
}
}
@@ -649,7 +649,6 @@ describe('LobeOpenAICompatibleFactory', () => {
it('should return bizErrorType with the cause when OpenAI.APIError is thrown with cause', async () => {
// Arrange
const errorInfo = {
stack: 'abc',
cause: {
message: 'api is undefined',
},
@@ -670,7 +669,6 @@ describe('LobeOpenAICompatibleFactory', () => {
endpoint: defaultBaseURL,
error: {
cause: { message: 'api is undefined' },
stack: 'abc',
},
errorType: bizErrorType,
provider,
@@ -681,7 +679,6 @@ describe('LobeOpenAICompatibleFactory', () => {
it('should return bizErrorType with an cause response with desensitize Url', async () => {
// Arrange
const errorInfo = {
stack: 'abc',
cause: { message: 'api is undefined' },
};
const apiError = new OpenAI.APIError(400, errorInfo, 'module error', {});
@@ -706,7 +703,6 @@ describe('LobeOpenAICompatibleFactory', () => {
endpoint: 'https://api.***.com/v1',
error: {
cause: { message: 'api is undefined' },
stack: 'abc',
},
errorType: bizErrorType,
provider,
@@ -780,7 +776,6 @@ describe('LobeOpenAICompatibleFactory', () => {
name: genericError.name,
cause: genericError.cause,
message: genericError.message,
stack: genericError.stack,
},
});
}
@@ -1444,8 +1439,13 @@ describe('LobeOpenAICompatibleFactory', () => {
const payload = {
messages: [{ content: 'Generate a person object', role: 'user' as const }],
schema: {
type: 'object',
properties: { name: { type: 'string' }, age: { type: 'number' } },
name: 'person_extractor',
description: 'Extract person information',
schema: {
type: 'object' as const,
properties: { name: { type: 'string' }, age: { type: 'number' } },
},
strict: true,
},
model: 'gpt-4o',
responseApi: true,
@@ -1476,7 +1476,10 @@ describe('LobeOpenAICompatibleFactory', () => {
const payload = {
messages: [{ content: 'Generate status', role: 'user' as const }],
schema: { type: 'object', properties: { status: { type: 'string' } } },
schema: {
name: 'status_extractor',
schema: { type: 'object' as const, properties: { status: { type: 'string' } } },
},
model: 'gpt-4o',
responseApi: true,
};
@@ -1513,7 +1516,10 @@ describe('LobeOpenAICompatibleFactory', () => {
const payload = {
messages: [{ content: 'Generate data', role: 'user' as const }],
schema: { type: 'object' },
schema: {
name: 'test_tool',
schema: { type: 'object' as const, properties: {} },
},
model: 'gpt-4o',
responseApi: true,
};
@@ -1536,7 +1542,10 @@ describe('LobeOpenAICompatibleFactory', () => {
const payload = {
messages: [{ content: 'Generate data', role: 'user' as const }],
schema: { type: 'object' },
schema: {
name: 'test_tool',
schema: { type: 'object' as const, properties: {} },
},
model: 'gpt-4o',
responseApi: true,
};
@@ -1560,22 +1569,25 @@ describe('LobeOpenAICompatibleFactory', () => {
const payload = {
messages: [{ content: 'Generate complex user data', role: 'user' as const }],
schema: {
type: 'object',
properties: {
user: {
type: 'object',
properties: {
name: { type: 'string' },
profile: {
type: 'object',
properties: {
age: { type: 'number' },
preferences: { type: 'array', items: { type: 'string' } },
name: 'user_extractor',
schema: {
type: 'object' as const,
properties: {
user: {
type: 'object',
properties: {
name: { type: 'string' },
profile: {
type: 'object',
properties: {
age: { type: 'number' },
preferences: { type: 'array', items: { type: 'string' } },
},
},
},
},
metadata: { type: 'object' },
},
metadata: { type: 'object' },
},
},
model: 'gpt-4o',
@@ -1605,7 +1617,10 @@ describe('LobeOpenAICompatibleFactory', () => {
const payload = {
messages: [{ content: 'Generate data', role: 'user' as const }],
schema: { type: 'object' },
schema: {
name: 'test_tool',
schema: { type: 'object' as const, properties: {} },
},
model: 'gpt-4o',
responseApi: true,
};
@@ -1634,8 +1649,11 @@ describe('LobeOpenAICompatibleFactory', () => {
const payload = {
messages: [{ content: 'Generate a person object', role: 'user' as const }],
schema: {
type: 'object',
properties: { name: { type: 'string' }, age: { type: 'number' } },
name: 'person_extractor',
schema: {
type: 'object' as const,
properties: { name: { type: 'string' }, age: { type: 'number' } },
},
},
model: 'gpt-4o',
// responseApi: false or undefined - uses chat completions API
@@ -1673,7 +1691,10 @@ describe('LobeOpenAICompatibleFactory', () => {
const payload = {
messages: [{ content: 'Generate status', role: 'user' as const }],
schema: { type: 'object', properties: { status: { type: 'string' } } },
schema: {
name: 'status_extractor',
schema: { type: 'object' as const, properties: { status: { type: 'string' } } },
},
model: 'gpt-4o',
responseApi: false,
};
@@ -1717,7 +1738,10 @@ describe('LobeOpenAICompatibleFactory', () => {
const payload = {
messages: [{ content: 'Generate data', role: 'user' as const }],
schema: { type: 'object' },
schema: {
name: 'test_tool',
schema: { type: 'object' as const, properties: {} },
},
model: 'gpt-4o',
responseApi: false,
};
@@ -1748,7 +1772,10 @@ describe('LobeOpenAICompatibleFactory', () => {
const payload = {
messages: [{ content: 'Generate data', role: 'user' as const }],
schema: { type: 'object' },
schema: {
name: 'test_tool',
schema: { type: 'object' as const, properties: {} },
},
model: 'gpt-4o',
responseApi: false,
};
@@ -1780,19 +1807,22 @@ describe('LobeOpenAICompatibleFactory', () => {
const payload = {
messages: [{ content: 'Generate items list', role: 'user' as const }],
schema: {
type: 'object',
properties: {
items: {
type: 'array',
name: 'abc',
schema: {
type: 'object' as const,
properties: {
items: {
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' },
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' },
},
},
},
total: { type: 'number' },
},
total: { type: 'number' },
},
},
model: 'gpt-4o',
@@ -1816,7 +1846,7 @@ describe('LobeOpenAICompatibleFactory', () => {
const payload = {
messages: [{ content: 'Generate data', role: 'user' as const }],
schema: { type: 'object' },
schema: { name: 'abc', schema: { type: 'object' } as any },
model: 'gpt-4o',
responseApi: false,
};
@@ -1826,6 +1856,205 @@ describe('LobeOpenAICompatibleFactory', () => {
);
});
});
describe('tool calling fallback', () => {
let instanceWithToolCalling: any;
beforeEach(() => {
const RuntimeClass = createOpenAICompatibleRuntime({
baseURL: 'https://api.test.com',
generateObject: {
useToolsCalling: true,
},
provider: 'test-provider',
});
instanceWithToolCalling = new RuntimeClass({ apiKey: 'test-key' });
});
it('should use tool calling when configured', async () => {
const mockResponse = {
choices: [
{
message: {
tool_calls: [
{
type: 'function' as const,
function: {
name: 'person_extractor',
arguments: '{"name":"Alice","age":28}',
},
},
],
},
},
],
};
vi.spyOn(instanceWithToolCalling['client'].chat.completions, 'create').mockResolvedValue(
mockResponse as any,
);
const payload = {
messages: [{ content: 'Extract person info', role: 'user' as const }],
schema: {
name: 'person_extractor',
description: 'Extract person information',
schema: {
type: 'object' as const,
properties: { name: { type: 'string' }, age: { type: 'number' } },
},
},
model: 'test-model',
};
const result = await instanceWithToolCalling.generateObject(payload);
expect(instanceWithToolCalling['client'].chat.completions.create).toHaveBeenCalledWith(
{
messages: payload.messages,
model: payload.model,
tools: [
{
type: 'function',
function: {
name: 'person_extractor',
description: 'Extract person information',
parameters: payload.schema.schema,
},
},
],
tool_choice: { type: 'function', function: { name: 'person_extractor' } },
user: undefined,
},
{ headers: undefined, signal: undefined },
);
expect(result).toEqual({ name: 'Alice', age: 28 });
});
it('should return undefined when no tool call found', async () => {
const mockResponse = {
choices: [
{
message: {
content: 'Some text response',
},
},
],
};
vi.spyOn(instanceWithToolCalling['client'].chat.completions, 'create').mockResolvedValue(
mockResponse as any,
);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const payload = {
messages: [{ content: 'Generate data', role: 'user' as const }],
schema: {
name: 'test_tool',
schema: { type: 'object' as const, properties: {} },
},
model: 'test-model',
};
const result = await instanceWithToolCalling.generateObject(payload);
expect(consoleSpy).toHaveBeenCalledWith('No tool call found in response');
expect(result).toBeUndefined();
consoleSpy.mockRestore();
});
it('should return undefined when tool call arguments parsing fails', async () => {
const mockResponse = {
choices: [
{
message: {
tool_calls: [
{
type: 'function' as const,
function: {
name: 'test_tool',
arguments: 'invalid json',
},
},
],
},
},
],
};
vi.spyOn(instanceWithToolCalling['client'].chat.completions, 'create').mockResolvedValue(
mockResponse as any,
);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const payload = {
messages: [{ content: 'Generate data', role: 'user' as const }],
schema: {
name: 'test_tool',
schema: { type: 'object' as const, properties: {} },
},
model: 'test-model',
};
const result = await instanceWithToolCalling.generateObject(payload);
expect(consoleSpy).toHaveBeenCalledWith('parse tool call arguments error:', 'invalid json');
expect(result).toBeUndefined();
consoleSpy.mockRestore();
});
it('should handle options correctly with tool calling', async () => {
const mockResponse = {
choices: [
{
message: {
tool_calls: [
{
type: 'function' as const,
function: {
name: 'data_extractor',
arguments: '{"data":"test"}',
},
},
],
},
},
],
};
vi.spyOn(instanceWithToolCalling['client'].chat.completions, 'create').mockResolvedValue(
mockResponse as any,
);
const payload = {
messages: [{ content: 'Extract data', role: 'user' as const }],
schema: {
name: 'data_extractor',
schema: { type: 'object' as const, properties: { data: { type: 'string' } } },
},
model: 'test-model',
};
const options = {
headers: { 'X-Custom': 'header' },
user: 'test-user',
signal: new AbortController().signal,
};
const result = await instanceWithToolCalling.generateObject(payload, options);
expect(instanceWithToolCalling['client'].chat.completions.create).toHaveBeenCalledWith(
expect.any(Object),
{ headers: options.headers, signal: options.signal },
);
expect(result).toEqual({ data: 'test' });
});
});
});
describe('models', () => {
@@ -116,6 +116,12 @@ interface OpenAICompatibleFactoryOptions<T extends Record<string, any> = any> {
bizError: ILobeAgentRuntimeErrorType;
invalidAPIKey: ILobeAgentRuntimeErrorType;
};
generateObject?: {
/**
* Use tool calling to simulate structured output for providers that don't support native structured output
*/
useToolsCalling?: boolean;
};
models?:
| ((params: { client: OpenAI }) => Promise<ChatModelCard[]>)
| {
@@ -142,6 +148,7 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
customClient,
responses,
createImage: customCreateImage,
generateObject: generateObjectConfig,
}: OpenAICompatibleFactoryOptions<T>) => {
const ErrorType = {
bizError: errorType?.bizError || AgentRuntimeErrorType.ProviderBizError,
@@ -391,6 +398,44 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
async generateObject(payload: GenerateObjectPayload, options?: GenerateObjectOptions) {
const { messages, schema, model, responseApi } = payload;
// Use tool calling fallback if configured
if (generateObjectConfig?.useToolsCalling) {
const tool: ChatCompletionTool = {
function: {
description:
schema.description || 'Generate structured output according to the provided schema',
name: schema.name || 'structured_output',
parameters: schema.schema,
},
type: 'function',
};
const res = await this.client.chat.completions.create(
{
messages,
model,
tool_choice: { function: { name: tool.function.name }, type: 'function' },
tools: [tool],
user: options?.user,
},
{ headers: options?.headers, signal: options?.signal },
);
const toolCall = res.choices[0].message.tool_calls?.[0];
if (!toolCall || toolCall.type !== 'function') {
console.error('No tool call found in response');
return undefined;
}
try {
return JSON.parse(toolCall.function.arguments);
} catch {
console.error('parse tool call arguments error:', toolCall.function.arguments);
return undefined;
}
}
if (responseApi) {
const res = await this.client!.responses.create(
{
@@ -160,7 +160,6 @@ export const testProvider = ({
cause: {
message: 'api is undefined',
},
stack: 'abc',
};
const apiError = new OpenAI.APIError(400, errorInfo, 'module error', {});
@@ -178,7 +177,6 @@ export const testProvider = ({
endpoint: defaultBaseURL,
error: {
cause: { message: 'api is undefined' },
stack: 'abc',
},
errorType: bizErrorType,
provider,
@@ -190,7 +188,6 @@ export const testProvider = ({
// Arrange
const errorInfo = {
cause: { message: 'api is undefined' },
stack: 'abc',
};
const apiError = new OpenAI.APIError(400, errorInfo, 'module error', {});
@@ -214,7 +211,6 @@ export const testProvider = ({
endpoint: 'https://api.***.com/v1',
error: {
cause: { message: 'api is undefined' },
stack: 'abc',
},
errorType: bizErrorType,
provider,
@@ -265,7 +261,6 @@ export const testProvider = ({
cause: genericError.cause,
message: genericError.message,
name: genericError.name,
stack: genericError.stack,
},
errorType: 'AgentRuntimeError',
provider,
@@ -12,7 +12,7 @@ describe('Anthropic generateObject', () => {
content: [
{
type: 'tool_use',
name: 'structured_output',
name: 'person_extractor',
input: { name: 'John', age: 30 },
},
],
@@ -23,8 +23,13 @@ describe('Anthropic generateObject', () => {
const payload = {
messages: [{ content: 'Generate a person object', role: 'user' as const }],
schema: {
type: 'object',
properties: { name: { type: 'string' }, age: { type: 'number' } },
name: 'person_extractor',
description: 'Extract person information',
schema: {
type: 'object' as const,
properties: { name: { type: 'string' }, age: { type: 'number' } },
required: ['name', 'age'],
},
},
model: 'claude-3-5-sonnet-20241022',
};
@@ -35,30 +40,24 @@ describe('Anthropic generateObject', () => {
expect.objectContaining({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 8192,
messages: [
{ content: 'Generate a person object', role: 'user' },
{
content:
'Please use the structured_output tool to provide your response in the required format.',
role: 'user',
},
],
messages: [{ content: 'Generate a person object', role: 'user' }],
tools: [
{
name: 'structured_output',
description: 'Generate structured output according to the provided schema',
name: 'person_extractor',
description: 'Extract person information',
input_schema: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name', 'age'],
},
},
],
tool_choice: {
type: 'tool',
name: 'structured_output',
name: 'person_extractor',
},
}),
expect.objectContaining({}),
@@ -74,7 +73,7 @@ describe('Anthropic generateObject', () => {
content: [
{
type: 'tool_use',
name: 'structured_output',
name: 'status_extractor',
input: { status: 'success' },
},
],
@@ -87,7 +86,10 @@ describe('Anthropic generateObject', () => {
{ content: 'You are a helpful assistant', role: 'system' as const },
{ content: 'Generate status', role: 'user' as const },
],
schema: { type: 'object', properties: { status: { type: 'string' } } },
schema: {
name: 'status_extractor',
schema: { type: 'object' as const, properties: { status: { type: 'string' } } },
},
model: 'claude-3-5-sonnet-20241022',
};
@@ -111,7 +113,7 @@ describe('Anthropic generateObject', () => {
content: [
{
type: 'tool_use',
name: 'structured_output',
name: 'data_extractor',
input: { data: 'test' },
},
],
@@ -121,7 +123,10 @@ describe('Anthropic generateObject', () => {
const payload = {
messages: [{ content: 'Generate data', role: 'user' as const }],
schema: { type: 'object', properties: { data: { type: 'string' } } },
schema: {
name: 'data_extractor',
schema: { type: 'object' as const, properties: { data: { type: 'string' } } },
},
model: 'claude-3-5-sonnet-20241022',
};
@@ -155,20 +160,18 @@ describe('Anthropic generateObject', () => {
},
};
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const payload = {
messages: [{ content: 'Generate data', role: 'user' as const }],
schema: { type: 'object' },
schema: {
name: 'test_tool',
schema: { type: 'object' },
},
model: 'claude-3-5-sonnet-20241022',
};
const result = await createAnthropicGenerateObject(mockClient as any, payload);
const result = await createAnthropicGenerateObject(mockClient as any, payload as any);
expect(consoleSpy).toHaveBeenCalledWith('No structured output tool use found in response');
expect(result).toBeUndefined();
consoleSpy.mockRestore();
});
it('should handle complex nested schemas', async () => {
@@ -178,7 +181,7 @@ describe('Anthropic generateObject', () => {
content: [
{
type: 'tool_use',
name: 'structured_output',
name: 'user_extractor',
input: {
user: {
name: 'Alice',
@@ -200,22 +203,26 @@ describe('Anthropic generateObject', () => {
const payload = {
messages: [{ content: 'Generate complex user data', role: 'user' as const }],
schema: {
type: 'object',
properties: {
user: {
type: 'object',
properties: {
name: { type: 'string' },
profile: {
type: 'object',
properties: {
age: { type: 'number' },
preferences: { type: 'array', items: { type: 'string' } },
name: 'user_extractor',
description: 'Extract complex user information',
schema: {
type: 'object' as const,
properties: {
user: {
type: 'object',
properties: {
name: { type: 'string' },
profile: {
type: 'object',
properties: {
age: { type: 'number' },
preferences: { type: 'array', items: { type: 'string' } },
},
},
},
},
metadata: { type: 'object' },
},
metadata: { type: 'object' },
},
},
model: 'claude-3-5-sonnet-20241022',
@@ -248,13 +255,16 @@ describe('Anthropic generateObject', () => {
const payload = {
messages: [{ content: 'Generate data', role: 'user' as const }],
schema: { type: 'object' },
schema: {
name: 'test_tool',
schema: { type: 'object' },
},
model: 'claude-3-5-sonnet-20241022',
};
await expect(createAnthropicGenerateObject(mockClient as any, payload)).rejects.toThrow(
'API Error: Model not found',
);
await expect(
createAnthropicGenerateObject(mockClient as any, payload as any),
).rejects.toThrow('API Error: Model not found');
});
it('should handle abort signals correctly', async () => {
@@ -269,7 +279,10 @@ describe('Anthropic generateObject', () => {
const payload = {
messages: [{ content: 'Generate data', role: 'user' as const }],
schema: { type: 'object' },
schema: {
name: 'test_tool',
schema: { type: 'object' },
},
model: 'claude-3-5-sonnet-20241022',
};
@@ -278,7 +291,7 @@ describe('Anthropic generateObject', () => {
};
await expect(
createAnthropicGenerateObject(mockClient as any, payload, options),
createAnthropicGenerateObject(mockClient as any, payload as any, options),
).rejects.toThrow();
});
});
@@ -1,8 +1,11 @@
import Anthropic from '@anthropic-ai/sdk';
import type Anthropic from '@anthropic-ai/sdk';
import debug from 'debug';
import { buildAnthropicMessages } from '../../core/contextBuilders/anthropic';
import { GenerateObjectOptions, GenerateObjectPayload } from '../../types';
const log = debug('lobe-model-runtime:anthropic:generate-object');
/**
* Generate structured output using Anthropic Claude API with Function Calling
*/
@@ -13,26 +16,25 @@ export const createAnthropicGenerateObject = async (
) => {
const { schema, messages, model } = payload;
// Convert OpenAI schema to Anthropic tool format
const tool = {
description: 'Generate structured output according to the provided schema',
input_schema: schema,
name: 'structured_output',
log('generateObject called with model: %s', model);
log('schema: %O', schema);
log('messages count: %d', messages.length);
// Convert OpenAI-style schema to Anthropic tool format
const tool: Anthropic.ToolUnion = {
description:
schema.description || 'Generate structured output according to the provided schema',
input_schema: schema.schema as any,
name: schema.name || 'structured_output',
};
log('converted tool: %O', tool);
// Convert messages to Anthropic format
const system_message = messages.find((m) => m.role === 'system');
const user_messages = messages.filter((m) => m.role !== 'system');
const anthropicMessages = await buildAnthropicMessages(user_messages);
// Add instruction to use the structured output tool
const enhancedMessages = [
...anthropicMessages,
{
content:
'Please use the structured_output tool to provide your response in the required format.',
role: 'user' as const,
},
];
log('converted %d messages to Anthropic format', anthropicMessages.length);
const systemPrompts = system_message?.content
? [
@@ -44,13 +46,15 @@ export const createAnthropicGenerateObject = async (
: undefined;
try {
log('calling Anthropic API with max_tokens: %d', 8192);
const response = await client.messages.create(
{
max_tokens: 8192,
messages: enhancedMessages,
messages: anthropicMessages,
model,
system: systemPrompts,
tool_choice: { name: 'structured_output', type: 'tool' },
tool_choice: { name: tool.name, type: 'tool' },
tools: [tool],
},
{
@@ -58,19 +62,23 @@ export const createAnthropicGenerateObject = async (
},
);
log('received response with %d content blocks', response.content.length);
log('response: %O', response);
// Extract the tool use result
const toolUseBlock = response.content.find(
(block) => block.type === 'tool_use' && block.name === 'structured_output',
(block) => block.type === 'tool_use' && block.name === tool.name,
);
if (!toolUseBlock || toolUseBlock.type !== 'tool_use') {
console.error('No structured output tool use found in response');
log('no tool use found in response (expected tool: %s)', tool.name);
return undefined;
}
log('extracted tool input: %O', toolUseBlock.input);
return toolUseBlock.input;
} catch (error) {
console.error('Anthropic generateObject error:', error);
log('generateObject error: %O', error);
throw error;
}
};
@@ -12,6 +12,11 @@ export const LobeDeepSeekAI = createOpenAICompatibleRuntime({
debug: {
chatCompletion: () => process.env.DEBUG_DEEPSEEK_CHAT_COMPLETION === '1',
},
// Deepseek don't support json format well
// use Tools calling to simulate
generateObject: {
useToolsCalling: true,
},
models: async ({ client }) => {
const modelsPage = (await client.models.list()) as any;
const modelList: DeepSeekModelCard[] = modelsPage.data;
@@ -101,7 +101,6 @@ describe('LobeOpenAI', () => {
it('should return ProviderBizError with the cause when OpenAI.APIError is thrown with cause', async () => {
// Arrange
const errorInfo = {
stack: 'abc',
cause: {
message: 'api is undefined',
},
@@ -122,7 +121,6 @@ describe('LobeOpenAI', () => {
endpoint: 'https://api.openai.com/v1',
error: {
cause: { message: 'api is undefined' },
stack: 'abc',
},
errorType: 'ProviderBizError',
provider: 'openai',
@@ -133,7 +131,6 @@ describe('LobeOpenAI', () => {
it('should return ProviderBizError with an cause response with desensitize Url', async () => {
// Arrange
const errorInfo = {
stack: 'abc',
cause: { message: 'api is undefined' },
};
const apiError = new OpenAI.APIError(400, errorInfo, 'module error', {});
@@ -158,7 +155,6 @@ describe('LobeOpenAI', () => {
endpoint: 'https://api.***.com/v1',
error: {
cause: { message: 'api is undefined' },
stack: 'abc',
},
errorType: 'ProviderBizError',
provider: 'openai',
@@ -188,7 +184,6 @@ describe('LobeOpenAI', () => {
name: genericError.name,
cause: genericError.cause,
message: genericError.message,
stack: genericError.stack,
},
});
}
@@ -263,7 +263,6 @@ describe('LobeZhipuAI', () => {
name: genericError.name,
cause: genericError.cause,
message: genericError.message,
stack: genericError.stack,
},
});
}
@@ -4,11 +4,23 @@ interface GenerateObjectMessage {
role: 'user' | 'system' | 'assistant';
}
interface GenerateObjectSchema {
description?: string;
name: string;
schema: {
additionalProperties?: boolean;
properties: Record<string, any>;
required?: string[];
type: 'object';
};
strict?: boolean;
}
export interface GenerateObjectPayload {
messages: GenerateObjectMessage[];
model: string;
responseApi?: boolean;
schema: any;
schema: GenerateObjectSchema;
}
export interface GenerateObjectOptions {
@@ -48,7 +48,6 @@ describe('handleOpenAIError', () => {
expect(result.errorResult).toEqual({
headers: { headers, status: 401 },
stack: apiError.stack,
status: 472,
});
expect(result.RuntimeError).toBeUndefined();
@@ -84,7 +83,6 @@ describe('handleOpenAIError', () => {
cause: { details: 'Error details' },
message: 'Generic error',
name: 'Error',
stack: error.stack,
},
});
});
@@ -100,7 +98,6 @@ describe('handleOpenAIError', () => {
cause: undefined,
message: 'Simple error',
name: 'Error',
stack: error.stack,
},
});
});
@@ -122,7 +119,6 @@ describe('handleOpenAIError', () => {
cause: undefined,
message: 'Custom error message',
name: 'CustomError',
stack: error.stack,
},
});
});
@@ -141,7 +137,6 @@ describe('handleOpenAIError', () => {
cause: undefined,
message: 'Object error',
name: undefined,
stack: undefined,
},
});
});
@@ -20,7 +20,7 @@ export const handleOpenAIError = (
}
// if there is no other request error, the error object is a Response like object
else {
errorResult = { headers: error.headers, stack: error.stack, status: error.status };
errorResult = { headers: error.headers, status: error.status };
}
return {
@@ -29,7 +29,7 @@ export const handleOpenAIError = (
} else {
const err = error as Error;
errorResult = { cause: err.cause, message: err.message, name: err.name, stack: err.stack };
errorResult = { cause: err.cause, message: err.message, name: err.name };
return {
RuntimeError: AgentRuntimeErrorType.AgentRuntimeError,
+13 -1
View File
@@ -54,12 +54,24 @@ export interface SendMessageServerResponse {
userMessageId: string;
}
export const StructureSchema = z.object({
description: z.string().optional(),
name: z.string(),
schema: z.object({
additionalProperties: z.boolean().optional(),
properties: z.record(z.string(), z.any()),
required: z.array(z.string()).optional(),
type: z.literal('object'),
}),
strict: z.boolean().optional(),
});
export const StructureOutputSchema = z.object({
keyVaultsPayload: z.string(),
messages: z.array(z.any()),
model: z.string(),
provider: z.string(),
schema: z.any(),
schema: StructureSchema,
});
export interface StructureOutputParams {
+1
View File
@@ -10,6 +10,7 @@ export * from './clientDB';
export * from './discover';
export * from './eval';
export * from './fetch';
export * from './files';
export * from './hotkey';
export * from './knowledgeBase';
export * from './llm';
+39 -26
View File
@@ -62,30 +62,45 @@ const InputEditor = memo<{ defaultRows?: number }>(() => {
};
}, [state.isEmpty]);
const enableMarkdown = useUserStore(preferenceSelectors.inputMarkdownRender);
const plugins = useMemo(
const enableRichRender = useUserStore(preferenceSelectors.inputMarkdownRender);
const richRenderProps = useMemo(
() =>
!enableMarkdown
? undefined
: [
ReactListPlugin,
ReactLinkPlugin,
ReactCodePlugin,
ReactCodeblockPlugin,
ReactHRPlugin,
ReactTablePlugin,
Editor.withProps(ReactMathPlugin, {
renderComp: expand
? undefined
: (props) => (
<FloatMenu
{...props}
getPopupContainer={() => (slashMenuRef as any)?.current}
/>
),
}),
],
[enableMarkdown],
!enableRichRender
? {
enablePasteMarkdown: false,
markdownOption: {
bold: false,
code: false,
header: false,
italic: false,
quote: false,
strikethrough: false,
underline: false,
underlineStrikethrough: false,
},
}
: {
plugins: [
ReactListPlugin,
ReactLinkPlugin,
ReactCodePlugin,
ReactCodeblockPlugin,
ReactHRPlugin,
ReactTablePlugin,
Editor.withProps(ReactMathPlugin, {
renderComp: expand
? undefined
: (props) => (
<FloatMenu
{...props}
getPopupContainer={() => (slashMenuRef as any)?.current}
/>
),
}),
],
},
[enableRichRender],
);
return (
@@ -94,8 +109,7 @@ const InputEditor = memo<{ defaultRows?: number }>(() => {
className={className}
content={''}
editor={editor}
enablePasteMarkdown={enableMarkdown}
markdownOption={enableMarkdown}
{...richRenderProps}
onBlur={() => {
disableScope(HotkeyEnum.AddUserMessage);
}}
@@ -145,7 +159,6 @@ const InputEditor = memo<{ defaultRows?: number }>(() => {
}
}}
placeholder={<Placeholder />}
plugins={plugins}
slashOption={{
items: slashItems,
renderComp: expand
@@ -16,7 +16,7 @@ const LoadingPlaceholder = memo<LoadingPlaceholderProps>(
({ identifier, requestArgs, apiName, loading }) => {
const Render = BuiltinToolPlaceholders[identifier || ''];
if (identifier) {
if (identifier && Render) {
return (
<Render apiName={apiName} args={safeParseJSON(requestArgs) || {}} identifier={identifier} />
);
+2 -3
View File
@@ -1,7 +1,7 @@
import { DEFAULT_AGENT_CONFIG, INBOX_SESSION_ID } from '@lobechat/const';
import { KnowledgeItem, KnowledgeType } from '@lobechat/types';
import { z } from 'zod';
import { INBOX_SESSION_ID } from '@/const/session';
import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
import { AgentModel } from '@/database/models/agent';
import { FileModel } from '@/database/models/file';
import { KnowledgeBaseModel } from '@/database/models/knowledgeBase';
@@ -11,7 +11,6 @@ import { pino } from '@/libs/logger';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { AgentService } from '@/server/services/agent';
import { KnowledgeItem, KnowledgeType } from '@/types/knowledgeBase';
const agentProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
+33 -1
View File
@@ -4,6 +4,7 @@ import {
StructureOutputSchema,
} from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import { LOADING_FLAT } from '@/const/message';
import { MessageModel } from '@/database/models/message';
@@ -15,6 +16,8 @@ import { AiChatService } from '@/server/services/aiChat';
import { FileService } from '@/server/services/file';
import { getXorPayload } from '@/utils/server';
const log = debug('lobe-lambda-router:ai-chat');
const aiChatProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
@@ -30,30 +33,45 @@ const aiChatProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
export const aiChatRouter = router({
outputJSON: aiChatProcedure.input(StructureOutputSchema).mutation(async ({ input }) => {
log('outputJSON called with provider: %s, model: %s', input.provider, input.model);
log('messages count: %d', input.messages.length);
log('schema: %O', input.schema);
let payload: object | undefined;
try {
payload = getXorPayload(input.keyVaultsPayload);
log('payload parsed successfully');
} catch (e) {
log('payload parse error: %O', e);
console.warn('user payload parse error', e);
}
if (!payload) {
log('payload is empty, throwing error');
throw new TRPCError({ code: 'BAD_REQUEST', message: 'keyVaultsPayload is not correct' });
}
log('initializing model runtime with provider: %s', input.provider);
const modelRuntime = initModelRuntimeWithUserPayload(input.provider, payload);
return modelRuntime.generateObject({
log('calling generateObject');
const result = await modelRuntime.generateObject({
messages: input.messages,
model: input.model,
schema: input.schema,
});
log('generateObject completed, result: %O', result);
return result;
}),
sendMessageInServer: aiChatProcedure
.input(AiSendMessageServerSchema)
.mutation(async ({ input, ctx }) => {
log('sendMessageInServer called for sessionId: %s', input.sessionId);
log('topicId: %s, newTopic: %O', input.topicId, input.newTopic);
let messageId: string;
let topicId = input.topicId!;
@@ -61,6 +79,7 @@ export const aiChatRouter = router({
// create topic if there should be a new topic
if (input.newTopic) {
log('creating new topic with title: %s', input.newTopic.title);
const topicItem = await ctx.topicModel.create({
messages: input.newTopic.topicMessageIds,
sessionId: input.sessionId,
@@ -68,9 +87,11 @@ export const aiChatRouter = router({
});
topicId = topicItem.id;
isCreatNewTopic = true;
log('new topic created with id: %s', topicId);
}
// create user message
log('creating user message with content length: %d', input.newUserMessage.content.length);
const userMessageItem = await ctx.messageModel.create({
content: input.newUserMessage.content,
files: input.newUserMessage.files,
@@ -80,7 +101,14 @@ export const aiChatRouter = router({
});
messageId = userMessageItem.id;
log('user message created with id: %s', messageId);
// create assistant message
log(
'creating assistant message with model: %s, provider: %s',
input.newAssistantMessage.model,
input.newAssistantMessage.provider,
);
const assistantMessageItem = await ctx.messageModel.create({
content: LOADING_FLAT,
fromModel: input.newAssistantMessage.model,
@@ -90,14 +118,18 @@ export const aiChatRouter = router({
sessionId: input.sessionId!,
topicId,
});
log('assistant message created with id: %s', assistantMessageItem.id);
// retrieve latest messages and topic with
log('retrieving messages and topics');
const { messages, topics } = await ctx.aiChatService.getMessagesAndTopics({
includeTopic: isCreatNewTopic,
sessionId: input.sessionId,
topicId,
});
log('retrieved %d messages, %d topics', messages.length, topics?.length ?? 0);
return {
assistantMessageId: assistantMessageItem.id,
isCreatNewTopic,
+2 -2
View File
@@ -1,8 +1,9 @@
import { DEFAULT_FILE_EMBEDDING_MODEL_ITEM } from '@lobechat/const';
import { SemanticSearchSchema } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { inArray } from 'drizzle-orm';
import { z } from 'zod';
import { DEFAULT_FILE_EMBEDDING_MODEL_ITEM } from '@/const/settings/knowledge';
import { AsyncTaskModel } from '@/database/models/asyncTask';
import { ChunkModel } from '@/database/models/chunk';
import { EmbeddingModel } from '@/database/models/embedding';
@@ -14,7 +15,6 @@ import { keyVaults, serverDatabase } from '@/libs/trpc/lambda/middleware';
import { getServerDefaultFilesConfig } from '@/server/globalConfig';
import { initModelRuntimeWithUserPayload } from '@/server/modules/ModelRuntime';
import { ChunkService } from '@/server/services/chunk';
import { SemanticSearchSchema } from '@/types/rag';
const chunkProcedure = authedProcedure
.use(serverDatabase)
+1 -2
View File
@@ -1,6 +1,5 @@
import { dispatch } from '@lobechat/electron-client-ipc';
import { FileMetadata } from '@/types/files';
import { FileMetadata } from '@lobechat/types';
/**
* 桌面应用文件API客户端服务
+40
View File
@@ -1,4 +1,14 @@
import {
EditLocalFileParams,
EditLocalFileResult,
GetCommandOutputParams,
GetCommandOutputResult,
GlobFilesParams,
GlobFilesResult,
GrepContentParams,
GrepContentResult,
KillCommandParams,
KillCommandResult,
ListLocalFileParams,
LocalFileItem,
LocalMoveFilesResultItem,
@@ -10,11 +20,14 @@ import {
OpenLocalFileParams,
OpenLocalFolderParams,
RenameLocalFileParams,
RunCommandParams,
RunCommandResult,
WriteLocalFileParams,
dispatch,
} from '@lobechat/electron-client-ipc';
class LocalFileService {
// File Operations
async listLocalFiles(params: ListLocalFileParams): Promise<LocalFileItem[]> {
return dispatch('listLocalFiles', params);
}
@@ -51,6 +64,33 @@ class LocalFileService {
return dispatch('writeLocalFile', params);
}
async editLocalFile(params: EditLocalFileParams): Promise<EditLocalFileResult> {
return dispatch('editLocalFile', params);
}
// Shell Commands
async runCommand(params: RunCommandParams): Promise<RunCommandResult> {
return dispatch('runCommand', params);
}
async getCommandOutput(params: GetCommandOutputParams): Promise<GetCommandOutputResult> {
return dispatch('getCommandOutput', params);
}
async killCommand(params: KillCommandParams): Promise<KillCommandResult> {
return dispatch('killCommand', params);
}
// Search & Find
async grepContent(params: GrepContentParams): Promise<GrepContentResult> {
return dispatch('grepContent', params);
}
async globFiles(params: GlobFilesParams): Promise<GlobFilesResult> {
return dispatch('globLocalFiles', params);
}
// Helper methods
async openLocalFileOrFolder(path: string, isDirectory: boolean) {
if (isDirectory) {
return this.openLocalFolder({ isDirectory, path });
@@ -0,0 +1,23 @@
import { ListLocalFileParams } from '@lobechat/electron-client-ipc';
import { Skeleton } from 'antd';
import React, { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { LocalFolder } from '@/features/LocalFile';
interface ListFilesProps {
args: ListLocalFileParams;
}
export const ListFiles = memo<ListFilesProps>(({ args }) => {
return (
<Flexbox gap={8}>
<LocalFolder path={args.path} />
<Flexbox gap={4}>
<Skeleton.Button active block style={{ height: 16 }} />
<Skeleton.Button active block style={{ height: 16 }} />
<Skeleton.Button active block style={{ height: 16 }} />
<Skeleton.Button active block style={{ height: 16 }} />
</Flexbox>
</Flexbox>
);
});
@@ -0,0 +1,9 @@
'use client';
import { memo } from 'react';
import Skeleton from '../Render/ReadLocalFile/ReadFileSkeleton';
const ReadLocalFile = memo(() => <Skeleton />);
export default ReadLocalFile;
@@ -0,0 +1,55 @@
import { LocalSearchFilesParams } from '@lobechat/electron-client-ipc';
import { Icon } from '@lobehub/ui';
import { Skeleton } from 'antd';
import { createStyles } from 'antd-style';
import { SearchIcon } from 'lucide-react';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
const useStyles = createStyles(({ css, token, cx }) => ({
query: cx(css`
padding-block: 4px;
padding-inline: 8px;
border-radius: 8px;
font-size: 12px;
color: ${token.colorTextSecondary};
&:hover {
background: ${token.colorFillTertiary};
}
`),
}));
interface SearchFilesProps {
args: LocalSearchFilesParams;
}
const SearchFiles = memo<SearchFilesProps>(({ args }) => {
const { styles } = useStyles();
return (
<Flexbox gap={8}>
<Flexbox align={'center'} distribution={'space-between'} gap={40} height={32} horizontal>
<Flexbox align={'center'} className={styles.query} gap={8} horizontal>
<Icon icon={SearchIcon} />
{args.keywords ? (
args.keywords
) : (
<Skeleton.Node active style={{ height: 20, width: 40 }} />
)}
</Flexbox>
<Skeleton.Node active style={{ height: 20, width: 40 }} />
</Flexbox>
<Flexbox gap={4}>
<Skeleton.Button active block style={{ height: 16 }} />
<Skeleton.Button active block style={{ height: 16 }} />
<Skeleton.Button active block style={{ height: 16 }} />
<Skeleton.Button active block style={{ height: 16 }} />
</Flexbox>
</Flexbox>
);
});
export default SearchFiles;
@@ -0,0 +1,25 @@
import { BuiltinPlaceholderProps } from '@lobechat/types';
import { memo } from 'react';
import { LocalSystemApiName } from '@/tools/local-system';
import { ListFiles } from './ListFiles';
import ReadLocalFile from './ReadLocalFile';
import SearchFiles from './SearchFiles';
const RenderMap = {
[LocalSystemApiName.searchLocalFiles]: SearchFiles,
[LocalSystemApiName.listLocalFiles]: ListFiles,
[LocalSystemApiName.readLocalFile]: ReadLocalFile,
// [LocalSystemApiName.renameLocalFile]: RenameLocalFile,
// [LocalSystemApiName.writeLocalFile]: WriteFile,
};
const Placeholder = memo<BuiltinPlaceholderProps>(({ apiName, args }) => {
const Render = RenderMap[apiName as any];
if (!Render) return;
return <Render args={(args || {}) as any} />;
});
export default Placeholder;
+3
View File
@@ -1,8 +1,11 @@
import { BuiltinPlaceholder } from '@lobechat/types';
import { LocalSystemManifest } from './local-system';
import LocalSystem from './local-system/Placeholder';
import { WebBrowsingManifest } from './web-browsing';
import WebBrowsing from './web-browsing/Placeholder';
export const BuiltinToolPlaceholders: Record<string, BuiltinPlaceholder> = {
[WebBrowsingManifest.identifier]: WebBrowsing as BuiltinPlaceholder,
[LocalSystemManifest.identifier]: LocalSystem as BuiltinPlaceholder,
};