mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-17 13:06:21 +00:00
🐛 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:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
+25
-6
@@ -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';
|
||||
|
||||
+89
-4
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 上将此文稿改为页面布局,方法是在"文稿"控制中关闭"文稿正文"。
|
||||
"这是一个引用(报告中的关键短语)的例子。轻点或点按此文本添加你自己的内容。"
|
||||
|
||||
|
||||
|
||||
|
||||
这是第二页的内容
|
||||
",
|
||||
},
|
||||
]
|
||||
`;
|
||||
Binary file not shown.
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,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';
|
||||
|
||||
/**
|
||||
* 代表一个完整的已加载文件,包含文件级信息和其所有页面/块。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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} />
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
import { dispatch } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { FileMetadata } from '@/types/files';
|
||||
import { FileMetadata } from '@lobechat/types';
|
||||
|
||||
/**
|
||||
* 桌面应用文件API客户端服务
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user