feat: v0.3.17 修复多项问题(sandbox/全局配置层/继承选项/配置刷新/附件提示/content_filter 错误处理)
- fix: sandbox: true → false(ESM preload 不兼容 sandbox 导致 window.metona 不可用,选择文件夹按钮无反应) - feat: 全局配置层(GlobalConfigService)— LLM/Agent 等配置跨工作空间共享,切换工作空间后不再丢失 - 读取时工作空间 DB 空值或默认值回退到全局 JSON - 写入时双写(DB + 全局 JSON) - 首次启动迁移旧配置到全局层 - fix: 工作空间继承选项显示条件放宽 + push 前校验避免覆盖目标已有文件(SOUL.md/agent.db) - fix: config:changed IPC 广播 + 前端监听,修改 Agent 配置后详情栏 maxIterations 实时刷新 - feat: 上传图片/文件时在 System Prompt 注入附件提示,避免 AI 在工作空间查找用户上传的文件 - feat: content_filter 错误识别(ContentFilterError)+ 友好提示,替代原始 JSON 错误体透传 - fix: JSDoc 注释中 llm.* / agent.* / onboarding.completed 中的 */ 被解析为注释结束符
This commit is contained in:
@@ -18,6 +18,24 @@ import type {
|
|||||||
import { MetonaErrorCode } from '../types';
|
import { MetonaErrorCode } from '../types';
|
||||||
import type { MetonaModelInfo } from '../types/metona-adapter';
|
import type { MetonaModelInfo } from '../types/metona-adapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内容审核错误 — Provider 的安全过滤策略触发的错误
|
||||||
|
*
|
||||||
|
* 当 Provider(如 MiMo)的 API 返回 content_filter 错误码时抛出。
|
||||||
|
* Engine 识别此错误后映射为 MetonaErrorCode.CONTENT_FILTERED,
|
||||||
|
* 前端显示友好提示而非原始 JSON 错误体。
|
||||||
|
*/
|
||||||
|
export class ContentFilterError extends Error {
|
||||||
|
/** Provider 返回的原始错误消息(如 "The request was rejected because it was considered high risk") */
|
||||||
|
readonly providerMessage: string;
|
||||||
|
|
||||||
|
constructor(providerMessage: string, context: string) {
|
||||||
|
super(`${context}: 内容被 Provider 安全审核拦截 - ${providerMessage}`);
|
||||||
|
this.name = 'ContentFilterError';
|
||||||
|
this.providerMessage = providerMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
||||||
// H-2 修复: provider → providerId(规范要求)
|
// H-2 修复: provider → providerId(规范要求)
|
||||||
abstract readonly providerId: string;
|
abstract readonly providerId: string;
|
||||||
@@ -157,10 +175,35 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
|||||||
/**
|
/**
|
||||||
* P1-4 修复: 构造带 HTTP status 属性的 Error 并抛出
|
* P1-4 修复: 构造带 HTTP status 属性的 Error 并抛出
|
||||||
* engine.isRetryableError 依赖 error.status 判断是否可重试(429/5xx)
|
* engine.isRetryableError 依赖 error.status 判断是否可重试(429/5xx)
|
||||||
|
*
|
||||||
|
* v0.3.17: 解析 Provider 返回的 JSON 错误体,识别 content_filter 错误码,
|
||||||
|
* 抛出 ContentFilterError 让 Engine 映射为 CONTENT_FILTERED 错误码。
|
||||||
*/
|
*/
|
||||||
protected async throwHttpError(response: Response, context: string): Promise<never> {
|
protected async throwHttpError(response: Response, context: string): Promise<never> {
|
||||||
let errorBody = '';
|
let errorBody = '';
|
||||||
try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ }
|
try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ }
|
||||||
|
|
||||||
|
// v0.3.17: 解析 JSON 错误体,识别 content_filter
|
||||||
|
if (errorBody) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(errorBody);
|
||||||
|
// 兼容 OpenAI 格式 {error: {code, message}} 和 MiMo/其他格式
|
||||||
|
const errObj = parsed?.error ?? parsed;
|
||||||
|
const errorCode = errObj?.code ?? errObj?.type ?? '';
|
||||||
|
const errorMessage = errObj?.message ?? '';
|
||||||
|
|
||||||
|
if (typeof errorCode === 'string' && errorCode.toLowerCase().includes('content_filter')) {
|
||||||
|
const providerMsg = errorMessage || '内容触发安全过滤策略';
|
||||||
|
const cfError = new ContentFilterError(providerMsg, context);
|
||||||
|
(cfError as ContentFilterError & { status: number }).status = response.status;
|
||||||
|
throw cfError;
|
||||||
|
}
|
||||||
|
} catch (parseErr) {
|
||||||
|
// JSON 解析失败(非 JSON 响应体),走原逻辑
|
||||||
|
if (parseErr instanceof ContentFilterError) throw parseErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
|
const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
|
||||||
(error as Error & { status: number }).status = response.status;
|
(error as Error & { status: number }).status = response.status;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -171,6 +214,16 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
|
|||||||
*/
|
*/
|
||||||
protected mapError(error: unknown): MetonaError {
|
protected mapError(error: unknown): MetonaError {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
// v0.3.17: 优先识别 ContentFilterError
|
||||||
|
if (error instanceof ContentFilterError) {
|
||||||
|
return {
|
||||||
|
code: MetonaErrorCode.CONTENT_FILTERED,
|
||||||
|
message: '内容被 Provider 安全审核拦截,请修改图片或文本后重试',
|
||||||
|
provider: this.providerId,
|
||||||
|
retryable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const msg = error.message.toLowerCase();
|
const msg = error.message.toLowerCase();
|
||||||
|
|
||||||
if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) {
|
if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
import { MetonaStreamEventType, MetonaFinishReason, MetonaErrorCode } from '../types';
|
import { MetonaStreamEventType, MetonaFinishReason, MetonaErrorCode } from '../types';
|
||||||
import { estimateMessagesTokens } from '../utils/token-estimator';
|
import { estimateMessagesTokens } from '../utils/token-estimator';
|
||||||
|
import { ContentFilterError } from '../adapters/base-adapter';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1149,6 +1150,8 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
// v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见
|
// v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见
|
||||||
// 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw
|
// 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw
|
||||||
if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) {
|
if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) {
|
||||||
|
// v0.3.17: 识别 ContentFilterError,映射为 CONTENT_FILTERED 错误码 + 友好消息
|
||||||
|
const isContentFilter = error instanceof ContentFilterError;
|
||||||
this.emit('streamEvent', {
|
this.emit('streamEvent', {
|
||||||
type: MetonaStreamEventType.ERROR,
|
type: MetonaStreamEventType.ERROR,
|
||||||
requestId: this.currentRequestId,
|
requestId: this.currentRequestId,
|
||||||
@@ -1158,8 +1161,10 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
runId: this.runId,
|
runId: this.runId,
|
||||||
error: {
|
error: {
|
||||||
code: MetonaErrorCode.UNKNOWN,
|
code: isContentFilter ? MetonaErrorCode.CONTENT_FILTERED : MetonaErrorCode.UNKNOWN,
|
||||||
message: error.message,
|
message: isContentFilter
|
||||||
|
? '内容被 Provider 安全审核拦截,请修改图片或文本后重试'
|
||||||
|
: error.message,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -163,6 +163,29 @@ export function registerAllIPCHandlers(
|
|||||||
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
|
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 附件提示注入:用户直接上传的文件/图片,避免 LLM 误以为需要在工作空间查找
|
||||||
|
// 解决场景:用户上传图片后,AI 先在工作空间找图片,找不到才意识到是用户上传的
|
||||||
|
const attachments = (userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> }).attachments;
|
||||||
|
if (Array.isArray(attachments) && attachments.length > 0) {
|
||||||
|
const attachmentList = attachments.map((att, i) => {
|
||||||
|
const typeLabel = att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
|
||||||
|
const note = att.type === 'image'
|
||||||
|
? 'visible via vision capability, do NOT search in workspace'
|
||||||
|
: att.type === 'text'
|
||||||
|
? 'content already inlined in the user message, do NOT search in workspace'
|
||||||
|
: 'uploaded directly by user, do NOT search in workspace';
|
||||||
|
return `${i + 1}. [${typeLabel}] ${att.name} — ${note}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const attachmentBlock = `## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}`;
|
||||||
|
|
||||||
|
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
|
||||||
|
? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}`
|
||||||
|
: attachmentBlock;
|
||||||
|
|
||||||
|
log.debug(`[AGENT] Injected ${attachments.length} attachment hints into system prompt`);
|
||||||
|
}
|
||||||
|
|
||||||
// 监听 Agent Loop 事件
|
// 监听 Agent Loop 事件
|
||||||
// F8: 主进程 text_delta 节流合并
|
// F8: 主进程 text_delta 节流合并
|
||||||
// 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒,
|
// 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒,
|
||||||
@@ -817,6 +840,11 @@ export function registerAllIPCHandlers(
|
|||||||
log.info(`[CONFIG] Log level updated to ${level}`);
|
log.info(`[CONFIG] Log level updated to ${level}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 广播配置变更事件,前端监听后更新 store(如 maxIterations 显示)
|
||||||
|
if (!mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('config:changed', { key, value });
|
||||||
|
}
|
||||||
|
|
||||||
// 工作空间路径变更时写入独立文件(下次启动生效)
|
// 工作空间路径变更时写入独立文件(下次启动生效)
|
||||||
if (key === 'workspace.path') {
|
if (key === 'workspace.path') {
|
||||||
try {
|
try {
|
||||||
@@ -978,6 +1006,13 @@ export function registerAllIPCHandlers(
|
|||||||
log.info(`[CONFIG] Log level updated to ${logLevelValue}`);
|
log.info(`[CONFIG] Log level updated to ${logLevelValue}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 第四步半:广播所有配置变更事件,前端监听后更新 store
|
||||||
|
if (!mainWindow.isDestroyed()) {
|
||||||
|
for (const { key, value } of entries) {
|
||||||
|
mainWindow.webContents.send('config:changed', { key, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 第五步:工作空间路径写入独立文件(下次启动生效)
|
// 第五步:工作空间路径写入独立文件(下次启动生效)
|
||||||
if (workspacePathValue) {
|
if (workspacePathValue) {
|
||||||
try {
|
try {
|
||||||
@@ -1061,7 +1096,7 @@ export function registerAllIPCHandlers(
|
|||||||
|
|
||||||
// ===== 工作空间校验与继承 =====
|
// ===== 工作空间校验与继承 =====
|
||||||
|
|
||||||
// 校验工作空间路径:检测路径有效性 + 4 个必需文件状态
|
// 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在
|
||||||
ipcMain.handle('workspace:check', async (_event, targetPath: string) => {
|
ipcMain.handle('workspace:check', async (_event, targetPath: string) => {
|
||||||
if (!targetPath || typeof targetPath !== 'string') {
|
if (!targetPath || typeof targetPath !== 'string') {
|
||||||
return { valid: false, reason: '路径不能为空' };
|
return { valid: false, reason: '路径不能为空' };
|
||||||
@@ -1080,6 +1115,7 @@ export function registerAllIPCHandlers(
|
|||||||
exists: false,
|
exists: false,
|
||||||
missingFiles: ['SOUL.md', 'MEMORY.md'],
|
missingFiles: ['SOUL.md', 'MEMORY.md'],
|
||||||
isNewWorkspace: true,
|
isNewWorkspace: true,
|
||||||
|
dbExists: false,
|
||||||
reason: '目录不存在,将在切换后自动创建',
|
reason: '目录不存在,将在切换后自动创建',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1101,12 +1137,16 @@ export function registerAllIPCHandlers(
|
|||||||
if (!existsSync(join(resolvedPath, f))) missingFiles.push(f);
|
if (!existsSync(join(resolvedPath, f))) missingFiles.push(f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验 4: 检测 .metona/agent.db 是否存在(用于判断是否显示"继承数据库"选项)
|
||||||
|
const dbExists = existsSync(join(resolvedPath, '.metona', 'agent.db'));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
path: resolvedPath,
|
path: resolvedPath,
|
||||||
exists: true,
|
exists: true,
|
||||||
missingFiles,
|
missingFiles,
|
||||||
isNewWorkspace: missingFiles.length === requiredFiles.length,
|
isNewWorkspace: missingFiles.length === requiredFiles.length,
|
||||||
|
dbExists,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import log from 'electron-log';
|
|||||||
import { DatabaseService } from './services/database.service';
|
import { DatabaseService } from './services/database.service';
|
||||||
import { SessionService } from './services/session.service';
|
import { SessionService } from './services/session.service';
|
||||||
import { ConfigService } from './services/config.service';
|
import { ConfigService } from './services/config.service';
|
||||||
|
import { GlobalConfigService } from './services/global-config.service';
|
||||||
import { WorkspaceService } from './services/workspace.service';
|
import { WorkspaceService } from './services/workspace.service';
|
||||||
import { AuditService } from './services/audit.service';
|
import { AuditService } from './services/audit.service';
|
||||||
import { SessionRecorder } from './services/session-recorder.service';
|
import { SessionRecorder } from './services/session-recorder.service';
|
||||||
@@ -131,8 +132,33 @@ async function initialize(): Promise<void> {
|
|||||||
databaseService.initialize();
|
databaseService.initialize();
|
||||||
const db = databaseService.getDB();
|
const db = databaseService.getDB();
|
||||||
|
|
||||||
|
// v0.3.17: 初始化全局配置层(跨工作空间共享 LLM/Agent/onboarding 等机器级配置)
|
||||||
|
// 必须在 ConfigService 创建前初始化,以便注入
|
||||||
|
const globalConfigService = new GlobalConfigService();
|
||||||
|
globalConfigService.initialize();
|
||||||
|
|
||||||
const sessionService = new SessionService(() => db);
|
const sessionService = new SessionService(() => db);
|
||||||
const configService = new ConfigService(() => db);
|
const configService = new ConfigService(() => db);
|
||||||
|
// 注入全局配置层:读取时回退到全局 JSON,写入时双写
|
||||||
|
configService.setGlobalConfig(globalConfigService);
|
||||||
|
|
||||||
|
// v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON
|
||||||
|
// 幂等设计:migrateFromWorkspaceDB 仅写入全局层不存在的 key,重复执行无副作用
|
||||||
|
// 解决场景:老用户从 v0.3.16 升级,LLM 配置在工作空间 DB 里,需要迁移到全局层
|
||||||
|
// 注意: 此处直接查 DB 而不通过 configService.getAll(),因为后者会合并全局层,导致迁移逻辑循环
|
||||||
|
const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{ key: string; value: string }>;
|
||||||
|
const workspaceConfig: Record<string, unknown> = {};
|
||||||
|
for (const row of configRows) {
|
||||||
|
try {
|
||||||
|
workspaceConfig[row.key] = JSON.parse(row.value);
|
||||||
|
} catch {
|
||||||
|
workspaceConfig[row.key] = row.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const migratedCount = globalConfigService.migrateFromWorkspaceDB(workspaceConfig);
|
||||||
|
if (migratedCount > 0) {
|
||||||
|
log.info(`[MIGRATION] Migrated ${migratedCount} global config keys from workspace DB to global layer`);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 日志服务 =====
|
// ===== 日志服务 =====
|
||||||
const auditService = new AuditService(() => db);
|
const auditService = new AuditService(() => db);
|
||||||
|
|||||||
@@ -130,6 +130,13 @@ const metonaAPI = {
|
|||||||
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
|
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
|
||||||
setBatch: (entries: Array<{ key: string; value: unknown }>) =>
|
setBatch: (entries: Array<{ key: string; value: unknown }>) =>
|
||||||
ipcRenderer.invoke('config:setBatch', entries),
|
ipcRenderer.invoke('config:setBatch', entries),
|
||||||
|
// v0.3.17: 监听配置变更广播(后端 config:set/setBatch 后触发,用于前端 store 实时更新)
|
||||||
|
onChanged: (callback: (data: { key: string; value: unknown }) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, data: { key: string; value: unknown }) =>
|
||||||
|
callback(data);
|
||||||
|
ipcRenderer.on('config:changed', listener);
|
||||||
|
return () => ipcRenderer.removeListener('config:changed', listener);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ===== 应用工具 =====
|
// ===== 应用工具 =====
|
||||||
|
|||||||
@@ -4,11 +4,17 @@
|
|||||||
* 管理 app_config 表的读写操作。
|
* 管理 app_config 表的读写操作。
|
||||||
* 所有配置值以 JSON 格式存储。
|
* 所有配置值以 JSON 格式存储。
|
||||||
*
|
*
|
||||||
|
* v0.3.17: 引入全局配置层(GlobalConfigService)。
|
||||||
|
* - 全局 key(llm.* / agent.* / onboarding.completed 等)读取时优先工作空间 DB,
|
||||||
|
* miss 时回退到全局 JSON,确保切换工作空间后 LLM 凭据和引导状态不丢失。
|
||||||
|
* - 写入时同时写工作空间 DB(兼容)和全局 JSON(跨工作空间共享)。
|
||||||
|
*
|
||||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 数据库配置
|
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 数据库配置
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
|
import { GlobalConfigService, isGlobalKey, isUnconfiguredGlobalKey } from './global-config.service';
|
||||||
|
|
||||||
export interface ConfigRow {
|
export interface ConfigRow {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -18,26 +24,63 @@ export interface ConfigRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
|
private globalConfig: GlobalConfigService | null = null;
|
||||||
|
|
||||||
constructor(private getDB: () => Database.Database) {}
|
constructor(private getDB: () => Database.Database) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入全局配置服务(可选,由 main.ts 在启动时调用)
|
||||||
|
* 注入后,全局 key 的读取会回退到全局层,写入会双写。
|
||||||
|
*/
|
||||||
|
setGlobalConfig(globalConfig: GlobalConfigService): void {
|
||||||
|
this.globalConfig = globalConfig;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取配置值
|
* 获取配置值
|
||||||
|
* 读取顺序:工作空间 DB → 全局 JSON(仅全局 key)
|
||||||
|
*
|
||||||
|
* 特殊处理:对于"空值视为未配置"的全局 key(如 llm.apiKey=''、onboarding.completed=false),
|
||||||
|
* 当工作空间 DB 的值为空/默认值时,回退到全局层读取真实值。
|
||||||
|
* 这解决了新工作空间 DB 被 seedDefaults 灌入空值导致配置丢失的问题。
|
||||||
*/
|
*/
|
||||||
get<T = unknown>(key: string): T | null {
|
get<T = unknown>(key: string): T | null {
|
||||||
const db = this.getDB();
|
const db = this.getDB();
|
||||||
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined;
|
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key) as ConfigRow | undefined;
|
||||||
|
|
||||||
if (!row) return null;
|
if (row) {
|
||||||
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(row.value) as T;
|
parsed = JSON.parse(row.value);
|
||||||
} catch {
|
} catch {
|
||||||
return row.value as T;
|
parsed = row.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全局 key 的空值回退:若工作空间 DB 存在该 key 但值为"未配置"(如 llm.apiKey=''),
|
||||||
|
// 且全局层有真实值,则返回全局层的值。
|
||||||
|
// 解决场景:新工作空间 DB 被 seedDefaults 灌入 llm.apiKey='' / onboarding.completed=false,
|
||||||
|
// 但用户在全局层已配置过,应回退到全局层。
|
||||||
|
if (this.globalConfig && isGlobalKey(key) && isUnconfiguredGlobalKey(key, parsed)) {
|
||||||
|
const globalValue = this.globalConfig.get<T>(key);
|
||||||
|
if (globalValue !== null && !isUnconfiguredGlobalKey(key, globalValue)) {
|
||||||
|
return globalValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工作空间 DB miss,若为全局 key 则回退到全局层
|
||||||
|
if (this.globalConfig && isGlobalKey(key)) {
|
||||||
|
return this.globalConfig.get<T>(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置配置值(保留已有 category)
|
* 设置配置值(保留已有 category)
|
||||||
|
* 全局 key 同时写入工作空间 DB 和全局 JSON
|
||||||
*/
|
*/
|
||||||
set(key: string, value: unknown): void {
|
set(key: string, value: unknown): void {
|
||||||
const db = this.getDB();
|
const db = this.getDB();
|
||||||
@@ -52,11 +95,17 @@ export class ConfigService {
|
|||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`).run(key, jsonValue, category, Date.now());
|
`).run(key, jsonValue, category, Date.now());
|
||||||
|
|
||||||
|
// 全局 key 同步写入全局层(跨工作空间共享)
|
||||||
|
if (this.globalConfig && isGlobalKey(key)) {
|
||||||
|
this.globalConfig.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
log.debug(`Config set: ${key}`);
|
log.debug(`Config set: ${key}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有配置
|
* 获取所有配置(工作空间 DB + 全局层合并)
|
||||||
|
* 合并规则与 get() 一致:全局 key 在 DB 中为"未配置"时回退到全局层
|
||||||
*/
|
*/
|
||||||
getAll(): Record<string, unknown> {
|
getAll(): Record<string, unknown> {
|
||||||
const db = this.getDB();
|
const db = this.getDB();
|
||||||
@@ -71,6 +120,21 @@ export class ConfigService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全局层合并:与 get() 的回退逻辑保持一致
|
||||||
|
if (this.globalConfig) {
|
||||||
|
const globalAll = this.globalConfig.getAll();
|
||||||
|
for (const [key, globalValue] of Object.entries(globalAll)) {
|
||||||
|
if (!(key in config)) {
|
||||||
|
// DB miss → 用全局层
|
||||||
|
config[key] = globalValue;
|
||||||
|
} else if (isGlobalKey(key) && isUnconfiguredGlobalKey(key, config[key])
|
||||||
|
&& !isUnconfiguredGlobalKey(key, globalValue)) {
|
||||||
|
// DB 有值但为"未配置"(seedDefaults 默认值/空值),全局层有真实值 → 用全局层
|
||||||
|
config[key] = globalValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* Global Config Service — 全局配置层(跨工作空间共享)
|
||||||
|
*
|
||||||
|
* 设计原因:
|
||||||
|
* 原实现中所有配置存在工作空间 DB({workspacePath}/.metona/agent.db)的 app_config 表里。
|
||||||
|
* 切换到新工作空间时,新 DB 被 seedDefaults 灌入空值,导致 LLM 凭据、onboarding.completed
|
||||||
|
* 等本应"机器级"的配置全部丢失,用户被迫重新配置。
|
||||||
|
*
|
||||||
|
* 方案:
|
||||||
|
* 将"机器级全局配置"抽到 userData/global-config.json,与工作空间解耦。
|
||||||
|
* 工作空间 DB 仍保留会话/消息/记忆/审计等"工作空间级数据"。
|
||||||
|
*
|
||||||
|
* 全局 key 规则:
|
||||||
|
* - llm.* / agent.* / ollama.* / {provider}.contextWindow / security.* / ui.* / logging.*
|
||||||
|
* - onboarding.completed
|
||||||
|
* 写入时同时写工作空间 DB(兼容旧逻辑)和全局 JSON;
|
||||||
|
* 读取时优先工作空间 DB,miss 时回退到全局 JSON。
|
||||||
|
*
|
||||||
|
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 配置存储
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { app } from 'electron';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import log from 'electron-log';
|
||||||
|
|
||||||
|
/** 全局配置文件路径(userData 下,与工作空间无关) */
|
||||||
|
const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json');
|
||||||
|
|
||||||
|
/** 全局配置 key 前缀清单(匹配这些前缀的 key 视为全局配置) */
|
||||||
|
const GLOBAL_KEY_PREFIXES = [
|
||||||
|
'llm.',
|
||||||
|
'agent.',
|
||||||
|
'ollama.',
|
||||||
|
'security.',
|
||||||
|
'ui.',
|
||||||
|
'logging.',
|
||||||
|
'deepseek.',
|
||||||
|
'agnes.',
|
||||||
|
'mimo.',
|
||||||
|
'onboarding.',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 判断 key 是否属于全局配置 */
|
||||||
|
export function isGlobalKey(key: string): boolean {
|
||||||
|
return GLOBAL_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* seedDefaults 灌入的默认值映射表(用于判断"DB 值等于默认值时回退全局层")
|
||||||
|
* 必须与 database.service.ts 的 seedDefaults 保持同步
|
||||||
|
*/
|
||||||
|
export const SEED_DEFAULTS: Record<string, unknown> = {
|
||||||
|
'llm.provider': '',
|
||||||
|
'llm.model': '',
|
||||||
|
'llm.apiKey': '',
|
||||||
|
'llm.baseURL': '',
|
||||||
|
'llm.temperature': 0,
|
||||||
|
'llm.maxTokens': 63488,
|
||||||
|
'llm.fallbackProvider': '',
|
||||||
|
'llm.fallbackModel': '',
|
||||||
|
'agent.maxIterations': 20,
|
||||||
|
'agent.totalTimeoutMs': 600000,
|
||||||
|
'agent.enableThinking': true,
|
||||||
|
'agent.thinkingEffort': 'high',
|
||||||
|
'agent.enableReflection': false,
|
||||||
|
'agent.confirmationTimeoutMs': 120000,
|
||||||
|
'agent.toolExecutionTimeoutMs': 120000,
|
||||||
|
'security.requireWriteConfirmation': true,
|
||||||
|
'security.maxFileWriteSizeKB': 1024,
|
||||||
|
'security.promptInjectionDefense': true,
|
||||||
|
'ui.theme': 'auto',
|
||||||
|
'ui.animationMode': 'auto',
|
||||||
|
'ui.fontSize': 'medium',
|
||||||
|
'logging.level': 'info',
|
||||||
|
'logging.auditEnabled': true,
|
||||||
|
'logging.traceEnabled': true,
|
||||||
|
'ollama.numCtx': null,
|
||||||
|
'deepseek.contextWindow': 1000000,
|
||||||
|
'agnes.contextWindow': 1000000,
|
||||||
|
'mimo.contextWindow': 131072,
|
||||||
|
'onboarding.completed': false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断全局 key 在工作空间 DB 中的值是否为"未配置"(应回退到全局层)
|
||||||
|
*
|
||||||
|
* 判断规则:
|
||||||
|
* - 值为空字符串/null/undefined → 未配置
|
||||||
|
* - 值等于 seedDefaults 默认值 → 未配置(新工作空间 DB 被灌入的默认值)
|
||||||
|
* - 其他 → 已配置(用户主动设置过,不回退)
|
||||||
|
*/
|
||||||
|
export function isUnconfiguredGlobalKey(key: string, value: unknown): boolean {
|
||||||
|
// 空值一律视为未配置
|
||||||
|
if (value === '' || value === null || value === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 值等于 seedDefaults 默认值 → 视为未配置(被 seedDefaults 灌入的)
|
||||||
|
const defaultValue = SEED_DEFAULTS[key];
|
||||||
|
if (defaultValue !== undefined && value === defaultValue) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalConfigData {
|
||||||
|
/** key → value(已 JSON.parse) */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GlobalConfigService {
|
||||||
|
private data: GlobalConfigData = {};
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化:读取全局配置文件
|
||||||
|
* 若文件不存在则创建空文件(首次启动)
|
||||||
|
*/
|
||||||
|
initialize(): void {
|
||||||
|
if (this.initialized) {
|
||||||
|
log.warn('[GlobalConfig] Already initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existsSync(GLOBAL_CONFIG_FILE)) {
|
||||||
|
const raw = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8');
|
||||||
|
this.data = JSON.parse(raw) as GlobalConfigData;
|
||||||
|
log.info(`[GlobalConfig] Loaded ${Object.keys(this.data).length} keys from ${GLOBAL_CONFIG_FILE}`);
|
||||||
|
} else {
|
||||||
|
// 确保父目录存在
|
||||||
|
const dir = join(GLOBAL_CONFIG_FILE, '..');
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
this.data = {};
|
||||||
|
this.flush();
|
||||||
|
log.info(`[GlobalConfig] Created new global config file: ${GLOBAL_CONFIG_FILE}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('[GlobalConfig] Failed to load, starting with empty config:', err);
|
||||||
|
this.data = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取全局配置
|
||||||
|
*/
|
||||||
|
get<T = unknown>(key: string): T | null {
|
||||||
|
if (!this.initialized) {
|
||||||
|
log.warn('[GlobalConfig] get() called before initialize()');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (key in this.data) {
|
||||||
|
return this.data[key] as T;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入全局配置(内存 + 立即落盘)
|
||||||
|
*/
|
||||||
|
set(key: string, value: unknown): void {
|
||||||
|
if (!this.initialized) {
|
||||||
|
log.warn('[GlobalConfig] set() called before initialize()');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.data[key] = value;
|
||||||
|
this.flush();
|
||||||
|
log.debug(`[GlobalConfig] set: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量写入(仅落盘一次,避免多次 IO)
|
||||||
|
*/
|
||||||
|
setBatch(entries: Array<{ key: string; value: unknown }>): void {
|
||||||
|
if (!this.initialized) {
|
||||||
|
log.warn('[GlobalConfig] setBatch() called before initialize()');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const { key, value } of entries) {
|
||||||
|
this.data[key] = value;
|
||||||
|
}
|
||||||
|
this.flush();
|
||||||
|
log.debug(`[GlobalConfig] setBatch: ${entries.length} keys`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有全局配置(用于迁移和调试)
|
||||||
|
*/
|
||||||
|
getAll(): GlobalConfigData {
|
||||||
|
return { ...this.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从工作空间 DB 迁移配置到全局层(首次启用全局配置层时调用)
|
||||||
|
*
|
||||||
|
* 迁移规则:
|
||||||
|
* - 仅迁移 isGlobalKey 匹配的 key
|
||||||
|
* - 仅当全局层该 key 不存在时才写入(不覆盖用户已保存的全局配置)
|
||||||
|
* - 仅迁移"已配置"的值(空值/seedDefaults 默认值不迁移)
|
||||||
|
* - false 不一律跳过:若 seedDefaults 默认值不是 false,则 false 是用户主动设置,应迁移
|
||||||
|
*
|
||||||
|
* @param workspaceConfig 工作空间 DB 的全部 app_config
|
||||||
|
*/
|
||||||
|
migrateFromWorkspaceDB(workspaceConfig: Record<string, unknown>): number {
|
||||||
|
if (!this.initialized) {
|
||||||
|
log.warn('[GlobalConfig] migrateFromWorkspaceDB() called before initialize()');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let migrated = 0;
|
||||||
|
for (const [key, value] of Object.entries(workspaceConfig)) {
|
||||||
|
if (!isGlobalKey(key)) continue;
|
||||||
|
// 全局层已有该 key,不覆盖
|
||||||
|
if (key in this.data) continue;
|
||||||
|
// 空值不迁移(seedDefaults 灌入的空值)
|
||||||
|
if (value === '' || value === null || value === undefined) continue;
|
||||||
|
// 值等于 seedDefaults 默认值不迁移(避免把默认值同步到全局层)
|
||||||
|
// 注意:isUnconfiguredGlobalKey 已包含"值等于默认值"判断,此处复用
|
||||||
|
if (isUnconfiguredGlobalKey(key, value)) continue;
|
||||||
|
|
||||||
|
this.data[key] = value;
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrated > 0) {
|
||||||
|
this.flush();
|
||||||
|
log.info(`[GlobalConfig] Migrated ${migrated} keys from workspace DB to global config`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 落盘到 JSON 文件
|
||||||
|
*/
|
||||||
|
private flush(): void {
|
||||||
|
try {
|
||||||
|
writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(this.data, null, 2), 'utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
log.error('[GlobalConfig] Failed to write config file:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,12 +54,12 @@ export class WindowManager {
|
|||||||
title: options.title ?? 'MetonaAI Desktop',
|
title: options.title ?? 'MetonaAI Desktop',
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/preload.mjs'),
|
preload: join(__dirname, '../preload/preload.mjs'),
|
||||||
// #37 修复: 启用沙箱,强化渲染进程隔离
|
// sandbox: false — preload 使用 ESM 格式(.mjs + import 语法),
|
||||||
// preload.ts 仅使用 contextBridge/ipcRenderer(sandbox 下可用)和
|
// Electron sandbox 不支持 ESM preload(官方 ESM 支持矩阵: Sandboxed = Unsupported)。
|
||||||
// @electron-toolkit/preload(兼容 sandbox),不依赖 fs/child_process 等 Node API,
|
// 若启用 sandbox: true,preload.mjs 的 import 语句无法解析,contextBridge 不执行,
|
||||||
// 因此可安全启用 sandbox: true。启用后渲染进程无法直接访问 Node API,
|
// window.metona 为 undefined,所有 IPC 调用静默失败。
|
||||||
// 缩小恶意网页通过 preload 漏洞访问 fs/child_process 的攻击面。
|
// 要启用 sandbox,需先将 preload 改为 CJS 格式 + require 语法(工程改动较大)。
|
||||||
sandbox: true,
|
sandbox: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+3
-3
@@ -9646,9 +9646,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.7.tgz",
|
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.8.tgz",
|
||||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-markdown": {
|
"node_modules/react-markdown": {
|
||||||
|
|||||||
@@ -148,11 +148,11 @@ function WorkspaceSettings() {
|
|||||||
setApplying(true);
|
setApplying(true);
|
||||||
try {
|
try {
|
||||||
// 如果勾选了继承文件,从旧工作空间复制到新工作空间
|
// 如果勾选了继承文件,从旧工作空间复制到新工作空间
|
||||||
// - SOUL.md: Agent 身份定义
|
// - SOUL.md: Agent 身份定义(仅目标缺少时才继承,避免覆盖已有定义)
|
||||||
// - .metona/agent.db: 数据库(会话/消息/记忆/Trace),后端白名单校验
|
// - .metona/agent.db: 数据库(仅目标不存在时才继承,避免覆盖已有数据)
|
||||||
const filesToInherit: string[] = [];
|
const filesToInherit: string[] = [];
|
||||||
if (inheritSoul) filesToInherit.push('SOUL.md');
|
if (inheritSoul && checkResult?.missingFiles?.includes('SOUL.md')) filesToInherit.push('SOUL.md');
|
||||||
if (inheritDatabase) filesToInherit.push('.metona/agent.db');
|
if (inheritDatabase && !checkResult?.dbExists) filesToInherit.push('.metona/agent.db');
|
||||||
|
|
||||||
// #51 修复: 继承数据库前校验源数据库完整性,避免继承损坏的数据库导致新工作空间数据丢失
|
// #51 修复: 继承数据库前校验源数据库完整性,避免继承损坏的数据库导致新工作空间数据丢失
|
||||||
if (inheritDatabase && currentPath) {
|
if (inheritDatabase && currentPath) {
|
||||||
@@ -277,18 +277,22 @@ function WorkspaceSettings() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 继承选项(仅当当前有工作空间且新工作空间需要创建文件时显示) */}
|
{/* 继承选项(当前有工作空间、目标不是同一目录、且目标有可继承的文件时显示) */}
|
||||||
{currentPath && currentPath !== pendingPath && (checkResult.isNewWorkspace || (checkResult.missingFiles && checkResult.missingFiles.length > 0)) && (
|
{currentPath && currentPath !== pendingPath &&
|
||||||
|
(checkResult.missingFiles?.includes('SOUL.md') || !checkResult.dbExists) && (
|
||||||
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
|
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||||
从当前工作空间继承基础设置:
|
从当前工作空间继承:
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{/* SOUL.md 继承:目标缺少 SOUL.md 时才允许勾选,避免覆盖已有定义 */}
|
||||||
|
{checkResult.missingFiles && checkResult.missingFiles.includes('SOUL.md') && (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Checkbox size="small" checked={inheritSoul} onChange={(e) => setInheritSoul(e.target.checked)} />}
|
control={<Checkbox size="small" checked={inheritSoul} onChange={(e) => setInheritSoul(e.target.checked)} />}
|
||||||
label={<Typography variant="caption">SOUL.md(身份与角色定义)</Typography>}
|
label={<Typography variant="caption">SOUL.md(身份与角色定义)</Typography>}
|
||||||
/>
|
/>
|
||||||
{/* 数据库继承:仅在新工作空间(空目录)时显示,避免覆盖已有工作空间的数据 */}
|
)}
|
||||||
{checkResult.isNewWorkspace && (
|
{/* 数据库继承:目标 .metona/agent.db 不存在时才显示,避免覆盖已有工作空间数据 */}
|
||||||
|
{!checkResult.dbExists && (
|
||||||
<>
|
<>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Checkbox size="small" checked={inheritDatabase} onChange={(e) => setInheritDatabase(e.target.checked)} />}
|
control={<Checkbox size="small" checked={inheritDatabase} onChange={(e) => setInheritDatabase(e.target.checked)} />}
|
||||||
|
|||||||
@@ -400,10 +400,15 @@ export function useAgentStream(): void {
|
|||||||
getStore().setCurrentRunId(null);
|
getStore().setCurrentRunId(null);
|
||||||
getStore().setAgentStatus('error');
|
getStore().setAgentStatus('error');
|
||||||
getStore().updateLastTraceStep({ completedAt: Date.now() });
|
getStore().updateLastTraceStep({ completedAt: Date.now() });
|
||||||
|
// v0.3.17: 对 content_filtered 错误码显示更友好的提示
|
||||||
|
const errorCode = data.error?.code;
|
||||||
|
const errorMessage = data.error?.message ?? '未知错误';
|
||||||
getStore().addMessage({
|
getStore().addMessage({
|
||||||
id: genMsgId('error'),
|
id: genMsgId('error'),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `错误: ${data.error?.message ?? '未知错误'}`,
|
content: errorCode === 'content_filtered'
|
||||||
|
? `⚠️ ${errorMessage}`
|
||||||
|
: `错误: ${errorMessage}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -596,4 +601,23 @@ export function useAgentStream(): void {
|
|||||||
|
|
||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// v0.3.17: 监听配置变更广播,实时更新 store 中的配置字段
|
||||||
|
// 解决场景:用户在 SettingsModal 修改 agent.maxIterations 后,详情栏分母不刷新
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.metona?.config?.onChanged) return;
|
||||||
|
|
||||||
|
const unsubscribe = window.metona.config.onChanged((data: { key: string; value: unknown }) => {
|
||||||
|
const store = useAgentStore.getState();
|
||||||
|
const { key, value } = data;
|
||||||
|
|
||||||
|
// 按需更新 store 中缓存的配置字段
|
||||||
|
if (key === 'agent.maxIterations' && typeof value === 'number') {
|
||||||
|
store.setMaxIterations(value);
|
||||||
|
}
|
||||||
|
// 其他 agent.* 配置项若 store 有对应字段,可在此扩展
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+4
@@ -164,6 +164,8 @@ interface MetonaConfigAPI {
|
|||||||
set: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>;
|
set: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>;
|
||||||
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
|
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
|
||||||
setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean; error?: string }>;
|
setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
// v0.3.17: 监听配置变更广播(后端 config:set/setBatch 后触发,用于前端 store 实时更新)
|
||||||
|
onChanged: (callback: (data: { key: string; value: unknown }) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== App API =====
|
// ===== App API =====
|
||||||
@@ -186,6 +188,8 @@ interface MetonaWorkspaceCheckResult {
|
|||||||
exists?: boolean;
|
exists?: boolean;
|
||||||
missingFiles?: string[];
|
missingFiles?: string[];
|
||||||
isNewWorkspace?: boolean;
|
isNewWorkspace?: boolean;
|
||||||
|
/** 目标目录下 .metona/agent.db 是否存在(用于判断是否显示"继承数据库"选项) */
|
||||||
|
dbExists?: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user