mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 19:50:09 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 518af5cbc6 |
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Application settings storage related constants
|
||||
*/
|
||||
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
||||
import { type NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { appStorageDir } from '@/const/dir';
|
||||
import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
|
||||
import { ElectronMainStore } from '@/types/store';
|
||||
import { type ElectronMainStore } from '@/types/store';
|
||||
|
||||
/**
|
||||
* Storage name
|
||||
@@ -32,4 +32,5 @@ export const STORE_DEFAULTS: ElectronMainStore = {
|
||||
shortcuts: DEFAULT_SHORTCUTS_CONFIG,
|
||||
storagePath: appStorageDir,
|
||||
themeMode: 'system',
|
||||
workingDirectories: {},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import type { ElectronAppState, ThemeMode } from '@lobechat/electron-client-ipc';
|
||||
@@ -21,6 +23,31 @@ import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:SystemCtr');
|
||||
|
||||
interface KnownApp {
|
||||
bundleName: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface InstalledApp {
|
||||
icon: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const KNOWN_APPS: KnownApp[] = [
|
||||
{ bundleName: 'Cursor.app', id: 'cursor', name: 'Cursor' },
|
||||
{ bundleName: 'Visual Studio Code.app', id: 'vscode', name: 'VS Code' },
|
||||
{ bundleName: 'Zed.app', id: 'zed', name: 'Zed' },
|
||||
{ bundleName: 'Xcode.app', id: 'xcode', name: 'Xcode' },
|
||||
{ bundleName: 'WebStorm.app', id: 'webstorm', name: 'WebStorm' },
|
||||
{ bundleName: 'IntelliJ IDEA.app', id: 'idea', name: 'IntelliJ IDEA' },
|
||||
{ bundleName: 'Ghostty.app', id: 'ghostty', name: 'Ghostty' },
|
||||
{ bundleName: 'iTerm.app', id: 'iterm', name: 'iTerm' },
|
||||
{ bundleName: 'Fork.app', id: 'fork', name: 'Fork' },
|
||||
{ bundleName: 'Trae.app', id: 'trae', name: 'Trae' },
|
||||
];
|
||||
|
||||
export default class SystemController extends ControllerModule {
|
||||
static override readonly groupName = 'system';
|
||||
private systemThemeListenerInitialized = false;
|
||||
@@ -123,8 +150,8 @@ export default class SystemController extends ControllerModule {
|
||||
buttons: [openSettingsButtonText, skipButtonText],
|
||||
cancelId: 1,
|
||||
defaultId: 0,
|
||||
message: message,
|
||||
title: title,
|
||||
message,
|
||||
title,
|
||||
type: 'info',
|
||||
});
|
||||
|
||||
@@ -230,6 +257,106 @@ export default class SystemController extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getWorkingDirectories(): Record<string, string> {
|
||||
return this.app.storeManager.get('workingDirectories', {});
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
setWorkingDirectory(payload: { key: string; path: string }): void {
|
||||
const current = this.app.storeManager.get('workingDirectories', {});
|
||||
this.app.storeManager.set('workingDirectories', { ...current, [payload.key]: payload.path });
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
removeWorkingDirectory(payload: { key: string }): void {
|
||||
const current = this.app.storeManager.get('workingDirectories', {});
|
||||
const { [payload.key]: _, ...rest } = current;
|
||||
this.app.storeManager.set('workingDirectories', rest);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getInstalledApps(): Promise<InstalledApp[]> {
|
||||
if (process.platform !== 'darwin') return [];
|
||||
|
||||
const results: InstalledApp[] = [];
|
||||
|
||||
// Finder is always available on macOS
|
||||
const finderPath = '/System/Library/CoreServices/Finder.app';
|
||||
try {
|
||||
const finderIcon = await app.getFileIcon(finderPath, { size: 'small' });
|
||||
results.push({ icon: finderIcon.toDataURL(), id: 'finder', name: 'Finder' });
|
||||
} catch {
|
||||
results.push({ icon: '', id: 'finder', name: 'Finder' });
|
||||
}
|
||||
|
||||
// Terminal.app is always available
|
||||
const terminalPaths = [
|
||||
'/System/Applications/Utilities/Terminal.app',
|
||||
'/Applications/Utilities/Terminal.app',
|
||||
];
|
||||
for (const tp of terminalPaths) {
|
||||
if (await pathExists(tp)) {
|
||||
try {
|
||||
const icon = await app.getFileIcon(tp, { size: 'small' });
|
||||
results.push({ icon: icon.toDataURL(), id: 'terminal', name: 'Terminal' });
|
||||
} catch {
|
||||
results.push({ icon: '', id: 'terminal', name: 'Terminal' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check known apps
|
||||
for (const knownApp of KNOWN_APPS) {
|
||||
const appPath = join('/Applications', knownApp.bundleName);
|
||||
if (await pathExists(appPath)) {
|
||||
try {
|
||||
const icon = await app.getFileIcon(appPath, { size: 'small' });
|
||||
results.push({ icon: icon.toDataURL(), id: knownApp.id, name: knownApp.name });
|
||||
} catch {
|
||||
results.push({ icon: '', id: knownApp.id, name: knownApp.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async openDirectoryInApp(payload: { appId: string; path: string }): Promise<void> {
|
||||
const { appId, path: dirPath } = payload;
|
||||
|
||||
if (appId === 'finder') {
|
||||
await shell.openPath(dirPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') return;
|
||||
|
||||
// Find app name from known apps or built-in
|
||||
let appName: string | undefined;
|
||||
if (appId === 'terminal') {
|
||||
appName = 'Terminal';
|
||||
} else {
|
||||
const known = KNOWN_APPS.find((a) => a.id === appId);
|
||||
appName = known?.name;
|
||||
}
|
||||
|
||||
if (!appName) return;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
exec(`open -a "${appName}" "${dirPath}"`, (error) => {
|
||||
if (error) {
|
||||
logger.error(`Failed to open directory in ${appName}:`, error);
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async setSystemThemeMode(themeMode: ThemeMode) {
|
||||
nativeTheme.themeSource = themeMode;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DataSyncConfig, NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
||||
import { type DataSyncConfig, type NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
||||
|
||||
export interface ElectronMainStore {
|
||||
dataSyncConfig: DataSyncConfig;
|
||||
@@ -13,6 +13,7 @@ export interface ElectronMainStore {
|
||||
shortcuts: Record<string, string>;
|
||||
storagePath: string;
|
||||
themeMode: 'dark' | 'light' | 'system';
|
||||
workingDirectories: Record<string, string>;
|
||||
}
|
||||
|
||||
export type StoreKey = keyof ElectronMainStore;
|
||||
|
||||
@@ -4,9 +4,9 @@ import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
import { workingDirectorySelectors } from '@/store/electron/selectors';
|
||||
import { useElectronStore } from '@/store/electron/store';
|
||||
|
||||
interface OutOfScopeWarningProps {
|
||||
/**
|
||||
@@ -34,9 +34,16 @@ const isPathWithinWorkingDirectory = (targetPath: string, workingDirectory: stri
|
||||
const OutOfScopeWarning = memo<OutOfScopeWarningProps>(({ paths }) => {
|
||||
const { t } = useTranslation('tool');
|
||||
|
||||
// Get working directory from topic or agent store
|
||||
const topicWorkingDir = useChatStore(topicSelectors.currentTopicWorkingDirectory);
|
||||
const agentWorkingDir = useAgentStore(agentSelectors.currentAgentWorkingDirectory);
|
||||
const activeTopicId = useChatStore((s) => s.activeTopicId);
|
||||
const activeAgentId = useAgentStore((s) => s.activeAgentId);
|
||||
|
||||
// Get working directory from Electron Store
|
||||
const topicWorkingDir = useElectronStore((s) =>
|
||||
activeTopicId ? workingDirectorySelectors.topicWorkingDirectory(activeTopicId)(s) : undefined,
|
||||
);
|
||||
const agentWorkingDir = useElectronStore((s) =>
|
||||
activeAgentId ? workingDirectorySelectors.agentWorkingDirectory(activeAgentId)(s) : undefined,
|
||||
);
|
||||
const workingDirectory = topicWorkingDir || agentWorkingDir;
|
||||
|
||||
// Find paths that are outside the working directory
|
||||
|
||||
@@ -59,11 +59,11 @@ describe('TopicModel - Update', () => {
|
||||
await serverDB.insert(topics).values({ userId, id: topicId, title: 'Test' });
|
||||
|
||||
const result = await topicModel.updateMetadata(topicId, {
|
||||
workingDirectory: '/path/to/dir',
|
||||
model: 'gpt-4',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].metadata).toEqual({ workingDirectory: '/path/to/dir' });
|
||||
expect(result[0].metadata).toEqual({ model: 'gpt-4' });
|
||||
});
|
||||
|
||||
it('should merge metadata with existing metadata', async () => {
|
||||
@@ -76,14 +76,13 @@ describe('TopicModel - Update', () => {
|
||||
});
|
||||
|
||||
const result = await topicModel.updateMetadata(topicId, {
|
||||
workingDirectory: '/new/path',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].metadata).toEqual({
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
workingDirectory: '/new/path',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,17 +92,17 @@ describe('TopicModel - Update', () => {
|
||||
userId,
|
||||
id: topicId,
|
||||
title: 'Test',
|
||||
metadata: { workingDirectory: '/old/path', model: 'gpt-4' },
|
||||
metadata: { provider: 'openai', model: 'gpt-4' },
|
||||
});
|
||||
|
||||
const result = await topicModel.updateMetadata(topicId, {
|
||||
workingDirectory: '/new/path',
|
||||
model: 'claude-3',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].metadata).toEqual({
|
||||
model: 'gpt-4',
|
||||
workingDirectory: '/new/path',
|
||||
model: 'claude-3',
|
||||
provider: 'openai',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,7 +116,7 @@ describe('TopicModel - Update', () => {
|
||||
});
|
||||
|
||||
const result = await topicModel.updateMetadata(topicId, {
|
||||
workingDirectory: '/path/to/dir',
|
||||
model: 'gpt-4',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
|
||||
@@ -17,6 +17,48 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
type BroadcastHandler<T extends MainBroadcastEventKey> = (data: MainBroadcastParams<T>) => void;
|
||||
type SharedBroadcastHandler = BroadcastHandler<MainBroadcastEventKey>;
|
||||
|
||||
const eventHandlerMap = new Map<MainBroadcastEventKey, Set<SharedBroadcastHandler>>();
|
||||
const eventListenerMap = new Map<MainBroadcastEventKey, (e: any, data: any) => void>();
|
||||
|
||||
const getIpcRenderer = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
return window.electron?.ipcRenderer;
|
||||
};
|
||||
|
||||
const ensureEventListener = (event: MainBroadcastEventKey) => {
|
||||
const ipcRenderer = getIpcRenderer();
|
||||
if (!ipcRenderer) return;
|
||||
if (eventListenerMap.has(event)) return;
|
||||
|
||||
const listener = (_e: any, data: unknown) => {
|
||||
const handlers = eventHandlerMap.get(event);
|
||||
if (!handlers) return;
|
||||
|
||||
for (const handler of handlers) {
|
||||
handler(data as MainBroadcastParams<typeof event>);
|
||||
}
|
||||
};
|
||||
|
||||
eventListenerMap.set(event, listener);
|
||||
ipcRenderer.on(event, listener);
|
||||
};
|
||||
|
||||
const cleanupEventListener = (event: MainBroadcastEventKey) => {
|
||||
const handlers = eventHandlerMap.get(event);
|
||||
if (handlers && handlers.size > 0) return;
|
||||
|
||||
const ipcRenderer = getIpcRenderer();
|
||||
const listener = eventListenerMap.get(event);
|
||||
|
||||
if (!ipcRenderer || !listener) return;
|
||||
|
||||
ipcRenderer.removeListener(event, listener);
|
||||
eventListenerMap.delete(event);
|
||||
};
|
||||
|
||||
export const useWatchBroadcast = <T extends MainBroadcastEventKey>(
|
||||
event: T,
|
||||
handler: (data: MainBroadcastParams<T>) => void,
|
||||
@@ -28,16 +70,33 @@ export const useWatchBroadcast = <T extends MainBroadcastEventKey>(
|
||||
}, [handler]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electron) return;
|
||||
const ipcRenderer = getIpcRenderer();
|
||||
if (!ipcRenderer) return;
|
||||
|
||||
const listener = (_e: any, data: MainBroadcastParams<T>) => {
|
||||
handlerRef.current(data);
|
||||
let handlers = eventHandlerMap.get(event);
|
||||
if (!handlers) {
|
||||
handlers = new Set<SharedBroadcastHandler>();
|
||||
eventHandlerMap.set(event, handlers);
|
||||
}
|
||||
|
||||
const sharedHandler: SharedBroadcastHandler = (data) => {
|
||||
handlerRef.current(data as MainBroadcastParams<T>);
|
||||
};
|
||||
|
||||
window.electron.ipcRenderer.on(event, listener);
|
||||
handlers.add(sharedHandler);
|
||||
ensureEventListener(event);
|
||||
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeListener(event, listener);
|
||||
const nextHandlers = eventHandlerMap.get(event);
|
||||
if (!nextHandlers) return;
|
||||
|
||||
nextHandlers.delete(sharedHandler);
|
||||
|
||||
if (nextHandlers.size === 0) {
|
||||
eventHandlerMap.delete(event);
|
||||
}
|
||||
|
||||
cleanupEventListener(event);
|
||||
};
|
||||
}, [event]);
|
||||
};
|
||||
|
||||
@@ -6,17 +6,3 @@
|
||||
* - implement: execute directly without asking
|
||||
*/
|
||||
export type AgentMode = 'auto' | 'plan' | 'ask' | 'implement';
|
||||
|
||||
/**
|
||||
* Local System configuration (desktop only)
|
||||
*/
|
||||
export interface LocalSystemConfig {
|
||||
/**
|
||||
* Local System working directory (desktop only)
|
||||
*/
|
||||
workingDirectory?: string;
|
||||
|
||||
// Future extensions:
|
||||
// allowedPaths?: string[];
|
||||
// deniedCommands?: string[];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SearchMode } from '../search';
|
||||
import { LocalSystemConfig } from './agentConfig';
|
||||
import { type SearchMode } from '../search';
|
||||
|
||||
export interface WorkingModel {
|
||||
model: string;
|
||||
@@ -10,46 +8,64 @@ export interface WorkingModel {
|
||||
}
|
||||
|
||||
export interface LobeAgentChatConfig {
|
||||
/**
|
||||
* Local System configuration (desktop only)
|
||||
*/
|
||||
localSystem?: LocalSystemConfig;
|
||||
enableAutoCreateTopic?: boolean;
|
||||
autoCreateTopicThreshold: number;
|
||||
|
||||
enableMaxTokens?: boolean;
|
||||
/**
|
||||
* Model ID to use for generating compression summaries
|
||||
*/
|
||||
compressionModelId?: string;
|
||||
|
||||
/**
|
||||
* Whether to enable streaming output
|
||||
* Disable context caching
|
||||
*/
|
||||
enableStreaming?: boolean;
|
||||
disableContextCaching?: boolean;
|
||||
|
||||
effort?: 'low' | 'medium' | 'high' | 'max';
|
||||
|
||||
/**
|
||||
* Whether to enable adaptive thinking (Claude Opus 4.6)
|
||||
*/
|
||||
enableAdaptiveThinking?: boolean;
|
||||
enableAutoCreateTopic?: boolean;
|
||||
/**
|
||||
* Whether to auto-scroll during AI streaming output
|
||||
* undefined = use global setting
|
||||
*/
|
||||
enableAutoScrollOnStreaming?: boolean;
|
||||
/**
|
||||
* Enable history message compression threshold
|
||||
* @deprecated Use enableContextCompression instead
|
||||
*/
|
||||
enableCompressHistory?: boolean;
|
||||
/**
|
||||
* Enable context compression
|
||||
* When enabled, old messages will be compressed into summaries when token threshold is reached
|
||||
*/
|
||||
enableContextCompression?: boolean;
|
||||
/**
|
||||
* Enable historical message count
|
||||
*/
|
||||
enableHistoryCount?: boolean;
|
||||
enableMaxTokens?: boolean;
|
||||
/**
|
||||
* Whether to enable reasoning
|
||||
*/
|
||||
enableReasoning?: boolean;
|
||||
/**
|
||||
* Whether to enable adaptive thinking (Claude Opus 4.6)
|
||||
*/
|
||||
enableAdaptiveThinking?: boolean;
|
||||
/**
|
||||
* Custom reasoning effort level
|
||||
*/
|
||||
enableReasoningEffort?: boolean;
|
||||
effort?: 'low' | 'medium' | 'high' | 'max';
|
||||
reasoningBudgetToken?: number;
|
||||
reasoningEffort?: 'low' | 'medium' | 'high';
|
||||
gpt5ReasoningEffort?: 'minimal' | 'low' | 'medium' | 'high';
|
||||
gpt5_1ReasoningEffort?: 'none' | 'low' | 'medium' | 'high';
|
||||
gpt5_2ReasoningEffort?: 'none' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
gpt5_2ProReasoningEffort?: 'medium' | 'high' | 'xhigh';
|
||||
/**
|
||||
* Output text verbosity control
|
||||
* Whether to enable streaming output
|
||||
*/
|
||||
textVerbosity?: 'low' | 'medium' | 'high';
|
||||
thinking?: 'disabled' | 'auto' | 'enabled';
|
||||
thinkingLevel?: 'minimal' | 'low' | 'medium' | 'high';
|
||||
thinkingBudget?: number;
|
||||
enableStreaming?: boolean;
|
||||
gpt5_1ReasoningEffort?: 'none' | 'low' | 'medium' | 'high';
|
||||
gpt5_2ProReasoningEffort?: 'medium' | 'high' | 'xhigh';
|
||||
gpt5_2ReasoningEffort?: 'none' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
gpt5ReasoningEffort?: 'minimal' | 'low' | 'medium' | 'high';
|
||||
/**
|
||||
* Number of historical messages
|
||||
*/
|
||||
historyCount?: number;
|
||||
/**
|
||||
* Image aspect ratio for image generation models
|
||||
*/
|
||||
@@ -58,41 +74,21 @@ export interface LobeAgentChatConfig {
|
||||
* Image resolution for image generation models
|
||||
*/
|
||||
imageResolution?: '1K' | '2K' | '4K';
|
||||
/**
|
||||
* Disable context caching
|
||||
*/
|
||||
disableContextCaching?: boolean;
|
||||
/**
|
||||
* Number of historical messages
|
||||
*/
|
||||
historyCount?: number;
|
||||
/**
|
||||
* Enable historical message count
|
||||
*/
|
||||
enableHistoryCount?: boolean;
|
||||
/**
|
||||
* Enable history message compression threshold
|
||||
* @deprecated Use enableContextCompression instead
|
||||
*/
|
||||
enableCompressHistory?: boolean;
|
||||
|
||||
/**
|
||||
* Enable context compression
|
||||
* When enabled, old messages will be compressed into summaries when token threshold is reached
|
||||
*/
|
||||
enableContextCompression?: boolean;
|
||||
/**
|
||||
* Model ID to use for generating compression summaries
|
||||
*/
|
||||
compressionModelId?: string;
|
||||
|
||||
inputTemplate?: string;
|
||||
reasoningBudgetToken?: number;
|
||||
reasoningEffort?: 'low' | 'medium' | 'high';
|
||||
|
||||
searchMode?: SearchMode;
|
||||
searchFCModel?: WorkingModel;
|
||||
urlContext?: boolean;
|
||||
useModelBuiltinSearch?: boolean;
|
||||
searchMode?: SearchMode;
|
||||
|
||||
/**
|
||||
* Output text verbosity control
|
||||
*/
|
||||
textVerbosity?: 'low' | 'medium' | 'high';
|
||||
|
||||
thinking?: 'disabled' | 'auto' | 'enabled';
|
||||
thinkingBudget?: number;
|
||||
thinkingLevel?: 'minimal' | 'low' | 'medium' | 'high';
|
||||
/**
|
||||
* Maximum length for tool execution result content (in characters)
|
||||
* This prevents context overflow when sending tool results back to LLM
|
||||
@@ -100,20 +96,10 @@ export interface LobeAgentChatConfig {
|
||||
*/
|
||||
toolResultMaxLength?: number;
|
||||
|
||||
/**
|
||||
* Whether to auto-scroll during AI streaming output
|
||||
* undefined = use global setting
|
||||
*/
|
||||
enableAutoScrollOnStreaming?: boolean;
|
||||
}
|
||||
/* eslint-enable */
|
||||
urlContext?: boolean;
|
||||
|
||||
/**
|
||||
* Zod schema for LocalSystemConfig
|
||||
*/
|
||||
export const LocalSystemConfigSchema = z.object({
|
||||
workingDirectory: z.string().optional(),
|
||||
});
|
||||
useModelBuiltinSearch?: boolean;
|
||||
}
|
||||
|
||||
export const AgentChatConfigSchema = z.object({
|
||||
autoCreateTopicThreshold: z.number().default(2),
|
||||
@@ -137,7 +123,6 @@ export const AgentChatConfigSchema = z.object({
|
||||
historyCount: z.number().optional(),
|
||||
imageAspectRatio: z.string().optional(),
|
||||
imageResolution: z.enum(['1K', '2K', '4K']).optional(),
|
||||
localSystem: LocalSystemConfigSchema.optional(),
|
||||
reasoningBudgetToken: z.number().optional(),
|
||||
reasoningEffort: z.enum(['low', 'medium', 'high']).optional(),
|
||||
searchFCModel: z
|
||||
|
||||
@@ -11,7 +11,6 @@ export type TimeGroupId =
|
||||
| `${number}-${string}`
|
||||
| `${number}`;
|
||||
|
||||
/* eslint-disable typescript-sort-keys/string-enum */
|
||||
export enum TopicDisplayMode {
|
||||
ByTime = 'byTime',
|
||||
Flat = 'flat',
|
||||
@@ -43,11 +42,6 @@ export interface ChatTopicMetadata {
|
||||
provider?: string;
|
||||
userMemoryExtractRunState?: TopicUserMemoryExtractRunState;
|
||||
userMemoryExtractStatus?: 'pending' | 'completed' | 'failed';
|
||||
/**
|
||||
* Local System working directory (desktop only)
|
||||
* Priority is higher than Agent-level settings
|
||||
*/
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
export interface ChatTopicSummary {
|
||||
|
||||
+206
-144
@@ -1,173 +1,235 @@
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { Button, Divider, Input, Space, Switch } from 'antd';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { App, Button, Divider, Dropdown, Input, Space, Switch } from 'antd';
|
||||
import { type MenuProps } from 'antd';
|
||||
import { ClipboardCopy, FolderOpen } from 'lucide-react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { electronSystemService } from '@/services/electron/system';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
import { workingDirectorySelectors } from '@/store/electron/selectors';
|
||||
import { useElectronStore } from '@/store/electron/store';
|
||||
|
||||
interface WorkingDirectoryContentProps {
|
||||
agentId: string;
|
||||
effectiveWorkingDirectory?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const WorkingDirectoryContent = memo<WorkingDirectoryContentProps>(({ agentId, onClose }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const WorkingDirectoryContent = memo<WorkingDirectoryContentProps>(
|
||||
({ agentId, effectiveWorkingDirectory, onClose }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const { message } = App.useApp();
|
||||
|
||||
// Get current values
|
||||
const agentWorkingDirectory = useAgentStore((s) =>
|
||||
agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s),
|
||||
);
|
||||
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
|
||||
const activeTopicId = useChatStore((s) => s.activeTopicId);
|
||||
const activeTopicId = useChatStore((s) => s.activeTopicId);
|
||||
|
||||
// Actions
|
||||
const updateAgentLocalSystemConfig = useAgentStore((s) => s.updateAgentLocalSystemConfigById);
|
||||
const updateTopicMetadata = useChatStore((s) => s.updateTopicMetadata);
|
||||
// Get current values from Electron Store
|
||||
const agentWorkingDirectory = useElectronStore((s) =>
|
||||
workingDirectorySelectors.agentWorkingDirectory(agentId)(s),
|
||||
);
|
||||
const topicWorkingDirectory = useElectronStore((s) =>
|
||||
activeTopicId ? workingDirectorySelectors.topicWorkingDirectory(activeTopicId)(s) : undefined,
|
||||
);
|
||||
|
||||
// Local state for editing
|
||||
const [agentDir, setAgentDir] = useState(agentWorkingDirectory || '');
|
||||
const [topicDir, setTopicDir] = useState(topicWorkingDirectory || '');
|
||||
const [useTopicOverride, setUseTopicOverride] = useState(!!topicWorkingDirectory);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Installed apps
|
||||
const installedApps = useElectronStore((s) => s.installedApps);
|
||||
const useFetchInstalledApps = useElectronStore((s) => s.useFetchInstalledApps);
|
||||
useFetchInstalledApps();
|
||||
|
||||
const handleSelectAgentFolder = useCallback(async () => {
|
||||
if (!isDesktop) return;
|
||||
const folder = await electronSystemService.selectFolder({
|
||||
defaultPath: agentDir || undefined,
|
||||
title: t('localSystem.workingDirectory.selectFolder'),
|
||||
});
|
||||
if (folder) setAgentDir(folder);
|
||||
}, [agentDir, t]);
|
||||
const openDirectoryInApp = useElectronStore((s) => s.openDirectoryInApp);
|
||||
|
||||
const handleSelectTopicFolder = useCallback(async () => {
|
||||
if (!isDesktop) return;
|
||||
const folder = await electronSystemService.selectFolder({
|
||||
defaultPath: topicDir || undefined,
|
||||
title: t('localSystem.workingDirectory.selectFolder'),
|
||||
});
|
||||
if (folder) setTopicDir(folder);
|
||||
}, [topicDir, t]);
|
||||
// Actions
|
||||
const setWorkingDirectory = useElectronStore((s) => s.setWorkingDirectory);
|
||||
const removeWorkingDirectory = useElectronStore((s) => s.removeWorkingDirectory);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Save agent working directory
|
||||
await updateAgentLocalSystemConfig(agentId, {
|
||||
workingDirectory: agentDir || undefined,
|
||||
// Local state for editing
|
||||
const [agentDir, setAgentDir] = useState(agentWorkingDirectory || '');
|
||||
const [topicDir, setTopicDir] = useState(topicWorkingDirectory || '');
|
||||
const [useTopicOverride, setUseTopicOverride] = useState(!!topicWorkingDirectory);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSelectAgentFolder = useCallback(async () => {
|
||||
if (!isDesktop) return;
|
||||
const folder = await electronSystemService.selectFolder({
|
||||
defaultPath: agentDir || undefined,
|
||||
title: t('localSystem.workingDirectory.selectFolder'),
|
||||
});
|
||||
if (folder) setAgentDir(folder);
|
||||
}, [agentDir, t]);
|
||||
|
||||
// Save topic working directory if override is enabled
|
||||
if (activeTopicId && useTopicOverride) {
|
||||
await updateTopicMetadata(activeTopicId, {
|
||||
workingDirectory: topicDir || undefined,
|
||||
});
|
||||
} else if (activeTopicId && !useTopicOverride && topicWorkingDirectory) {
|
||||
// Clear topic override if disabled
|
||||
await updateTopicMetadata(activeTopicId, {
|
||||
workingDirectory: undefined,
|
||||
});
|
||||
const handleSelectTopicFolder = useCallback(async () => {
|
||||
if (!isDesktop) return;
|
||||
const folder = await electronSystemService.selectFolder({
|
||||
defaultPath: topicDir || undefined,
|
||||
title: t('localSystem.workingDirectory.selectFolder'),
|
||||
});
|
||||
if (folder) setTopicDir(folder);
|
||||
}, [topicDir, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (agentDir) {
|
||||
await setWorkingDirectory(`agent:${agentId}`, agentDir);
|
||||
} else {
|
||||
await removeWorkingDirectory(`agent:${agentId}`);
|
||||
}
|
||||
|
||||
if (activeTopicId && useTopicOverride) {
|
||||
if (topicDir) {
|
||||
await setWorkingDirectory(`topic:${activeTopicId}`, topicDir);
|
||||
} else {
|
||||
await removeWorkingDirectory(`topic:${activeTopicId}`);
|
||||
}
|
||||
} else if (activeTopicId && !useTopicOverride && topicWorkingDirectory) {
|
||||
await removeWorkingDirectory(`topic:${activeTopicId}`);
|
||||
}
|
||||
|
||||
onClose?.();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
agentId,
|
||||
agentDir,
|
||||
activeTopicId,
|
||||
useTopicOverride,
|
||||
topicDir,
|
||||
topicWorkingDirectory,
|
||||
setWorkingDirectory,
|
||||
removeWorkingDirectory,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
onClose?.();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
agentId,
|
||||
agentDir,
|
||||
activeTopicId,
|
||||
useTopicOverride,
|
||||
topicDir,
|
||||
topicWorkingDirectory,
|
||||
updateAgentLocalSystemConfig,
|
||||
updateTopicMetadata,
|
||||
onClose,
|
||||
]);
|
||||
const handleCopyPath = useCallback(() => {
|
||||
if (!effectiveWorkingDirectory) return;
|
||||
navigator.clipboard.writeText(effectiveWorkingDirectory);
|
||||
message.success(t('localSystem.workingDirectory.copiedPath'));
|
||||
}, [effectiveWorkingDirectory, message, t]);
|
||||
|
||||
return (
|
||||
<Flexbox gap={12} style={{ maxWidth: 600, minWidth: 320 }}>
|
||||
<Flexbox gap={4}>
|
||||
<Text style={{ fontSize: 12 }} type="secondary">
|
||||
{t('localSystem.workingDirectory.agentLevel')}
|
||||
</Text>
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Input
|
||||
placeholder={t('localSystem.workingDirectory.placeholder')}
|
||||
size="small"
|
||||
style={{ flex: 1, fontSize: 12 }}
|
||||
value={agentDir}
|
||||
variant={'filled'}
|
||||
onChange={(e) => setAgentDir(e.target.value)}
|
||||
/>
|
||||
{isDesktop && (
|
||||
<Button size="small" type={'text'} onClick={handleSelectAgentFolder}>
|
||||
<Icon icon={FolderOpen} size={14} />
|
||||
const openInMenuItems: MenuProps['items'] = useMemo(() => {
|
||||
if (!effectiveWorkingDirectory) return [];
|
||||
|
||||
const appItems: MenuProps['items'] = installedApps.map((app) => ({
|
||||
icon: app.icon ? (
|
||||
<img alt={app.name} src={app.icon} style={{ borderRadius: 4, height: 16, width: 16 }} />
|
||||
) : undefined,
|
||||
key: app.id,
|
||||
label: app.name,
|
||||
onClick: () => openDirectoryInApp(app.id, effectiveWorkingDirectory),
|
||||
}));
|
||||
|
||||
return [
|
||||
...appItems,
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
icon: <Icon icon={ClipboardCopy} size={16} />,
|
||||
key: 'copy-path',
|
||||
label: t('localSystem.workingDirectory.copyPath'),
|
||||
onClick: handleCopyPath,
|
||||
},
|
||||
];
|
||||
}, [effectiveWorkingDirectory, installedApps, openDirectoryInApp, handleCopyPath, t]);
|
||||
|
||||
return (
|
||||
<Flexbox gap={12} style={{ maxWidth: 600, minWidth: 320 }}>
|
||||
{effectiveWorkingDirectory && (
|
||||
<>
|
||||
<Flexbox horizontal align="center" gap={8} justify="space-between">
|
||||
<Text
|
||||
ellipsis={{ tooltip: effectiveWorkingDirectory }}
|
||||
style={{ flex: 1, fontFamily: 'var(--lobe-font-family-code)', fontSize: 12 }}
|
||||
>
|
||||
{effectiveWorkingDirectory}
|
||||
</Text>
|
||||
<Dropdown menu={{ items: openInMenuItems }} trigger={['click']}>
|
||||
<Button size="small">{t('localSystem.workingDirectory.open')}</Button>
|
||||
</Dropdown>
|
||||
</Flexbox>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Flexbox gap={4}>
|
||||
<Text style={{ fontSize: 12 }} type="secondary">
|
||||
{t('localSystem.workingDirectory.agentLevel')}
|
||||
</Text>
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Input
|
||||
placeholder={t('localSystem.workingDirectory.placeholder')}
|
||||
size="small"
|
||||
style={{ flex: 1, fontSize: 12 }}
|
||||
value={agentDir}
|
||||
variant={'filled'}
|
||||
onChange={(e) => setAgentDir(e.target.value)}
|
||||
/>
|
||||
{isDesktop && (
|
||||
<Button size="small" type={'text'} onClick={handleSelectAgentFolder}>
|
||||
<Icon icon={FolderOpen} size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
{activeTopicId && (
|
||||
<>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
|
||||
<Flexbox
|
||||
horizontal
|
||||
align="center"
|
||||
gap={8}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setUseTopicOverride(!useTopicOverride);
|
||||
}}
|
||||
>
|
||||
<Switch checked={useTopicOverride} size="small" onChange={setUseTopicOverride} />
|
||||
<Text style={{ fontSize: 12 }}>
|
||||
{t('localSystem.workingDirectory.topicOverride')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
|
||||
{useTopicOverride && (
|
||||
<Flexbox gap={4}>
|
||||
<Text style={{ fontSize: 12 }} type="secondary">
|
||||
{t('localSystem.workingDirectory.topicLevel')}
|
||||
</Text>
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Input
|
||||
placeholder={t('localSystem.workingDirectory.placeholder')}
|
||||
size="small"
|
||||
style={{ flex: 1, fontSize: 12 }}
|
||||
value={topicDir}
|
||||
variant={'filled'}
|
||||
onChange={(e) => setTopicDir(e.target.value)}
|
||||
/>
|
||||
{isDesktop && (
|
||||
<Button size="small" type={'text'} onClick={handleSelectTopicFolder}>
|
||||
<Icon icon={FolderOpen} size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Flexbox horizontal justify="flex-end">
|
||||
<Space>
|
||||
<Button size="small" onClick={onClose}>
|
||||
{t('cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button loading={loading} size="small" type="primary" onClick={handleSave}>
|
||||
{t('save', { ns: 'common' })}
|
||||
</Button>
|
||||
</Space>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
{activeTopicId && (
|
||||
<>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
|
||||
<Flexbox
|
||||
horizontal
|
||||
align="center"
|
||||
gap={8}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setUseTopicOverride(!useTopicOverride);
|
||||
}}
|
||||
>
|
||||
<Switch checked={useTopicOverride} size="small" onChange={setUseTopicOverride} />
|
||||
<Text style={{ fontSize: 12 }}>{t('localSystem.workingDirectory.topicOverride')}</Text>
|
||||
</Flexbox>
|
||||
|
||||
{useTopicOverride && (
|
||||
<Flexbox gap={4}>
|
||||
<Text style={{ fontSize: 12 }} type="secondary">
|
||||
{t('localSystem.workingDirectory.topicLevel')}
|
||||
</Text>
|
||||
<Flexbox horizontal gap={8}>
|
||||
<Input
|
||||
placeholder={t('localSystem.workingDirectory.placeholder')}
|
||||
size="small"
|
||||
style={{ flex: 1, fontSize: 12 }}
|
||||
value={topicDir}
|
||||
variant={'filled'}
|
||||
onChange={(e) => setTopicDir(e.target.value)}
|
||||
/>
|
||||
{isDesktop && (
|
||||
<Button size="small" type={'text'} onClick={handleSelectTopicFolder}>
|
||||
<Icon icon={FolderOpen} size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Flexbox horizontal justify="flex-end">
|
||||
<Space>
|
||||
<Button size="small" onClick={onClose}>
|
||||
{t('cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button loading={loading} size="small" type="primary" onClick={handleSave}>
|
||||
{t('save', { ns: 'common' })}
|
||||
</Button>
|
||||
</Space>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
WorkingDirectoryContent.displayName = 'WorkingDirectoryContent';
|
||||
|
||||
|
||||
+37
-7
@@ -2,13 +2,14 @@ import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
|
||||
import { Flexbox, Icon, Popover, Tooltip } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { LaptopIcon, SquircleDashed } from 'lucide-react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
import { workingDirectorySelectors } from '@/store/electron/selectors';
|
||||
import { useElectronStore } from '@/store/electron/store';
|
||||
|
||||
import WorkingDirectoryContent from './WorkingDirectoryContent';
|
||||
|
||||
@@ -36,6 +37,7 @@ const WorkingDirectory = memo(() => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const agentId = useAgentStore((s) => s.activeAgentId);
|
||||
const activeTopicId = useChatStore((s) => s.activeTopicId);
|
||||
|
||||
// Check if local-system plugin is enabled for current agent
|
||||
const plugins = useAgentStore((s) =>
|
||||
@@ -46,10 +48,32 @@ const WorkingDirectory = memo(() => {
|
||||
[plugins],
|
||||
);
|
||||
|
||||
// Get working directory from Topic (higher priority) or Agent (fallback)
|
||||
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
|
||||
const agentWorkingDirectory = useAgentStore((s) =>
|
||||
agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined,
|
||||
// Load working directories from Electron Store
|
||||
const useFetchWorkingDirectories = useElectronStore((s) => s.useFetchWorkingDirectories);
|
||||
useFetchWorkingDirectories();
|
||||
|
||||
// Lazy migration: move old chatConfig.localSystem.workingDirectory to Electron Store
|
||||
const setWorkingDirectory = useElectronStore((s) => s.setWorkingDirectory);
|
||||
const updateAgentChatConfig = useAgentStore((s) => s.updateAgentChatConfigById);
|
||||
const migratedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId || migratedRef.current.has(agentId)) return;
|
||||
const agentData = useAgentStore.getState().agentMap[agentId] as any;
|
||||
const oldDir = agentData?.chatConfig?.localSystem?.workingDirectory;
|
||||
if (oldDir && !useElectronStore.getState().workingDirectories[`agent:${agentId}`]) {
|
||||
migratedRef.current.add(agentId);
|
||||
setWorkingDirectory(`agent:${agentId}`, oldDir);
|
||||
updateAgentChatConfig(agentId, { localSystem: undefined } as any);
|
||||
}
|
||||
}, [agentId, setWorkingDirectory, updateAgentChatConfig]);
|
||||
|
||||
// Get working directory from Electron Store: Topic (higher priority) or Agent (fallback)
|
||||
const topicWorkingDirectory = useElectronStore((s) =>
|
||||
activeTopicId ? workingDirectorySelectors.topicWorkingDirectory(activeTopicId)(s) : undefined,
|
||||
);
|
||||
const agentWorkingDirectory = useElectronStore((s) =>
|
||||
agentId ? workingDirectorySelectors.agentWorkingDirectory(agentId)(s) : undefined,
|
||||
);
|
||||
|
||||
const effectiveWorkingDirectory = topicWorkingDirectory || agentWorkingDirectory;
|
||||
@@ -89,10 +113,16 @@ const WorkingDirectory = memo(() => {
|
||||
);
|
||||
return (
|
||||
<Popover
|
||||
content={<WorkingDirectoryContent agentId={agentId} onClose={() => setOpen(false)} />}
|
||||
open={open}
|
||||
placement="bottomRight"
|
||||
trigger="click"
|
||||
content={
|
||||
<WorkingDirectoryContent
|
||||
agentId={agentId}
|
||||
effectiveWorkingDirectory={effectiveWorkingDirectory}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div>
|
||||
|
||||
@@ -36,7 +36,6 @@ vi.mock('@/store/agent/selectors', () => ({
|
||||
agentSelectors: {
|
||||
currentAgentModel: () => 'gpt-4',
|
||||
currentAgentModelProvider: () => 'openai',
|
||||
currentAgentWorkingDirectory: () => undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -46,10 +45,10 @@ vi.mock('@/store/chat', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/chat/selectors', () => ({
|
||||
topicSelectors: {
|
||||
currentTopicWorkingDirectory: () => undefined,
|
||||
},
|
||||
vi.mock('@/store/electron/store', () => ({
|
||||
getElectronStoreState: () => ({
|
||||
workingDirectories: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../GlobalAgentContextManager', () => ({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { template } from 'es-toolkit/compat';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { agentSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
import { getElectronStoreState } from '@/store/electron/store';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userProfileSelectors } from '@/store/user/selectors';
|
||||
|
||||
@@ -157,13 +157,23 @@ export const VARIABLE_GENERATORS = {
|
||||
* Working directory: Topic-level setting takes priority over Agent-level setting
|
||||
*/
|
||||
workingDirectory: () => {
|
||||
const electronState = getElectronStoreState();
|
||||
const activeTopicId = useChatStore.getState().activeTopicId;
|
||||
const activeAgentId = useAgentStore.getState().activeAgentId;
|
||||
|
||||
// First check topic-level working directory
|
||||
const topicWorkingDir = topicSelectors.currentTopicWorkingDirectory(useChatStore.getState());
|
||||
if (topicWorkingDir) return topicWorkingDir;
|
||||
if (activeTopicId) {
|
||||
const topicDir = electronState.workingDirectories[`topic:${activeTopicId}`];
|
||||
if (topicDir) return topicDir;
|
||||
}
|
||||
|
||||
// Fallback to agent-level working directory
|
||||
const agentWorkingDir = agentSelectors.currentAgentWorkingDirectory(useAgentStore.getState());
|
||||
return agentWorkingDir ?? '(not specified, use user Desktop directory as default)';
|
||||
if (activeAgentId) {
|
||||
const agentDir = electronState.workingDirectories[`agent:${activeAgentId}`];
|
||||
if (agentDir) return agentDir;
|
||||
}
|
||||
|
||||
return '(not specified, use user Desktop directory as default)';
|
||||
},
|
||||
} as Record<string, () => string>;
|
||||
|
||||
|
||||
@@ -369,6 +369,10 @@ export default {
|
||||
'localSystem.workingDirectory.topicDescription':
|
||||
'Override Agent default for this conversation only',
|
||||
'localSystem.workingDirectory.topicLevel': 'Conversation override',
|
||||
'localSystem.workingDirectory.copiedPath': 'Copied!',
|
||||
'localSystem.workingDirectory.copyPath': 'Copy Path',
|
||||
'localSystem.workingDirectory.open': 'Open',
|
||||
'localSystem.workingDirectory.openIn': 'Open In',
|
||||
'localSystem.workingDirectory.topicOverride': 'Override for this conversation',
|
||||
'mcpEmpty.deployment': 'No deployment options',
|
||||
'mcpEmpty.prompts': 'No prompts',
|
||||
|
||||
@@ -515,7 +515,6 @@ export const topicRouter = router({
|
||||
metadata: z.object({
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
workingDirectory: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -245,7 +245,7 @@ export class AgentRuntimeService {
|
||||
// need be removed
|
||||
modelRuntimeConfig,
|
||||
userId,
|
||||
workingDirectory: agentConfig?.chatConfig?.localSystem?.workingDirectory,
|
||||
workingDirectory: appContext?.workingDirectory,
|
||||
...appContext,
|
||||
},
|
||||
// modelRuntimeConfig at state level for executor fallback
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface OperationCreationParams {
|
||||
groupId?: string | null;
|
||||
threadId?: string | null;
|
||||
topicId?: string | null;
|
||||
workingDirectory?: string;
|
||||
};
|
||||
autoStart?: boolean;
|
||||
initialContext: AgentRuntimeContext;
|
||||
|
||||
@@ -65,6 +65,26 @@ class ElectronSystemService {
|
||||
}): Promise<string | undefined> {
|
||||
return this.ipc.system.selectFolder(params);
|
||||
}
|
||||
|
||||
async getWorkingDirectories(): Promise<Record<string, string>> {
|
||||
return this.ipc.system.getWorkingDirectories();
|
||||
}
|
||||
|
||||
async setWorkingDirectory(key: string, path: string): Promise<void> {
|
||||
return this.ipc.system.setWorkingDirectory({ key, path });
|
||||
}
|
||||
|
||||
async removeWorkingDirectory(key: string): Promise<void> {
|
||||
return this.ipc.system.removeWorkingDirectory({ key });
|
||||
}
|
||||
|
||||
async getInstalledApps(): Promise<Array<{ icon: string; id: string; name: string }>> {
|
||||
return this.ipc.system.getInstalledApps();
|
||||
}
|
||||
|
||||
async openDirectoryInApp(appId: string, path: string): Promise<void> {
|
||||
return this.ipc.system.openDirectoryInApp({ appId, path });
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance of the service
|
||||
|
||||
@@ -78,10 +78,7 @@ export class TopicService {
|
||||
return lambdaClient.topic.updateTopic.mutate({ id, value: data });
|
||||
};
|
||||
|
||||
updateTopicMetadata = (
|
||||
id: string,
|
||||
metadata: { model?: string; provider?: string; workingDirectory?: string },
|
||||
) => {
|
||||
updateTopicMetadata = (id: string, metadata: { model?: string; provider?: string }) => {
|
||||
return lambdaClient.topic.updateTopicMetadata.mutate({ id, metadata });
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DEFAULT_PROVIDER } from '@lobechat/business-const';
|
||||
import { DEFAULT_MODEL, DEFAUTT_AGENT_TTS_CONFIG } from '@lobechat/const';
|
||||
import { type AgentBuilderContext } from '@lobechat/context-engine';
|
||||
import { type AgentMode, type LobeAgentTTSConfig, type LocalSystemConfig } from '@lobechat/types';
|
||||
import { type AgentMode, type LobeAgentTTSConfig } from '@lobechat/types';
|
||||
|
||||
import { type AgentStoreState } from '../initialState';
|
||||
import { agentSelectors } from './selectors';
|
||||
@@ -73,23 +73,6 @@ const getAgentEnableModeById =
|
||||
return mode !== undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get local system config by agentId
|
||||
* Now reads from chatConfig.localSystem
|
||||
*/
|
||||
const getAgentLocalSystemConfigById =
|
||||
(agentId: string) =>
|
||||
(s: AgentStoreState): LocalSystemConfig | undefined =>
|
||||
agentSelectors.getAgentConfigById(agentId)(s)?.chatConfig?.localSystem;
|
||||
|
||||
/**
|
||||
* Get working directory by agentId
|
||||
*/
|
||||
const getAgentWorkingDirectoryById =
|
||||
(agentId: string) =>
|
||||
(s: AgentStoreState): string | undefined =>
|
||||
getAgentLocalSystemConfigById(agentId)(s)?.workingDirectory;
|
||||
|
||||
/**
|
||||
* Get agent builder context by agentId
|
||||
* Used for injecting current agent config/meta into Agent Builder context
|
||||
@@ -128,13 +111,11 @@ export const agentByIdSelectors = {
|
||||
getAgentEnableModeById,
|
||||
getAgentFilesById,
|
||||
getAgentKnowledgeBasesById,
|
||||
getAgentLocalSystemConfigById,
|
||||
getAgentModeById,
|
||||
getAgentModelById,
|
||||
getAgentModelProviderById,
|
||||
getAgentPluginsById,
|
||||
getAgentSystemRoleById,
|
||||
getAgentTTSById,
|
||||
getAgentWorkingDirectoryById,
|
||||
isAgentConfigLoadingById,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type KnowledgeItem,
|
||||
type LobeAgentConfig,
|
||||
type LobeAgentTTSConfig,
|
||||
type LocalSystemConfig,
|
||||
type MetaData,
|
||||
} from '@lobechat/types';
|
||||
import { KnowledgeType } from '@lobechat/types';
|
||||
@@ -247,19 +246,6 @@ const currentAgentMode = (s: AgentStoreState): AgentMode | undefined => {
|
||||
*/
|
||||
const isAgentModeEnabled = (s: AgentStoreState): boolean => currentAgentMode(s) !== undefined;
|
||||
|
||||
/**
|
||||
* Get current agent's local system config
|
||||
* Now reads from chatConfig.localSystem
|
||||
*/
|
||||
const currentAgentLocalSystemConfig = (s: AgentStoreState): LocalSystemConfig | undefined =>
|
||||
currentAgentConfig(s)?.chatConfig?.localSystem;
|
||||
|
||||
/**
|
||||
* Get current agent's working directory
|
||||
*/
|
||||
const currentAgentWorkingDirectory = (s: AgentStoreState): string | undefined =>
|
||||
currentAgentLocalSystemConfig(s)?.workingDirectory;
|
||||
|
||||
const isCurrentAgentExternal = (s: AgentStoreState): boolean => !currentAgentData(s)?.virtual;
|
||||
|
||||
export const agentSelectors = {
|
||||
@@ -269,7 +255,6 @@ export const agentSelectors = {
|
||||
currentAgentDescription,
|
||||
currentAgentFiles,
|
||||
currentAgentKnowledgeBases,
|
||||
currentAgentLocalSystemConfig,
|
||||
currentAgentMeta,
|
||||
currentAgentMode,
|
||||
currentAgentModel,
|
||||
@@ -280,7 +265,6 @@ export const agentSelectors = {
|
||||
currentAgentTTSVoice,
|
||||
currentAgentTags,
|
||||
currentAgentTitle,
|
||||
currentAgentWorkingDirectory,
|
||||
currentEnabledKnowledge,
|
||||
currentKnowledgeIds,
|
||||
displayableAgentPlugins,
|
||||
|
||||
@@ -11,11 +11,7 @@ import { agentService } from '@/services/agent';
|
||||
import { type StoreSetter } from '@/store/types';
|
||||
import { getUserStoreState } from '@/store/user';
|
||||
import { userProfileSelectors } from '@/store/user/selectors';
|
||||
import {
|
||||
type LobeAgentChatConfig,
|
||||
type LobeAgentConfig,
|
||||
type LocalSystemConfig,
|
||||
} from '@/types/agent';
|
||||
import { type LobeAgentChatConfig, type LobeAgentConfig } from '@/types/agent';
|
||||
import { type MetaData } from '@/types/meta';
|
||||
import { merge } from '@/utils/merge';
|
||||
|
||||
@@ -190,15 +186,6 @@ export class AgentSliceActionImpl {
|
||||
await this.#get().optimisticUpdateAgentConfig(agentId, config, controller.signal);
|
||||
};
|
||||
|
||||
updateAgentLocalSystemConfigById = async (
|
||||
agentId: string,
|
||||
config: Partial<LocalSystemConfig>,
|
||||
): Promise<void> => {
|
||||
if (!agentId) return;
|
||||
|
||||
await this.#get().updateAgentChatConfigById(agentId, { localSystem: config });
|
||||
};
|
||||
|
||||
updateAgentMeta = async (meta: Partial<MetaData>): Promise<void> => {
|
||||
const { activeAgentId } = this.#get();
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { getAgentStoreState } from '@/store/agent';
|
||||
import { agentSelectors } from '@/store/agent/selectors';
|
||||
import { createAgentExecutors } from '@/store/chat/agents/createAgentExecutors';
|
||||
import { type ChatStore } from '@/store/chat/store';
|
||||
import { getElectronStoreState } from '@/store/electron/store';
|
||||
import { getFileStoreState } from '@/store/file/store';
|
||||
import { pageAgentRuntime } from '@/store/tool/slices/builtin/executors/lobe-page-agent';
|
||||
import { type StoreSetter } from '@/store/types';
|
||||
@@ -182,8 +183,13 @@ export class StreamingExecutorActionImpl {
|
||||
provider: agentConfigData.provider!,
|
||||
};
|
||||
|
||||
const topicWorkingDirectory = topicSelectors.currentTopicWorkingDirectory(this.#get());
|
||||
const agentWorkingDirectory = agentSelectors.currentAgentWorkingDirectory(getAgentStoreState());
|
||||
const electronState = getElectronStoreState();
|
||||
const topicWorkingDirectory = topicId
|
||||
? electronState.workingDirectories[`topic:${topicId}`]
|
||||
: undefined;
|
||||
const agentWorkingDirectory = agentId
|
||||
? electronState.workingDirectories[`agent:${agentId}`]
|
||||
: undefined;
|
||||
const workingDirectory = topicWorkingDirectory ?? agentWorkingDirectory;
|
||||
|
||||
// Create initial state or use provided state
|
||||
|
||||
@@ -68,15 +68,6 @@ const currentActiveTopicSummary = (s: ChatStoreState): ChatTopicSummary | undefi
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current active topic's working directory
|
||||
* Returns undefined if no topic is active or no working directory is set
|
||||
*/
|
||||
const currentTopicWorkingDirectory = (s: ChatStoreState): string | undefined => {
|
||||
const activeTopic = currentActiveTopic(s);
|
||||
return activeTopic?.metadata?.workingDirectory;
|
||||
};
|
||||
|
||||
const isCreatingTopic = (s: ChatStoreState) => s.creatingTopic;
|
||||
const isUndefinedTopics = (s: ChatStoreState) => !currentTopics(s);
|
||||
const isInSearchMode = (s: ChatStoreState) => s.inSearchingMode;
|
||||
@@ -148,7 +139,6 @@ export const topicSelectors = {
|
||||
currentTopicCount,
|
||||
currentTopicData,
|
||||
currentTopicLength,
|
||||
currentTopicWorkingDirectory,
|
||||
currentTopics,
|
||||
currentTopicsWithoutCron,
|
||||
currentUnFavTopics,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { type SWRResponse } from 'swr';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { mutate } from '@/libs/swr';
|
||||
import { electronSystemService } from '@/services/electron/system';
|
||||
import { type InstalledApp } from '@/store/electron/initialState';
|
||||
import { type StoreSetter } from '@/store/types';
|
||||
|
||||
import { type ElectronStore } from '../store';
|
||||
|
||||
const ELECTRON_WORKING_DIRECTORIES_KEY = 'electron:getWorkingDirectories';
|
||||
const ELECTRON_INSTALLED_APPS_KEY = 'electron:getInstalledApps';
|
||||
|
||||
type Setter = StoreSetter<ElectronStore>;
|
||||
export const workingDirectorySlice = (set: Setter, get: () => ElectronStore, _api?: unknown) =>
|
||||
new WorkingDirectoryActionImpl(set, get, _api);
|
||||
|
||||
export class WorkingDirectoryActionImpl {
|
||||
readonly #get: () => ElectronStore;
|
||||
readonly #set: Setter;
|
||||
|
||||
constructor(set: Setter, get: () => ElectronStore, _api?: unknown) {
|
||||
void _api;
|
||||
this.#set = set;
|
||||
this.#get = get;
|
||||
}
|
||||
|
||||
refreshWorkingDirectories = async (): Promise<void> => {
|
||||
await mutate(ELECTRON_WORKING_DIRECTORIES_KEY);
|
||||
};
|
||||
|
||||
setWorkingDirectory = async (key: string, path: string): Promise<void> => {
|
||||
const current = this.#get().workingDirectories;
|
||||
this.#set({ workingDirectories: { ...current, [key]: path } });
|
||||
await electronSystemService.setWorkingDirectory(key, path);
|
||||
};
|
||||
|
||||
removeWorkingDirectory = async (key: string): Promise<void> => {
|
||||
const { [key]: _, ...rest } = this.#get().workingDirectories;
|
||||
this.#set({ workingDirectories: rest });
|
||||
await electronSystemService.removeWorkingDirectory(key);
|
||||
};
|
||||
|
||||
openDirectoryInApp = async (appId: string, path: string): Promise<void> => {
|
||||
await electronSystemService.openDirectoryInApp(appId, path);
|
||||
};
|
||||
|
||||
useFetchInstalledApps = (): SWRResponse => {
|
||||
return useSWR<InstalledApp[]>(
|
||||
ELECTRON_INSTALLED_APPS_KEY,
|
||||
async () => electronSystemService.getInstalledApps(),
|
||||
{
|
||||
onSuccess: (data: InstalledApp[]) => {
|
||||
if (!isEqual(data, this.#get().installedApps)) {
|
||||
this.#set({ installedApps: data });
|
||||
}
|
||||
},
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
useFetchWorkingDirectories = (): SWRResponse => {
|
||||
return useSWR<Record<string, string>>(
|
||||
ELECTRON_WORKING_DIRECTORIES_KEY,
|
||||
async () => electronSystemService.getWorkingDirectories(),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (!isEqual(data, this.#get().workingDirectories)) {
|
||||
this.#set({ workingDirectories: data });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkingDirectoryAction = Pick<
|
||||
WorkingDirectoryActionImpl,
|
||||
keyof WorkingDirectoryActionImpl
|
||||
>;
|
||||
@@ -20,10 +20,17 @@ export const defaultProxySettings: NetworkProxySettings = {
|
||||
proxyType: 'http',
|
||||
};
|
||||
|
||||
export interface InstalledApp {
|
||||
icon: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ElectronState extends NavigationHistoryState, RecentPagesState {
|
||||
appState: ElectronAppState;
|
||||
dataSyncConfig: DataSyncConfig;
|
||||
desktopHotkeys: Record<string, string>;
|
||||
installedApps: InstalledApp[];
|
||||
isAppStateInit?: boolean;
|
||||
isConnectingServer?: boolean;
|
||||
isConnectionDrawerOpen?: boolean;
|
||||
@@ -32,6 +39,7 @@ export interface ElectronState extends NavigationHistoryState, RecentPagesState
|
||||
isSyncActive?: boolean;
|
||||
proxySettings: NetworkProxySettings;
|
||||
remoteServerSyncError?: { message?: string; type: RemoteServerError };
|
||||
workingDirectories: Record<string, string>;
|
||||
}
|
||||
|
||||
export const initialState: ElectronState = {
|
||||
@@ -40,6 +48,7 @@ export const initialState: ElectronState = {
|
||||
appState: {},
|
||||
dataSyncConfig: { storageMode: 'cloud' },
|
||||
desktopHotkeys: {},
|
||||
installedApps: [],
|
||||
isAppStateInit: false,
|
||||
isConnectingServer: false,
|
||||
isConnectionDrawerOpen: false,
|
||||
@@ -47,4 +56,5 @@ export const initialState: ElectronState = {
|
||||
isInitRemoteServerConfig: false,
|
||||
isSyncActive: false,
|
||||
proxySettings: defaultProxySettings,
|
||||
workingDirectories: {},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './desktopState';
|
||||
export * from './hotkey';
|
||||
export * from './sync';
|
||||
export * from './workingDirectory';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type ElectronState } from '@/store/electron/initialState';
|
||||
|
||||
const agentWorkingDirectory = (agentId: string) => (s: ElectronState) =>
|
||||
s.workingDirectories[`agent:${agentId}`];
|
||||
|
||||
const topicWorkingDirectory = (topicId: string) => (s: ElectronState) =>
|
||||
s.workingDirectories[`topic:${topicId}`];
|
||||
|
||||
export const workingDirectorySelectors = {
|
||||
agentWorkingDirectory,
|
||||
topicWorkingDirectory,
|
||||
};
|
||||
@@ -14,6 +14,8 @@ import { type ElectronSettingsAction } from './actions/settings';
|
||||
import { settingsSlice } from './actions/settings';
|
||||
import { type ElectronRemoteServerAction } from './actions/sync';
|
||||
import { remoteSyncSlice } from './actions/sync';
|
||||
import { type WorkingDirectoryAction } from './actions/workingDirectory';
|
||||
import { workingDirectorySlice } from './actions/workingDirectory';
|
||||
import { type ElectronState } from './initialState';
|
||||
import { initialState } from './initialState';
|
||||
|
||||
@@ -26,7 +28,8 @@ export interface ElectronStore
|
||||
ElectronAppAction,
|
||||
ElectronSettingsAction,
|
||||
NavigationHistoryAction,
|
||||
RecentPagesAction {
|
||||
RecentPagesAction,
|
||||
WorkingDirectoryAction {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
@@ -34,7 +37,8 @@ type ElectronStoreAction = ElectronRemoteServerAction &
|
||||
ElectronAppAction &
|
||||
ElectronSettingsAction &
|
||||
NavigationHistoryAction &
|
||||
RecentPagesAction;
|
||||
RecentPagesAction &
|
||||
WorkingDirectoryAction;
|
||||
|
||||
const createStore: StateCreator<ElectronStore, [['zustand/devtools', never]]> = (
|
||||
...parameters: Parameters<StateCreator<ElectronStore, [['zustand/devtools', never]]>>
|
||||
@@ -46,6 +50,7 @@ const createStore: StateCreator<ElectronStore, [['zustand/devtools', never]]> =
|
||||
settingsSlice(...parameters),
|
||||
createNavigationHistorySlice(...parameters),
|
||||
createRecentPagesSlice(...parameters),
|
||||
workingDirectorySlice(...parameters),
|
||||
]),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user