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:
Innei
2026-04-29 19:39:31 +08:00
committed by GitHub
parent 28c2e9002a
commit 7b6978271a
33 changed files with 1611 additions and 28 deletions
@@ -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);
}
}
@@ -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;
}
+2
View File
@@ -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;
+27 -12
View File
@@ -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,
});
+9 -3
View File
@@ -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(
+23 -1
View File
@@ -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,
};
};