feat: update stroge and tool's ui

This commit is contained in:
adnaan
2026-02-26 17:47:05 +08:00
parent 92094b29e0
commit f1d6fd3639
12 changed files with 689 additions and 212 deletions
+14 -11
View File
@@ -33,9 +33,12 @@ import {
export interface IPCContext {
getMainWindow: () => BrowserWindow | null
createWindow: () => BrowserWindow
mainStore: Store
/** 根据 key 路由到正确的 store */
resolveStore: (key: string) => Store
credentialsStore: Store
preferencesStore: Store
workspaceMetaStore: Store
bootstrapStore: Store
setMainStore: (store: Store) => void
// 窗口-工作区管理(用于单项目单窗口模式)
findWindowByWorkspace?: (roots: string[]) => BrowserWindow | null
setWindowWorkspace?: (windowId: number, roots: string[]) => void
@@ -46,13 +49,13 @@ export interface IPCContext {
* 注册所有安全的 IPC handlers
*/
export function registerAllHandlers(context: IPCContext) {
const { getMainWindow, createWindow, mainStore, bootstrapStore, setMainStore } = context
const { getMainWindow, createWindow, resolveStore, preferencesStore, workspaceMetaStore, bootstrapStore } = context
// 窗口控制
registerWindowHandlers(createWindow)
// 文件操作(安全版)
registerSecureFileHandlers(getMainWindow, mainStore, (event) => {
registerSecureFileHandlers(getMainWindow, workspaceMetaStore, (event) => {
// 优先使用请求来源窗口的工作区(支持多窗口隔离)
if (event && context.getWindowWorkspace) {
const windowId = event.sender.id
@@ -62,14 +65,14 @@ export function registerAllHandlers(context: IPCContext) {
}
}
// 回退到全局存储
return mainStore.get('lastWorkspaceSession') as { roots: string[] } | null
return workspaceMetaStore.get('lastWorkspaceSession') as { roots: string[] } | null
}, {
findWindowByWorkspace: context.findWindowByWorkspace,
setWindowWorkspace: context.setWindowWorkspace,
})
// 设置(传入安全模块引用)
registerSettingsHandlers(mainStore, bootstrapStore, setMainStore, {
// 设置(传入 resolveStore 和各 store 引用)
registerSettingsHandlers(resolveStore, preferencesStore, bootstrapStore, {
securityManager,
updateWhitelist,
getWhitelist
@@ -86,7 +89,7 @@ export function registerAllHandlers(context: IPCContext) {
}
}
// 回退到全局存储
return mainStore.get('lastWorkspaceSession') as { roots: string[] } | null
return workspaceMetaStore.get('lastWorkspaceSession') as { roots: string[] } | null
}, context.getWindowWorkspace)
// 搜索
@@ -95,11 +98,11 @@ export function registerAllHandlers(context: IPCContext) {
// LLM
registerLLMHandlers(getMainWindow)
// 索引 - 传入 mainStore 以读取保存的 embedding 配置
registerIndexingHandlers(getMainWindow, mainStore)
// 索引 - 传入 workspaceMetaStore 以读取保存的 embedding 配置
registerIndexingHandlers(getMainWindow, workspaceMetaStore)
// LSP 语言服务
registerLspHandlers(mainStore)
registerLspHandlers(preferencesStore)
// HTTP 请求(用于 web_search / read_url
registerHttpHandlers()
+7 -7
View File
@@ -8,21 +8,21 @@ import { getIndexService, initIndexServiceWithConfig, EmbeddingConfig, IndexMode
import { ok, failFromError, Result } from '@shared/types/result'
import Store from 'electron-store'
let _mainStore: Store | null = null
let _configStore: Store | null = null
function getSavedConfig(): Partial<IndexConfig> | undefined {
if (!_mainStore) return undefined
return _mainStore.get('indexConfig') as Partial<IndexConfig> | undefined
if (!_configStore) return undefined
return _configStore.get('indexConfig') as Partial<IndexConfig> | undefined
}
function saveConfig(updates: Partial<IndexConfig>): void {
if (!_mainStore) return
if (!_configStore) return
const current = getSavedConfig() || {}
_mainStore.set('indexConfig', { ...current, ...updates })
_configStore.set('indexConfig', { ...current, ...updates })
}
export function registerIndexingHandlers(getMainWindow: () => BrowserWindow | null, mainStore?: Store) {
_mainStore = mainStore || null
export function registerIndexingHandlers(getMainWindow: () => BrowserWindow | null, configStore?: Store) {
_configStore = configStore || null
// 初始化
ipcMain.handle('index:initialize', async (_, workspacePath: string): Promise<Result<void>> => {
+11 -11
View File
@@ -7,8 +7,8 @@ import { toAppError } from '@shared/utils/errorHandler'
import { ipcMain } from 'electron'
import { lspManager, LanguageId } from '../lspManager'
import { EXTENSION_TO_LANGUAGE } from '@shared/languages'
import {
getLspServerStatus,
import {
getLspServerStatus,
installServer,
installBasicServers,
getLspBinDir,
@@ -45,11 +45,11 @@ async function getServerForUri(uri: string, workspacePath: string): Promise<stri
return lspManager.ensureServerForFile(filePath, languageId, workspacePath)
}
// mainStore 引用,用于保存 LSP 配置
let _mainStore: any = null
// preferencesStore 引用,用于保存 LSP 配置
let _preferencesStore: any = null
export function registerLspHandlers(mainStore?: any): void {
_mainStore = mainStore
export function registerLspHandlers(preferencesStore?: any): void {
_preferencesStore = preferencesStore
// 启动服务器
ipcMain.handle('lsp:start', async (_, workspacePath: string) => {
@@ -329,7 +329,7 @@ export function registerLspHandlers(mainStore?: any): void {
// 先获取 call hierarchy item
const items = await lspManager.prepareCallHierarchy(serverName, params.uri, params.line, params.character)
if (!items || items.length === 0) return []
// 获取 incoming calls
return await lspManager.getIncomingCalls(serverName, items[0])
} catch {
@@ -345,7 +345,7 @@ export function registerLspHandlers(mainStore?: any): void {
// 先获取 call hierarchy item
const items = await lspManager.prepareCallHierarchy(serverName, params.uri, params.line, params.character)
if (!items || items.length === 0) return []
// 获取 outgoing calls
return await lspManager.getOutgoingCalls(serverName, items[0])
} catch {
@@ -411,11 +411,11 @@ export function registerLspHandlers(mainStore?: any): void {
ipcMain.handle('lsp:setCustomBinDir', (_, customPath: string | null) => {
setCustomLspBinDir(customPath)
// 保存到配置文件
if (_mainStore) {
if (_preferencesStore) {
if (customPath) {
_mainStore.set('lspSettings.customBinDir', customPath)
_preferencesStore.set('lspSettings.customBinDir', customPath)
} else {
_mainStore.delete('lspSettings.customBinDir')
_preferencesStore.delete('lspSettings.customBinDir')
}
}
return { success: true }
+13 -21
View File
@@ -20,9 +20,9 @@ interface SecurityModuleRef {
let securityRef: SecurityModuleRef | null = null
export function registerSettingsHandlers(
mainStore: Store,
resolveStore: (key: string) => Store,
preferencesStore: Store,
_bootstrapStore: Store,
setMainStore: (store: Store) => void,
securityModule?: SecurityModuleRef
) {
// 保存安全模块引用
@@ -31,18 +31,19 @@ export function registerSettingsHandlers(
}
// 获取设置
ipcMain.handle('settings:get', (_, key: string) => mainStore.get(key))
ipcMain.handle('settings:get', (_, key: string) => resolveStore(key).get(key))
// 设置值(自动清理无效字段)
ipcMain.handle('settings:set', (_event, key: string, value: unknown) => {
const store = resolveStore(key)
// 清理配置值,移除不存在的字段
const cleanedValue = cleanConfigValue(key, value)
// electron-store 不允许设置 undefined,需要使用 delete
if (cleanedValue === undefined) {
mainStore.delete(key as any)
store.delete(key as any)
} else {
mainStore.set(key, cleanedValue)
store.set(key, cleanedValue)
}
// 广播给所有窗口
@@ -87,14 +88,14 @@ export function registerSettingsHandlers(
securityRef.updateWhitelist(defaultShellCommands, defaultGitCommands)
}
// 保存到配置
const currentSecuritySettings = mainStore.get('securitySettings', {}) as any
// 保存到配置(安全设置在 preferencesStore 中)
const currentSecuritySettings = preferencesStore.get('securitySettings', {}) as any
const newSecuritySettings = {
...currentSecuritySettings,
allowedShellCommands: defaultShellCommands,
allowedGitSubcommands: defaultGitCommands
}
mainStore.set('securitySettings', newSecuritySettings)
preferencesStore.set('securitySettings', newSecuritySettings)
return { shell: defaultShellCommands, git: defaultGitCommands }
})
@@ -104,22 +105,13 @@ export function registerSettingsHandlers(
return getUserConfigDir()
})
// 设置配置路径
// 设置配置路径(不再支持迁移整个 store,只设置路径)
ipcMain.handle('settings:setConfigPath', async (_, newPath: string) => {
try {
if (!fs.existsSync(newPath)) {
fs.mkdirSync(newPath, { recursive: true })
}
// 保存新路径
setUserConfigDir(newPath)
// 迁移当前配置到新位置
const currentData = mainStore.store
const newStore = new Store({ name: 'config', cwd: newPath })
newStore.store = currentData
setMainStore(newStore)
return true
} catch (err) {
logger.ipc.error('[Settings] Failed to set config path:', err)
@@ -127,9 +119,9 @@ export function registerSettingsHandlers(
}
})
// 恢复工作区 (Legacy fallback, secureFile.ts has a better one)
// 恢复工作区 (Legacy fallback)
ipcMain.handle('workspace:restore:legacy', () => {
return mainStore.get('lastWorkspacePath')
return resolveStore('lastWorkspacePath').get('lastWorkspacePath')
})
// 获取用户数据路径
@@ -142,7 +134,7 @@ export function registerSettingsHandlers(
try {
const path = require('path')
const logPath = path.join(getUserConfigDir(), 'logs', 'main.log')
if (fs.existsSync(logPath)) {
const content = fs.readFileSync(logPath, 'utf-8')
// 返回最后 10000 行或 1MB 的内容
+53 -16
View File
@@ -33,19 +33,54 @@ const WINDOW_CONFIG = {
// Store(延迟初始化)
// ==========================================
let bootstrapStore: Store<Record<string, unknown>>
let mainStore: Store<Record<string, unknown>>
let credentialsStore: Store<Record<string, unknown>>
let preferencesStore: Store<Record<string, unknown>>
let workspaceMetaStore: Store<Record<string, unknown>>
/**
* 辅助函数:根据 key 路由到正确的 store
*
* 路由规则:
* - credentials.* / providerConfigs → credentialsStore
* - workspace.* / lastWorkspacePath / recentWorkspaces / embeddingConfig / indexOptions → workspaceMetaStore
* - 其余 → preferencesStore
*/
function resolveStore(key: string): Store<Record<string, unknown>> {
if (key.startsWith('credentials.') || key === 'providerConfigs') return credentialsStore
if (
key.startsWith('workspace.') ||
key === 'lastWorkspacePath' ||
key === 'lastWorkspaceSession' ||
key === 'recentWorkspaces' ||
key === 'embeddingConfig' ||
key === 'indexOptions'
) return workspaceMetaStore
return preferencesStore
}
async function initStores() {
const fs = await import('fs')
const { default: Store } = await import('electron-store')
bootstrapStore = new Store({ name: 'bootstrap' })
const customConfigPath = bootstrapStore.get('customConfigPath') as string | undefined
const storeOptions: { name: string; cwd?: string } = { name: 'config' }
if (customConfigPath && fs.existsSync(customConfigPath)) {
storeOptions.cwd = customConfigPath
const baseCwd = (customConfigPath && fs.existsSync(customConfigPath)) ? customConfigPath : undefined
const mkOpts = (name: string) => baseCwd ? { name, cwd: baseCwd } : { name }
credentialsStore = new Store(mkOpts('credentials'))
preferencesStore = new Store(mkOpts('preferences'))
workspaceMetaStore = new Store(mkOpts('workspace-meta'))
// 迁移旧 config.json(如果存在)
try {
const { migrateLegacyConfig } = await import('./services/configMigration')
const { getUserConfigDir } = await import('./services/configPath')
const configDir = baseCwd || getUserConfigDir()
migrateLegacyConfig(configDir, credentialsStore, preferencesStore, workspaceMetaStore)
} catch (err) {
logger.system.error('[Main] Config migration error:', err)
}
mainStore = new Store(storeOptions)
}
// ==========================================
@@ -255,20 +290,20 @@ async function initializeModules(firstWin: BrowserWindow) {
securityManager = security.securityManager
// 从配置加载自定义 LSP 安装路径
const customLspPath = mainStore.get('lspSettings.customBinDir') as string | undefined
const customLspPath = preferencesStore.get('lspSettings.customBinDir') as string | undefined
if (customLspPath) {
lspInstaller.setCustomLspBinDir(customLspPath)
}
// 注册窗口控制
windowIpc.registerWindowHandlers(createWindow)
// 注册更新服务
updaterIpc.registerUpdaterHandlers()
updaterService.updateService.initialize(firstWin)
// 配置安全模块
const securityConfig = mainStore.get('securitySettings', {
const securityConfig = preferencesStore.get('securitySettings', {
enablePermissionConfirm: true,
enableAuditLog: true,
strictWorkspaceMode: true,
@@ -286,9 +321,11 @@ async function initializeModules(firstWin: BrowserWindow) {
ipc.registerAllHandlers({
getMainWindow,
createWindow,
mainStore,
resolveStore,
credentialsStore,
preferencesStore,
workspaceMetaStore,
bootstrapStore,
setMainStore: (store: Store<Record<string, unknown>>) => { mainStore = store },
findWindowByWorkspace,
setWindowWorkspace: (id: number, roots: string[]) => windowWorkspaces.set(id, roots),
getWindowWorkspace: (id: number) => windowWorkspaces.get(id) || null,
@@ -325,12 +362,12 @@ async function initializeModules(firstWin: BrowserWindow) {
// 捕获未处理的异常(包括原生模块异常)
process.on('uncaughtException', (error: Error) => {
logger.system.error('[Main] Uncaught Exception:', error)
// 如果是 node-pty 相关的错误,提供更友好的提示
if (error.message?.includes('Napi::Error') || error.message?.includes('node-pty')) {
logger.system.error('[Main] node-pty native module error detected. Please run: npm run rebuild')
}
// 不退出应用,让用户继续使用其他功能
// 只在开发模式下显示错误
if (!app.isPackaged) {
@@ -341,7 +378,7 @@ process.on('uncaughtException', (error: Error) => {
// 捕获未处理的 Promise 拒绝
process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
logger.system.error('[Main] Unhandled Rejection:', reason)
if (!app.isPackaged) {
console.error('Unhandled rejection at:', promise, 'reason:', reason)
}
@@ -356,10 +393,10 @@ app.whenReady().then(async () => {
await initStores()
// 2. 检查是否启用文件日志
const appSettings = mainStore.get('app-settings') as any
const appSettings = preferencesStore.get('app-settings') as any
const enableFileLogging = appSettings?.enableFileLogging ?? false
logger.system.info('[Main] File logging setting loaded:', { enableFileLogging, type: typeof enableFileLogging })
if (enableFileLogging) {
const { getUserConfigDir } = await import('./services/configPath')
const logPath = path.join(getUserConfigDir(), 'logs', 'main.log')
+128
View File
@@ -0,0 +1,128 @@
/**
* 旧 config.json → 新三文件结构迁移
*
* 将单一 config.json 拆分为:
* - credentials.json — API keys、provider 凭证
* - preferences.json — 用户偏好、agent/editor/security 配置
* - workspace-meta.json — 工作区历史、embedding 配置
*
* 迁移完成后删除旧 config.json
*/
import * as fs from 'fs'
import * as path from 'path'
import { logger } from '@shared/utils/Logger'
import type Store from 'electron-store'
// 凭证字段(provider 级别)
const CREDENTIAL_FIELDS = ['apiKey', 'baseUrl', 'timeout', 'headers'] as const
// workspace-meta 顶层 key
const WORKSPACE_META_KEYS = [
'lastWorkspacePath',
'lastWorkspaceSession',
'recentWorkspaces',
'embeddingConfig',
'indexOptions',
] as const
/**
* 检测并执行迁移
*
* @returns true 如果执行了迁移
*/
export function migrateLegacyConfig(
configDir: string,
credentialsStore: Store,
preferencesStore: Store,
workspaceMetaStore: Store,
): boolean {
const legacyPath = path.join(configDir, 'config.json')
if (!fs.existsSync(legacyPath)) {
return false
}
// 如果目标文件已存在,说明已迁移,跳过
const credentialsPath = path.join(configDir, 'credentials.json')
if (fs.existsSync(credentialsPath)) {
// 已迁移但旧文件未删除,直接删
try { fs.unlinkSync(legacyPath) } catch { /* ignore */ }
return false
}
try {
const raw = fs.readFileSync(legacyPath, 'utf-8')
const legacy = JSON.parse(raw) as Record<string, unknown>
// ---- 提取凭证 ----
const credentialsData: Record<string, unknown> = {}
const appSettings = (legacy['app-settings'] || {}) as Record<string, unknown>
const providerConfigs = (appSettings.providerConfigs || {}) as Record<string, Record<string, unknown>>
// 从 providerConfigs 提取凭证(apiKey, baseUrl 等)
const cleanedProviders: Record<string, Record<string, unknown>> = {}
const preferencesProviders: Record<string, Record<string, unknown>> = {}
for (const [id, config] of Object.entries(providerConfigs)) {
const cred: Record<string, unknown> = {}
const pref: Record<string, unknown> = {}
for (const [key, value] of Object.entries(config)) {
if ((CREDENTIAL_FIELDS as readonly string[]).includes(key)) {
cred[key] = value
} else {
pref[key] = value
}
}
if (Object.keys(cred).length > 0) cleanedProviders[id] = cred
if (Object.keys(pref).length > 0) preferencesProviders[id] = pref
}
credentialsData.providerConfigs = cleanedProviders
// ---- 提取偏好 ----
const preferencesData: Record<string, unknown> = {}
// 从 app-settings 提取(排除 providerConfigs
const { providerConfigs: _, ...appSettingsRest } = appSettings
preferencesData['app-settings'] = {
...appSettingsRest,
// 保留非凭证的 provider 配置(customModels, protocol, displayName 等)
providerConfigs: preferencesProviders,
}
// 顶层偏好 key
if (legacy.themeId !== undefined) preferencesData.themeId = legacy.themeId
if (legacy.currentTheme !== undefined) preferencesData.currentTheme = legacy.currentTheme
if (legacy.customThemes !== undefined) preferencesData.customThemes = legacy.customThemes
// editorConfig 和 securitySettings 可能直接在顶层
if (legacy.editorConfig !== undefined) preferencesData.editorConfig = legacy.editorConfig
if (legacy.securitySettings !== undefined) preferencesData.securitySettings = legacy.securitySettings
// LSP
if (legacy.lspSettings !== undefined) preferencesData.lspSettings = legacy.lspSettings
// ---- 提取工作区元数据 ----
const workspaceData: Record<string, unknown> = {}
for (const key of WORKSPACE_META_KEYS) {
if (legacy[key] !== undefined) {
workspaceData[key] = legacy[key]
}
}
// ---- 写入新 store ----
credentialsStore.store = credentialsData
preferencesStore.store = preferencesData
workspaceMetaStore.store = workspaceData
// ---- 删除旧文件 ----
fs.unlinkSync(legacyPath)
logger.system.info('[ConfigMigration] Successfully migrated config.json to split stores')
return true
} catch (error) {
logger.system.error('[ConfigMigration] Migration failed:', error)
return false
}
}
+58 -20
View File
@@ -1,6 +1,11 @@
/**
* MCP OAuth 认证存储
* 持久化存储 OAuth tokens 和客户端信息
*
* 改进:
* - 使用 fs.promises 异步 IO,不阻塞主进程
* - 原子写入(写临时文件 + rename),防止写入中断导致数据损坏
* - 读写锁防止并发 read-modify-write 竞态
*/
import * as fs from 'fs'
@@ -30,6 +35,31 @@ export interface McpAuthEntry {
serverUrl?: string
}
// ============ 原子写入 + 读写锁 ============
/**
* 原子写入:先写临时文件再 rename,防止写入中途断电/崩溃导致数据损坏
*/
async function atomicWrite(filepath: string, data: string): Promise<void> {
const tmpPath = `${filepath}.${process.pid}.tmp`
await fs.promises.writeFile(tmpPath, data, { mode: 0o600 })
await fs.promises.rename(tmpPath, filepath)
}
/**
* 简单的 Promise 链锁,保证串行执行
*/
let writeLock: Promise<void> = Promise.resolve()
function withLock<T>(fn: () => Promise<T>): Promise<T> {
const prev = writeLock
let resolve: () => void
writeLock = new Promise(r => { resolve = r! })
return prev.then(fn).finally(() => resolve())
}
// ============ McpAuthStore ============
export namespace McpAuthStore {
const getFilePath = () => path.join(app.getPath('userData'), 'mcp-auth.json')
@@ -49,8 +79,12 @@ export namespace McpAuthStore {
export async function all(): Promise<Record<string, McpAuthEntry>> {
try {
const filepath = getFilePath()
if (!fs.existsSync(filepath)) return {}
const content = fs.readFileSync(filepath, 'utf-8')
try {
await fs.promises.access(filepath, fs.constants.F_OK)
} catch {
return {}
}
const content = await fs.promises.readFile(filepath, 'utf-8')
return JSON.parse(content)
} catch {
return {}
@@ -58,28 +92,32 @@ export namespace McpAuthStore {
}
export async function set(mcpName: string, entry: McpAuthEntry, serverUrl?: string): Promise<void> {
try {
const filepath = getFilePath()
const data = await all()
if (serverUrl) {
entry.serverUrl = serverUrl
await withLock(async () => {
try {
const filepath = getFilePath()
const data = await all()
if (serverUrl) {
entry.serverUrl = serverUrl
}
data[mcpName] = entry
await atomicWrite(filepath, JSON.stringify(data, null, 2))
} catch (err) {
logger.mcp?.error('[McpAuthStore] Failed to save:', err)
}
data[mcpName] = entry
fs.writeFileSync(filepath, JSON.stringify(data, null, 2), { mode: 0o600 })
} catch (err) {
logger.mcp?.error('[McpAuthStore] Failed to save:', err)
}
})
}
export async function remove(mcpName: string): Promise<void> {
try {
const filepath = getFilePath()
const data = await all()
delete data[mcpName]
fs.writeFileSync(filepath, JSON.stringify(data, null, 2), { mode: 0o600 })
} catch (err) {
logger.mcp?.error('[McpAuthStore] Failed to remove:', err)
}
await withLock(async () => {
try {
const filepath = getFilePath()
const data = await all()
delete data[mcpName]
await atomicWrite(filepath, JSON.stringify(data, null, 2))
} catch (err) {
logger.mcp?.error('[McpAuthStore] Failed to remove:', err)
}
})
}
export async function updateTokens(
+14 -16
View File
@@ -1,8 +1,8 @@
/**
* Agent 数据持久化存储
*
* 使用 adnifyDir 服务将数据存储到 .adnify/sessions.json
* 通过 setSessionsPartialDirty 实现延迟批量写入
* 使用 adnifyDir 服务将数据存储到 .adnify/sessions/ 目录
* 每个线程对应一个独立 JSON 文件,通过 dirty flag 延迟批量写入
*/
import { logger } from '@utils/Logger'
@@ -11,31 +11,29 @@ import { adnifyDir } from '@services/adnifyDirService'
/**
* 自定义 Zustand Storage
* 通过 adnifyDir 服务存储到 .adnify/sessions.json
* 使用 dirty flag 机制,由 adnifyDir 统一调度刷盘
* 通过 adnifyDir 服务存储到 .adnify/sessions/ 目录
*
* getItem: 从 _meta.json + 各线程文件组装完整的 store 数据
* setItem: 拆分到线程级文件,只标记变化的线程为 dirty
* removeItem: 清除所有 session 数据
*/
export const agentStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
const sessions = await adnifyDir.getSessions()
if (sessions[name]) {
return JSON.stringify(sessions[name])
}
return null
getItem: async (_name: string): Promise<string | null> => {
const data = await adnifyDir.getFullSessionData()
if (!data) return null
return JSON.stringify(data)
},
setItem: async (name: string, value: string): Promise<void> => {
try {
const parsed = JSON.parse(value)
// 使用 dirty flag 机制,延迟写入
adnifyDir.setSessionsPartialDirty(name, parsed)
adnifyDir.setFullSessionDataDirty(name, parsed)
} catch (error) {
logger.agent.error('[AgentStorage] Failed to parse:', error)
}
},
removeItem: async (name: string): Promise<void> => {
const sessions = await adnifyDir.getSessions()
delete sessions[name]
await adnifyDir.saveSessions(sessions)
removeItem: async (_name: string): Promise<void> => {
await adnifyDir.clearAllSessions()
},
}
+97 -49
View File
@@ -95,58 +95,111 @@ const ToolCallCard = memo(function ToolCallCard({
}
}, [isRunning, isStreaming])
// 获取简短描述
const description = useMemo(() => {
// 获取动态状态文本 (替换原有的重复文件名逻辑)
const statusText = useMemo(() => {
const name = toolCall.name
// 终端命令
const status = toolCall.status
const isRunning = status === 'running' || status === 'pending' || isStreaming
const isSuccess = status === 'success'
const isError = status === 'error'
const formatPath = (p: string | unknown) => p ? getFileName(p as string) : ''
// 终端
if (name === 'run_command') {
return args.command as string
const cmd = args.command as string
if (!cmd) return isRunning ? 'Preparing cmd...' : ''
if (isRunning) return `Executing ${cmd}`
if (isSuccess) return `Executed ${cmd}`
if (isError) return `Command failed: ${cmd}`
return cmd
}
// 文件路径类工具
if ([
'read_file', 'write_file', 'create_file', 'edit_file',
'create_file_or_folder', 'delete_file_or_folder',
'replace_file_content', 'get_lint_errors',
'find_references', 'go_to_definition', 'get_hover_info', 'get_document_symbols'
].includes(name)) {
const path = args.path as string
return path ? getFileName(path) : ''
}
// 多文件读取
// 读取多文件
if (name === 'read_multiple_files') {
const paths = args.paths as string[]
return paths?.length ? `${paths.length} files` : ''
const count = (args.paths as string[])?.length || 0
if (isRunning) return `Reading ${count} files...`
if (isSuccess) return `Read ${count} files`
if (isError) return `Failed to read files`
return `Reading files`
}
// 目录类
if (name === 'list_directory' || name === 'get_dir_tree') {
const path = args.path as string
return path ? getFileName(path) || '.' : '.'
// 读取单文件或目录
if (['read_file', 'list_directory', 'get_dir_tree'].includes(name)) {
const target = formatPath(args.path)
if (!target) return isRunning ? 'Reading...' : ''
if (isRunning) return `Reading ${target}...`
if (isSuccess) return `Read ${target}`
if (isError) return `Failed to read ${target}`
return `Reading ${target}`
}
// 搜索类
if (name === 'search_files') {
const pattern = (args.pattern || args.query) as string
return pattern ? `"${pattern}"` : ''
// 写入/创建
if (['write_file', 'create_file', 'create_file_or_folder'].includes(name)) {
const target = formatPath(args.path)
if (!target) return isRunning ? 'Creating...' : ''
if (isRunning) return `Creating ${target}...`
if (isSuccess) return `Created ${target}`
if (isError) return `Failed to create ${target}`
return `Creating ${target}`
}
if (name === 'codebase_search' || name === 'web_search' || name === 'uiux_search') {
const query = args.query as string
return query ? `"${query}"` : ''
// 编辑
if (['edit_file', 'replace_file_content'].includes(name)) {
const target = formatPath(args.path)
if (!target) return isRunning ? 'Editing...' : ''
if (isRunning) return `Editing ${target}...`
if (isSuccess) return `Updated ${target}`
if (isError) return `Failed to edit ${target}`
return `Editing ${target}`
}
// 删除
if (name === 'delete_file_or_folder') {
const target = formatPath(args.path)
if (!target) return isRunning ? 'Deleting...' : ''
if (isRunning) return `Deleting ${target}...`
if (isSuccess) return `Deleted ${target}`
if (isError) return `Failed to delete ${target}`
return `Deleting ${target}`
}
// 搜索
if (['search_files', 'codebase_search', 'web_search', 'uiux_search'].includes(name)) {
const query = (args.pattern || args.query) as string
const qStr = query ? `"${query}"` : ''
if (!qStr) return isRunning ? 'Searching...' : ''
if (isRunning) return `Searching ${qStr}...`
if (isSuccess) return `Searched ${qStr}`
if (isError) return `Search failed`
return `Searching ${qStr}`
}
// URL
if (name === 'read_url') {
const url = args.url as string
return url ? new URL(url).hostname : ''
}
// Plan
if (name === 'ask_user') {
const question = args.question as string
return question ? question.slice(0, 30) + (question.length > 30 ? '...' : '') : ''
let hostname = ''
if (url) { try { hostname = new URL(url).hostname } catch { hostname = url } }
if (!hostname) return isRunning ? 'Reading URL...' : ''
if (isRunning) return `Reading ${hostname}...`
if (isSuccess) return `Read ${hostname}`
if (isError) return `Failed to read ${hostname}`
return `Reading ${hostname}`
}
// Return null/empty string by default
return ''
}, [toolCall.name, args])
// LSP类
if (['get_lint_errors', 'find_references', 'go_to_definition', 'get_hover_info', 'get_document_symbols'].includes(name)) {
const target = formatPath(args.path)
if (!target) return isRunning ? 'Analyzing...' : ''
if (isRunning) return `Analyzing ${target}...`
if (isSuccess) return `Analyzed ${target}`
if (isError) return `Analysis failed`
return `Analyzing ${target}`
}
const isMissingDescription = !description && (isStreaming || isRunning)
// 默认 fallback
return isRunning ? 'Processing...' : ''
}, [toolCall.name, toolCall.status, args, isStreaming])
const handleCopyResult = () => {
if (toolCall.result) {
@@ -448,11 +501,11 @@ const ToolCallCard = memo(function ToolCallCard({
return (
<div className={`group my-1 rounded-xl border overflow-hidden ${cardStyle} animate-slide-in-right relative`}>
{/* Animated Dashed Border for running state */}
{/* Sweeping Light Effect for running state */}
{(isStreaming || isRunning) && (
<div className="absolute inset-0 pointer-events-none rounded-xl border border-dashed border-accent/40 animate-[spin_10s_linear_infinite]"
style={{ WebkitMaskImage: 'linear-gradient(to bottom right, black, transparent)', opacity: 0.5 }}
/>
<div className="absolute inset-0 pointer-events-none rounded-xl overflow-hidden">
<div className="absolute inset-0 w-[200%] h-full bg-gradient-to-r from-transparent via-accent/20 to-transparent animate-shimmer" />
</div>
)}
{/* Header */}
@@ -485,15 +538,10 @@ const ToolCallCard = memo(function ToolCallCard({
>
{TOOL_LABELS[toolCall.name] || toolCall.name}
</span>
{description ? (
{statusText ? (
<>
<span className="text-text-muted/30">|</span>
<span className="text-xs truncate font-mono text-text-muted">{description}</span>
</>
) : isMissingDescription ? (
<>
<span className="text-text-muted/30">|</span>
<span className="text-xs truncate font-mono text-text-muted italic opacity-70">editing...</span>
<span className="text-xs truncate text-text-muted">{statusText}</span>
</>
) : (isStreaming || isRunning) && (
<span className="text-xs text-text-muted/50 italic animate-pulse ml-2">Processing...</span>
+34 -1
View File
@@ -1,10 +1,16 @@
/**
* 模式状态管理
*
* 通过 electron-store (preferencesStore) 持久化,
* 与其他设置统一存储后端,通过 IPC 调用 settings:get/set
*/
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { persist, createJSONStorage } from 'zustand/middleware'
import { WorkMode } from './types'
import { api } from '@/renderer/services/electronAPI'
const STORE_KEY = 'modeStore'
interface ModeState {
/** 当前工作模式 */
@@ -24,6 +30,32 @@ interface ModeActions {
type ModeStore = ModeState & ModeActions
/**
* 自定义 Storage:通过 IPC 存到 electron-store 的 preferencesStore
* 统一与其他设置的存储后端,避免使用 localStorage
*/
const electronStoreStorage = {
getItem: async (name: string): Promise<string | null> => {
try {
const value = await api.settings.get(`${STORE_KEY}.${name}`)
return value ? JSON.stringify(value) : null
} catch {
return null
}
},
setItem: async (name: string, value: string): Promise<void> => {
try {
const parsed = JSON.parse(value)
await api.settings.set(`${STORE_KEY}.${name}`, parsed)
} catch { /* ignore */ }
},
removeItem: async (name: string): Promise<void> => {
try {
await api.settings.set(`${STORE_KEY}.${name}`, undefined)
} catch { /* ignore */ }
},
}
export const useModeStore = create<ModeStore>()(
persist(
(set, get) => ({
@@ -54,6 +86,7 @@ export const useModeStore = create<ModeStore>()(
}),
{
name: 'adnify-mode-store',
storage: createJSONStorage(() => electronStoreStorage),
partialize: (state) => ({
currentMode: state.currentMode
})
+255 -60
View File
@@ -4,7 +4,9 @@
* 所有项目级数据都存储在 .adnify 目录下:
* .adnify/
* ├── index/ # 代码库向量索引
* ├── sessions.json # Agent 会话历史(包含检查点
* ├── sessions/ # Agent 会话(按线程拆分
* │ ├── _meta.json # 线程元数据(currentThreadId, threadIds
* │ └── {threadId}.json # 单个线程数据
* ├── settings.json # 项目级设置
* ├── workspace-state.json # 工作区状态(打开的文件等)
* └── rules.md # 项目 AI 规则
@@ -19,7 +21,7 @@ export const ADNIFY_DIR_NAME = '.adnify'
// 子目录和文件
export const ADNIFY_FILES = {
INDEX_DIR: 'index',
SESSIONS: 'sessions.json',
SESSIONS_DIR: 'sessions',
SETTINGS: 'settings.json',
WORKSPACE_STATE: 'workspace-state.json',
RULES: 'rules.md',
@@ -29,18 +31,13 @@ type AdnifyFile = typeof ADNIFY_FILES[keyof typeof ADNIFY_FILES]
// ============ 数据类型定义 ============
/** Agent 会话数据 */
export interface SessionsData {
/** zustand store 数据 */
'adnify-agent-store'?: {
state: {
threads: Record<string, unknown>
currentThreadId: string | null
}
version: number
}
/** 其他会话相关数据 */
[key: string]: unknown
/** 线程元数据 */
export interface SessionMeta {
currentThreadId: string | null
threadIds: string[]
/** 非线程数据(branches, messageCheckpoints 等) */
extra: Record<string, unknown>
version: number
}
/** 工作区状态 */
@@ -101,6 +98,13 @@ const DEFAULT_PROJECT_SETTINGS: ProjectSettingsData = {
},
}
const DEFAULT_SESSION_META: SessionMeta = {
currentThreadId: null,
threadIds: [],
extra: {},
version: 0,
}
// ============ 服务实现 ============
class AdnifyDirService {
@@ -110,22 +114,26 @@ class AdnifyDirService {
// 内存缓存
private cache: {
sessions: SessionsData | null
sessionMeta: SessionMeta | null
threads: Map<string, unknown>
workspaceState: WorkspaceStateData | null
settings: ProjectSettingsData | null
} = {
sessions: null,
sessionMeta: null,
threads: new Map(),
workspaceState: null,
settings: null,
}
// 脏标记
private dirty: {
sessions: boolean
sessionMeta: boolean
dirtyThreads: Set<string>
workspaceState: boolean
settings: boolean
} = {
sessions: false,
sessionMeta: false,
dirtyThreads: new Set(),
workspaceState: false,
settings: false,
}
@@ -142,7 +150,6 @@ class AdnifyDirService {
try {
const adnifyPath = `${rootPath}/${ADNIFY_DIR_NAME}`
const exists = await api.file.exists(adnifyPath)
if (!exists) {
await api.file.ensureDir(adnifyPath)
}
@@ -154,6 +161,13 @@ class AdnifyDirService {
await api.file.ensureDir(indexPath)
}
// 创建 sessions 子目录
const sessionsPath = `${adnifyPath}/${ADNIFY_FILES.SESSIONS_DIR}`
const sessionsExists = await api.file.exists(sessionsPath)
if (!sessionsExists) {
await api.file.ensureDir(sessionsPath)
}
this.initializedRoots.add(rootPath)
logger.system.info('[AdnifyDir] Root initialized:', rootPath)
return true
@@ -176,6 +190,7 @@ class AdnifyDirService {
this.primaryRoot = rootPath
await this.initialize(rootPath)
await this.migrateOldSessions()
await this.loadAllData()
this.initialized = true
logger.system.info('[AdnifyDir] Primary root set:', rootPath)
@@ -185,8 +200,8 @@ class AdnifyDirService {
this.primaryRoot = null
this.initializedRoots.clear()
this.initialized = false
this.cache = { sessions: null, workspaceState: null, settings: null }
this.dirty = { sessions: false, workspaceState: false, settings: false }
this.cache = { sessionMeta: null, threads: new Map(), workspaceState: null, settings: null }
this.dirty = { sessionMeta: false, dirtyThreads: new Set(), workspaceState: false, settings: false }
logger.system.info('[AdnifyDir] Reset')
}
@@ -201,11 +216,21 @@ class AdnifyDirService {
const promises: Promise<void>[] = []
if (this.dirty.sessions && this.cache.sessions) {
promises.push(this.writeJsonFile(ADNIFY_FILES.SESSIONS, this.cache.sessions))
this.dirty.sessions = false
// 刷新 session meta
if (this.dirty.sessionMeta && this.cache.sessionMeta) {
promises.push(this.writeSessionFile('_meta.json', this.cache.sessionMeta))
this.dirty.sessionMeta = false
}
// 刷新 dirty 线程(只写变化的)
for (const threadId of this.dirty.dirtyThreads) {
const data = this.cache.threads.get(threadId)
if (data) {
promises.push(this.writeSessionFile(`${threadId}.json`, data))
}
}
this.dirty.dirtyThreads.clear()
if (this.dirty.workspaceState && this.cache.workspaceState) {
promises.push(this.writeJsonFile(ADNIFY_FILES.WORKSPACE_STATE, this.cache.workspaceState))
this.dirty.workspaceState = false
@@ -253,50 +278,149 @@ class AdnifyDirService {
return `${this.getDirPath(rootPath)}/${file}`
}
// ============ 数据操作 (基于 Primary Root) ============
// ============ Session 操作(线程级别) ============
async getSessions(): Promise<SessionsData> {
if (this.cache.sessions) return this.cache.sessions
if (!this.isInitialized()) return {}
const data = await this.readJsonFile<SessionsData>(ADNIFY_FILES.SESSIONS)
this.cache.sessions = data || {}
return this.cache.sessions
async getSessionMeta(): Promise<SessionMeta> {
if (this.cache.sessionMeta) return this.cache.sessionMeta
if (!this.isInitialized()) return { ...DEFAULT_SESSION_META }
const data = await this.readSessionFile<SessionMeta>('_meta.json')
this.cache.sessionMeta = data || { ...DEFAULT_SESSION_META }
return this.cache.sessionMeta
}
/**
* 保存 sessions(立即写入,用于关键操作)
*/
async saveSessions(data: SessionsData): Promise<void> {
this.cache.sessions = data
this.dirty.sessions = true
if (this.isInitialized()) {
await this.writeJsonFile(ADNIFY_FILES.SESSIONS, data)
this.dirty.sessions = false
async getThreadData(threadId: string): Promise<unknown | null> {
if (this.cache.threads.has(threadId)) return this.cache.threads.get(threadId)!
if (!this.isInitialized()) return null
const data = await this.readSessionFile<unknown>(`${threadId}.json`)
if (data) {
this.cache.threads.set(threadId, data)
}
return data
}
/**
* 更新 sessions 部分数据(立即写入,用于关键操作
* 设置线程数据为脏(延迟写入
* 这是 agentStorage 调用的主入口
*/
async updateSessionsPartial(key: string, value: unknown): Promise<void> {
const sessions = await this.getSessions()
sessions[key] = value
await this.saveSessions(sessions)
}
/**
* 设置 sessions 部分数据为脏(延迟写入,用于频繁更新)
* 这是推荐的高频更新方法
*/
setSessionsPartialDirty(key: string, value: unknown): void {
if (!this.cache.sessions) {
this.cache.sessions = {}
}
this.cache.sessions[key] = value
this.dirty.sessions = true
setThreadDirty(threadId: string, data: unknown): void {
this.cache.threads.set(threadId, data)
this.dirty.dirtyThreads.add(threadId)
this.scheduleFlush()
}
/**
* 设置 session meta 为脏(延迟写入)
*/
setSessionMetaDirty(meta: SessionMeta): void {
this.cache.sessionMeta = meta
this.dirty.sessionMeta = true
this.scheduleFlush()
}
/**
* 删除线程数据文件
*/
async deleteThreadData(threadId: string): Promise<void> {
this.cache.threads.delete(threadId)
this.dirty.dirtyThreads.delete(threadId)
// 更新 meta
const meta = await this.getSessionMeta()
meta.threadIds = meta.threadIds.filter(id => id !== threadId)
this.setSessionMetaDirty(meta)
// 删除文件
if (this.isInitialized()) {
try {
const filePath = `${this.getDirPath()}/${ADNIFY_FILES.SESSIONS_DIR}/${threadId}.json`
await api.file.delete(filePath)
} catch { /* ignore */ }
}
}
/**
* 清除所有 session 数据
*/
async clearAllSessions(): Promise<void> {
const meta = await this.getSessionMeta()
for (const threadId of meta.threadIds) {
this.cache.threads.delete(threadId)
try {
const filePath = `${this.getDirPath()}/${ADNIFY_FILES.SESSIONS_DIR}/${threadId}.json`
await api.file.delete(filePath)
} catch { /* ignore */ }
}
this.cache.sessionMeta = { ...DEFAULT_SESSION_META }
this.dirty.sessionMeta = true
this.dirty.dirtyThreads.clear()
await this.writeSessionFile('_meta.json', this.cache.sessionMeta)
}
/**
* 兼容方法:供 agentStorage 使用
* 从线程级文件构建完整的 store persist 数据
*/
async getFullSessionData(): Promise<Record<string, unknown> | null> {
const meta = await this.getSessionMeta()
if (meta.threadIds.length === 0 && !meta.currentThreadId) return null
const threads: Record<string, unknown> = {}
for (const threadId of meta.threadIds) {
const data = await this.getThreadData(threadId)
if (data) threads[threadId] = data
}
return {
state: {
threads,
currentThreadId: meta.currentThreadId,
...meta.extra,
},
version: meta.version,
}
}
/**
* 兼容方法:供 agentStorage 使用
* 将完整 store persist 数据拆分到线程级文件
*/
setFullSessionDataDirty(_storeKey: string, parsed: Record<string, unknown>): void {
const state = parsed.state as Record<string, unknown> | undefined
if (!state) return
const threads = (state.threads || {}) as Record<string, unknown>
const currentThreadId = state.currentThreadId as string | null
const { threads: _, currentThreadId: __, ...extra } = state
// 更新 meta
const threadIds = Object.keys(threads)
const meta: SessionMeta = {
currentThreadId,
threadIds,
extra,
version: (parsed.version as number) || 0,
}
this.setSessionMetaDirty(meta)
// 标记变化的线程为 dirty
for (const [threadId, data] of Object.entries(threads)) {
const cached = this.cache.threads.get(threadId)
// 只在数据有变化时才标记(简单引用比较)
if (cached !== data) {
this.setThreadDirty(threadId, data)
}
}
// 清理已删除的线程缓存
for (const cachedId of this.cache.threads.keys()) {
if (!threads[cachedId]) {
this.cache.threads.delete(cachedId)
}
}
}
// ============ workspace / settings 操作 ============
async getWorkspaceState(): Promise<WorkspaceStateData> {
if (this.cache.workspaceState) return this.cache.workspaceState
if (!this.isInitialized()) return { ...DEFAULT_WORKSPACE_STATE }
@@ -364,18 +488,89 @@ class AdnifyDirService {
// ============ 内部方法 ============
/**
* 旧 sessions.json → 新 sessions/ 目录迁移
*/
private async migrateOldSessions(): Promise<void> {
if (!this.primaryRoot) return
const oldPath = `${this.getDirPath()}/sessions.json`
try {
const exists = await api.file.exists(oldPath)
if (!exists) return
const content = await api.file.read(oldPath)
if (!content) return
const oldData = JSON.parse(content)
// 旧格式:{ 'adnify-agent-store': { state: { threads, currentThreadId }, version } }
const storeData = oldData['adnify-agent-store']
if (!storeData?.state?.threads) {
// 无有效数据,删除旧文件
await api.file.delete(oldPath)
return
}
const { threads, currentThreadId, ...extra } = storeData.state as Record<string, unknown>
const threadsMap = threads as Record<string, unknown>
const threadIds = Object.keys(threadsMap)
// 写入各线程文件
for (const [threadId, data] of Object.entries(threadsMap)) {
await this.writeSessionFile(`${threadId}.json`, data)
}
// 写入 meta
const meta: SessionMeta = {
currentThreadId: currentThreadId as string | null,
threadIds,
extra,
version: storeData.version || 0,
}
await this.writeSessionFile('_meta.json', meta)
// 删除旧文件
await api.file.delete(oldPath)
logger.system.info(`[AdnifyDir] Migrated sessions.json → sessions/ (${threadIds.length} threads)`)
} catch (error) {
logger.system.error('[AdnifyDir] Sessions migration failed:', error)
}
}
private async loadAllData(): Promise<void> {
const [sessions, workspaceState, settings] = await Promise.all([
this.readJsonFile<SessionsData>(ADNIFY_FILES.SESSIONS),
const [sessionMeta, workspaceState, settings] = await Promise.all([
this.readSessionFile<SessionMeta>('_meta.json'),
this.readJsonFile<WorkspaceStateData>(ADNIFY_FILES.WORKSPACE_STATE),
this.readJsonFile<ProjectSettingsData>(ADNIFY_FILES.SETTINGS),
])
this.cache.sessions = sessions || {}
this.cache.sessionMeta = sessionMeta || { ...DEFAULT_SESSION_META }
this.cache.workspaceState = workspaceState || { ...DEFAULT_WORKSPACE_STATE }
this.cache.settings = settings ? { ...DEFAULT_PROJECT_SETTINGS, ...settings } : { ...DEFAULT_PROJECT_SETTINGS }
logger.system.info('[AdnifyDir] Loaded all data from disk')
}
private async readSessionFile<T>(fileName: string): Promise<T | null> {
try {
const filePath = `${this.getDirPath()}/${ADNIFY_FILES.SESSIONS_DIR}/${fileName}`
const content = await api.file.read(filePath)
if (!content) return null
return JSON.parse(content) as T
} catch {
return null
}
}
private async writeSessionFile<T>(fileName: string, data: T): Promise<void> {
try {
const filePath = `${this.getDirPath()}/${ADNIFY_FILES.SESSIONS_DIR}/${fileName}`
const content = JSON.stringify(data, null, 2)
await api.file.write(filePath, content)
} catch (error) {
logger.system.error(`[AdnifyDir] Failed to write session file ${fileName}:`, error)
}
}
private async readJsonFile<T>(file: AdnifyFile): Promise<T | null> {
try {
const content = await api.file.read(this.getFilePath(file))
+5
View File
@@ -69,6 +69,7 @@ export default {
'scale-in': 'scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'pulse-glow': 'pulseGlow 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'shimmer': 'shimmer 2s linear infinite',
},
keyframes: {
fadeIn: {
@@ -98,6 +99,10 @@ export default {
pulseGlow: {
'0%, 100%': { opacity: '1', boxShadow: '0 0 0px rgb(var(--accent) / 0)' },
'50%': { opacity: '0.9', boxShadow: '0 0 15px rgb(var(--accent) / 0.3)' },
},
shimmer: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(50%)' },
}
},
backdropBlur: {