mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
✨ feat(chat): support local file mention snapshots (#14278)
* ✨ support local file mention snapshots * ✨ feat(local-file-mention): implement useLocalFileMention hook for local file search functionality Signed-off-by: Innei <tukon479@gmail.com> * 🐛 fix desktop project file index fallback --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
||||
import { access, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
type PickFileResult,
|
||||
type PrepareSkillDirectoryParams,
|
||||
type PrepareSkillDirectoryResult,
|
||||
type ProjectFileIndexEntry,
|
||||
type ProjectFileIndexParams,
|
||||
type ProjectFileIndexResult,
|
||||
type RenameLocalFileResult,
|
||||
type ResolveSkillResourcePathParams,
|
||||
type ResolveSkillResourcePathResult,
|
||||
@@ -42,6 +45,7 @@ import {
|
||||
writeLocalFile,
|
||||
} from '@lobechat/local-file-shell';
|
||||
import { dialog, shell } from 'electron';
|
||||
import { execa } from 'execa';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
|
||||
@@ -81,6 +85,50 @@ const resolveNearestExistingRealPath = async (targetPath: string): Promise<strin
|
||||
}
|
||||
};
|
||||
|
||||
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
|
||||
|
||||
const createProjectFileEntry = (
|
||||
root: string,
|
||||
absolutePath: string,
|
||||
isDirectory: boolean,
|
||||
): ProjectFileIndexEntry => {
|
||||
const relativePath = toPosixRelativePath(path.relative(root, absolutePath));
|
||||
|
||||
return {
|
||||
isDirectory,
|
||||
name: path.basename(absolutePath),
|
||||
path: absolutePath,
|
||||
relativePath: isDirectory ? `${relativePath}/` : relativePath,
|
||||
};
|
||||
};
|
||||
|
||||
const collectProjectDirectories = (files: string[], root: string): ProjectFileIndexEntry[] => {
|
||||
const directories = new Set<string>();
|
||||
|
||||
for (const filePath of files) {
|
||||
let current = path.dirname(filePath);
|
||||
while (current && current !== root && current.startsWith(`${root}${path.sep}`)) {
|
||||
if (directories.has(current)) break;
|
||||
directories.add(current);
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
|
||||
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
|
||||
};
|
||||
|
||||
const createDetectedProjectFileEntry = async (
|
||||
root: string,
|
||||
absolutePath: string,
|
||||
): Promise<ProjectFileIndexEntry> => {
|
||||
try {
|
||||
const stats = await stat(absolutePath);
|
||||
return createProjectFileEntry(root, absolutePath, stats.isDirectory());
|
||||
} catch {
|
||||
return createProjectFileEntry(root, absolutePath, false);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSafePathRealPrefixes = async (): Promise<string[]> => {
|
||||
const prefixes = new Set<string>(SAFE_PATH_PREFIXES);
|
||||
|
||||
@@ -413,14 +461,127 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
// ==================== Search & Find ====================
|
||||
|
||||
@IpcMethod()
|
||||
async getProjectFileIndex(params: ProjectFileIndexParams = {}): Promise<ProjectFileIndexResult> {
|
||||
const requestedScope = params.scope || process.cwd();
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const rootResult = await execa(
|
||||
'git',
|
||||
['-C', requestedScope, 'rev-parse', '--show-toplevel'],
|
||||
{
|
||||
reject: false,
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
const root = rootResult.exitCode === 0 ? rootResult.stdout.trim() : requestedScope;
|
||||
|
||||
if (rootResult.exitCode === 0) {
|
||||
const [trackedResult, untrackedResult] = await Promise.all([
|
||||
execa(
|
||||
'git',
|
||||
['-C', root, '-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'],
|
||||
{
|
||||
reject: false,
|
||||
timeout: 10_000,
|
||||
},
|
||||
),
|
||||
execa(
|
||||
'git',
|
||||
[
|
||||
'-C',
|
||||
root,
|
||||
'-c',
|
||||
'core.quotepath=false',
|
||||
'ls-files',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
],
|
||||
{ reject: false, timeout: 10_000 },
|
||||
),
|
||||
]);
|
||||
|
||||
if (trackedResult.exitCode !== 0) {
|
||||
throw new Error(trackedResult.stderr || 'git ls-files failed');
|
||||
}
|
||||
|
||||
const files = [
|
||||
...trackedResult.stdout.split('\n'),
|
||||
...(untrackedResult.exitCode === 0 ? untrackedResult.stdout.split('\n') : []),
|
||||
]
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((relativePath) => path.resolve(root, relativePath));
|
||||
|
||||
const seen = new Set<string>();
|
||||
const fileEntries = files
|
||||
.filter((filePath) => {
|
||||
if (seen.has(filePath)) return false;
|
||||
seen.add(filePath);
|
||||
return true;
|
||||
})
|
||||
.map((filePath) => createProjectFileEntry(root, filePath, false));
|
||||
|
||||
const entries = [...collectProjectDirectories(files, root), ...fileEntries];
|
||||
logger.debug('Project file index built from git', {
|
||||
duration: Date.now() - startedAt,
|
||||
entries: entries.length,
|
||||
files: fileEntries.length,
|
||||
requestedScope,
|
||||
root,
|
||||
});
|
||||
|
||||
return {
|
||||
entries,
|
||||
indexedAt: new Date().toISOString(),
|
||||
root,
|
||||
source: 'git',
|
||||
totalCount: entries.length,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Git project file index failed, falling back to glob', {
|
||||
error,
|
||||
requestedScope,
|
||||
});
|
||||
}
|
||||
|
||||
const fallback = await this.searchService.glob({ pattern: '**/*', scope: requestedScope });
|
||||
const files = fallback.files.map((filePath) => path.resolve(filePath));
|
||||
const entries = await Promise.all(
|
||||
files.map((filePath) => createDetectedProjectFileEntry(requestedScope, filePath)),
|
||||
);
|
||||
|
||||
logger.debug('Project file index built from glob', {
|
||||
duration: Date.now() - startedAt,
|
||||
entries: entries.length,
|
||||
engine: fallback.engine,
|
||||
requestedScope,
|
||||
});
|
||||
|
||||
return {
|
||||
entries,
|
||||
indexedAt: new Date().toISOString(),
|
||||
root: requestedScope,
|
||||
source: 'glob',
|
||||
totalCount: entries.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle IPC event for local file search
|
||||
*/
|
||||
@IpcMethod()
|
||||
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
|
||||
const effectiveDirectory = params.directory ?? params.scope;
|
||||
|
||||
logger.debug('Received file search request:', {
|
||||
directory: params.directory,
|
||||
effectiveDirectory,
|
||||
limit: params.limit,
|
||||
keywords: params.keywords,
|
||||
scope: params.scope,
|
||||
});
|
||||
|
||||
// Build search options from params, mapping directory to onlyIn
|
||||
@@ -436,7 +597,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
liveUpdate: params.liveUpdate,
|
||||
modifiedAfter: params.modifiedAfter ? new Date(params.modifiedAfter) : undefined,
|
||||
modifiedBefore: params.modifiedBefore ? new Date(params.modifiedBefore) : undefined,
|
||||
onlyIn: params.directory, // Map directory param to onlyIn option
|
||||
onlyIn: effectiveDirectory,
|
||||
sortBy: params.sortBy,
|
||||
sortDirection: params.sortDirection,
|
||||
};
|
||||
@@ -446,6 +607,14 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
logger.debug('File search completed', {
|
||||
count: results.length,
|
||||
directory: params.directory,
|
||||
effectiveDirectory,
|
||||
results: results.slice(0, 5).map((result) => ({
|
||||
engine: result.engine,
|
||||
isDirectory: result.isDirectory,
|
||||
name: result.name,
|
||||
path: result.path,
|
||||
})),
|
||||
scope: params.scope,
|
||||
});
|
||||
return results;
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { type App } from '@/core/App';
|
||||
|
||||
import LocalFileCtr from '../LocalFileCtr';
|
||||
|
||||
const { ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
|
||||
const { execaMock, ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
|
||||
execaMock: vi.fn(),
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
}));
|
||||
@@ -14,6 +15,10 @@ vi.mock('@/utils/net-fetch', () => ({
|
||||
netFetch: fetchMock,
|
||||
}));
|
||||
|
||||
vi.mock('execa', () => ({
|
||||
execa: execaMock,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -535,6 +540,18 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use scope as the default search directory', async () => {
|
||||
mockSearchService.search.mockResolvedValue([]);
|
||||
|
||||
await localFileCtr.handleLocalFilesSearch({ keywords: 'src', scope: '/workspace/project' });
|
||||
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('src', {
|
||||
keywords: 'src',
|
||||
limit: 30,
|
||||
onlyIn: '/workspace/project',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array on search error', async () => {
|
||||
mockSearchService.search.mockRejectedValue(new Error('Search failed'));
|
||||
|
||||
@@ -544,6 +561,94 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectFileIndex', () => {
|
||||
it('should build a project file index from git files', async () => {
|
||||
execaMock
|
||||
.mockResolvedValueOnce({ exitCode: 0, stdout: '/workspace/project' })
|
||||
.mockResolvedValueOnce({
|
||||
exitCode: 0,
|
||||
stdout: 'src/index.ts\nsrc/components/Button.tsx',
|
||||
})
|
||||
.mockResolvedValueOnce({ exitCode: 0, stdout: 'tmp/local.ts' });
|
||||
|
||||
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
|
||||
|
||||
expect(result.source).toBe('git');
|
||||
expect(result.root).toBe('/workspace/project');
|
||||
expect(result.entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
isDirectory: true,
|
||||
path: '/workspace/project/src',
|
||||
relativePath: 'src/',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/src/index.ts',
|
||||
relativePath: 'src/index.ts',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/tmp/local.ts',
|
||||
relativePath: 'tmp/local.ts',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.totalCount).toBe(result.entries.length);
|
||||
});
|
||||
|
||||
it('should fall back to glob when git indexing fails', async () => {
|
||||
execaMock.mockResolvedValueOnce({ exitCode: 1, stdout: '' });
|
||||
mockSearchService.glob.mockResolvedValue({
|
||||
engine: 'fast-glob',
|
||||
files: ['/workspace/project/src', '/workspace/project/src/index.ts'],
|
||||
success: true,
|
||||
total_files: 2,
|
||||
});
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath: string) => ({
|
||||
isDirectory: () => filePath === '/workspace/project/src',
|
||||
}));
|
||||
|
||||
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
|
||||
|
||||
expect(result.source).toBe('glob');
|
||||
expect(result.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
isDirectory: true,
|
||||
path: '/workspace/project/src',
|
||||
relativePath: 'src/',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/src/index.ts',
|
||||
relativePath: 'src/index.ts',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should mark glob entries as files when stat fails', async () => {
|
||||
execaMock.mockResolvedValueOnce({ exitCode: 1, stdout: '' });
|
||||
mockSearchService.glob.mockResolvedValue({
|
||||
engine: 'fast-glob',
|
||||
files: ['/workspace/project/src/index.ts'],
|
||||
success: true,
|
||||
total_files: 1,
|
||||
});
|
||||
vi.mocked(mockFsPromises.stat).mockRejectedValue(new Error('missing'));
|
||||
|
||||
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
|
||||
|
||||
expect(result.source).toBe('glob');
|
||||
expect(result.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/src/index.ts',
|
||||
relativePath: 'src/index.ts',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGlobFiles', () => {
|
||||
it('should glob files successfully', async () => {
|
||||
const mockResult = {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
GTDTodoInjector,
|
||||
HistorySummaryProvider,
|
||||
KnowledgeInjector,
|
||||
LocalSystemToolSnapshotInjector,
|
||||
OnboardingActionHintInjector,
|
||||
OnboardingContextInjector,
|
||||
OnboardingSyntheticStateInjector,
|
||||
@@ -329,6 +330,8 @@ export class MessagesEngine {
|
||||
new SelectedToolInjector({ enabled: hasSelectedTools, selectedTools }),
|
||||
// Page selections (inject user-selected text into each user message)
|
||||
new PageSelectionsInjector({ enabled: isPageEditorEnabled }),
|
||||
// Local-system file snapshots (replay send-time @file reads as real tool results)
|
||||
new LocalSystemToolSnapshotInjector({ enabled: true }),
|
||||
// Page Editor context (inject current page content to last user message)
|
||||
new PageEditorContextInjector({
|
||||
enabled: isPageEditorEnabled,
|
||||
|
||||
@@ -129,6 +129,67 @@ describe('MessagesEngine', () => {
|
||||
expect(hasHistorySummary).toBe(true);
|
||||
});
|
||||
|
||||
it('should replay local-system tool snapshots as tool results', async () => {
|
||||
const now = Date.now();
|
||||
const messages: UIChatMessage[] = [
|
||||
{
|
||||
content: '<localFile name="a.ts" path="/tmp/a.ts" />',
|
||||
createdAt: now,
|
||||
id: 'msg-1',
|
||||
metadata: {
|
||||
localSystemToolSnapshots: [
|
||||
{
|
||||
apiName: 'readLocalFile',
|
||||
arguments: { path: '/tmp/a.ts' },
|
||||
capturedAt: '2026-04-28T12:21:08.785Z',
|
||||
content: 'File: /tmp/a.ts (lines 0-200)\n\nconst a = 1;\n',
|
||||
identifier: 'lobe-local-system',
|
||||
snapshotId: 'local-system-snapshot-1',
|
||||
success: true,
|
||||
toolCallId: 'call_local-system-snapshot-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
role: 'user',
|
||||
updatedAt: now,
|
||||
} as UIChatMessage,
|
||||
];
|
||||
|
||||
const params = createBasicParams({
|
||||
capabilities: {
|
||||
isCanUseFC: () => true,
|
||||
isCanUseVideo: () => false,
|
||||
isCanUseVision: () => false,
|
||||
},
|
||||
messages,
|
||||
});
|
||||
const engine = new MessagesEngine(params);
|
||||
|
||||
const result = await engine.process();
|
||||
|
||||
expect(result.metadata.LocalSystemToolSnapshotInjectorInjectedCount).toBe(1);
|
||||
expect(result.messages).toContainEqual({
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
arguments: '{"path":"/tmp/a.ts"}',
|
||||
name: 'lobe-local-system____readLocalFile____builtin',
|
||||
},
|
||||
id: 'call_local-system-snapshot-1',
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.messages).toContainEqual({
|
||||
content: 'File: /tmp/a.ts (lines 0-200)\n\nconst a = 1;\n',
|
||||
name: 'lobe-local-system____readLocalFile____builtin',
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_local-system-snapshot-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom formatHistorySummary when provided', async () => {
|
||||
const historySummary = 'test summary';
|
||||
const formatHistorySummary = vi.fn((s: string) => `<custom>${s}</custom>`);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { LocalSystemToolSnapshot } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { BaseProcessor } from '../base/BaseProcessor';
|
||||
import type { PipelineContext, ProcessorOptions } from '../types';
|
||||
|
||||
const log = debug('context-engine:provider:LocalSystemToolSnapshotInjector');
|
||||
|
||||
declare module '../types' {
|
||||
interface PipelineContextMetadataOverrides {
|
||||
LocalSystemToolSnapshotInjectorInjectedCount?: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocalSystemToolSnapshotInjectorConfig {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const createToolArguments = (snapshot: LocalSystemToolSnapshot): string =>
|
||||
JSON.stringify(snapshot.arguments);
|
||||
|
||||
const createAssistantToolMessage = (message: any, snapshot: LocalSystemToolSnapshot) => ({
|
||||
content: '',
|
||||
createdAt: message.createdAt,
|
||||
id: `${message.id}-${snapshot.snapshotId}-assistant`,
|
||||
role: 'assistant',
|
||||
tools: [
|
||||
{
|
||||
apiName: snapshot.apiName,
|
||||
arguments: createToolArguments(snapshot),
|
||||
id: snapshot.toolCallId,
|
||||
identifier: snapshot.identifier,
|
||||
type: 'builtin',
|
||||
},
|
||||
],
|
||||
updatedAt: message.updatedAt,
|
||||
});
|
||||
|
||||
const createToolResultMessage = (message: any, snapshot: LocalSystemToolSnapshot) => ({
|
||||
content: snapshot.content ?? '',
|
||||
createdAt: message.createdAt,
|
||||
id: `${message.id}-${snapshot.snapshotId}-tool`,
|
||||
plugin: {
|
||||
apiName: snapshot.apiName,
|
||||
arguments: createToolArguments(snapshot),
|
||||
id: snapshot.toolCallId,
|
||||
identifier: snapshot.identifier,
|
||||
type: 'builtin',
|
||||
},
|
||||
pluginError: snapshot.error,
|
||||
pluginState: snapshot.state,
|
||||
role: 'tool',
|
||||
tool_call_id: snapshot.toolCallId,
|
||||
updatedAt: message.updatedAt,
|
||||
});
|
||||
|
||||
export class LocalSystemToolSnapshotInjector extends BaseProcessor {
|
||||
readonly name = 'LocalSystemToolSnapshotInjector';
|
||||
|
||||
constructor(
|
||||
private config: LocalSystemToolSnapshotInjectorConfig = {},
|
||||
options: ProcessorOptions = {},
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
|
||||
const clonedContext = this.cloneContext(context);
|
||||
|
||||
if (!this.config.enabled) return this.markAsExecuted(clonedContext);
|
||||
|
||||
let injectedCount = 0;
|
||||
const nextMessages: any[] = [];
|
||||
|
||||
for (const message of clonedContext.messages) {
|
||||
nextMessages.push(message);
|
||||
|
||||
if (message.role !== 'user') continue;
|
||||
|
||||
const snapshots = message.metadata?.localSystemToolSnapshots as
|
||||
| LocalSystemToolSnapshot[]
|
||||
| undefined;
|
||||
|
||||
if (!snapshots?.length) continue;
|
||||
|
||||
for (const snapshot of snapshots) {
|
||||
nextMessages.push(createAssistantToolMessage(message, snapshot));
|
||||
nextMessages.push(createToolResultMessage(message, snapshot));
|
||||
injectedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
clonedContext.messages = nextMessages;
|
||||
|
||||
if (injectedCount > 0) {
|
||||
clonedContext.metadata.LocalSystemToolSnapshotInjectorInjectedCount = injectedCount;
|
||||
log('Injected %d local-system tool snapshots', injectedCount);
|
||||
}
|
||||
|
||||
return this.markAsExecuted(clonedContext);
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PipelineContext } from '../../types';
|
||||
import { LocalSystemToolSnapshotInjector } from '../LocalSystemToolSnapshotInjector';
|
||||
|
||||
const createContext = (messages: any[] = []): PipelineContext => ({
|
||||
initialState: {
|
||||
messages: [],
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
},
|
||||
isAborted: false,
|
||||
messages,
|
||||
metadata: {
|
||||
maxTokens: 4000,
|
||||
model: 'test-model',
|
||||
},
|
||||
});
|
||||
|
||||
describe('LocalSystemToolSnapshotInjector', () => {
|
||||
it('should replay persisted readLocalFile snapshots as real tool call/result pairs', async () => {
|
||||
const injector = new LocalSystemToolSnapshotInjector({ enabled: true });
|
||||
const context = createContext([
|
||||
{
|
||||
content: 'Read this file',
|
||||
createdAt: 1,
|
||||
id: 'user-1',
|
||||
metadata: {
|
||||
localSystemToolSnapshots: [
|
||||
{
|
||||
apiName: 'readLocalFile',
|
||||
arguments: { path: '/tmp/a.ts' },
|
||||
capturedAt: '2026-04-28T00:00:00.000Z',
|
||||
content: '<file path="/tmp/a.ts">hello</file>',
|
||||
identifier: 'lobe-local-system',
|
||||
snapshotId: 'snapshot-1',
|
||||
state: { content: 'hello', path: '/tmp/a.ts' },
|
||||
success: true,
|
||||
toolCallId: 'call_snapshot_1',
|
||||
},
|
||||
],
|
||||
},
|
||||
role: 'user',
|
||||
updatedAt: 1,
|
||||
},
|
||||
{ content: 'Answer', id: 'assistant-1', role: 'assistant' },
|
||||
]);
|
||||
|
||||
const result = await injector.process(context);
|
||||
|
||||
expect(result.messages).toHaveLength(4);
|
||||
expect(result.messages[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
tools: [
|
||||
{
|
||||
apiName: 'readLocalFile',
|
||||
arguments: '{"path":"/tmp/a.ts"}',
|
||||
id: 'call_snapshot_1',
|
||||
identifier: 'lobe-local-system',
|
||||
type: 'builtin',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.messages[2]).toMatchObject({
|
||||
content: '<file path="/tmp/a.ts">hello</file>',
|
||||
plugin: {
|
||||
apiName: 'readLocalFile',
|
||||
identifier: 'lobe-local-system',
|
||||
type: 'builtin',
|
||||
},
|
||||
pluginState: { content: 'hello', path: '/tmp/a.ts' },
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_snapshot_1',
|
||||
});
|
||||
expect(result.metadata.LocalSystemToolSnapshotInjectorInjectedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export { GTDPlanInjector } from './GTDPlanInjector';
|
||||
export { GTDTodoInjector } from './GTDTodoInjector';
|
||||
export { HistorySummaryProvider } from './HistorySummary';
|
||||
export { KnowledgeInjector } from './KnowledgeInjector';
|
||||
export { LocalSystemToolSnapshotInjector } from './LocalSystemToolSnapshotInjector';
|
||||
export { OnboardingActionHintInjector } from './OnboardingActionHintInjector';
|
||||
export { OnboardingContextInjector } from './OnboardingContextInjector';
|
||||
export { OnboardingSyntheticStateInjector } from './OnboardingSyntheticStateInjector';
|
||||
@@ -90,6 +91,7 @@ export type { GTDPlan, GTDPlanInjectorConfig } from './GTDPlanInjector';
|
||||
export type { GTDTodoInjectorConfig, GTDTodoItem, GTDTodoList } from './GTDTodoInjector';
|
||||
export type { HistorySummaryConfig } from './HistorySummary';
|
||||
export type { KnowledgeInjectorConfig } from './KnowledgeInjector';
|
||||
export type { LocalSystemToolSnapshotInjectorConfig } from './LocalSystemToolSnapshotInjector';
|
||||
export type {
|
||||
OnboardingContext,
|
||||
OnboardingContextInjectorConfig,
|
||||
|
||||
@@ -170,6 +170,26 @@ export interface LocalSearchFilesParams {
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ProjectFileIndexEntry {
|
||||
isDirectory: boolean;
|
||||
name: string;
|
||||
path: string;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
export interface ProjectFileIndexParams {
|
||||
/** Working directory used to resolve the project root. Defaults to Electron process cwd. */
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface ProjectFileIndexResult {
|
||||
entries: ProjectFileIndexEntry[];
|
||||
indexedAt: string;
|
||||
root: string;
|
||||
source: 'git' | 'glob';
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface OpenLocalFileParams {
|
||||
path: string;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface SendNewMessage {
|
||||
editorData?: Record<string, any>;
|
||||
// if message has attached with files, then add files to message and the agent
|
||||
files?: string[];
|
||||
metadata?: MessageMetadata;
|
||||
/** Page selections attached to this message (for Ask AI functionality) */
|
||||
pageSelections?: PageSelection[];
|
||||
parentId?: string;
|
||||
@@ -142,6 +143,7 @@ export const AiSendMessageServerSchema = z.object({
|
||||
content: z.string(),
|
||||
editorData: z.record(z.unknown()).optional(),
|
||||
files: z.array(z.string()).optional(),
|
||||
metadata: MessageMetadataSchema.optional(),
|
||||
pageSelections: z.array(PageSelectionSchema).optional(),
|
||||
parentId: z.string().optional(),
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,34 @@ import { z } from 'zod';
|
||||
import type { PageSelection } from './pageSelection';
|
||||
import { PageSelectionSchema } from './pageSelection';
|
||||
|
||||
const LocalSystemToolSnapshotSchema = z.object({
|
||||
apiName: z.enum(['readLocalFile', 'listLocalFiles']),
|
||||
arguments: z.record(z.string(), z.unknown()),
|
||||
capturedAt: z.string(),
|
||||
content: z.string().nullable(),
|
||||
error: z.unknown().optional(),
|
||||
identifier: z.literal('lobe-local-system'),
|
||||
result: z.unknown().optional(),
|
||||
snapshotId: z.string(),
|
||||
state: z.unknown().optional(),
|
||||
success: z.boolean(),
|
||||
toolCallId: z.string(),
|
||||
});
|
||||
|
||||
export interface LocalSystemToolSnapshot {
|
||||
apiName: 'readLocalFile' | 'listLocalFiles';
|
||||
arguments: Record<string, unknown>;
|
||||
capturedAt: string;
|
||||
content: string | null;
|
||||
error?: unknown;
|
||||
identifier: 'lobe-local-system';
|
||||
result?: unknown;
|
||||
snapshotId: string;
|
||||
state?: unknown;
|
||||
success: boolean;
|
||||
toolCallId: string;
|
||||
}
|
||||
|
||||
export interface ModelTokensUsage {
|
||||
// Prediction tokens
|
||||
acceptedPredictionTokens?: number;
|
||||
@@ -101,6 +129,7 @@ export const MessageMetadataSchema = ModelUsageSchema.merge(ModelPerformanceSche
|
||||
inspectExpanded: z.boolean().optional(),
|
||||
isMultimodal: z.boolean().optional(),
|
||||
isSupervisor: z.boolean().optional(),
|
||||
localSystemToolSnapshots: z.array(LocalSystemToolSnapshotSchema).optional(),
|
||||
pageSelections: z.array(PageSelectionSchema).optional(),
|
||||
// Canonical nested shape — flat fields above are deprecated. Must be listed
|
||||
// here so zod doesn't strip them from writes going through UpdateMessageParamsSchema
|
||||
@@ -200,6 +229,10 @@ export interface MessageMetadata {
|
||||
isSupervisor?: boolean;
|
||||
/** @deprecated use `metadata.performance` instead */
|
||||
latency?: number;
|
||||
/**
|
||||
* Local-system tool snapshots materialized when the user sent @file mentions.
|
||||
*/
|
||||
localSystemToolSnapshots?: LocalSystemToolSnapshot[];
|
||||
/** @deprecated use `metadata.usage` instead */
|
||||
outputAudioTokens?: number;
|
||||
/** @deprecated use `metadata.usage` instead */
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import {
|
||||
File,
|
||||
FileArchive,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileText,
|
||||
Folder,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { getFileExtension } from './localFileDisplay';
|
||||
|
||||
const CODE_EXTENSIONS = new Set([
|
||||
'c',
|
||||
'cpp',
|
||||
'cs',
|
||||
'css',
|
||||
'go',
|
||||
'html',
|
||||
'java',
|
||||
'js',
|
||||
'jsx',
|
||||
'kt',
|
||||
'lua',
|
||||
'mjs',
|
||||
'php',
|
||||
'py',
|
||||
'rb',
|
||||
'rs',
|
||||
'scss',
|
||||
'sh',
|
||||
'sql',
|
||||
'swift',
|
||||
'ts',
|
||||
'tsx',
|
||||
'vue',
|
||||
'yaml',
|
||||
'yml',
|
||||
]);
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['gif', 'heic', 'ico', 'jpeg', 'jpg', 'png', 'svg', 'webp']);
|
||||
const TEXT_EXTENSIONS = new Set(['csv', 'log', 'md', 'mdx', 'rtf', 'txt']);
|
||||
const ARCHIVE_EXTENSIONS = new Set(['7z', 'gz', 'rar', 'tar', 'tgz', 'zip']);
|
||||
|
||||
const resolveIcon = (name: string, isDirectory?: boolean): LucideIcon => {
|
||||
if (isDirectory) return Folder;
|
||||
|
||||
const extension = getFileExtension(name);
|
||||
if (CODE_EXTENSIONS.has(extension)) return FileCode;
|
||||
if (IMAGE_EXTENSIONS.has(extension)) return FileImage;
|
||||
if (TEXT_EXTENSIONS.has(extension)) return FileText;
|
||||
if (ARCHIVE_EXTENSIONS.has(extension)) return FileArchive;
|
||||
|
||||
return File;
|
||||
};
|
||||
|
||||
interface LocalFileIconProps {
|
||||
isDirectory?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const LocalFileIcon = memo<LocalFileIconProps>(({ name, isDirectory }) => (
|
||||
<Icon icon={resolveIcon(name, isDirectory)} size={16} />
|
||||
));
|
||||
|
||||
LocalFileIcon.displayName = 'LocalFileIcon';
|
||||
|
||||
export default LocalFileIcon;
|
||||
@@ -3,6 +3,7 @@ import { cx } from 'antd-style';
|
||||
import { createElement, isValidElement, type MouseEvent, type ReactNode } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { compactDirectoryTail, compactFileName } from './localFileDisplay';
|
||||
import { styles } from './style';
|
||||
|
||||
interface MenuItemProps {
|
||||
@@ -16,6 +17,18 @@ const MenuItem = memo<MenuItemProps>(({ item, active, extra, onClick }) => {
|
||||
const handleMouseDown = (event: MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
const metadata = item.metadata as Record<string, unknown> | undefined;
|
||||
const isLocalFile = metadata?.type === 'localFile';
|
||||
const localFileName = String(metadata?.name ?? item.label);
|
||||
const localFilePath =
|
||||
typeof metadata?.relativePath === 'string'
|
||||
? metadata.relativePath
|
||||
: typeof metadata?.path === 'string'
|
||||
? metadata.path
|
||||
: '';
|
||||
const directoryTail = isLocalFile
|
||||
? compactDirectoryTail(localFilePath, localFileName, metadata?.isDirectory === true)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -40,8 +53,23 @@ const MenuItem = memo<MenuItemProps>(({ item, active, extra, onClick }) => {
|
||||
: item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.itemLabel}>{item.label}</span>
|
||||
{extra}
|
||||
{isLocalFile ? (
|
||||
<>
|
||||
<span className={styles.localFileName} title={localFileName}>
|
||||
{compactFileName(localFileName)}
|
||||
</span>
|
||||
{directoryTail && (
|
||||
<span className={styles.localFilePath} title={localFilePath}>
|
||||
{directoryTail}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.itemLabel}>{item.label}</span>
|
||||
{extra}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ interface SearchViewProps {
|
||||
|
||||
const SEARCH_RESULT_CATEGORY_LABEL: Record<string, string> = {
|
||||
agent: 'Agent',
|
||||
localFile: 'File',
|
||||
member: 'Member',
|
||||
skill: 'Skill',
|
||||
tool: 'Tool',
|
||||
@@ -22,6 +23,11 @@ const getSearchResultCategoryLabel = (item: ISlashMenuOption): string | undefine
|
||||
const metadata = item.metadata as Record<string, unknown> | undefined;
|
||||
const type = metadata?.type;
|
||||
|
||||
if (type === 'localFile') {
|
||||
if (typeof metadata?.relativePath === 'string') return metadata.relativePath;
|
||||
if (typeof metadata?.path === 'string') return metadata.path;
|
||||
}
|
||||
|
||||
return typeof type === 'string' ? SEARCH_RESULT_CATEGORY_LABEL[type] : undefined;
|
||||
};
|
||||
|
||||
@@ -34,6 +40,7 @@ const SearchView = memo<SearchViewProps>(({ options, activeKey, onSelectItem })
|
||||
<div className={styles.scrollArea}>
|
||||
{options.map((item) => {
|
||||
const categoryLabel = getSearchResultCategoryLabel(item);
|
||||
const isLocalFile = item.metadata?.type === 'localFile';
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
@@ -41,7 +48,7 @@ const SearchView = memo<SearchViewProps>(({ options, activeKey, onSelectItem })
|
||||
item={item}
|
||||
key={item.key}
|
||||
extra={
|
||||
categoryLabel ? (
|
||||
categoryLabel && !isLocalFile ? (
|
||||
<span className={styles.categoryExtra}>{categoryLabel}</span>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { compactDirectoryTail, compactFileName, getFileExtension } from './localFileDisplay';
|
||||
|
||||
describe('localFileDisplay', () => {
|
||||
it('should preserve filename extension when compacting long names', () => {
|
||||
const result = compactFileName(
|
||||
'very-long-generated-component-name-for-settings-panel.test.tsx',
|
||||
);
|
||||
|
||||
expect(result).toContain('...');
|
||||
expect(result.endsWith('test.tsx')).toBe(true);
|
||||
});
|
||||
|
||||
it('should display the distinguishing directory tail instead of the shared path prefix', () => {
|
||||
expect(
|
||||
compactDirectoryTail(
|
||||
'src/features/ChatInput/InputEditor/MentionMenu/MenuItem.tsx',
|
||||
'MenuItem.tsx',
|
||||
),
|
||||
).toBe('.../ChatInput/InputEditor/MentionMenu/');
|
||||
});
|
||||
|
||||
it('should detect file extensions', () => {
|
||||
expect(getFileExtension('index.tsx')).toBe('tsx');
|
||||
expect(getFileExtension('.gitignore')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const MAX_FILENAME_LENGTH = 34;
|
||||
const MAX_TAIL_SEGMENTS = 3;
|
||||
|
||||
const splitPathSegments = (path: string) => path.split('/').filter(Boolean);
|
||||
|
||||
export const getFileExtension = (name: string) => {
|
||||
const index = name.lastIndexOf('.');
|
||||
if (index <= 0 || index === name.length - 1) return '';
|
||||
|
||||
return name.slice(index + 1).toLowerCase();
|
||||
};
|
||||
|
||||
export const compactFileName = (name: string) => {
|
||||
if (name.length <= MAX_FILENAME_LENGTH) return name;
|
||||
|
||||
const extensionIndex = name.lastIndexOf('.');
|
||||
const compoundExtensionIndex =
|
||||
extensionIndex > 0 ? name.lastIndexOf('.', extensionIndex - 1) : -1;
|
||||
const compoundSuffix =
|
||||
compoundExtensionIndex > 0 && name.length - compoundExtensionIndex <= 12
|
||||
? name.slice(compoundExtensionIndex + 1)
|
||||
: '';
|
||||
const suffix = compoundSuffix || name.slice(-10);
|
||||
const suffixBudget = suffix.length;
|
||||
const prefixBudget = Math.max(8, MAX_FILENAME_LENGTH - suffixBudget - 3);
|
||||
|
||||
return `${name.slice(0, prefixBudget)}...${suffix}`;
|
||||
};
|
||||
|
||||
export const compactDirectoryTail = (
|
||||
pathValue: string,
|
||||
fileName: string,
|
||||
isDirectory?: boolean,
|
||||
) => {
|
||||
const normalized = pathValue.replaceAll('\\', '/');
|
||||
const segments = splitPathSegments(normalized);
|
||||
if (segments.length === 0) return '';
|
||||
|
||||
const directorySegments =
|
||||
isDirectory || segments.at(-1) !== fileName ? segments : segments.slice(0, -1);
|
||||
if (directorySegments.length === 0) return '';
|
||||
|
||||
const tail = directorySegments.slice(-MAX_TAIL_SEGMENTS).join('/');
|
||||
const prefix = directorySegments.length > MAX_TAIL_SEGMENTS ? '.../' : '';
|
||||
|
||||
return `${prefix}${tail}/`;
|
||||
};
|
||||
@@ -20,13 +20,18 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
}
|
||||
`,
|
||||
categoryExtra: css`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
|
||||
max-width: 180px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextQuaternary};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
container: css`
|
||||
position: fixed;
|
||||
@@ -98,6 +103,33 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
itemLabel: css`
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
localFileName: css`
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
min-width: 88px;
|
||||
|
||||
font-size: 13px;
|
||||
color: ${cssVar.colorText};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
localFilePath: css`
|
||||
overflow: hidden;
|
||||
flex: 0 1 auto;
|
||||
|
||||
max-width: 190px;
|
||||
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextQuaternary};
|
||||
text-align: end;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ISlashMenuOption } from '@lobehub/editor';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type MentionCategoryId = 'agent' | 'topic' | 'member' | 'skill' | 'tool';
|
||||
export type MentionCategoryId = 'agent' | 'topic' | 'member' | 'skill' | 'tool' | 'localFile';
|
||||
|
||||
export interface MentionCategory {
|
||||
icon: ReactNode;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { HotkeyEnum, KeyEnum } from '@lobechat/const/hotkeys';
|
||||
import { HETEROGENEOUS_TYPE_LABELS } from '@lobechat/heterogeneous-agents';
|
||||
import { chainInputCompletion } from '@lobechat/prompts';
|
||||
import { chainInputCompletion, escapeXmlAttr } from '@lobechat/prompts';
|
||||
import { isCommandPressed, merge } from '@lobechat/utils';
|
||||
import { INSERT_MENTION_COMMAND, ReactAutoCompletePlugin, ReactMathPlugin } from '@lobehub/editor';
|
||||
import { Editor, FloatMenu, useEditorState } from '@lobehub/editor/react';
|
||||
@@ -37,6 +37,7 @@ import type { MentionMenuState } from './MentionMenu/types';
|
||||
import Placeholder, { type PlaceholderVariant } from './Placeholder';
|
||||
import { CHAT_INPUT_EMBED_PLUGINS, createChatInputRichPlugins } from './plugins';
|
||||
import { INSERT_REFER_TOPIC_COMMAND } from './ReferTopic';
|
||||
import { useLocalFileMention } from './useLocalFileMention';
|
||||
import { useMentionCategories } from './useMentionCategories';
|
||||
|
||||
const className = cx(css`
|
||||
@@ -75,6 +76,16 @@ const InputEditor = memo<{
|
||||
const categoriesRef = useRef(categories);
|
||||
categoriesRef.current = categories;
|
||||
|
||||
// Get agent's model info for vision support check and handle paste upload
|
||||
const agentId = useAgentId();
|
||||
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s));
|
||||
const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s));
|
||||
const heterogeneousType = useAgentStore(
|
||||
(s) => agentByIdSelectors.getAgencyConfigById(agentId)(s)?.heterogeneousProvider?.type,
|
||||
);
|
||||
|
||||
const { enableLocalFileMention, searchLocalFiles } = useLocalFileMention();
|
||||
|
||||
const allMentionItems = useMemo(() => categories.flatMap((c) => c.items), [categories]);
|
||||
|
||||
const fuse = useMemo(
|
||||
@@ -92,25 +103,22 @@ const InputEditor = memo<{
|
||||
) => {
|
||||
if (search?.matchingString) {
|
||||
stateRef.current = { isSearch: true, matchingString: search.matchingString };
|
||||
return fuse.search(search.matchingString).map((r) => r.item);
|
||||
const [localFileItems, mentionItems] = await Promise.all([
|
||||
searchLocalFiles(search.matchingString),
|
||||
Promise.resolve(fuse.search(search.matchingString).map((r) => r.item)),
|
||||
]);
|
||||
|
||||
return [...localFileItems, ...mentionItems];
|
||||
}
|
||||
stateRef.current = { isSearch: false, matchingString: '' };
|
||||
return [...allMentionItems];
|
||||
},
|
||||
[allMentionItems, fuse],
|
||||
[allMentionItems, fuse, searchLocalFiles],
|
||||
);
|
||||
|
||||
const MentionMenuComp = useMemo(() => createMentionMenu(stateRef, categoriesRef), []);
|
||||
|
||||
const enableMention = allMentionItems.length > 0;
|
||||
|
||||
// Get agent's model info for vision support check and handle paste upload
|
||||
const agentId = useAgentId();
|
||||
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s));
|
||||
const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s));
|
||||
const heterogeneousType = useAgentStore(
|
||||
(s) => agentByIdSelectors.getAgencyConfigById(agentId)(s)?.heterogeneousProvider?.type,
|
||||
);
|
||||
const enableMention = allMentionItems.length > 0 || enableLocalFileMention;
|
||||
const heterogeneousName = heterogeneousType
|
||||
? (HETEROGENEOUS_TYPE_LABELS[heterogeneousType] ?? heterogeneousType)
|
||||
: undefined;
|
||||
@@ -231,6 +239,13 @@ const InputEditor = memo<{
|
||||
if (mention.metadata?.type === 'topic') {
|
||||
return `<refer_topic name="${mention.metadata.topicTitle}" id="${mention.metadata.topicId}" />`;
|
||||
}
|
||||
if (mention.metadata?.type === 'localFile') {
|
||||
const name = escapeXmlAttr(String(mention.metadata.name ?? mention.label));
|
||||
const path = escapeXmlAttr(String(mention.metadata.path ?? ''));
|
||||
const isDirectory = mention.metadata.isDirectory ? ' isDirectory' : '';
|
||||
|
||||
return `<localFile name="${name}" path="${path}"${isDirectory} />`;
|
||||
}
|
||||
return `<mention name="${mention.label}" id="${mention.metadata.id}" />`;
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { getProjectFileIndexMock } = vi.hoisted(() => ({
|
||||
getProjectFileIndexMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/electron/localFileService', () => ({
|
||||
localFileService: {
|
||||
getProjectFileIndex: getProjectFileIndexMock,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('localFileMentionIndex', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
getProjectFileIndexMock.mockReset();
|
||||
});
|
||||
|
||||
it('should reuse the project file index across keyword searches', async () => {
|
||||
getProjectFileIndexMock.mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
isDirectory: false,
|
||||
name: 'index.ts',
|
||||
path: '/workspace/project/src/index.ts',
|
||||
relativePath: 'src/index.ts',
|
||||
},
|
||||
{
|
||||
isDirectory: false,
|
||||
name: 'Button.tsx',
|
||||
path: '/workspace/project/src/components/Button.tsx',
|
||||
relativePath: 'src/components/Button.tsx',
|
||||
},
|
||||
],
|
||||
indexedAt: '2026-04-28T00:00:00.000Z',
|
||||
root: '/workspace/project',
|
||||
source: 'git',
|
||||
totalCount: 2,
|
||||
});
|
||||
|
||||
const { searchProjectFileMentionIndex } = await import('./localFileMentionIndex');
|
||||
|
||||
await searchProjectFileMentionIndex('/workspace/project', 'src', 20);
|
||||
const secondResult = await searchProjectFileMentionIndex('/workspace/project', 'button', 20);
|
||||
|
||||
expect(getProjectFileIndexMock).toHaveBeenCalledTimes(1);
|
||||
expect(secondResult).toEqual([
|
||||
expect.objectContaining({
|
||||
path: '/workspace/project/src/components/Button.tsx',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ProjectFileIndexEntry } from '@lobechat/electron-client-ipc';
|
||||
import debug from 'debug';
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
import { localFileService } from '@/services/electron/localFileService';
|
||||
|
||||
const log = debug('chat-input:local-file-mention:index');
|
||||
|
||||
const INDEX_REFRESH_INTERVAL = 5000;
|
||||
|
||||
interface LocalFileMentionIndex {
|
||||
entries: ProjectFileIndexEntry[];
|
||||
fuse: Fuse<ProjectFileIndexEntry>;
|
||||
indexedAt: number;
|
||||
root: string;
|
||||
}
|
||||
|
||||
interface LocalFileMentionIndexCache {
|
||||
index?: LocalFileMentionIndex;
|
||||
refreshPromise?: Promise<LocalFileMentionIndex>;
|
||||
}
|
||||
|
||||
const cache = new Map<string, LocalFileMentionIndexCache>();
|
||||
|
||||
const buildIndex = async (scope: string): Promise<LocalFileMentionIndex> => {
|
||||
const startedAt = Date.now();
|
||||
const result = await localFileService.getProjectFileIndex({ scope });
|
||||
const fuse = new Fuse(result.entries, {
|
||||
ignoreLocation: true,
|
||||
keys: ['relativePath', 'name', 'path'],
|
||||
threshold: 0.35,
|
||||
});
|
||||
|
||||
log('Built project file mention index', {
|
||||
duration: Date.now() - startedAt,
|
||||
entries: result.entries.length,
|
||||
root: result.root,
|
||||
source: result.source,
|
||||
});
|
||||
|
||||
return {
|
||||
entries: result.entries,
|
||||
fuse,
|
||||
indexedAt: Date.now(),
|
||||
root: result.root,
|
||||
};
|
||||
};
|
||||
|
||||
const refreshIndex = (scope: string, entry: LocalFileMentionIndexCache) => {
|
||||
if (entry.refreshPromise) return entry.refreshPromise;
|
||||
|
||||
entry.refreshPromise = buildIndex(scope)
|
||||
.then((index) => {
|
||||
entry.index = index;
|
||||
return index;
|
||||
})
|
||||
.finally(() => {
|
||||
entry.refreshPromise = undefined;
|
||||
});
|
||||
|
||||
return entry.refreshPromise;
|
||||
};
|
||||
|
||||
const getCacheEntry = (scope: string) => {
|
||||
const existing = cache.get(scope);
|
||||
if (existing) return existing;
|
||||
|
||||
const entry: LocalFileMentionIndexCache = {};
|
||||
cache.set(scope, entry);
|
||||
return entry;
|
||||
};
|
||||
|
||||
export const warmProjectFileMentionIndex = (scope: string | undefined) => {
|
||||
if (!scope) return;
|
||||
|
||||
const entry = getCacheEntry(scope);
|
||||
if (entry.index || entry.refreshPromise) return;
|
||||
|
||||
void refreshIndex(scope, entry);
|
||||
};
|
||||
|
||||
export const searchProjectFileMentionIndex = async (
|
||||
scope: string | undefined,
|
||||
query: string,
|
||||
limit: number,
|
||||
): Promise<ProjectFileIndexEntry[]> => {
|
||||
if (!scope) return [];
|
||||
|
||||
const entry = getCacheEntry(scope);
|
||||
const isStale = !entry.index || Date.now() - entry.index.indexedAt > INDEX_REFRESH_INTERVAL;
|
||||
|
||||
if (!entry.index) {
|
||||
await refreshIndex(scope, entry);
|
||||
} else if (isStale) {
|
||||
void refreshIndex(scope, entry);
|
||||
}
|
||||
|
||||
const index = entry.index;
|
||||
if (!index) return [];
|
||||
|
||||
return index.fuse.search(query, { limit }).map((result) => result.item);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ISlashMenuOption } from '@lobehub/editor';
|
||||
import debug from 'debug';
|
||||
import { createElement, useCallback, useEffect } from 'react';
|
||||
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
|
||||
import { useAgentId } from '../hooks/useAgentId';
|
||||
import {
|
||||
searchProjectFileMentionIndex,
|
||||
warmProjectFileMentionIndex,
|
||||
} from './localFileMentionIndex';
|
||||
import LocalFileIcon from './MentionMenu/LocalFileIcon';
|
||||
|
||||
const LOCAL_SYSTEM_IDENTIFIER = 'lobe-local-system';
|
||||
const MAX_LOCAL_FILE_MENTION_ITEMS = 20;
|
||||
const log = debug('chat-input:local-file-mention');
|
||||
|
||||
export interface UseLocalFileMentionResult {
|
||||
enableLocalFileMention: boolean;
|
||||
searchLocalFiles: (matchingString: string) => Promise<ISlashMenuOption[]>;
|
||||
}
|
||||
|
||||
export const useLocalFileMention = (): UseLocalFileMentionResult => {
|
||||
const agentId = useAgentId();
|
||||
const agentPlugins = useAgentStore((s) => agentByIdSelectors.getAgentPluginsById(agentId)(s));
|
||||
const heterogeneousType = useAgentStore(
|
||||
(s) => agentByIdSelectors.getAgencyConfigById(agentId)(s)?.heterogeneousProvider?.type,
|
||||
);
|
||||
const agentWorkingDirectory = useAgentStore((s) =>
|
||||
agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s),
|
||||
);
|
||||
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
|
||||
const workingDirectory = topicWorkingDirectory || agentWorkingDirectory;
|
||||
|
||||
const enableLocalFileMention =
|
||||
!!heterogeneousType || agentPlugins.includes(LOCAL_SYSTEM_IDENTIFIER);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableLocalFileMention) return;
|
||||
warmProjectFileMentionIndex(workingDirectory);
|
||||
}, [enableLocalFileMention, workingDirectory]);
|
||||
|
||||
const searchLocalFiles = useCallback(
|
||||
async (matchingString: string): Promise<ISlashMenuOption[]> => {
|
||||
const keywords = matchingString.trim();
|
||||
if (!enableLocalFileMention || !keywords) {
|
||||
log('Skip search', {
|
||||
enableLocalFileMention,
|
||||
hasKeywords: !!keywords,
|
||||
matchingString,
|
||||
workingDirectory,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
log('Search indexed local files', {
|
||||
keywords,
|
||||
limit: MAX_LOCAL_FILE_MENTION_ITEMS,
|
||||
workingDirectory,
|
||||
});
|
||||
const files = await searchProjectFileMentionIndex(
|
||||
workingDirectory,
|
||||
keywords,
|
||||
MAX_LOCAL_FILE_MENTION_ITEMS,
|
||||
);
|
||||
|
||||
log('Search indexed local files completed', {
|
||||
count: files.length,
|
||||
results: files.slice(0, 5).map((file) => ({
|
||||
isDirectory: file.isDirectory,
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
})),
|
||||
workingDirectory,
|
||||
});
|
||||
|
||||
return files.map((file) => ({
|
||||
icon: createElement(LocalFileIcon, {
|
||||
isDirectory: file.isDirectory,
|
||||
name: file.name,
|
||||
}),
|
||||
key: `local-file-${file.path}`,
|
||||
label: file.name || file.path,
|
||||
metadata: {
|
||||
isDirectory: file.isDirectory,
|
||||
name: file.name || file.path.split('/').pop() || file.path,
|
||||
path: file.path,
|
||||
relativePath: file.relativePath,
|
||||
timestamp: 0,
|
||||
type: 'localFile' as const,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[useLocalFileMention] Failed to search local files:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[enableLocalFileMention, workingDirectory],
|
||||
);
|
||||
|
||||
return { enableLocalFileMention, searchLocalFiles };
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ISlashMenuOption } from '@lobehub/editor';
|
||||
|
||||
export interface UseLocalFileMentionResult {
|
||||
enableLocalFileMention: boolean;
|
||||
searchLocalFiles: (matchingString: string) => Promise<ISlashMenuOption[]>;
|
||||
}
|
||||
|
||||
const searchLocalFiles = async (): Promise<ISlashMenuOption[]> => [];
|
||||
|
||||
export const useLocalFileMention = (): UseLocalFileMentionResult => ({
|
||||
enableLocalFileMention: false,
|
||||
searchLocalFiles,
|
||||
});
|
||||
@@ -151,9 +151,15 @@ export const aiChatRouter = router({
|
||||
log('creating user message with content length: %d', input.newUserMessage.content.length);
|
||||
|
||||
// Build user message metadata with pageSelections if present
|
||||
const userMessageMetadata = input.newUserMessage.pageSelections?.length
|
||||
? { pageSelections: input.newUserMessage.pageSelections }
|
||||
: undefined;
|
||||
const userMessageMetadata =
|
||||
input.newUserMessage.metadata || input.newUserMessage.pageSelections?.length
|
||||
? {
|
||||
...input.newUserMessage.metadata,
|
||||
...(input.newUserMessage.pageSelections?.length
|
||||
? { pageSelections: input.newUserMessage.pageSelections }
|
||||
: undefined),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const userMessageItem = await ctx.messageModel.create({
|
||||
agentId: input.agentId,
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
type OpenLocalFolderParams,
|
||||
type PrepareSkillDirectoryParams,
|
||||
type PrepareSkillDirectoryResult,
|
||||
type ProjectFileIndexParams,
|
||||
type ProjectFileIndexResult,
|
||||
type RenameLocalFileParams,
|
||||
type ResolveSkillResourcePathParams,
|
||||
type ResolveSkillResourcePathResult,
|
||||
@@ -54,6 +56,10 @@ class LocalFileService {
|
||||
return ensureElectronIpc().localSystem.handleLocalFilesSearch(params);
|
||||
}
|
||||
|
||||
async getProjectFileIndex(params: ProjectFileIndexParams): Promise<ProjectFileIndexResult> {
|
||||
return ensureElectronIpc().localSystem.getProjectFileIndex(params);
|
||||
}
|
||||
|
||||
async openLocalFile(params: OpenLocalFileParams) {
|
||||
return ensureElectronIpc().localSystem.handleOpenLocalFile(params);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ vi.mock('zustand/traditional');
|
||||
|
||||
const executeHeterogeneousAgentMock = vi.hoisted(() => vi.fn());
|
||||
const mockConstEnv = vi.hoisted(() => ({ isDesktop: false }));
|
||||
const mockLocalFileService = vi.hoisted(() => ({
|
||||
listLocalFiles: vi.fn(),
|
||||
readLocalFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@lobechat/const', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LobechatConstModule>();
|
||||
@@ -36,6 +40,10 @@ vi.mock('../heterogeneousAgentExecutor', () => ({
|
||||
executeHeterogeneousAgent: (...args: any[]) => executeHeterogeneousAgentMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/electron/localFileService', () => ({
|
||||
localFileService: mockLocalFileService,
|
||||
}));
|
||||
|
||||
// Mock lambdaClient to prevent network requests
|
||||
vi.mock('@/libs/trpc/client', () => ({
|
||||
lambdaClient: {
|
||||
@@ -837,6 +845,161 @@ describe('ConversationLifecycle actions', () => {
|
||||
expect.any(AbortController),
|
||||
);
|
||||
});
|
||||
|
||||
it('should materialize local file mention editor data into persisted tool-result snapshots', async () => {
|
||||
mockConstEnv.isDesktop = true;
|
||||
setupMockSelectors({
|
||||
agentConfig: {
|
||||
agencyConfig: {
|
||||
heterogeneousProvider: { command: 'codex', type: 'codex' },
|
||||
},
|
||||
},
|
||||
});
|
||||
mockLocalFileService.readLocalFile.mockResolvedValue({
|
||||
charCount: 17,
|
||||
content: 'export const x = 1;',
|
||||
fileType: 'text',
|
||||
filename: 'foo.ts',
|
||||
loc: [0, 200],
|
||||
totalCharCount: 17,
|
||||
totalLineCount: 1,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
const sendMessageInServerSpy = vi
|
||||
.spyOn(aiChatService, 'sendMessageInServer')
|
||||
.mockResolvedValue({
|
||||
assistantMessageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
|
||||
messages: [
|
||||
createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user' }),
|
||||
createMockMessage({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, role: 'assistant' }),
|
||||
],
|
||||
topicId: TEST_IDS.TOPIC_ID,
|
||||
topics: [],
|
||||
userMessageId: TEST_IDS.USER_MESSAGE_ID,
|
||||
} as any);
|
||||
|
||||
executeHeterogeneousAgentMock.mockResolvedValue(undefined);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage({
|
||||
context: createTestContext(),
|
||||
editorData: {
|
||||
root: {
|
||||
children: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
label: 'foo.ts',
|
||||
metadata: {
|
||||
name: 'foo.ts',
|
||||
path: '/Users/me/project/foo.ts',
|
||||
type: 'localFile',
|
||||
},
|
||||
type: 'mention',
|
||||
},
|
||||
{ text: ' 这个文件是什么', type: 'text' },
|
||||
],
|
||||
type: 'paragraph',
|
||||
},
|
||||
],
|
||||
type: 'root',
|
||||
},
|
||||
},
|
||||
message: '<localFile name="foo.ts" path="/Users/me/project/foo.ts" /> 这个文件是什么',
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockLocalFileService.readLocalFile).toHaveBeenCalledWith({
|
||||
path: '/Users/me/project/foo.ts',
|
||||
});
|
||||
const payload = sendMessageInServerSpy.mock.calls[0]?.[0];
|
||||
expect(payload?.newUserMessage.metadata?.localSystemToolSnapshots).toMatchObject([
|
||||
{
|
||||
apiName: 'readLocalFile',
|
||||
arguments: { path: '/Users/me/project/foo.ts' },
|
||||
content: expect.stringContaining('export const x = 1;'),
|
||||
identifier: 'lobe-local-system',
|
||||
success: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should preserve local file snapshots for runtime when server response omits metadata', async () => {
|
||||
mockConstEnv.isDesktop = true;
|
||||
setupMockSelectors({
|
||||
agentConfig: {
|
||||
plugins: ['lobe-local-system'],
|
||||
},
|
||||
});
|
||||
mockLocalFileService.readLocalFile.mockResolvedValue({
|
||||
charCount: 17,
|
||||
content: 'export const x = 1;',
|
||||
fileType: 'text',
|
||||
filename: 'foo.ts',
|
||||
loc: [0, 200],
|
||||
totalCharCount: 17,
|
||||
totalLineCount: 1,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
vi.spyOn(aiChatService, 'sendMessageInServer').mockResolvedValue({
|
||||
assistantMessageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
|
||||
isCreateNewTopic: true,
|
||||
messages: [
|
||||
createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user' }),
|
||||
createMockMessage({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, role: 'assistant' }),
|
||||
],
|
||||
topicId: TEST_IDS.TOPIC_ID,
|
||||
topics: { items: [], total: 0 },
|
||||
userMessageId: TEST_IDS.USER_MESSAGE_ID,
|
||||
} as any);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage({
|
||||
context: createTestContext(),
|
||||
editorData: {
|
||||
root: {
|
||||
children: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
label: 'foo.ts',
|
||||
metadata: {
|
||||
name: 'foo.ts',
|
||||
path: '/Users/me/project/foo.ts',
|
||||
type: 'localFile',
|
||||
},
|
||||
type: 'mention',
|
||||
},
|
||||
{ text: ' 这个文件是什么', type: 'text' },
|
||||
],
|
||||
type: 'paragraph',
|
||||
},
|
||||
],
|
||||
type: 'root',
|
||||
},
|
||||
},
|
||||
message: '<localFile name="foo.ts" path="/Users/me/project/foo.ts" /> 这个文件是什么',
|
||||
});
|
||||
});
|
||||
|
||||
const runtimePayload = vi.mocked(result.current.internal_execAgentRuntime).mock
|
||||
.calls[0]?.[0];
|
||||
const runtimeUserMessage = runtimePayload?.messages.find(
|
||||
(message) => message.id === TEST_IDS.USER_MESSAGE_ID,
|
||||
);
|
||||
|
||||
expect(runtimeUserMessage?.metadata?.localSystemToolSnapshots).toMatchObject([
|
||||
{
|
||||
apiName: 'readLocalFile',
|
||||
arguments: { path: '/Users/me/project/foo.ts' },
|
||||
content: expect.stringContaining('export const x = 1;'),
|
||||
identifier: 'lobe-local-system',
|
||||
success: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('optimistic topic updatedAt', () => {
|
||||
|
||||
@@ -9,7 +9,9 @@ export { injectReferTopicNode } from './editorDataHelpers';
|
||||
export type { SingleAgentMentionDirectRoute } from './parseCommands';
|
||||
export {
|
||||
hasNonActionContent,
|
||||
mergeLocalFileReferences,
|
||||
parseCommandsFromEditorData,
|
||||
parseLocalFileReferencesFromEditorData,
|
||||
parseMentionedAgentsFromEditorData,
|
||||
parseSelectedSkillsFromEditorData,
|
||||
parseSelectedToolsFromEditorData,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
parseCommandsFromEditorData,
|
||||
parseLocalFileReferencesFromEditorData,
|
||||
parseMentionedAgentsFromEditorData,
|
||||
parseSelectedSkillsFromEditorData,
|
||||
parseSelectedToolsFromEditorData,
|
||||
@@ -657,3 +658,44 @@ describe('parseSingleAgentMentionDirectRoute', () => {
|
||||
expect(parseSingleAgentMentionDirectRoute(editorData)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLocalFileReferencesFromEditorData', () => {
|
||||
it('should extract local file mention metadata in document order', () => {
|
||||
const editorData = {
|
||||
root: {
|
||||
children: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
label: 'README.md',
|
||||
metadata: {
|
||||
name: 'README.md',
|
||||
path: '/Users/me/project/README.md',
|
||||
type: 'localFile',
|
||||
},
|
||||
type: 'mention',
|
||||
},
|
||||
{
|
||||
label: 'src',
|
||||
metadata: {
|
||||
isDirectory: true,
|
||||
name: 'src',
|
||||
path: '/Users/me/project/src',
|
||||
type: 'localFile',
|
||||
},
|
||||
type: 'mention',
|
||||
},
|
||||
],
|
||||
type: 'paragraph',
|
||||
},
|
||||
],
|
||||
type: 'root',
|
||||
},
|
||||
};
|
||||
|
||||
expect(parseLocalFileReferencesFromEditorData(editorData)).toEqual([
|
||||
{ isDirectory: false, name: 'README.md', path: '/Users/me/project/README.md' },
|
||||
{ isDirectory: true, name: 'src', path: '/Users/me/project/src' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,23 @@ export interface SingleAgentMentionDirectRoute {
|
||||
agent: RuntimeMentionedAgent;
|
||||
}
|
||||
|
||||
export interface ParsedLocalFileReference {
|
||||
isDirectory?: boolean;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const appendLocalFileReference = (
|
||||
references: ParsedLocalFileReference[],
|
||||
seen: Set<string>,
|
||||
reference: ParsedLocalFileReference,
|
||||
) => {
|
||||
if (!reference.path || seen.has(reference.path)) return;
|
||||
|
||||
seen.add(reference.path);
|
||||
references.push(reference);
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk the Lexical JSON tree to find all action-tag nodes.
|
||||
* Returns the extracted action tags in document order.
|
||||
@@ -112,6 +129,43 @@ export const parseMentionedAgentsFromEditorData = (
|
||||
return agents;
|
||||
};
|
||||
|
||||
export const parseLocalFileReferencesFromEditorData = (
|
||||
editorData: Record<string, any> | undefined,
|
||||
): ParsedLocalFileReference[] => {
|
||||
if (!editorData) return [];
|
||||
|
||||
const references: ParsedLocalFileReference[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
walkMentionNode(editorData.root, (label, metadata) => {
|
||||
if (metadata?.type !== 'localFile') return;
|
||||
|
||||
const path = metadata.path as string | undefined;
|
||||
if (!path) return;
|
||||
|
||||
appendLocalFileReference(references, seen, {
|
||||
isDirectory: metadata.isDirectory === true,
|
||||
name: (metadata.name as string | undefined) || label || path.split('/').pop() || path,
|
||||
path,
|
||||
});
|
||||
});
|
||||
|
||||
return references;
|
||||
};
|
||||
|
||||
export const mergeLocalFileReferences = (
|
||||
references: ParsedLocalFileReference[],
|
||||
): ParsedLocalFileReference[] => {
|
||||
const merged: ParsedLocalFileReference[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const reference of references) {
|
||||
appendLocalFileReference(merged, seen, reference);
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect the direct-route shorthand:
|
||||
* exactly one mention node in the whole document, and that mention is the
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
ChatToolPayload,
|
||||
ChatVideoItem,
|
||||
ConversationContext,
|
||||
MessageMetadata,
|
||||
SendMessageParams,
|
||||
SendMessageServerResponse,
|
||||
UIChatMessage,
|
||||
@@ -59,12 +60,15 @@ import type { CommandSendOverrides, SingleAgentMentionDirectRoute } from './comm
|
||||
import {
|
||||
hasNonActionContent,
|
||||
injectReferTopicNode,
|
||||
mergeLocalFileReferences,
|
||||
parseLocalFileReferencesFromEditorData,
|
||||
parseMentionedAgentsFromEditorData,
|
||||
parseSelectedSkillsFromEditorData,
|
||||
parseSelectedToolsFromEditorData,
|
||||
parseSingleAgentMentionDirectRoute,
|
||||
processCommands,
|
||||
} from './commandBus';
|
||||
import { materializeLocalSystemToolSnapshots } from './localSystemToolSnapshots';
|
||||
/**
|
||||
* Extended params for sendMessage with context
|
||||
*/
|
||||
@@ -118,6 +122,30 @@ const isAbortError = (error: unknown, abortController?: AbortController) =>
|
||||
const createAbortError = () =>
|
||||
Object.assign(new Error('Compression cancelled'), { name: 'AbortError' });
|
||||
|
||||
const attachSendTimeMetadataToUserMessage = (
|
||||
messages: UIChatMessage[],
|
||||
userMessageId: string,
|
||||
metadata: MessageMetadata | undefined,
|
||||
): UIChatMessage[] => {
|
||||
if (!metadata) return messages;
|
||||
|
||||
let changed = false;
|
||||
const nextMessages = messages.map((message) => {
|
||||
if (message.id !== userMessageId) return message;
|
||||
|
||||
changed = true;
|
||||
return {
|
||||
...message,
|
||||
metadata: {
|
||||
...(message.metadata ?? undefined),
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return changed ? nextMessages : messages;
|
||||
};
|
||||
|
||||
export class ConversationLifecycleActionImpl {
|
||||
readonly #get: () => ChatStore;
|
||||
|
||||
@@ -155,6 +183,7 @@ export class ConversationLifecycleActionImpl {
|
||||
message,
|
||||
editorData: inputEditorData,
|
||||
files,
|
||||
metadata,
|
||||
onlyAddUserMessage,
|
||||
context,
|
||||
messages: inputMessages,
|
||||
@@ -164,12 +193,16 @@ export class ConversationLifecycleActionImpl {
|
||||
}: SendMessageWithContextParams): Promise<SendMessageResult | undefined> => {
|
||||
let editorData = inputEditorData;
|
||||
const { internal_execAgentRuntime, mainInputEditor } = this.#get();
|
||||
const { agentId } = context;
|
||||
const selectedSkills = parseSelectedSkillsFromEditorData(editorData);
|
||||
const selectedTools = parseSelectedToolsFromEditorData(editorData);
|
||||
const mentionedAgents = parseMentionedAgentsFromEditorData(editorData);
|
||||
|
||||
const localFileReferences = mergeLocalFileReferences(
|
||||
parseLocalFileReferencesFromEditorData(editorData),
|
||||
);
|
||||
|
||||
// Use context from params (required)
|
||||
const { agentId } = context;
|
||||
// If creating new thread (isNew + scope='thread'), threadId will be created by server
|
||||
const isCreatingNewThread = context.isNew && context.scope === 'thread';
|
||||
// Build newThread params for server from new context format
|
||||
@@ -184,6 +217,9 @@ export class ConversationLifecycleActionImpl {
|
||||
|
||||
if (!agentId) return;
|
||||
|
||||
const agentConfig = agentSelectors.getAgentConfigById(agentId)(getAgentStoreState());
|
||||
const heterogeneousProvider = agentConfig?.agencyConfig?.heterogeneousProvider;
|
||||
|
||||
// ── Command Bus: extract and process built-in commands from editorData ──
|
||||
const commandOverrides: CommandSendOverrides = processCommands({
|
||||
message,
|
||||
@@ -254,6 +290,22 @@ export class ConversationLifecycleActionImpl {
|
||||
};
|
||||
|
||||
const fileIdList = files?.map((f) => f.id);
|
||||
const canMaterializeLocalFiles =
|
||||
isDesktop &&
|
||||
localFileReferences.length > 0 &&
|
||||
!metadata?.localSystemToolSnapshots?.length &&
|
||||
(!!heterogeneousProvider || !!agentConfig?.plugins?.includes('lobe-local-system'));
|
||||
const localSystemToolSnapshots = canMaterializeLocalFiles
|
||||
? await materializeLocalSystemToolSnapshots(localFileReferences)
|
||||
: [];
|
||||
const userMessageMetadata =
|
||||
metadata || pageSelections?.length || localSystemToolSnapshots.length
|
||||
? {
|
||||
...metadata,
|
||||
...(pageSelections?.length ? { pageSelections } : undefined),
|
||||
...(localSystemToolSnapshots.length ? { localSystemToolSnapshots } : undefined),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Enrich selected skills/tools with preloaded content, injected directly
|
||||
// via SelectedSkillInjector/SelectedToolInjector — no fake tool-call preload messages
|
||||
@@ -292,6 +344,7 @@ export class ConversationLifecycleActionImpl {
|
||||
editorData: editorData ?? undefined,
|
||||
files: fileIdList,
|
||||
interruptMode: 'soft',
|
||||
metadata: userMessageMetadata,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
runningAgentOp.id,
|
||||
@@ -370,8 +423,8 @@ export class ConversationLifecycleActionImpl {
|
||||
threadId: operationContext.threadId ?? undefined,
|
||||
imageList: tempImages.length > 0 ? tempImages : undefined,
|
||||
videoList: tempVideos.length > 0 ? tempVideos : undefined,
|
||||
// Pass pageSelections metadata for immediate display
|
||||
metadata: pageSelections?.length ? { pageSelections } : undefined,
|
||||
// Pass metadata for immediate display
|
||||
metadata: userMessageMetadata,
|
||||
},
|
||||
{ operationId, tempMessageId: tempId },
|
||||
);
|
||||
@@ -402,8 +455,6 @@ export class ConversationLifecycleActionImpl {
|
||||
|
||||
// ── External agent mode: delegate to heterogeneous agent CLI (desktop only) ──
|
||||
// Per-agent heterogeneousProvider config takes priority over the global gateway mode.
|
||||
const agentConfig = agentSelectors.getAgentConfigById(agentId)(getAgentStoreState());
|
||||
const heterogeneousProvider = agentConfig?.agencyConfig?.heterogeneousProvider;
|
||||
if (isDesktop && heterogeneousProvider) {
|
||||
// Resolve cwd up-front so the new topic is bound to a project at
|
||||
// creation time. Otherwise the row stays NULL until the post-execution
|
||||
@@ -444,6 +495,7 @@ export class ConversationLifecycleActionImpl {
|
||||
content: message,
|
||||
editorData,
|
||||
files: fileIdList,
|
||||
metadata: userMessageMetadata,
|
||||
pageSelections,
|
||||
parentId,
|
||||
},
|
||||
@@ -637,6 +689,7 @@ export class ConversationLifecycleActionImpl {
|
||||
content: persistedContent,
|
||||
editorData,
|
||||
files: fileIdList,
|
||||
metadata: userMessageMetadata,
|
||||
pageSelections,
|
||||
parentId,
|
||||
},
|
||||
@@ -720,6 +773,15 @@ export class ConversationLifecycleActionImpl {
|
||||
|
||||
// Create final context with updated topicId/threadId from server response
|
||||
const finalContext = { ...operationContext, topicId: finalTopicId, threadId: finalThreadId };
|
||||
data = {
|
||||
...data,
|
||||
messages: attachSendTimeMetadataToUserMessage(
|
||||
data.messages,
|
||||
data.userMessageId,
|
||||
userMessageMetadata,
|
||||
),
|
||||
};
|
||||
|
||||
this.#get().replaceMessages(data.messages, {
|
||||
context: finalContext,
|
||||
action: 'sendMessage/serverResponse',
|
||||
|
||||
@@ -1831,6 +1831,7 @@ export const executeHeterogeneousAgent = async (
|
||||
editorData: merged.editorData,
|
||||
files: mergedFiles,
|
||||
message: merged.content,
|
||||
metadata: merged.metadata,
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.error(
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { ListLocalFilesResult, LocalReadFileResult } from '@lobechat/electron-client-ipc';
|
||||
import { formatFileContent, formatFileList } from '@lobechat/prompts';
|
||||
import type { LocalSystemToolSnapshot } from '@lobechat/types';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { localFileService } from '@/services/electron/localFileService';
|
||||
|
||||
import type { ParsedLocalFileReference } from './commandBus/parseCommands';
|
||||
|
||||
const LOCAL_SYSTEM_IDENTIFIER = 'lobe-local-system';
|
||||
const READ_LOCAL_FILE = 'readLocalFile';
|
||||
const LIST_LOCAL_FILES = 'listLocalFiles';
|
||||
const DEFAULT_DIRECTORY_LIMIT = 100;
|
||||
|
||||
const createSnapshotId = () => `local-system-snapshot-${nanoid()}`;
|
||||
|
||||
const createToolCallId = (snapshotId: string) => `call_${snapshotId}`;
|
||||
|
||||
const normalizeReadResult = (result: LocalReadFileResult) => ({
|
||||
charCount: result.charCount,
|
||||
content: result.content,
|
||||
fileType: result.fileType,
|
||||
filename: result.filename,
|
||||
loc: result.loc,
|
||||
totalCharCount: result.totalCharCount,
|
||||
totalLineCount: result.totalLineCount,
|
||||
});
|
||||
|
||||
const createReadSnapshot = async (
|
||||
reference: ParsedLocalFileReference,
|
||||
capturedAt: string,
|
||||
): Promise<LocalSystemToolSnapshot> => {
|
||||
const snapshotId = createSnapshotId();
|
||||
const args = { path: reference.path };
|
||||
|
||||
try {
|
||||
const result = await localFileService.readLocalFile(args);
|
||||
const content = formatFileContent({
|
||||
content: result.content,
|
||||
lineRange: result.loc,
|
||||
path: reference.path,
|
||||
});
|
||||
const state = {
|
||||
charCount: result.charCount,
|
||||
content: result.content,
|
||||
fileType: result.fileType,
|
||||
filename: result.filename,
|
||||
loc: result.loc,
|
||||
path: reference.path,
|
||||
totalCharCount: result.totalCharCount,
|
||||
totalLines: result.totalLineCount,
|
||||
};
|
||||
|
||||
return {
|
||||
apiName: READ_LOCAL_FILE,
|
||||
arguments: args,
|
||||
capturedAt,
|
||||
content,
|
||||
identifier: LOCAL_SYSTEM_IDENTIFIER,
|
||||
result: normalizeReadResult(result),
|
||||
snapshotId,
|
||||
state,
|
||||
success: true,
|
||||
toolCallId: createToolCallId(snapshotId),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
return {
|
||||
apiName: READ_LOCAL_FILE,
|
||||
arguments: args,
|
||||
capturedAt,
|
||||
content: message,
|
||||
error: { message, type: 'LocalFileReadError' },
|
||||
identifier: LOCAL_SYSTEM_IDENTIFIER,
|
||||
snapshotId,
|
||||
success: false,
|
||||
toolCallId: createToolCallId(snapshotId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeListResult = (result: ListLocalFilesResult) => ({
|
||||
files: result.files,
|
||||
totalCount: result.totalCount,
|
||||
});
|
||||
|
||||
const createListSnapshot = async (
|
||||
reference: ParsedLocalFileReference,
|
||||
capturedAt: string,
|
||||
): Promise<LocalSystemToolSnapshot> => {
|
||||
const snapshotId = createSnapshotId();
|
||||
const args = { limit: DEFAULT_DIRECTORY_LIMIT, path: reference.path };
|
||||
|
||||
try {
|
||||
const result = await localFileService.listLocalFiles(args);
|
||||
const content = formatFileList({
|
||||
directory: reference.path,
|
||||
files: result.files.map((file) => ({
|
||||
isDirectory: file.isDirectory,
|
||||
name: file.name,
|
||||
})),
|
||||
totalCount: result.totalCount,
|
||||
});
|
||||
|
||||
return {
|
||||
apiName: LIST_LOCAL_FILES,
|
||||
arguments: args,
|
||||
capturedAt,
|
||||
content,
|
||||
identifier: LOCAL_SYSTEM_IDENTIFIER,
|
||||
result: normalizeListResult(result),
|
||||
snapshotId,
|
||||
state: normalizeListResult(result),
|
||||
success: true,
|
||||
toolCallId: createToolCallId(snapshotId),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
return {
|
||||
apiName: LIST_LOCAL_FILES,
|
||||
arguments: args,
|
||||
capturedAt,
|
||||
content: message,
|
||||
error: { message, type: 'LocalDirectoryListError' },
|
||||
identifier: LOCAL_SYSTEM_IDENTIFIER,
|
||||
snapshotId,
|
||||
success: false,
|
||||
toolCallId: createToolCallId(snapshotId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const materializeLocalSystemToolSnapshots = async (
|
||||
references: ParsedLocalFileReference[],
|
||||
): Promise<LocalSystemToolSnapshot[]> => {
|
||||
if (references.length === 0) return [];
|
||||
|
||||
const capturedAt = new Date().toISOString();
|
||||
|
||||
return Promise.all(
|
||||
references.map((reference) =>
|
||||
reference.isDirectory
|
||||
? createListSnapshot(reference, capturedAt)
|
||||
: createReadSnapshot(reference, capturedAt),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -775,6 +775,7 @@ export class StreamingExecutorActionImpl {
|
||||
editorData: merged.editorData,
|
||||
files: mergedFiles,
|
||||
message: mergedContent,
|
||||
metadata: merged.metadata,
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.error(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ConversationContext } from '@lobechat/types';
|
||||
import type { ConversationContext, MessageMetadata } from '@lobechat/types';
|
||||
|
||||
/**
|
||||
* Operation Type Definitions
|
||||
@@ -197,6 +197,7 @@ export interface QueuedMessage {
|
||||
files?: string[];
|
||||
id: string;
|
||||
interruptMode: 'soft' | 'hard';
|
||||
metadata?: MessageMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,6 +208,7 @@ export interface MergedQueuedMessage {
|
||||
/** Lexical editor JSON state for rich text rendering */
|
||||
editorData?: Record<string, any>;
|
||||
files: string[];
|
||||
metadata?: MessageMetadata;
|
||||
}
|
||||
|
||||
const createTextNode = (text: string) => ({
|
||||
@@ -289,10 +291,30 @@ const mergeQueuedEditorData = (messages: QueuedMessage[]): Record<string, any> |
|
||||
*/
|
||||
export const mergeQueuedMessages = (messages: QueuedMessage[]): MergedQueuedMessage => {
|
||||
const sorted = [...messages].sort((a, b) => a.createdAt - b.createdAt);
|
||||
const metadata = sorted.reduce<MessageMetadata | undefined>((acc, message) => {
|
||||
if (!message.metadata) return acc;
|
||||
const localSystemToolSnapshots = [
|
||||
...(acc?.localSystemToolSnapshots ?? []),
|
||||
...(message.metadata.localSystemToolSnapshots ?? []),
|
||||
];
|
||||
const pageSelections = [
|
||||
...(acc?.pageSelections ?? []),
|
||||
...(message.metadata.pageSelections ?? []),
|
||||
];
|
||||
|
||||
return {
|
||||
...acc,
|
||||
...message.metadata,
|
||||
...(localSystemToolSnapshots.length ? { localSystemToolSnapshots } : undefined),
|
||||
...(pageSelections.length ? { pageSelections } : undefined),
|
||||
};
|
||||
}, undefined);
|
||||
|
||||
return {
|
||||
content: sorted.map((m) => m.content).join('\n\n'),
|
||||
editorData: mergeQueuedEditorData(sorted),
|
||||
files: sorted.flatMap((m) => m.files ?? []),
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user