feat: 升级至 v0.3.15 — MiMo 多模态 + 工作空间数据库继承 + 引导窗口上下文配置 + TS 错误修复
- feat(mimo): 适配器支持图片输入,将 images 转为 OpenAI 兼容 content parts 数组 - feat(workspace): 切换工作空间时支持继承数据库 agent.db(SQLite backup API 原子导出,自动 checkpoint WAL) - feat(onboarding): 引导弹窗 LLM 配置加上下文长度字段(DeepSeek/Agnes=1000000、MiMo=131072、Ollama=null) - fix(types): 修复 16 个 TypeScript 编译错误(setConfigLoaded 接口声明、10 个 IPC 返回类型加 error 字段、MUI TextField readOnly slot 迁移) - chore(workspace): 移除死代码 traces 目录(trace 数据实际存储于数据库 sessions.metadata 字段,traces 目录从未被任何代码读写) - docs(mimo): API 文档补充多模态输入章节和 curl/Python 代码示例 - chore: 版本号 0.3.14 → 0.3.15
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 生产级通用 AI Agent 智能体桌面应用
|
> 生产级通用 AI Agent 智能体桌面应用
|
||||||
|
|
||||||
[](./package.json)
|
[](./package.json)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://www.electronjs.org/)
|
[](https://www.electronjs.org/)
|
||||||
[](https://react.dev/)
|
[](https://react.dev/)
|
||||||
|
|||||||
@@ -132,6 +132,7 @@
|
|||||||
<a href="#tools">🔧 工具调用 (Function Calling)</a>
|
<a href="#tools">🔧 工具调用 (Function Calling)</a>
|
||||||
<a href="#websearch">🌐 联网搜索</a>
|
<a href="#websearch">🌐 联网搜索</a>
|
||||||
<a href="#tts">🎙️ 语音合成 (TTS)</a>
|
<a href="#tts">🎙️ 语音合成 (TTS)</a>
|
||||||
|
<a href="#multimodal">🖼️ 多模态输入</a>
|
||||||
<a href="#examples">💻 代码示例</a>
|
<a href="#examples">💻 代码示例</a>
|
||||||
<a href="#errors">⚠️ 错误码说明</a>
|
<a href="#errors">⚠️ 错误码说明</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,6 +332,56 @@ Content-Type: application/json</pre>
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== MULTIMODAL ===== -->
|
||||||
|
<section id="multimodal">
|
||||||
|
<h2>🖼️ 多模态输入</h2>
|
||||||
|
<p>当 <code>messages[].content</code> 为数组时,可混合传入文本、图像内容。采用 OpenAI 兼容的 content parts 数组格式。</p>
|
||||||
|
|
||||||
|
<h3>content parts 字段</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th width="200">字段名</th><th width="90">类型</th><th>描述</th></tr>
|
||||||
|
<tr><td><code>content</code></td><td><span class="type-tag">string | array</span></td><td>纯文本字符串,或 content parts 数组</td></tr>
|
||||||
|
<tr><td><code>content[].type</code></td><td><span class="type-tag">string</span></td><td>part 类型:<code>"text"</code> / <code>"image_url"</code></td></tr>
|
||||||
|
<tr><td><code>content[].text</code></td><td><span class="type-tag">string</span></td><td>文本内容(type 为 <code>"text"</code> 时必填)</td></tr>
|
||||||
|
<tr><td><code>content[].image_url</code></td><td><span class="type-tag">object</span></td><td>图像对象(type 为 <code>"image_url"</code> 时必填)</td></tr>
|
||||||
|
<tr><td><code>content[].image_url.url</code></td><td><span class="type-tag">string</span></td><td>图像公网 URL 或 base64 data URI(格式:<code>data:image/png;base64,...</code>)</td></tr>
|
||||||
|
<tr><td><code>content[].image_url.detail</code></td><td><span class="type-tag">string</span></td><td>可选,清晰度:<code>"low"</code> / <code>"high"</code> / <code>"auto"</code>(默认 <code>"auto"</code>)</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>📌 支持的图像格式:</strong>
|
||||||
|
<ul style="padding-left:20px;margin:8px 0 0;">
|
||||||
|
<li><strong>URL</strong>:公网可访问的 HTTPS 图片地址</li>
|
||||||
|
<li><strong>Base64 data URI</strong>:<code>data:image/{format};base64,{base64_data}</code>,支持 png / jpeg / gif / webp</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>多模态消息示例</h3>
|
||||||
|
<pre><span class="code-lang">json</span>{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "请描述这张图片的内容" },
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": "https://example.com/image.jpg",
|
||||||
|
"detail": "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}</pre>
|
||||||
|
|
||||||
|
<div class="warn-box">
|
||||||
|
<strong>⚠️ 多模态限制:</strong>
|
||||||
|
<ul style="padding-left:20px;margin:8px 0 0;">
|
||||||
|
<li>仅 <code>mimo-v2.5-pro</code> / <code>mimo-v2.5</code> 文本模型支持图像输入</li>
|
||||||
|
<li>TTS 系列模型(<code>mimo-v2.5-tts*</code>)不支持多模态图像输入</li>
|
||||||
|
<li>历史消息中的图片 content 数组应原样保留,不建议在多轮对话中删除</li>
|
||||||
|
<li>base64 data URI 会显著增加请求体大小,建议对大图先压缩或使用公网 URL</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ===== RESPONSE ===== -->
|
<!-- ===== RESPONSE ===== -->
|
||||||
<section id="response">
|
<section id="response">
|
||||||
<h2>📤 响应对象(非流式输出)</h2>
|
<h2>📤 响应对象(非流式输出)</h2>
|
||||||
@@ -746,6 +797,100 @@ audio_bytes = base64.b64decode(audio_data)
|
|||||||
with output("output.wav", "wb") as f:
|
with output("output.wav", "wb") as f:
|
||||||
f.write(audio_bytes)
|
f.write(audio_bytes)
|
||||||
print("音频已保存到 output.wav")</pre>
|
print("音频已保存到 output.wav")</pre>
|
||||||
|
|
||||||
|
<h3>多模态图像输入</h3>
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" onclick="switchTab(event,'tab-mm-curl')">curl</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab(event,'tab-mm-python')">Python (OpenAI SDK)</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab(event,'tab-mm-base64')">Python (Base64 本地图片)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-mm-curl" class="tab-content active">
|
||||||
|
<pre><span class="code-lang">bash</span>curl --location --request POST 'https://api.xiaomimimo.com/v1/chat/completions' \
|
||||||
|
--header "api-key: $MIMO_API_KEY" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data-raw '{
|
||||||
|
"model": "mimo-v2.5-pro",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "请描述这张图片的内容"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": "https://example.com/image.jpg",
|
||||||
|
"detail": "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max_completion_tokens": 1024,
|
||||||
|
"thinking": {"type": "disabled"}
|
||||||
|
}'</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-mm-python" class="tab-content">
|
||||||
|
<pre><span class="code-lang">python</span>from openai import OpenAI
|
||||||
|
|
||||||
|
client = OpenAI(api_key="$MIMO_API_KEY", base_url="https://api.xiaomimimo.com/v1")
|
||||||
|
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model="mimo-v2.5-pro",
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "请描述这张图片的内容"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": "https://example.com/image.jpg",
|
||||||
|
"detail": "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_completion_tokens=1024,
|
||||||
|
thinking={"type": "disabled"}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(response.choices[0].message.content)
|
||||||
|
print(f"图像 token: {response.usage.prompt_tokens_details.image_tokens}")</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-mm-base64" class="tab-content">
|
||||||
|
<pre><span class="code-lang">python</span>import base64
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
client = OpenAI(api_key="$MIMO_API_KEY", base_url="https://api.xiaomimimo.com/v1")
|
||||||
|
|
||||||
|
# 读取本地图片并转为 base64 data URI
|
||||||
|
with open("local_image.jpg", "rb") as f:
|
||||||
|
image_data = base64.b64encode(f.read()).decode("utf-8")
|
||||||
|
image_data_uri = f"data:image/jpeg;base64,{image_data}"
|
||||||
|
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model="mimo-v2.5-pro",
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "这张图里有什么?"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": image_data_uri}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_completion_tokens=1024
|
||||||
|
)
|
||||||
|
|
||||||
|
print(response.choices[0].message.content)</pre>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ===== ERRORS ===== -->
|
<!-- ===== ERRORS ===== -->
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export class MimoAdapter extends BaseAdapter {
|
|||||||
* 构建 MiMo 原生请求体
|
* 构建 MiMo 原生请求体
|
||||||
*
|
*
|
||||||
* MiMo 特有参数:
|
* MiMo 特有参数:
|
||||||
|
* - 多模态图片:user 消息的 images[] → OpenAI content 数组 [{type:"text"}, {type:"image_url"}]
|
||||||
* - thinking: { type: "enabled" / "disabled" } — 与 DeepSeek 一致
|
* - thinking: { type: "enabled" / "disabled" } — 与 DeepSeek 一致
|
||||||
* - max_completion_tokens — 非 max_tokens(MiMo 使用新字段名)
|
* - max_completion_tokens — 非 max_tokens(MiMo 使用新字段名)
|
||||||
* - stream_options: { include_usage: true } — 流式返回 usage
|
* - stream_options: { include_usage: true } — 流式返回 usage
|
||||||
@@ -163,6 +164,32 @@ export class MimoAdapter extends BaseAdapter {
|
|||||||
const messages = buildOpenAICompatibleMessages(request);
|
const messages = buildOpenAICompatibleMessages(request);
|
||||||
const tools = buildOpenAICompatibleTools(request.tools);
|
const tools = buildOpenAICompatibleTools(request.tools);
|
||||||
|
|
||||||
|
// === MiMo 多模态:将 images 转为 OpenAI content 数组 ===
|
||||||
|
// buildOpenAICompatibleMessages 不处理图片(各 Provider 自行处理)
|
||||||
|
// MiMo 是 OpenAI 兼容 API,多模态格式与 Agnes AI 一致
|
||||||
|
const nonSystemMsgs = request.messages.filter((m) => m.role !== 'system');
|
||||||
|
let imageCount = 0;
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
// messages[0] 是 system,非 system 消息从 messages[1] 开始
|
||||||
|
if (i === 0) continue;
|
||||||
|
const origMsg = nonSystemMsgs[i - 1];
|
||||||
|
if (!origMsg?.images?.length) continue;
|
||||||
|
|
||||||
|
imageCount += origMsg.images.length;
|
||||||
|
|
||||||
|
const contentParts: Array<Record<string, unknown>> = [];
|
||||||
|
if (origMsg.content) {
|
||||||
|
contentParts.push({ type: 'text', text: origMsg.content });
|
||||||
|
}
|
||||||
|
for (const img of origMsg.images) {
|
||||||
|
contentParts.push({
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: img.url },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
messages[i].content = contentParts;
|
||||||
|
}
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
model: this.config.defaultModel,
|
model: this.config.defaultModel,
|
||||||
messages,
|
messages,
|
||||||
|
|||||||
+60
-11
@@ -1132,25 +1132,74 @@ export function registerAllIPCHandlers(
|
|||||||
mkdirSync(resolvedTarget, { recursive: true });
|
mkdirSync(resolvedTarget, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 继承白名单:键为前端传入的标识,值为实际相对路径分段
|
||||||
|
// - SOUL.md: 工作空间根目录的 Agent 身份定义
|
||||||
|
// - .metona/agent.db: 工作空间内的 SQLite 数据库(会话/消息/记忆/Trace)
|
||||||
|
// 白名单映射防止路径遍历攻击(不直接使用用户传入的路径拼接到 fs 调用)
|
||||||
|
const INHERIT_WHITELIST: Record<string, string[]> = {
|
||||||
|
'SOUL.md': ['SOUL.md'],
|
||||||
|
'.metona/agent.db': ['.metona', 'agent.db'],
|
||||||
|
};
|
||||||
|
|
||||||
const inherited: string[] = [];
|
const inherited: string[] = [];
|
||||||
const failed: Array<{ file: string; error: string }> = [];
|
const failed: Array<{ file: string; error: string }> = [];
|
||||||
|
|
||||||
for (const fileName of files) {
|
for (const fileName of files) {
|
||||||
// 仅允许继承白名单文件(防止路径遍历)— v0.3.14: 仅 SOUL.md
|
const pathSegments = INHERIT_WHITELIST[fileName];
|
||||||
if (!['SOUL.md'].includes(fileName)) {
|
// 不在白名单中:拒绝(防止路径遍历)
|
||||||
|
if (!pathSegments) {
|
||||||
failed.push({ file: fileName, error: '不在继承白名单中' });
|
failed.push({ file: fileName, error: '不在继承白名单中' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const srcFile = join(resolvedSource, fileName);
|
const srcFile = join(resolvedSource, ...pathSegments);
|
||||||
const dstFile = join(resolvedTarget, fileName);
|
const dstFile = join(resolvedTarget, ...pathSegments);
|
||||||
try {
|
try {
|
||||||
if (existsSync(srcFile)) {
|
// 确保目标文件的父目录存在(如 .metona/)
|
||||||
copyFileSync(srcFile, dstFile);
|
const dstDir = join(resolvedTarget, ...pathSegments.slice(0, -1));
|
||||||
inherited.push(fileName);
|
if (!existsSync(dstDir)) {
|
||||||
log.info(`[WORKSPACE] Inherited ${fileName}: ${resolvedSource} → ${resolvedTarget}`);
|
mkdirSync(dstDir, { recursive: true });
|
||||||
} else {
|
|
||||||
failed.push({ file: fileName, error: '源文件不存在' });
|
|
||||||
}
|
}
|
||||||
|
if (!existsSync(srcFile)) {
|
||||||
|
failed.push({ file: fileName, error: '源文件不存在' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 数据库文件特殊处理 ===
|
||||||
|
// agent.db 启用了 WAL 模式(database.service.ts:53),直接 copyFileSync 存在两个问题:
|
||||||
|
// 1. WAL 中未 checkpoint 的事务会丢失(最近会话/消息可能没了)
|
||||||
|
// 2. 主进程可能仍持有 db 句柄,Windows 上文件锁定会导致复制失败或不一致
|
||||||
|
// 解决方案:用 better-sqlite3 的 backup API(自带 checkpoint + 一致性快照)
|
||||||
|
if (fileName === '.metona/agent.db') {
|
||||||
|
try {
|
||||||
|
const Database = (await import('better-sqlite3')).default;
|
||||||
|
// 以只读方式打开源库(如果主进程已持有句柄,better-sqlite3 会共享锁)
|
||||||
|
const srcDb = new Database(srcFile, { readonly: true, fileMustExist: true });
|
||||||
|
try {
|
||||||
|
// backup API 原子性导出完整快照,自动合并 WAL 内容
|
||||||
|
srcDb.backup(dstFile);
|
||||||
|
inherited.push(fileName);
|
||||||
|
log.info(`[WORKSPACE] Inherited ${fileName} (via SQLite backup): ${resolvedSource} → ${resolvedTarget}`);
|
||||||
|
} finally {
|
||||||
|
srcDb.close();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// backup 失败时回退到 copyFileSync(至少保证基本可用)
|
||||||
|
log.warn(`[WORKSPACE] SQLite backup failed, fallback to copyFileSync: ${(err as Error).message}`);
|
||||||
|
try {
|
||||||
|
copyFileSync(srcFile, dstFile);
|
||||||
|
inherited.push(fileName);
|
||||||
|
log.info(`[WORKSPACE] Inherited ${fileName} (fallback copyFileSync): ${resolvedSource} → ${resolvedTarget}`);
|
||||||
|
} catch (err2) {
|
||||||
|
failed.push({ file: fileName, error: `backup 和 fallback 均失败: ${(err2 as Error).message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 普通文件直接复制 ===
|
||||||
|
copyFileSync(srcFile, dstFile);
|
||||||
|
inherited.push(fileName);
|
||||||
|
log.info(`[WORKSPACE] Inherited ${fileName}: ${resolvedSource} → ${resolvedTarget}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failed.push({ file: fileName, error: (err as Error).message });
|
failed.push({ file: fileName, error: (err as Error).message });
|
||||||
}
|
}
|
||||||
@@ -1166,7 +1215,7 @@ export function registerAllIPCHandlers(
|
|||||||
const files = workspaceService.reload(); // 同步外部可能的手动修改
|
const files = workspaceService.reload(); // 同步外部可能的手动修改
|
||||||
|
|
||||||
const REQUIRED = ['SOUL.md', 'MEMORY.md'] as const;
|
const REQUIRED = ['SOUL.md', 'MEMORY.md'] as const;
|
||||||
const AUTO_DIRS = ['logs', 'traces', '.metona'] as const;
|
const AUTO_DIRS = ['logs', '.metona'] as const;
|
||||||
|
|
||||||
const fileInfos = REQUIRED.map((name) => {
|
const fileInfos = REQUIRED.map((name) => {
|
||||||
const filePath = join(workspacePath, name);
|
const filePath = join(workspacePath, name);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export interface WorkspaceInfo {
|
|||||||
|
|
||||||
const REQUIRED_FILES = ['SOUL.md', 'MEMORY.md'] as const;
|
const REQUIRED_FILES = ['SOUL.md', 'MEMORY.md'] as const;
|
||||||
|
|
||||||
const AUTO_CREATED_DIRS = ['logs', 'traces', '.metona'] as const;
|
const AUTO_CREATED_DIRS = ['logs', '.metona'] as const;
|
||||||
|
|
||||||
const MEMORY_TEMPLATE = `# MEMORY.md — AI 持久记忆
|
const MEMORY_TEMPLATE = `# MEMORY.md — AI 持久记忆
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export class WorkspaceService {
|
|||||||
// 创建工作空间目录
|
// 创建工作空间目录
|
||||||
this.ensureDirectory(this.workspacePath);
|
this.ensureDirectory(this.workspacePath);
|
||||||
|
|
||||||
// 创建自动目录(logs/, traces/, .metona/)
|
// 创建自动目录(logs/, .metona/)
|
||||||
for (const dir of AUTO_CREATED_DIRS) {
|
for (const dir of AUTO_CREATED_DIRS) {
|
||||||
this.ensureDirectory(join(this.workspacePath, dir));
|
this.ensureDirectory(join(this.workspacePath, dir));
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.14",
|
"version": "0.3.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.14",
|
"version": "0.3.15",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.14",
|
"version": "0.3.15",
|
||||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||||
"main": "dist-electron/main/main.js",
|
"main": "dist-electron/main/main.js",
|
||||||
"author": "Metona Team",
|
"author": "Metona Team",
|
||||||
|
|||||||
@@ -20,9 +20,24 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState('');
|
||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
const [workspacePath, setWorkspacePath] = useState('');
|
const [workspacePath, setWorkspacePath] = useState('');
|
||||||
|
// 上下文窗口(联动 Provider:ollama 可空=由模型决定;其他 provider 默认值见 DEFAULT_CTX)
|
||||||
|
const [contextWindow, setContextWindow] = useState<number | null>(null);
|
||||||
|
|
||||||
if (onboardingCompleted) return null;
|
if (onboardingCompleted) return null;
|
||||||
|
|
||||||
|
// 各 Provider 上下文窗口默认值(与 SettingsModal 保持一致)
|
||||||
|
// ollama 返回 null(由模型决定),其他 provider 返回正整数
|
||||||
|
const DEFAULT_CTX: Record<string, number | null> = {
|
||||||
|
deepseek: 1_000_000,
|
||||||
|
agnes: 1_000_000,
|
||||||
|
mimo: 131_072,
|
||||||
|
ollama: null,
|
||||||
|
};
|
||||||
|
// 上下文窗口校验:ollama 允许空,最小 512;其他 provider 最小 4096
|
||||||
|
const ctxMin = provider === 'ollama' ? 512 : 4096;
|
||||||
|
const ctxError =
|
||||||
|
contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < ctxMin);
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
if (step < STEPS.length - 1) { setStep(step + 1); return; }
|
if (step < STEPS.length - 1) { setStep(step + 1); return; }
|
||||||
try {
|
try {
|
||||||
@@ -39,6 +54,14 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
if (model.trim()) entries.push({ key: 'llm.model', value: model.trim() });
|
if (model.trim()) entries.push({ key: 'llm.model', value: model.trim() });
|
||||||
if (apiKey.trim()) entries.push({ key: 'llm.apiKey', value: apiKey.trim() });
|
if (apiKey.trim()) entries.push({ key: 'llm.apiKey', value: apiKey.trim() });
|
||||||
if (workspacePath.trim()) entries.push({ key: 'workspace.path', value: workspacePath.trim() });
|
if (workspacePath.trim()) entries.push({ key: 'workspace.path', value: workspacePath.trim() });
|
||||||
|
// 上下文窗口:根据 Provider 落库到对应 key
|
||||||
|
// - ollama: ollama.numCtx(允许 null=由模型决定)
|
||||||
|
// - deepseek/agnes/mimo: {provider}.contextWindow(必须有值且 >= 4096)
|
||||||
|
if (provider === 'ollama') {
|
||||||
|
entries.push({ key: 'ollama.numCtx', value: contextWindow });
|
||||||
|
} else if (provider && contextWindow != null && contextWindow >= 4096) {
|
||||||
|
entries.push({ key: `${provider}.contextWindow`, value: contextWindow });
|
||||||
|
}
|
||||||
entries.push({ key: 'onboarding.completed', value: true });
|
entries.push({ key: 'onboarding.completed', value: true });
|
||||||
|
|
||||||
const r = await window.metona.config.setBatch(entries);
|
const r = await window.metona.config.setBatch(entries);
|
||||||
@@ -48,6 +71,10 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
|
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
|
||||||
|
// 同步 contextWindow 到 Agent Store(与 SettingsModal 行为一致)
|
||||||
|
if (contextWindow != null && contextWindow >= ctxMin) {
|
||||||
|
useAgentStore.setState({ contextWindow });
|
||||||
|
}
|
||||||
// P1-6 修复: Onboarding 完成后标记配置已加载
|
// P1-6 修复: Onboarding 完成后标记配置已加载
|
||||||
useAgentStore.getState().setConfigLoaded(true);
|
useAgentStore.getState().setConfigLoaded(true);
|
||||||
}
|
}
|
||||||
@@ -81,7 +108,12 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>选择 Provider 并填写 API 信息。Base URL 和模型名称支持任意输入。</Typography>
|
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>选择 Provider 并填写 API 信息。Base URL 和模型名称支持任意输入。</Typography>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<FormControl size="small"><InputLabel>Provider</InputLabel>
|
<FormControl size="small"><InputLabel>Provider</InputLabel>
|
||||||
<Select value={provider} label="Provider" onChange={(e) => setProvider(e.target.value)}>
|
<Select value={provider} label="Provider" onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setProvider(v);
|
||||||
|
// 联动默认上下文窗口(与 SettingsModal 默认值一致)
|
||||||
|
setContextWindow(DEFAULT_CTX[v] ?? null);
|
||||||
|
}}>
|
||||||
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
<MenuItem value="deepseek">DeepSeek</MenuItem>
|
||||||
<MenuItem value="agnes">Agnes AI</MenuItem>
|
<MenuItem value="agnes">Agnes AI</MenuItem>
|
||||||
<MenuItem value="mimo">MiMo (小米)</MenuItem>
|
<MenuItem value="mimo">MiMo (小米)</MenuItem>
|
||||||
@@ -93,6 +125,20 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
<TextField size="small" label="API Key" type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-...(本地模型可留空)"
|
<TextField size="small" label="API Key" type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-...(本地模型可留空)"
|
||||||
slotProps={{ input: { endAdornment: <InputAdornment position="end"><IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton></InputAdornment> } }}
|
slotProps={{ input: { endAdornment: <InputAdornment position="end"><IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton></InputAdornment> } }}
|
||||||
/>
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label={provider === 'ollama' ? '上下文长度 (num_ctx)' : '上下文窗口 (contextWindow)'}
|
||||||
|
type="number"
|
||||||
|
value={contextWindow ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setContextWindow(v === '' ? null : Number(v));
|
||||||
|
}}
|
||||||
|
placeholder={provider === 'ollama' ? '默认由模型决定(如 2048、4096、128000)' : '如 64000、128000、1000000'}
|
||||||
|
slotProps={{ htmlInput: { min: ctxMin, step: ctxMin } }}
|
||||||
|
error={ctxError}
|
||||||
|
helperText={ctxError ? `最小值为 ${ctxMin}` : (provider === 'ollama' ? ' ' : '用于上下文压缩判断,不传给 API')}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ function WorkspaceSettings() {
|
|||||||
const [checkResult, setCheckResult] = useState<MetonaWorkspaceCheckResult | null>(null);
|
const [checkResult, setCheckResult] = useState<MetonaWorkspaceCheckResult | null>(null);
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
const [inheritSoul, setInheritSoul] = useState(true);
|
const [inheritSoul, setInheritSoul] = useState(true);
|
||||||
|
// 数据库继承:默认勾选(历史会话/消息/记忆/Trace 丢失不可逆,默认带过去更安全)
|
||||||
|
const [inheritDatabase, setInheritDatabase] = useState(true);
|
||||||
const [applying, setApplying] = useState(false);
|
const [applying, setApplying] = useState(false);
|
||||||
const [showRestartDialog, setShowRestartDialog] = useState(false);
|
const [showRestartDialog, setShowRestartDialog] = useState(false);
|
||||||
|
|
||||||
@@ -145,9 +147,12 @@ function WorkspaceSettings() {
|
|||||||
if (!pendingPath || !checkResult?.valid) return;
|
if (!pendingPath || !checkResult?.valid) return;
|
||||||
setApplying(true);
|
setApplying(true);
|
||||||
try {
|
try {
|
||||||
// 如果勾选了继承文件,从旧工作空间复制到新工作空间(v0.3.14: 仅 SOUL.md)
|
// 如果勾选了继承文件,从旧工作空间复制到新工作空间
|
||||||
|
// - SOUL.md: Agent 身份定义
|
||||||
|
// - .metona/agent.db: 数据库(会话/消息/记忆/Trace),后端白名单校验
|
||||||
const filesToInherit: string[] = [];
|
const filesToInherit: string[] = [];
|
||||||
if (inheritSoul) filesToInherit.push('SOUL.md');
|
if (inheritSoul) filesToInherit.push('SOUL.md');
|
||||||
|
if (inheritDatabase) filesToInherit.push('.metona/agent.db');
|
||||||
|
|
||||||
if (filesToInherit.length > 0 && currentPath) {
|
if (filesToInherit.length > 0 && currentPath) {
|
||||||
try {
|
try {
|
||||||
@@ -260,6 +265,18 @@ function WorkspaceSettings() {
|
|||||||
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 && (
|
||||||
|
<>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox size="small" checked={inheritDatabase} onChange={(e) => setInheritDatabase(e.target.checked)} />}
|
||||||
|
label={<Typography variant="caption">数据库 agent.db(会话/消息/记忆/Trace 历史记录)</Typography>}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}>
|
||||||
|
勾选后将复制当前工作空间的全部历史数据到新工作空间(通过 SQLite backup API 原子性导出)
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}>
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}>
|
||||||
MEMORY.md 不继承(记忆与工作空间项目上下文绑定)
|
MEMORY.md 不继承(记忆与工作空间项目上下文绑定)
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -1288,8 +1305,7 @@ function LogsSettings() {
|
|||||||
fullWidth
|
fullWidth
|
||||||
value={logFilePath}
|
value={logFilePath}
|
||||||
placeholder={logPathLoading ? '正在获取路径...' : '路径不可用'}
|
placeholder={logPathLoading ? '正在获取路径...' : '路径不可用'}
|
||||||
readOnly
|
slotProps={{ input: { readOnly: true, sx: { fontSize: 11, fontFamily: 'monospace' } } }}
|
||||||
slotProps={{ input: { sx: { fontSize: 11, fontFamily: 'monospace' } } }}
|
|
||||||
/>
|
/>
|
||||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
*
|
*
|
||||||
* 展示当前工作空间的:
|
* 展示当前工作空间的:
|
||||||
* - 根路径(可在文件管理器中打开)
|
* - 根路径(可在文件管理器中打开)
|
||||||
* - 4 个核心文件(SOUL/AGENTS/MEMORY/USERS):状态、大小、修改时间、内容预览
|
* - 2 个核心文件(SOUL/MEMORY):状态、大小、修改时间、内容预览
|
||||||
* - 自动目录(logs/traces/.metona):存在性和文件数
|
* - 自动目录(logs/.metona):存在性和文件数
|
||||||
*
|
*
|
||||||
* Agent 完成任务后自动刷新(thinking/executing → idle)。
|
* Agent 完成任务后自动刷新(thinking/executing → idle)。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ interface AgentState {
|
|||||||
updateLastAssistantMessage: (delta: string) => void;
|
updateLastAssistantMessage: (delta: string) => void;
|
||||||
setAgentStatus: (status: AgentStatus) => void;
|
setAgentStatus: (status: AgentStatus) => void;
|
||||||
setStreaming: (streaming: boolean) => void;
|
setStreaming: (streaming: boolean) => void;
|
||||||
|
// P1-6 修复: 配置加载完成标志的 setter
|
||||||
|
setConfigLoaded: (loaded: boolean) => void;
|
||||||
setCurrentRunId: (runId: string | null) => void;
|
setCurrentRunId: (runId: string | null) => void;
|
||||||
addTraceStep: (step: TraceStep) => void;
|
addTraceStep: (step: TraceStep) => void;
|
||||||
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
|
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
|
||||||
|
|||||||
Vendored
+10
-10
@@ -85,8 +85,8 @@ interface MetonaSessionInfo {
|
|||||||
interface MetonaSessionsAPI {
|
interface MetonaSessionsAPI {
|
||||||
list: () => Promise<MetonaSessionInfo[]>;
|
list: () => Promise<MetonaSessionInfo[]>;
|
||||||
create: (title?: string) => Promise<MetonaSessionInfo>;
|
create: (title?: string) => Promise<MetonaSessionInfo>;
|
||||||
rename: (sessionId: string, title: string) => Promise<{ success: boolean }>;
|
rename: (sessionId: string, title: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
delete: (sessionId: string) => Promise<{ success: boolean }>;
|
delete: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
getMessages: (sessionId: string) => Promise<Array<{
|
getMessages: (sessionId: string) => Promise<Array<{
|
||||||
id: string;
|
id: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -98,8 +98,8 @@ interface MetonaSessionsAPI {
|
|||||||
iteration?: number;
|
iteration?: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}>>;
|
}>>;
|
||||||
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean }>;
|
pin: (sessionId: string, pinned: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||||
archive: (sessionId: string, archived: boolean) => Promise<{ success: boolean }>;
|
archive: (sessionId: string, archived: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||||
deleteMessage: (messageId: string) => Promise<{ success: boolean }>;
|
deleteMessage: (messageId: string) => Promise<{ success: boolean }>;
|
||||||
clearMessages: (sessionId: string) => Promise<{ success: boolean }>;
|
clearMessages: (sessionId: string) => Promise<{ success: boolean }>;
|
||||||
saveTrace: (sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }) => Promise<{ success: boolean }>;
|
saveTrace: (sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }) => Promise<{ success: boolean }>;
|
||||||
@@ -126,9 +126,9 @@ interface MetonaMCPServerStatus {
|
|||||||
|
|
||||||
interface MetonaMCPAPI {
|
interface MetonaMCPAPI {
|
||||||
listServers: () => Promise<MetonaMCPServerStatus[]>;
|
listServers: () => Promise<MetonaMCPServerStatus[]>;
|
||||||
addServer: (config: MetonaMCPServerConfig) => Promise<{ success: boolean }>;
|
addServer: (config: MetonaMCPServerConfig) => Promise<{ success: boolean; error?: string }>;
|
||||||
removeServer: (name: string) => Promise<{ success: boolean }>;
|
removeServer: (name: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
toggleServer: (name: string, enabled: boolean) => Promise<{ success: boolean }>;
|
toggleServer: (name: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Memory API =====
|
// ===== Memory API =====
|
||||||
@@ -161,9 +161,9 @@ interface MetonaMemoryAPI {
|
|||||||
|
|
||||||
interface MetonaConfigAPI {
|
interface MetonaConfigAPI {
|
||||||
get: (key: string) => Promise<unknown>;
|
get: (key: string) => Promise<unknown>;
|
||||||
set: (key: string, value: unknown) => Promise<{ success: boolean }>;
|
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 }>;
|
setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean; error?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== App API =====
|
// ===== App API =====
|
||||||
@@ -247,7 +247,7 @@ interface MetonaToolInfo {
|
|||||||
|
|
||||||
interface MetonaToolsAPI {
|
interface MetonaToolsAPI {
|
||||||
list: () => Promise<MetonaToolInfo[]>;
|
list: () => Promise<MetonaToolInfo[]>;
|
||||||
toggle: (toolName: string, enabled: boolean) => Promise<{ success: boolean }>;
|
toggle: (toolName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== SearXNG API =====
|
// ===== SearXNG API =====
|
||||||
|
|||||||
Reference in New Issue
Block a user