feat: v1.1.0 — 上下文引擎重构 + 技能系统升级 + SOUL.md 支持
- 版本号更新: 1.0.0 → 1.1.0,全量同步 package.json/lock/README/docs/UI - 上下文长度自动检测: 切换模型时从 model_info 读取实际 context_length - SOUL.md 支持: 每次发送自动扫描工作空间 SOUL.md,注入为首条 system 消息且不可压缩 - 系统提示词卡片: AI 回复顶部可折叠展示实际发送给模型的完整 system prompt - Token 校准: 利用 Ollama 返回的实际计数动态修正估算器(EMA) - 消息重要性评分: 纯规则打分,trim/summarize 按重要性而非时间顺序 - 结构化压缩: LLM 压缩输出 JSON 格式(topics/decisions/pendingTasks/constraints) - 技能系统 v1.1: 语义匹配(embedding) + 参数自优化 + 未使用衰减 + 技能链合并 - 修复: 100+ TypeScript strict 模式编译错误清零
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-v1--stable--release-E8734A?style=flat-square" alt="version">
|
<img src="https://img.shields.io/badge/version-v1.1.0-E8734A?style=flat-square" alt="version">
|
||||||
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
|
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
|
||||||
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
|
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
||||||
@@ -212,7 +212,7 @@ SQLite (sql.js WASM),7 张表,WAL 模式 + FTS5 全文搜索:
|
|||||||
```bash
|
```bash
|
||||||
git clone https://gitee.com/thzxx/metona-ollama-desktop.git
|
git clone https://gitee.com/thzxx/metona-ollama-desktop.git
|
||||||
cd metona-ollama-desktop
|
cd metona-ollama-desktop
|
||||||
git checkout desktop-v1-stable-release
|
git checkout desktop-v1.1.0
|
||||||
npm config set registry https://registry.npmmirror.com
|
npm config set registry https://registry.npmmirror.com
|
||||||
npm install
|
npm install
|
||||||
npm start
|
npm start
|
||||||
@@ -225,7 +225,7 @@ npm start
|
|||||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||||
```
|
```
|
||||||
|
|
||||||
产出:`release/Metona Ollama Setup v1-stable-release.exe`
|
产出:`release/Metona Ollama Setup v1.1.0.exe`
|
||||||
|
|
||||||
## 🛠️ 常用命令
|
## 🛠️ 常用命令
|
||||||
|
|
||||||
@@ -433,7 +433,7 @@ SQLite (sql.js WASM), 7 tables, WAL mode + FTS5 full-text search:
|
|||||||
```bash
|
```bash
|
||||||
git clone https://gitee.com/thzxx/metona-ollama-desktop.git
|
git clone https://gitee.com/thzxx/metona-ollama-desktop.git
|
||||||
cd metona-ollama-desktop
|
cd metona-ollama-desktop
|
||||||
git checkout desktop-v1-stable-release
|
git checkout desktop-v1.1.0
|
||||||
npm config set registry https://registry.npmmirror.com
|
npm config set registry https://registry.npmmirror.com
|
||||||
npm install
|
npm install
|
||||||
npm start
|
npm start
|
||||||
@@ -446,7 +446,7 @@ npm start
|
|||||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||||
```
|
```
|
||||||
|
|
||||||
Output: `release/Metona Ollama Setup v1-stable-release.exe`
|
Output: `release/Metona Ollama Setup v1.1.0.exe`
|
||||||
|
|
||||||
## 🛠️ Common Commands
|
## 🛠️ Common Commands
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -14,7 +14,7 @@
|
|||||||
# 1. 克隆并安装依赖
|
# 1. 克隆并安装依赖
|
||||||
git clone https://gitee.com/thzxx/metona-ollama-desktop.git
|
git clone https://gitee.com/thzxx/metona-ollama-desktop.git
|
||||||
cd metona-ollama-desktop
|
cd metona-ollama-desktop
|
||||||
git checkout desktop-v1-stable-release
|
git checkout desktop-v1.1.0
|
||||||
npm config set registry https://registry.npmmirror.com
|
npm config set registry https://registry.npmmirror.com
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ npm run dist # NSIS 安装包
|
|||||||
|
|
||||||
| 文件 | 说明 |
|
| 文件 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `release/Metona Ollama Setup v1-stable-release.exe` | NSIS 安装包(可选目录、创建快捷方式) |
|
| `release/Metona Ollama Setup v1.1.0.exe` | NSIS 安装包(可选目录、创建快捷方式) |
|
||||||
|
|
||||||
> v3.2.0 起不再提供绿色便携版,仅保留 NSIS 安装包。
|
> v3.2.0 起不再提供绿色便携版,仅保留 NSIS 安装包。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Metona Ollama Desktop — 开发规范
|
# Metona Ollama Desktop — 开发规范
|
||||||
|
|
||||||
> 版本: v1-stable-release | 更新: 2026-04-24 | 维护: 项目团队
|
> 版本: v1.1.0 | 更新: 2026-04-24 | 维护: 项目团队
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sql.js": "^1.11.0"
|
"sql.js": "^1.11.0"
|
||||||
@@ -1867,7 +1867,7 @@
|
|||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/assert-plus": {
|
"node_modules/assert-plus": {
|
||||||
"version": "v1-stable-release",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz",
|
||||||
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
|
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1913,7 +1913,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/at-least-node": {
|
"node_modules/at-least-node": {
|
||||||
"version": "v1-stable-release",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/at-least-node/-/at-least-node-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/at-least-node/-/at-least-node-1.0.0.tgz",
|
||||||
"integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
|
"integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
||||||
"main": "dist/main/main.js",
|
"main": "dist/main/main.js",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"requestedExecutionLevel": "asInvoker"
|
"requestedExecutionLevel": "asInvoker"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"artifactName": "Metona Ollama Setup v1-stable-release.${ext}",
|
"artifactName": "Metona Ollama Setup v1.1.0.${ext}",
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
"createDesktopShortcut": true,
|
"createDesktopShortcut": true,
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
# Metona Ollama Desktop v1-stable-release
|
# Metona Ollama Desktop v1.1.0
|
||||||
|
|
||||||
> 本地 Ollama AI 桌面客户端 — 首个稳定版本
|
> 本地 Ollama AI 桌面客户端 — 首个稳定版本
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ Metona Ollama Desktop 是一个基于 **Ollama** 的本地 AI 桌面客户端,
|
|||||||
|
|
||||||
## 安装说明
|
## 安装说明
|
||||||
|
|
||||||
1. 下载 `Metona Ollama Setup v1-stable-release.exe`
|
1. 下载 `Metona Ollama Setup v1.1.0.exe`
|
||||||
2. 运行安装程序,可自定义安装目录
|
2. 运行安装程序,可自定义安装目录
|
||||||
3. 确保本地已安装并运行 [Ollama](https://ollama.com)
|
3. 确保本地已安装并运行 [Ollama](https://ollama.com)
|
||||||
4. 启动 Metona Ollama,在设置中配置 Ollama 服务地址
|
4. 启动 Metona Ollama,在设置中配置 Ollama 服务地址
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ export function createMenu(): void {
|
|||||||
dialog.showMessageBox(mainWindow!, {
|
dialog.showMessageBox(mainWindow!, {
|
||||||
type: 'info',
|
type: 'info',
|
||||||
title: '关于 Metona Ollama',
|
title: '关于 Metona Ollama',
|
||||||
message: 'Metona Ollama Desktop v3.2.2',
|
message: 'Metona Ollama Desktop v1.1.0',
|
||||||
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
|
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
|
||||||
icon: getIconPath()
|
icon: getIconPath()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { state, KEYS } from '../state/state.js';
|
|||||||
import { logError, logSession } from '../services/log-service.js';
|
import { logError, logSession } from '../services/log-service.js';
|
||||||
import { marked } from '../utils/marked-config.js';
|
import { marked } from '../utils/marked-config.js';
|
||||||
import { escapeHtml, formatTime } from '../utils/utils.js';
|
import { escapeHtml, formatTime } from '../utils/utils.js';
|
||||||
|
import { estimateTokens } from '../services/context-manager.js';
|
||||||
import type { ChatSession, ChatMessage, ToolCallRecord } from '../types.js';
|
import type { ChatSession, ChatMessage, ToolCallRecord } from '../types.js';
|
||||||
|
|
||||||
function getFileIcon(filename: string): string {
|
function getFileIcon(filename: string): string {
|
||||||
@@ -30,6 +31,31 @@ let scrollBtnEl: HTMLElement;
|
|||||||
let autoScroll = true;
|
let autoScroll = true;
|
||||||
let currentPlaceholder: HTMLElement | null = null;
|
let currentPlaceholder: HTMLElement | null = null;
|
||||||
|
|
||||||
|
/** 渲染可折叠系统提示词卡片 */
|
||||||
|
let _sysPromptIdCounter = 0;
|
||||||
|
function renderSystemPromptCard(sysPrompt: string): string {
|
||||||
|
const id = `sysprompt_${++_sysPromptIdCounter}`;
|
||||||
|
const tokenEstimate = estimateTokens(sysPrompt);
|
||||||
|
const preview = sysPrompt.length > 120 ? sysPrompt.slice(0, 120).replace(/\n/g, ' ') + '…' : sysPrompt.replace(/\n/g, ' ');
|
||||||
|
return `<div class="system-prompt-card">
|
||||||
|
<div class="system-prompt-header" onclick="
|
||||||
|
var el=document.getElementById('${id}');
|
||||||
|
var chev=this.querySelector('.sp-chevron');
|
||||||
|
el.style.display=el.style.display==='none'?'':'none';
|
||||||
|
chev.classList.toggle('sp-expanded');
|
||||||
|
">
|
||||||
|
<span class="sp-icon">📋</span>
|
||||||
|
<span class="sp-title">系统提示词</span>
|
||||||
|
<span class="sp-tokens">~${tokenEstimate} tokens</span>
|
||||||
|
<span class="sp-preview">${escapeHtml(preview)}</span>
|
||||||
|
<svg class="sp-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="system-prompt-body" id="${id}" style="display:none;">
|
||||||
|
<pre>${escapeHtml(sysPrompt)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function initChatArea(): void {
|
export function initChatArea(): void {
|
||||||
chatAreaEl = document.querySelector('#chatArea')!;
|
chatAreaEl = document.querySelector('#chatArea')!;
|
||||||
messagesContainerEl = document.querySelector('#messagesContainer')!;
|
messagesContainerEl = document.querySelector('#messagesContainer')!;
|
||||||
@@ -118,6 +144,12 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
|
|||||||
const safeContent = (msg.content != null) ? String(msg.content) : '';
|
const safeContent = (msg.content != null) ? String(msg.content) : '';
|
||||||
|
|
||||||
if (msg.role === 'assistant') {
|
if (msg.role === 'assistant') {
|
||||||
|
// ── 系统提示词折叠卡片(每条助理消息顶部展示)──
|
||||||
|
const sysPrompt = state.get<string>('_lastSystemPrompt', '');
|
||||||
|
if (sysPrompt) {
|
||||||
|
contentHtml += renderSystemPromptCard(sysPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.think) {
|
if (msg.think) {
|
||||||
contentHtml += `
|
contentHtml += `
|
||||||
<div class="think-block">
|
<div class="think-block">
|
||||||
@@ -273,6 +305,21 @@ export function updateLastAssistantMessage(
|
|||||||
const loadingText = lastMsg.querySelector('.loading-text');
|
const loadingText = lastMsg.querySelector('.loading-text');
|
||||||
if (loadingDots) loadingDots.remove();
|
if (loadingDots) loadingDots.remove();
|
||||||
if (loadingText) loadingText.remove();
|
if (loadingText) loadingText.remove();
|
||||||
|
|
||||||
|
// 流式消息首次转正:注入系统提示词折叠卡片
|
||||||
|
const sysPrompt = state.get<string>('_lastSystemPrompt', '');
|
||||||
|
if (sysPrompt && !lastMsg.querySelector('.system-prompt-card')) {
|
||||||
|
const msgBody = lastMsg.querySelector('.msg-body');
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = renderSystemPromptCard(sysPrompt);
|
||||||
|
const card = tempDiv.firstElementChild!;
|
||||||
|
const thinkBlock = msgBody?.querySelector('.think-block');
|
||||||
|
if (thinkBlock) {
|
||||||
|
msgBody!.insertBefore(card, thinkBlock);
|
||||||
|
} else {
|
||||||
|
msgBody!.insertBefore(card, msgBody!.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model) {
|
if (model) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder, resetCurrentPlaceholder
|
appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder, resetCurrentPlaceholder
|
||||||
} from './chat-area.js';
|
} from './chat-area.js';
|
||||||
import { showToast } from './toast.js';
|
import { showToast } from './toast.js';
|
||||||
import { addToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal } from './workspace-panel.js';
|
import { addToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath } from './workspace-panel.js';
|
||||||
import { ChatDB } from '../db/chat-db.js';
|
import { ChatDB } from '../db/chat-db.js';
|
||||||
import { OllamaAPI } from '../api/ollama.js';
|
import { OllamaAPI } from '../api/ollama.js';
|
||||||
import { runAgentLoop } from '../services/agent-engine.js';
|
import { runAgentLoop } from '../services/agent-engine.js';
|
||||||
@@ -94,9 +94,7 @@ export function initInputArea(): void {
|
|||||||
const bridge = getBridge();
|
const bridge = getBridge();
|
||||||
if (!bridge) return;
|
if (!bridge) return;
|
||||||
const paths = await bridge.dialog.openFile({
|
const paths = await bridge.dialog.openFile({
|
||||||
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }],
|
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }]
|
||||||
properties: ['openFile'],
|
|
||||||
multiple: false
|
|
||||||
});
|
});
|
||||||
if (!paths || paths.length === 0) return;
|
if (!paths || paths.length === 0) return;
|
||||||
await handleImagePaths(paths);
|
await handleImagePaths(paths);
|
||||||
@@ -141,7 +139,7 @@ async function handleImagePaths(paths: string[]): Promise<void> {
|
|||||||
for (const filePath of paths) {
|
for (const filePath of paths) {
|
||||||
const name = filePath.split(/[/\\]/).pop() || filePath;
|
const name = filePath.split(/[/\\]/).pop() || filePath;
|
||||||
try {
|
try {
|
||||||
const result = await bridge.fs.readFileBase64(filePath);
|
const result = await bridge!.fs.readFileBase64(filePath);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
appendSystemMessage(`❌ 读取 ${name} 失败: ${result.error}`);
|
appendSystemMessage(`❌ 读取 ${name} 失败: ${result.error}`);
|
||||||
continue;
|
continue;
|
||||||
@@ -186,7 +184,7 @@ async function handleTextFilePaths(paths: string[]): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await bridge.fs.readFile(filePath);
|
const result = await bridge!.fs.readFile(filePath);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
appendSystemMessage(`❌ 读取 ${name} 失败: ${result.error}`);
|
appendSystemMessage(`❌ 读取 ${name} 失败: ${result.error}`);
|
||||||
continue;
|
continue;
|
||||||
@@ -308,7 +306,7 @@ async function handleRetry(): Promise<void> {
|
|||||||
...(retryThinkContent && { think: retryThinkContent }),
|
...(retryThinkContent && { think: retryThinkContent }),
|
||||||
...(toolCallRecords?.length && { toolCalls: toolCallRecords }),
|
...(toolCallRecords?.length && { toolCalls: toolCallRecords }),
|
||||||
};
|
};
|
||||||
state.update(KEYS.CURRENT_SESSION, (s: ChatSession | null) => ({
|
state.update(KEYS.CURRENT_SESSION, (s: any) => ({
|
||||||
...s, messages: [...s.messages, prevMsg], updatedAt: Date.now()
|
...s, messages: [...s.messages, prevMsg], updatedAt: Date.now()
|
||||||
}));
|
}));
|
||||||
renderMessages();
|
renderMessages();
|
||||||
@@ -353,7 +351,7 @@ async function handleRetry(): Promise<void> {
|
|||||||
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
||||||
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
||||||
};
|
};
|
||||||
state.update(KEYS.CURRENT_SESSION, (s: ChatSession | null) => ({
|
state.update(KEYS.CURRENT_SESSION, (s: any) => ({
|
||||||
...s, messages: [...s.messages, lastMsg], updatedAt: Date.now()
|
...s, messages: [...s.messages, lastMsg], updatedAt: Date.now()
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -367,7 +365,7 @@ async function handleRetry(): Promise<void> {
|
|||||||
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
||||||
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
||||||
};
|
};
|
||||||
state.update(KEYS.CURRENT_SESSION, (s: ChatSession | null) => ({
|
state.update(KEYS.CURRENT_SESSION, (s: any) => ({
|
||||||
...s, messages: [...s.messages, assistantMsg], updatedAt: Date.now()
|
...s, messages: [...s.messages, assistantMsg], updatedAt: Date.now()
|
||||||
}));
|
}));
|
||||||
updateLastAssistantMessage(finalContent, retryThinkContent || null, loopStats || null, undefined, Date.now(), toolRecords?.length ? toolRecords : undefined);
|
updateLastAssistantMessage(finalContent, retryThinkContent || null, loopStats || null, undefined, Date.now(), toolRecords?.length ? toolRecords : undefined);
|
||||||
@@ -644,7 +642,7 @@ export async function sendMessage(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isFirstMsg = currentSession.messages.length === 0;
|
const isFirstMsg = currentSession.messages.length === 0;
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||||
...session,
|
...session,
|
||||||
...(isFirstMsg && {
|
...(isFirstMsg && {
|
||||||
title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30),
|
title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30),
|
||||||
@@ -682,7 +680,7 @@ export async function sendMessage(): Promise<void> {
|
|||||||
|
|
||||||
let assistantContent = '';
|
let assistantContent = '';
|
||||||
let thinkContent = '';
|
let thinkContent = '';
|
||||||
let finalStats: { eval_count?: number; prompt_eval_count?: number; total_duration?: number } | null = null;
|
let finalStats: any = null;
|
||||||
let modelName = '';
|
let modelName = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -697,6 +695,20 @@ export async function sendMessage(): Promise<void> {
|
|||||||
};
|
};
|
||||||
if (numCtx) chatParams.options = { num_ctx: numCtx, temperature };
|
if (numCtx) chatParams.options = { num_ctx: numCtx, temperature };
|
||||||
|
|
||||||
|
// ── 扫描工作空间 SOUL.md ──
|
||||||
|
let soulContent = '';
|
||||||
|
const workspaceDir = getWorkspaceDirPath();
|
||||||
|
if (workspaceDir) {
|
||||||
|
try {
|
||||||
|
const soulResult = await window.metonaDesktop?.workspace.readFile(
|
||||||
|
workspaceDir.replace(/\/+$/, '') + '/SOUL.md'
|
||||||
|
);
|
||||||
|
if (soulResult?.success && soulResult.content) {
|
||||||
|
soulContent = `[SOUL.md] ${soulResult.content}`;
|
||||||
|
}
|
||||||
|
} catch { /* SOUL.md 不存在,静默跳过 */ }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Agent 记忆注入 ──
|
// ── Agent 记忆注入 ──
|
||||||
if (isMemoryEnabled()) {
|
if (isMemoryEnabled()) {
|
||||||
const userMessage = text || (msgsToAdd.find(m => m.role === 'user')?.content || '');
|
const userMessage = text || (msgsToAdd.find(m => m.role === 'user')?.content || '');
|
||||||
@@ -704,7 +716,7 @@ export async function sendMessage(): Promise<void> {
|
|||||||
const relevantMemories = searchMemories(userMessage, 6);
|
const relevantMemories = searchMemories(userMessage, 6);
|
||||||
if (relevantMemories.length > 0) {
|
if (relevantMemories.length > 0) {
|
||||||
const memoryContext = buildMemoryContext(relevantMemories);
|
const memoryContext = buildMemoryContext(relevantMemories);
|
||||||
chatParams.system = memoryContext;
|
chatParams.system = (soulContent ? soulContent + '\n\n' : '') + memoryContext;
|
||||||
for (const m of relevantMemories) {
|
for (const m of relevantMemories) {
|
||||||
await markMemoryUsed(m.id);
|
await markMemoryUsed(m.id);
|
||||||
}
|
}
|
||||||
@@ -712,6 +724,12 @@ export async function sendMessage(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (soulContent && !chatParams.system) {
|
||||||
|
chatParams.system = soulContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存入 state 供 chat-area 渲染系统提示词卡片
|
||||||
|
state.set('_lastSystemPrompt', chatParams.system || '');
|
||||||
|
|
||||||
await api.chatStream(chatParams as any, (chunk: OllamaStreamChunk) => {
|
await api.chatStream(chatParams as any, (chunk: OllamaStreamChunk) => {
|
||||||
if (chunk.message) {
|
if (chunk.message) {
|
||||||
@@ -752,9 +770,9 @@ export async function sendMessage(): Promise<void> {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
...(modelName && { model: modelName }),
|
...(modelName && { model: modelName }),
|
||||||
...(thinkContent && { think: thinkContent }),
|
...(thinkContent && { think: thinkContent }),
|
||||||
...(finalStats && { eval_count: finalStats.eval_count, prompt_eval_count: finalStats.prompt_eval_count, total_duration: finalStats.total_duration })
|
...((finalStats || {}) as any)
|
||||||
};
|
};
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||||
...session,
|
...session,
|
||||||
messages: [...session.messages, assistantMsg],
|
messages: [...session.messages, assistantMsg],
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
@@ -800,7 +818,7 @@ export async function sendMessage(): Promise<void> {
|
|||||||
...(thinkContent && { think: thinkContent }),
|
...(thinkContent && { think: thinkContent }),
|
||||||
stopped: true
|
stopped: true
|
||||||
};
|
};
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||||
...session,
|
...session,
|
||||||
messages: [...session.messages, partialMsg],
|
messages: [...session.messages, partialMsg],
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
@@ -829,7 +847,7 @@ export async function sendMessage(): Promise<void> {
|
|||||||
const contentDiv = placeholder?.querySelector('.msg-content');
|
const contentDiv = placeholder?.querySelector('.msg-content');
|
||||||
|
|
||||||
if (assistantContent && contentDiv) {
|
if (assistantContent && contentDiv) {
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||||
...session,
|
...session,
|
||||||
messages: [...session.messages, { role: 'assistant', content: assistantContent + '\n\n`[已中断]`', timestamp: Date.now() } as ChatMessage],
|
messages: [...session.messages, { role: 'assistant', content: assistantContent + '\n\n`[已中断]`', timestamp: Date.now() } as ChatMessage],
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
@@ -864,14 +882,14 @@ export async function sendMessage(): Promise<void> {
|
|||||||
/** 更新当前会话消息中最近一个 'running' 状态的同名工具记录 */
|
/** 更新当前会话消息中最近一个 'running' 状态的同名工具记录 */
|
||||||
function updateMessageToolRecord(toolName: string, status: 'success' | 'error', result: Record<string, unknown>): void {
|
function updateMessageToolRecord(toolName: string, status: 'success' | 'error', result: Record<string, unknown>): void {
|
||||||
let updated = false;
|
let updated = false;
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => {
|
state.update(KEYS.CURRENT_SESSION, (session: any) => {
|
||||||
if (!session) return session;
|
if (!session) return session;
|
||||||
const messages = [...session.messages];
|
const messages = [...session.messages];
|
||||||
// 从后往前找最近一条包含该工具 running 状态记录的消息
|
// 从后往前找最近一条包含该工具 running 状态记录的消息
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const msg = messages[i];
|
const msg = messages[i];
|
||||||
if (!msg.toolCalls) continue;
|
if (!msg.toolCalls) continue;
|
||||||
const idx = msg.toolCalls.findIndex(tc => tc.name === toolName && tc.status === 'running');
|
const idx = msg.toolCalls.findIndex((tc: any) => tc.name === toolName && tc.status === 'running');
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
const updatedRecords = [...msg.toolCalls];
|
const updatedRecords = [...msg.toolCalls];
|
||||||
updatedRecords[idx] = { ...updatedRecords[idx], status, result };
|
updatedRecords[idx] = { ...updatedRecords[idx], status, result };
|
||||||
@@ -919,7 +937,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isFirstMsg = currentSession.messages.length === 0;
|
const isFirstMsg = currentSession.messages.length === 0;
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||||
...session,
|
...session,
|
||||||
...(isFirstMsg && {
|
...(isFirstMsg && {
|
||||||
title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30),
|
title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30),
|
||||||
@@ -1009,7 +1027,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
...(iterationEvalCount > 0 && { eval_count: iterationEvalCount }),
|
...(iterationEvalCount > 0 && { eval_count: iterationEvalCount }),
|
||||||
...(iterationDuration > 0 && { total_duration: iterationDuration }),
|
...(iterationDuration > 0 && { total_duration: iterationDuration }),
|
||||||
};
|
};
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||||
...session,
|
...session,
|
||||||
messages: [...session.messages, prevMsg],
|
messages: [...session.messages, prevMsg],
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
@@ -1075,7 +1093,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
||||||
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
||||||
};
|
};
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||||
...session,
|
...session,
|
||||||
messages: [...session.messages, lastMsg],
|
messages: [...session.messages, lastMsg],
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
@@ -1094,7 +1112,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
|
||||||
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
|
||||||
};
|
};
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||||
...session,
|
...session,
|
||||||
messages: [...session.messages, assistantMsg],
|
messages: [...session.messages, assistantMsg],
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
@@ -1133,7 +1151,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
...(abortToolRecords?.length && { toolCalls: abortToolRecords }),
|
...(abortToolRecords?.length && { toolCalls: abortToolRecords }),
|
||||||
stopped: true
|
stopped: true
|
||||||
};
|
};
|
||||||
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
|
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||||
...session,
|
...session,
|
||||||
messages: [...session.messages, partialMsg],
|
messages: [...session.messages, partialMsg],
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
|
|||||||
@@ -122,11 +122,22 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise<void>
|
|||||||
const info = await api.showModel(m.name);
|
const info = await api.showModel(m.name);
|
||||||
const caps = info.capabilities || [];
|
const caps = info.capabilities || [];
|
||||||
const isCompletion = caps.includes('completion');
|
const isCompletion = caps.includes('completion');
|
||||||
|
|
||||||
|
// 从 model_info 提取上下文长度
|
||||||
|
let contextLength = 2048;
|
||||||
|
for (const key of Object.keys(info.model_info || {})) {
|
||||||
|
if (key.endsWith('.context_length')) {
|
||||||
|
contextLength = Number(info.model_info![key]) || 2048;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
modelCapabilityCache.set(m.name, {
|
modelCapabilityCache.set(m.name, {
|
||||||
think: caps.includes('thinking'),
|
think: caps.includes('thinking'),
|
||||||
vision: caps.includes('vision'),
|
vision: caps.includes('vision'),
|
||||||
tools: caps.includes('tools'),
|
tools: caps.includes('tools'),
|
||||||
completion: isCompletion,
|
completion: isCompletion,
|
||||||
|
contextLength,
|
||||||
});
|
});
|
||||||
if (!isCompletion) {
|
if (!isCompletion) {
|
||||||
const opt = modelSelectEl.querySelector(`option[value="${CSS.escape(m.name)}"]`);
|
const opt = modelSelectEl.querySelector(`option[value="${CSS.escape(m.name)}"]`);
|
||||||
@@ -149,7 +160,11 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise<void>
|
|||||||
|
|
||||||
async function checkModelCapability(modelName: string): Promise<void> {
|
async function checkModelCapability(modelName: string): Promise<void> {
|
||||||
if (modelCapabilityCache.has(modelName)) {
|
if (modelCapabilityCache.has(modelName)) {
|
||||||
applyCapability(modelName, modelCapabilityCache.get(modelName)!);
|
const cached = modelCapabilityCache.get(modelName)!;
|
||||||
|
state.set(KEYS.NUM_CTX, cached.contextLength);
|
||||||
|
const displayEl = document.querySelector('#displayNumCtx');
|
||||||
|
if (displayEl) displayEl.textContent = `${cached.contextLength.toLocaleString()} tokens`;
|
||||||
|
applyCapability(modelName, cached);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,18 +174,40 @@ async function checkModelCapability(modelName: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const info = await api.showModel(modelName);
|
const info = await api.showModel(modelName);
|
||||||
const capabilities = info.capabilities || [];
|
const capabilities = info.capabilities || [];
|
||||||
const caps = {
|
|
||||||
|
// 从 model_info 中提取模型实际上下文长度
|
||||||
|
let contextLength = 2048; // 默认保守值
|
||||||
|
const modelInfo = info.model_info || {};
|
||||||
|
for (const key of Object.keys(modelInfo)) {
|
||||||
|
if (key.endsWith('.context_length')) {
|
||||||
|
contextLength = Number(modelInfo[key]) || 2048;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const caps: ModelCaps = {
|
||||||
think: capabilities.includes('thinking'),
|
think: capabilities.includes('thinking'),
|
||||||
vision: capabilities.includes('vision'),
|
vision: capabilities.includes('vision'),
|
||||||
tools: capabilities.includes('tools'),
|
tools: capabilities.includes('tools'),
|
||||||
completion: capabilities.includes('completion'),
|
completion: capabilities.includes('completion'),
|
||||||
|
contextLength,
|
||||||
};
|
};
|
||||||
modelCapabilityCache.set(modelName, caps);
|
modelCapabilityCache.set(modelName, caps);
|
||||||
logDebug(`能力检测: ${modelName}`, capabilities.join(', '));
|
logDebug(`能力检测: ${modelName}`, `ctx: ${contextLength}, ` + capabilities.join(', '));
|
||||||
|
|
||||||
|
// 自动设置上下文长度为模型实际支持的值
|
||||||
|
state.set(KEYS.NUM_CTX, contextLength);
|
||||||
|
const db = state.get<any>(KEYS.DB);
|
||||||
|
if (db) await db.saveSetting('numCtx', contextLength);
|
||||||
|
|
||||||
|
// 更新设置面板显示
|
||||||
|
const displayEl = document.querySelector('#displayNumCtx');
|
||||||
|
if (displayEl) displayEl.textContent = `${contextLength.toLocaleString()} tokens`;
|
||||||
|
|
||||||
applyCapability(modelName, caps);
|
applyCapability(modelName, caps);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logWarn(`能力检测失败: ${modelName}`, (err as Error).message);
|
logWarn(`能力检测失败: ${modelName}`, (err as Error).message);
|
||||||
const fallback = { think: false, vision: false, tools: false, completion: true };
|
const fallback: ModelCaps = { think: false, vision: false, tools: false, completion: true, contextLength: 2048 };
|
||||||
modelCapabilityCache.set(modelName, fallback);
|
modelCapabilityCache.set(modelName, fallback);
|
||||||
applyCapability(modelName, fallback);
|
applyCapability(modelName, fallback);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,16 +39,6 @@ export function initSettingsModal(): void {
|
|||||||
}, 500);
|
}, 500);
|
||||||
document.querySelector('#inputServerUrl')!.addEventListener('input', saveServerUrl);
|
document.querySelector('#inputServerUrl')!.addEventListener('input', saveServerUrl);
|
||||||
|
|
||||||
const saveNumCtx = debounce(async () => {
|
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
|
||||||
const val = (document.querySelector('#inputNumCtx') as HTMLInputElement).value.trim();
|
|
||||||
const numCtx = val ? parseInt(val) : 24576;
|
|
||||||
state.set(KEYS.NUM_CTX, numCtx);
|
|
||||||
if (db) await db.saveSetting('numCtx', numCtx);
|
|
||||||
logSetting('上下文长度', `${numCtx} tokens`);
|
|
||||||
}, 500);
|
|
||||||
document.querySelector('#inputNumCtx')!.addEventListener('input', saveNumCtx);
|
|
||||||
|
|
||||||
const tempSlider = document.querySelector('#inputTemperature') as HTMLInputElement;
|
const tempSlider = document.querySelector('#inputTemperature') as HTMLInputElement;
|
||||||
const tempDisplay = document.querySelector('#tempValue')!;
|
const tempDisplay = document.querySelector('#tempValue')!;
|
||||||
tempSlider.addEventListener('input', () => {
|
tempSlider.addEventListener('input', () => {
|
||||||
@@ -250,9 +240,9 @@ export function initSettingsModal(): void {
|
|||||||
const result = await bridge.workspace.setDir(newDir);
|
const result = await bridge.workspace.setDir(newDir);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showToast('工作空间目录已更新', 'success');
|
showToast('工作空间目录已更新', 'success');
|
||||||
logSetting('工作空间目录', result.dir);
|
logSetting('工作空间目录', result.dir!);
|
||||||
} else {
|
} else {
|
||||||
showToast(`设置失败: ${result.error}`, 'error');
|
showToast(`设置失败: ${result.error!}`, 'error');
|
||||||
// 恢复原值
|
// 恢复原值
|
||||||
const info = await bridge.workspace.getDir();
|
const info = await bridge.workspace.getDir();
|
||||||
inputWorkspaceDir.value = info.dir;
|
inputWorkspaceDir.value = info.dir;
|
||||||
@@ -401,7 +391,7 @@ async function importSessions(filePath: string): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const bridge = window.metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
const result = await bridge.fs.readFileBase64(filePath);
|
const result = await bridge!.fs.readFileBase64(filePath);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
showToast(`读取文件失败: ${result.error}`, 'error');
|
showToast(`读取文件失败: ${result.error}`, 'error');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ function renderBarChart(
|
|||||||
|
|
||||||
/** 渲染全局柱状图(按会话) */
|
/** 渲染全局柱状图(按会话) */
|
||||||
function renderGlobalChart(stats: AllSessionsTokenStats): void {
|
function renderGlobalChart(stats: AllSessionsTokenStats): void {
|
||||||
const chartEl = dashboardModalEl.querySelector('#tdChart')!;
|
const chartEl = dashboardModalEl.querySelector('#tdChart') as HTMLElement;
|
||||||
const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null;
|
const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null;
|
||||||
const savedScrollLeft = barsEl?.scrollLeft ?? 0;
|
const savedScrollLeft = barsEl?.scrollLeft ?? 0;
|
||||||
|
|
||||||
@@ -329,7 +329,7 @@ function renderGlobalChart(stats: AllSessionsTokenStats): void {
|
|||||||
|
|
||||||
/** 渲染全局明细表格 */
|
/** 渲染全局明细表格 */
|
||||||
function renderGlobalTable(stats: AllSessionsTokenStats): void {
|
function renderGlobalTable(stats: AllSessionsTokenStats): void {
|
||||||
const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle');
|
const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null;
|
||||||
if (tableTitleEl) tableTitleEl.textContent = '📋 会话汇总明细';
|
if (tableTitleEl) tableTitleEl.textContent = '📋 会话汇总明细';
|
||||||
|
|
||||||
// 动态更新表头
|
// 动态更新表头
|
||||||
@@ -406,7 +406,7 @@ function renderSessionView(): void {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// 柱状图
|
// 柱状图
|
||||||
const chartEl = dashboardModalEl.querySelector('#tdChart')!;
|
const chartEl = dashboardModalEl.querySelector('#tdChart') as HTMLElement;
|
||||||
const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null;
|
const barsEl = chartEl.querySelector('.td-chart-bars') as HTMLElement | null;
|
||||||
const savedScrollLeft = barsEl?.scrollLeft ?? 0;
|
const savedScrollLeft = barsEl?.scrollLeft ?? 0;
|
||||||
|
|
||||||
@@ -423,7 +423,7 @@ function renderSessionView(): void {
|
|||||||
renderBarChart(chartEl, chartData, '按轮次', undefined, savedScrollLeft);
|
renderBarChart(chartEl, chartData, '按轮次', undefined, savedScrollLeft);
|
||||||
|
|
||||||
// 表格标题
|
// 表格标题
|
||||||
const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle');
|
const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null;
|
||||||
if (tableTitleEl) tableTitleEl.textContent = '📋 轮次明细(按时间倒序)';
|
if (tableTitleEl) tableTitleEl.textContent = '📋 轮次明细(按时间倒序)';
|
||||||
|
|
||||||
// 动态更新表头
|
// 动态更新表头
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { state, KEYS } from '../state/state.js';
|
|||||||
import { setToolEnabled, setRunCommandMode } from '../services/tool-registry.js';
|
import { setToolEnabled, setRunCommandMode } from '../services/tool-registry.js';
|
||||||
import { showToast } from './toast.js';
|
import { showToast } from './toast.js';
|
||||||
import { logInfo, logWarn } from '../services/log-service.js';
|
import { logInfo, logWarn } from '../services/log-service.js';
|
||||||
import type { ChatDB } from '../types.js';
|
import type { ChatDB } from '../db/chat-db.js';
|
||||||
|
|
||||||
let modalEl: HTMLElement;
|
let modalEl: HTMLElement;
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ function isDesktop(): boolean {
|
|||||||
return !!window.metonaDesktop?.isDesktop;
|
return !!window.metonaDesktop?.isDesktop;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 桌面端 DB 桥接 */
|
/** 桌面端 DB 桥接(isDesktop 已检查,安全断言非空) */
|
||||||
function dbBridge() {
|
function dbBridge() {
|
||||||
return window.metonaDesktop?.db;
|
return window.metonaDesktop!.db!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChatDB {
|
export class ChatDB {
|
||||||
@@ -201,7 +201,7 @@ export class ChatDB {
|
|||||||
|
|
||||||
async getSetting<T = unknown>(key: string, defaultValue: T | null = null): Promise<T> {
|
async getSetting<T = unknown>(key: string, defaultValue: T | null = null): Promise<T> {
|
||||||
if (isDesktop()) {
|
if (isDesktop()) {
|
||||||
return dbBridge().getSetting(key, defaultValue);
|
return dbBridge().getSetting(key, defaultValue) as Promise<T>;
|
||||||
}
|
}
|
||||||
return this._idbGetSetting(key, defaultValue);
|
return this._idbGetSetting(key, defaultValue);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<span class="logo">🦙</span>
|
<span class="logo">🦙</span>
|
||||||
<span class="app-title">Metona Ollama</span>
|
<span class="app-title">Metona Ollama</span>
|
||||||
<span class="app-version">v1-stable-release</span>
|
<span class="app-version">v1.1.0</span>
|
||||||
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||||
@@ -272,12 +272,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<label class="setting-label">上下文长度(num_ctx)</label>
|
<label class="setting-label">上下文长度(自动检测)</label>
|
||||||
<div style="display:flex;gap:8px;align-items:center;">
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
<input class="setting-input" id="inputNumCtx" type="number" min="512" max="131072" step="512" value="24576" style="margin-bottom:0;">
|
<span class="setting-input" id="displayNumCtx" style="margin-bottom:0;display:flex;align-items:center;background:var(--bg-layer);padding:9px 12px;">24576 tokens</span>
|
||||||
<span class="text-muted" style="white-space:nowrap;">tokens</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted" style="margin-top:4px;font-size:11px;">常见值:2048 / 4096 / 8192 / 32768。值越大上下文越长,显存占用越高。</p>
|
<p class="text-muted" style="margin-top:4px;font-size:11px;">选择模型时自动从模型元数据中获取实际上下文长度。</p>
|
||||||
<label class="setting-label" style="margin-top:12px;">温度(Temperature):<span id="tempValue">0.7</span></label>
|
<label class="setting-label" style="margin-top:12px;">温度(Temperature):<span id="tempValue">0.7</span></label>
|
||||||
<input type="range" id="inputTemperature" class="temp-slider" min="0" max="2" step="0.1" value="0.7">
|
<input type="range" id="inputTemperature" class="temp-slider" min="0" max="2" step="0.1" value="0.7">
|
||||||
<p class="text-muted" style="margin-top:4px;font-size:11px;">低值更精确稳定,高值更有创意多样(0 = 确定性,2 = 最大随机)</p>
|
<p class="text-muted" style="margin-top:4px;font-size:11px;">低值更精确稳定,高值更有创意多样(0 = 确定性,2 = 最大随机)</p>
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
|
|||||||
created_at: s.createdAt,
|
created_at: s.createdAt,
|
||||||
updated_at: s.updatedAt
|
updated_at: s.updatedAt
|
||||||
})),
|
})),
|
||||||
messages: sessions.flatMap(s =>
|
messages: (sessions as any[]).flatMap((s: any) =>
|
||||||
s.messages.map((m: Record<string, unknown>, idx: number) => ({
|
(s.messages || []).map((m: any, idx: number) => ({
|
||||||
id: `${s.id}_msg_${idx}_${m.timestamp}`,
|
id: `${s.id}_msg_${idx}_${m.timestamp}`,
|
||||||
session_id: s.id,
|
session_id: s.id,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
@@ -120,7 +120,7 @@ async function migrateIndexedDBToSQLite(db: ChatDB): Promise<void> {
|
|||||||
type: m.type,
|
type: m.type,
|
||||||
content: m.content,
|
content: m.content,
|
||||||
importance: m.importance || 5,
|
importance: m.importance || 5,
|
||||||
tags: m.tags?.length ? JSON.stringify(m.tags) : null,
|
tags: (m.tags as any[])?.length ? JSON.stringify(m.tags) : null,
|
||||||
source: m.source || null,
|
source: m.source || null,
|
||||||
session_id: m.sessionId || null,
|
session_id: m.sessionId || null,
|
||||||
use_count: m.useCount || 0,
|
use_count: m.useCount || 0,
|
||||||
@@ -354,9 +354,8 @@ async function init(): Promise<void> {
|
|||||||
state.set(KEYS.NUM_CTX, numCtx);
|
state.set(KEYS.NUM_CTX, numCtx);
|
||||||
state.set('temperature', temperature);
|
state.set('temperature', temperature);
|
||||||
|
|
||||||
(document.querySelector('#inputNumCtx') as HTMLInputElement).value = String(numCtx);
|
|
||||||
(document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature);
|
(document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature);
|
||||||
document.querySelector('#tempValue')!.textContent = parseFloat(temperature).toFixed(1);
|
document.querySelector('#tempValue')!.textContent = parseFloat(String(temperature)).toFixed(1);
|
||||||
|
|
||||||
// ── v5.1.1 Agent 最大轮数 ──
|
// ── v5.1.1 Agent 最大轮数 ──
|
||||||
const maxTurns = await db.getSetting('maxTurns', 85);
|
const maxTurns = await db.getSetting('maxTurns', 85);
|
||||||
|
|||||||
@@ -18,14 +18,15 @@ import { showToast } from '../components/toast.js';
|
|||||||
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse } from './log-service.js';
|
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse } from './log-service.js';
|
||||||
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
|
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
|
||||||
import { generateId } from '../utils/utils.js';
|
import { generateId } from '../utils/utils.js';
|
||||||
import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD } from './context-manager.js';
|
import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD, recordActualTokens } from './context-manager.js';
|
||||||
import type {
|
import type {
|
||||||
OllamaMessage,
|
OllamaMessage,
|
||||||
OllamaStreamChunk,
|
OllamaStreamChunk,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
ToolCallRecord,
|
ToolCallRecord,
|
||||||
TraceEntry
|
TraceEntry,
|
||||||
|
ChatSession
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2; // 工具错误自动重试次数
|
const MAX_RETRIES = 2; // 工具错误自动重试次数
|
||||||
@@ -173,7 +174,7 @@ async function inferUserProfile(userMsg: string, assistantMsg: string): Promise<
|
|||||||
const bridge = window.metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (!bridge?.db?.getSetting) return;
|
if (!bridge?.db?.getSetting) return;
|
||||||
|
|
||||||
const profile: Record<string, unknown> = await bridge.db.getSetting('user_profile', {}) || {};
|
const profile: Record<string, any> = await bridge.db.getSetting('user_profile', {}) || {};
|
||||||
|
|
||||||
// 技术栈检测
|
// 技术栈检测
|
||||||
const techPatterns: Record<string, RegExp> = {
|
const techPatterns: Record<string, RegExp> = {
|
||||||
@@ -363,7 +364,7 @@ export interface AgentCallbacks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 保存执行轨迹到 SQLite */
|
/** 保存执行轨迹到 SQLite */
|
||||||
async function saveTrace(trace: Omit<TraceEntry, 'id' | 'createdAt'>): Promise<void> {
|
async function saveTrace(trace: Record<string, any>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const bridge = window.metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (!bridge?.db) return;
|
if (!bridge?.db) return;
|
||||||
@@ -390,7 +391,7 @@ export async function runAgentLoop(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const api = state.get<OllamaAPI>(KEYS.API);
|
const api = state.get<OllamaAPI>(KEYS.API);
|
||||||
const model = state.get<string>('_defaultModel', '');
|
const model = state.get<string>('_defaultModel', '');
|
||||||
const currentSession = state.get(KEYS.CURRENT_SESSION);
|
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||||||
const sessionId = currentSession?.id || 'unknown';
|
const sessionId = currentSession?.id || 'unknown';
|
||||||
|
|
||||||
if (!api || !model) {
|
if (!api || !model) {
|
||||||
@@ -411,6 +412,24 @@ export async function runAgentLoop(
|
|||||||
const messages: OllamaMessage[] = [];
|
const messages: OllamaMessage[] = [];
|
||||||
let systemPromptParts: string[] = [];
|
let systemPromptParts: string[] = [];
|
||||||
|
|
||||||
|
// ── 扫描工作空间 SOUL.md ──
|
||||||
|
const workspaceDir = getWorkspaceDirPath();
|
||||||
|
if (workspaceDir) {
|
||||||
|
try {
|
||||||
|
const soulResult = await window.metonaDesktop?.workspace.readFile(
|
||||||
|
workspaceDir.replace(/\/+$/, '') + '/SOUL.md'
|
||||||
|
);
|
||||||
|
if (soulResult?.success && soulResult.content) {
|
||||||
|
// SOUL.md 注入为独立 system 消息,标记为不可压缩
|
||||||
|
messages.push({
|
||||||
|
role: 'system',
|
||||||
|
content: `[SOUL.md] ${soulResult.content}`,
|
||||||
|
});
|
||||||
|
logInfo('SOUL.md 已注入系统提示词', `${soulResult.lines || 0} 行`);
|
||||||
|
}
|
||||||
|
} catch { /* SOUL.md 不存在或读取失败,静默跳过 */ }
|
||||||
|
}
|
||||||
|
|
||||||
// 注入记忆上下文
|
// 注入记忆上下文
|
||||||
if (isMemoryEnabled() && userContent) {
|
if (isMemoryEnabled() && userContent) {
|
||||||
const relevantMemories = searchMemories(userContent, 6);
|
const relevantMemories = searchMemories(userContent, 6);
|
||||||
@@ -423,7 +442,6 @@ export async function runAgentLoop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 注入工作空间上下文
|
// 注入工作空间上下文
|
||||||
const workspaceDir = getWorkspaceDirPath();
|
|
||||||
if (workspaceDir) {
|
if (workspaceDir) {
|
||||||
systemPromptParts.push(`【工作空间】
|
systemPromptParts.push(`【工作空间】
|
||||||
当前工作空间目录: ${workspaceDir}
|
当前工作空间目录: ${workspaceDir}
|
||||||
@@ -447,7 +465,7 @@ export async function runAgentLoop(
|
|||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
if (db) {
|
if (db) {
|
||||||
try {
|
try {
|
||||||
const userProfile = await db.getSetting('user_profile', null);
|
const userProfile = await db.getSetting('user_profile', null) as Record<string, any> | null;
|
||||||
if (userProfile && typeof userProfile === 'object') {
|
if (userProfile && typeof userProfile === 'object') {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (userProfile.tech_stack?.length) parts.push(`技术栈: ${userProfile.tech_stack.join(', ')}`);
|
if (userProfile.tech_stack?.length) parts.push(`技术栈: ${userProfile.tech_stack.join(', ')}`);
|
||||||
@@ -475,6 +493,9 @@ export async function runAgentLoop(
|
|||||||
|
|
||||||
messages.push({ role: 'system', content: fullSystemPrompt });
|
messages.push({ role: 'system', content: fullSystemPrompt });
|
||||||
|
|
||||||
|
// 将完整系统提示词存入 state,供 input-area 在助理消息上展示
|
||||||
|
state.set('_lastSystemPrompt', fullSystemPrompt);
|
||||||
|
|
||||||
// 添加历史消息
|
// 添加历史消息
|
||||||
for (const msg of historyMessages) {
|
for (const msg of historyMessages) {
|
||||||
messages.push({
|
messages.push({
|
||||||
@@ -503,7 +524,7 @@ export async function runAgentLoop(
|
|||||||
logInfo(`自动上下文压缩触发: tokens≈${estimateTokens(messages.map(m => m.content || '').join(''))} > ${Math.floor(numCtx * AUTO_COMPRESS_THRESHOLD)} (50% of ${numCtx})`);
|
logInfo(`自动上下文压缩触发: tokens≈${estimateTokens(messages.map(m => m.content || '').join(''))} > ${Math.floor(numCtx * AUTO_COMPRESS_THRESHOLD)} (50% of ${numCtx})`);
|
||||||
const compressAC = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER) || new AbortController();
|
const compressAC = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER) || new AbortController();
|
||||||
try {
|
try {
|
||||||
const compressed = await compressWithLLM(messages, api, model, { abortController: compressAC });
|
const compressed = await compressWithLLM(messages, api as any, model, { abortController: compressAC });
|
||||||
if (compressed.length < messages.length || estimateTokens(compressed.map(m => m.content || '').join('')) < estimateTokens(messages.map(m => m.content || '').join(''))) {
|
if (compressed.length < messages.length || estimateTokens(compressed.map(m => m.content || '').join('')) < estimateTokens(messages.map(m => m.content || '').join(''))) {
|
||||||
messages.length = 0;
|
messages.length = 0;
|
||||||
messages.push(...compressed);
|
messages.push(...compressed);
|
||||||
@@ -633,10 +654,25 @@ export async function runAgentLoop(
|
|||||||
totalPromptEvalCount += loopPromptEvalCount;
|
totalPromptEvalCount += loopPromptEvalCount;
|
||||||
totalInferenceNs += loopInferenceNs;
|
totalInferenceNs += loopInferenceNs;
|
||||||
state.set('_currentEvalCount', totalEvalCount);
|
state.set('_currentEvalCount', totalEvalCount);
|
||||||
|
|
||||||
|
// Token 校准:用 Ollama 返回的实际计数修正估算器(在重置前保存本轮值)
|
||||||
|
const thisLoopEval = loopEvalCount;
|
||||||
|
const thisLoopPrompt = loopPromptEvalCount;
|
||||||
// 重置本轮计数器(下一轮重新从 chunk 收集)
|
// 重置本轮计数器(下一轮重新从 chunk 收集)
|
||||||
loopEvalCount = 0;
|
loopEvalCount = 0;
|
||||||
loopPromptEvalCount = 0;
|
loopPromptEvalCount = 0;
|
||||||
loopInferenceNs = 0;
|
loopInferenceNs = 0;
|
||||||
|
|
||||||
|
// Token 校准:用 Ollama 返回的实际计数自动修正估算器
|
||||||
|
try {
|
||||||
|
if (thisLoopEval > 0 || thisLoopPrompt > 0) {
|
||||||
|
const estimatedThisLoop = estimateTokens(messages.map(m => m.content || '').join(''));
|
||||||
|
if (estimatedThisLoop > 0) {
|
||||||
|
recordActualTokens(thisLoopPrompt, thisLoopEval, estimatedThisLoop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* 校准失败不阻塞主流程 */ }
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
logInfo('流式调用已中止');
|
logInfo('流式调用已中止');
|
||||||
@@ -722,7 +758,7 @@ export async function runAgentLoop(
|
|||||||
// v4.2 自动提取技能
|
// v4.2 自动提取技能
|
||||||
if (allToolRecords.length >= 2) {
|
if (allToolRecords.length >= 2) {
|
||||||
try {
|
try {
|
||||||
await extractSkillsFromToolRecords(allToolRecords, userContent, currentSession?.title || '');
|
await extractSkillsFromToolRecords(allToolRecords, userContent, (currentSession as ChatSession)?.title || '');
|
||||||
} catch { /* 不阻塞 */ }
|
} catch { /* 不阻塞 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,31 @@
|
|||||||
import type { OllamaMessage, OllamaStreamChunk } from '../types.js';
|
import type { OllamaMessage, OllamaStreamChunk } from '../types.js';
|
||||||
import { logInfo, logWarn, logSuccess, logError } from './log-service.js';
|
import { logInfo, logWarn, logSuccess, logError } from './log-service.js';
|
||||||
|
|
||||||
/** 粗略估算 token 数 */
|
// ── Token 校准系统 ──
|
||||||
|
|
||||||
|
/** 校准比例:actualTokens / estimatedTokens,基于 Ollama 返回的实际计数动态修正 */
|
||||||
|
let _tokenCalibrationRatio = 1.0;
|
||||||
|
let _calibrationSamples = 0;
|
||||||
|
const MIN_CALIBRATION_SAMPLES = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 Ollama 返回的实际 token 计数,用于校准估算器。
|
||||||
|
* 在 agent-engine.ts 每轮流式完成后调用。
|
||||||
|
* @param actualInputTokens Ollama 返回的 prompt_eval_count
|
||||||
|
* @param actualOutputTokens Ollama 返回的 eval_count
|
||||||
|
* @param estimatedTokens 本轮消息调用 estimateTokens 的合计值
|
||||||
|
*/
|
||||||
|
export function recordActualTokens(actualInputTokens: number, actualOutputTokens: number, estimatedCount: number): void {
|
||||||
|
if (actualInputTokens <= 0 || estimatedCount <= 0) return;
|
||||||
|
const actualTotal = actualInputTokens + actualOutputTokens;
|
||||||
|
const sampleRatio = actualTotal / Math.max(1, estimatedCount);
|
||||||
|
// 指数移动平均,平滑异常值
|
||||||
|
const alpha = 0.3;
|
||||||
|
_tokenCalibrationRatio = _tokenCalibrationRatio * (1 - alpha) + sampleRatio * alpha;
|
||||||
|
_calibrationSamples++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 估算 token 数(自动使用校准后的比例) */
|
||||||
export function estimateTokens(text: string): number {
|
export function estimateTokens(text: string): number {
|
||||||
if (!text) return 0;
|
if (!text) return 0;
|
||||||
// 中文按 1.5 字/token,英文按 4 字符/token
|
// 中文按 1.5 字/token,英文按 4 字符/token
|
||||||
@@ -17,7 +41,17 @@ export function estimateTokens(text: string): number {
|
|||||||
if (/[\u4e00-\u9fff]/.test(ch)) chineseChars++;
|
if (/[\u4e00-\u9fff]/.test(ch)) chineseChars++;
|
||||||
else otherChars++;
|
else otherChars++;
|
||||||
}
|
}
|
||||||
return Math.ceil(chineseChars / 1.5 + otherChars / 4);
|
const raw = Math.ceil(chineseChars / 1.5 + otherChars / 4);
|
||||||
|
// 应用校准比例(仅在有足够样本后)
|
||||||
|
if (_calibrationSamples >= MIN_CALIBRATION_SAMPLES) {
|
||||||
|
return Math.ceil(raw * _tokenCalibrationRatio);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前校准比例(供调试用) */
|
||||||
|
export function getTokenCalibration(): { ratio: number; samples: number } {
|
||||||
|
return { ratio: _tokenCalibrationRatio, samples: _calibrationSamples };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 自动压缩阈值:当消息 token 占 context window 比例超过此值时触发自动压缩 */
|
/** 自动压缩阈值:当消息 token 占 context window 比例超过此值时触发自动压缩 */
|
||||||
@@ -27,6 +61,57 @@ export const AUTO_COMPRESS_THRESHOLD = 0.5;
|
|||||||
const COMPRESS_KEEP_HEAD = 2;
|
const COMPRESS_KEEP_HEAD = 2;
|
||||||
const COMPRESS_KEEP_TAIL = 2;
|
const COMPRESS_KEEP_TAIL = 2;
|
||||||
|
|
||||||
|
// ── 消息重要性评分(纯规则,不调用 LLM)──
|
||||||
|
|
||||||
|
/** 重要性评分:0-10,越高越应该保留。SOUL.md 和规则记忆始终保留。 */
|
||||||
|
export function scoreMessageImportance(msg: OllamaMessage): number {
|
||||||
|
let score = 5; // 默认中等
|
||||||
|
|
||||||
|
// SOUL.md 消息永远不可压缩
|
||||||
|
if (msg.content?.startsWith('[SOUL.md]')) return 10;
|
||||||
|
|
||||||
|
// 角色权重
|
||||||
|
if (msg.role === 'user') score += 2; // 用户消息最重要
|
||||||
|
if (msg.role === 'system') score -= 2; // 系统消息通常可压缩
|
||||||
|
|
||||||
|
// 已压缩标记 → 低权重(避免压缩产物堆积)
|
||||||
|
if (msg.compressed) score = 1;
|
||||||
|
|
||||||
|
const content = msg.content || '';
|
||||||
|
|
||||||
|
// 关键词检测
|
||||||
|
const highValuePatterns = [/路径|目录|path|file|config|配置|命令|command|exec/i,
|
||||||
|
/错误|error|失败|fail|bug|fix|修复|解决/i,
|
||||||
|
/版本|version|API|http|url|端口|port|localhost/i,
|
||||||
|
/记住|保存|memory|偏好|偏好|规则|rule/i,
|
||||||
|
/完成|done|✓|success|成功|结果|result/i,
|
||||||
|
/项目|project|工作空间|workspace|git|repo|仓库/i,
|
||||||
|
];
|
||||||
|
const lowValuePatterns = [/好的|明白|ok|知道了|嗯|哦|好/i,
|
||||||
|
/继续|请|帮我|可以吗/i,
|
||||||
|
/谢谢|感谢|不客气/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of highValuePatterns) {
|
||||||
|
if (p.test(content)) { score += 1; break; }
|
||||||
|
}
|
||||||
|
for (const p of lowValuePatterns) {
|
||||||
|
if (p.test(content) && content.length < 100) { score -= 2; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具调用 → 高价值
|
||||||
|
if (msg.tool_calls?.length) score += 2;
|
||||||
|
|
||||||
|
// 长度加分:长消息通常包含更多信息
|
||||||
|
if (content.length > 500) score += 1;
|
||||||
|
if (content.length > 2000) score += 1;
|
||||||
|
|
||||||
|
// 图像附件 → 中等价值(大但语义密度低)
|
||||||
|
if (msg.images?.length) score -= 1;
|
||||||
|
|
||||||
|
return Math.max(1, Math.min(10, score));
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContextBuildOptions {
|
export interface ContextBuildOptions {
|
||||||
/** 滑动窗口大小(最近 N 条消息完整保留) */
|
/** 滑动窗口大小(最近 N 条消息完整保留) */
|
||||||
windowSize?: number;
|
windowSize?: number;
|
||||||
@@ -127,9 +212,28 @@ export function shouldAutoCompress(messages: OllamaMessage[], numCtx: number): b
|
|||||||
return totalTokens > threshold;
|
return totalTokens > threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 结构化压缩 ──
|
||||||
|
|
||||||
|
/** 结构化摘要 */
|
||||||
|
export interface StructuredSummary {
|
||||||
|
/** 讨论的主题 */
|
||||||
|
topics: string[];
|
||||||
|
/** 已做出的决定 */
|
||||||
|
decisions: string[];
|
||||||
|
/** 未完成的待办事项 */
|
||||||
|
pendingTasks: string[];
|
||||||
|
/** 发现的约束/规则 */
|
||||||
|
constraints: string[];
|
||||||
|
/** 关键知识点(跨会话有价值的信息) */
|
||||||
|
knowledge: string[];
|
||||||
|
/** 工具调用结果摘要 */
|
||||||
|
toolResults: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LLM 摘要压缩:调用模型对中间消息生成摘要
|
* LLM 摘要压缩:调用模型对中间消息生成结构化 JSON 摘要。
|
||||||
* 保留首尾各 keepHead/keepTail 条消息,中间用 LLM 摘要替换
|
* 保留首尾各 keepHead/keepTail 条消息,中间用 JSON 摘要替换。
|
||||||
|
* v6.0: 输出结构化 JSON,支持增量合并。
|
||||||
*
|
*
|
||||||
* @returns 压缩后的消息列表(包含 compressed 标记的摘要消息)
|
* @returns 压缩后的消息列表(包含 compressed 标记的摘要消息)
|
||||||
*/
|
*/
|
||||||
@@ -182,14 +286,14 @@ export async function compressWithLLM(
|
|||||||
|
|
||||||
logInfo(`上下文压缩: 开始 LLM 摘要,${uncompressedMiddle.length} 条消息待压缩`);
|
logInfo(`上下文压缩: 开始 LLM 摘要,${uncompressedMiddle.length} 条消息待压缩`);
|
||||||
|
|
||||||
let summary = '';
|
let summaryJson = '';
|
||||||
try {
|
try {
|
||||||
await api.chatStream(
|
await api.chatStream(
|
||||||
{
|
{
|
||||||
model,
|
model,
|
||||||
messages: [{
|
messages: [{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: `请将以下对话摘要为一段简洁的上下文总结。保留关键信息:讨论的主题、得出的结论、用户的偏好、未完成的任务、关键工具调用结果。用中文输出,不超过 ${maxSummaryTokens} 字。\n\n对话记录:\n${conversationText}`
|
content: `请将以下对话摘要为结构化 JSON。保留关键信息,用中文输出。严格按此 JSON 格式返回(不要输出其他内容):\n\n{\n "topics": ["讨论的 2-4 个核心主题"],\n "decisions": ["已做出的决定(最多 3 条)"],\n "pendingTasks": ["尚未完成的任务(最多 3 条)"],\n "constraints": ["发现的约束/规则/偏好(最多 3 条)"],\n "knowledge": ["跨会话有价值的长期知识点(最多 3 条)"],\n "toolResults": ["关键工具调用结果摘要(最多 3 条)"]\n}\n\n对话记录:\n${conversationText}`
|
||||||
}],
|
}],
|
||||||
stream: true,
|
stream: true,
|
||||||
think: false,
|
think: false,
|
||||||
@@ -197,7 +301,7 @@ export async function compressWithLLM(
|
|||||||
},
|
},
|
||||||
(chunk: OllamaStreamChunk) => {
|
(chunk: OllamaStreamChunk) => {
|
||||||
if (chunk.message?.content) {
|
if (chunk.message?.content) {
|
||||||
summary += chunk.message.content;
|
summaryJson += chunk.message.content;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
options.abortController
|
options.abortController
|
||||||
@@ -211,15 +315,34 @@ export async function compressWithLLM(
|
|||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!summary.trim()) {
|
if (!summaryJson.trim()) {
|
||||||
logWarn('上下文压缩: 模型未返回摘要内容');
|
logWarn('上下文压缩: 模型未返回摘要内容');
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析 JSON 摘要(容错:提取第一个 JSON 块或直接解析)
|
||||||
|
let parsed: StructuredSummary;
|
||||||
|
try {
|
||||||
|
const jsonMatch = summaryJson.match(/\{[\s\S]*"topics"[\s\S]*\}/);
|
||||||
|
parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : JSON.parse(summaryJson);
|
||||||
|
} catch {
|
||||||
|
logWarn('上下文压缩: JSON 解析失败,使用纯文本摘要');
|
||||||
|
parsed = { topics: [summaryJson.slice(0, 100)], decisions: [], pendingTasks: [], constraints: [], knowledge: [], toolResults: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建结构化摘要消息文本
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (parsed.topics.length) parts.push(`📌 主题: ${parsed.topics.join(';')}`);
|
||||||
|
if (parsed.decisions.length) parts.push(`✅ 决策: ${parsed.decisions.join(';')}`);
|
||||||
|
if (parsed.pendingTasks.length) parts.push(`⏳ 待办: ${parsed.pendingTasks.join(';')}`);
|
||||||
|
if (parsed.constraints.length) parts.push(`📏 约束: ${parsed.constraints.join(';')}`);
|
||||||
|
if (parsed.knowledge.length) parts.push(`🧠 知识点: ${parsed.knowledge.join(';')}`);
|
||||||
|
if (parsed.toolResults.length) parts.push(`🔧 工具结果: ${parsed.toolResults.join(';')}`);
|
||||||
|
|
||||||
// 构建压缩后的摘要消息
|
// 构建压缩后的摘要消息
|
||||||
const summaryMsg: OllamaMessage = {
|
const summaryMsg: OllamaMessage = {
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `📋 以下是对之前对话的摘要(已压缩 ${uncompressedMiddle.length} 条消息):\n\n${summary}`,
|
content: `📋 以下是对之前对话的摘要(已压缩 ${uncompressedMiddle.length} 条消息):\n\n${parts.join('\n')}`,
|
||||||
compressed: true
|
compressed: true
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -243,7 +366,8 @@ export async function compressWithLLM(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将较早的消息每 batchSize 条压缩为一段摘要(快速文本截取,不调用 LLM)
|
* 将较早的消息每 batchSize 条压缩为一段摘要。
|
||||||
|
* v6.0: 重要性高的消息保留更多内容,低价值消息激进截断。
|
||||||
*/
|
*/
|
||||||
function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): OllamaMessage[] {
|
function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): OllamaMessage[] {
|
||||||
const summaries: OllamaMessage[] = [];
|
const summaries: OllamaMessage[] = [];
|
||||||
@@ -262,7 +386,7 @@ function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): O
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 快速摘要(不调用模型,直接提取关键信息)
|
* 快速摘要:重要性高的消息保留更多信息,低价值的激进截断
|
||||||
*/
|
*/
|
||||||
function createQuickSummary(messages: OllamaMessage[]): string {
|
function createQuickSummary(messages: OllamaMessage[]): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
@@ -270,7 +394,21 @@ function createQuickSummary(messages: OllamaMessage[]): string {
|
|||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const role = msg.role === 'user' ? '用户' : 'AI';
|
const role = msg.role === 'user' ? '用户' : 'AI';
|
||||||
const content = msg.content || '';
|
const content = msg.content || '';
|
||||||
const preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
|
const importance = scoreMessageImportance(msg);
|
||||||
|
|
||||||
|
let preview: string;
|
||||||
|
if (importance >= 8) {
|
||||||
|
// 高价值消息:保留 200 字
|
||||||
|
preview = content.length > 200 ? content.slice(0, 200) + '...' : content;
|
||||||
|
} else if (importance >= 5) {
|
||||||
|
// 中等价值:保留 100 字
|
||||||
|
preview = content.length > 100 ? content.slice(0, 100) + '...' : content;
|
||||||
|
} else {
|
||||||
|
// 低价值:仅保留 40 字或跳过
|
||||||
|
if (content.trim().length < 10) continue;
|
||||||
|
preview = content.length > 40 ? content.slice(0, 40) + '...' : content;
|
||||||
|
}
|
||||||
|
|
||||||
if (preview.trim()) {
|
if (preview.trim()) {
|
||||||
parts.push(`${role}: ${preview}`);
|
parts.push(`${role}: ${preview}`);
|
||||||
}
|
}
|
||||||
@@ -284,31 +422,46 @@ function createQuickSummary(messages: OllamaMessage[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据 token 限制裁剪消息
|
* 根据 token 限制裁剪消息。
|
||||||
|
* v6.0: 按重要性评分决定保留顺序——重要性低的优先被丢弃。
|
||||||
*/
|
*/
|
||||||
function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaMessage[] {
|
function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaMessage[] {
|
||||||
let totalTokens = 0;
|
// 分离 system 和非 system 消息
|
||||||
const result: OllamaMessage[] = [];
|
const systemMsgs = messages.filter(m => m.role === 'system');
|
||||||
|
const nonSystemMsgs = messages.filter(m => m.role !== 'system');
|
||||||
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
if (nonSystemMsgs.length <= 4) return messages; // 消息太少不裁剪
|
||||||
const msg = messages[i];
|
|
||||||
const msgTokens = estimateTokens(msg.content || '') +
|
|
||||||
(msg.images ? msg.images.length * 100 : 0) +
|
|
||||||
(msg.tool_calls ? msg.tool_calls.length * 50 : 0);
|
|
||||||
|
|
||||||
// system 消息始终保留
|
// 计算每条消息的 token + 重要性
|
||||||
if (msg.role === 'system') {
|
const scored = nonSystemMsgs.map(m => ({
|
||||||
totalTokens += msgTokens;
|
msg: m,
|
||||||
result.unshift(msg);
|
tokens: estimateTokens(m.content || '') +
|
||||||
continue;
|
(m.images ? m.images.length * 100 : 0) +
|
||||||
}
|
(m.tool_calls ? m.tool_calls.length * 50 : 0),
|
||||||
|
importance: scoreMessageImportance(m),
|
||||||
|
}));
|
||||||
|
|
||||||
if (totalTokens + msgTokens > maxTokens && result.length > 2) {
|
// system 消息的 token 消耗
|
||||||
break;
|
const systemTokens = systemMsgs.reduce((sum, m) => sum + estimateTokens(m.content || ''), 0);
|
||||||
}
|
const availableTokens = maxTokens - systemTokens;
|
||||||
|
|
||||||
totalTokens += msgTokens;
|
// 按重要性降序排列,取能装下的最大数量
|
||||||
result.unshift(msg);
|
scored.sort((a, b) => b.importance - a.importance);
|
||||||
|
|
||||||
|
let usedTokens = 0;
|
||||||
|
const kept = new Set<number>(); // 保留的索引
|
||||||
|
for (let i = 0; i < scored.length; i++) {
|
||||||
|
if (usedTokens + scored[i].tokens > availableTokens && kept.size >= 2) break;
|
||||||
|
usedTokens += scored[i].tokens;
|
||||||
|
kept.add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按原始顺序重建:system → 按重要性保留的非system
|
||||||
|
const result: OllamaMessage[] = [...systemMsgs];
|
||||||
|
// 使用原始顺序而非重要性顺序,保持对话的时间线
|
||||||
|
const keptSet = new Set(scored.filter((_, i) => kept.has(i)).map(s => s.msg));
|
||||||
|
for (const m of nonSystemMsgs) {
|
||||||
|
if (keptSet.has(m)) result.push(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ const PBKDF2_ITERATIONS = 100000;
|
|||||||
|
|
||||||
async function deriveKeyBytes(salt: Uint8Array): Promise<Uint8Array> {
|
async function deriveKeyBytes(salt: Uint8Array): Promise<Uint8Array> {
|
||||||
const passBytes = new TextEncoder().encode(PASSPHRASE);
|
const passBytes = new TextEncoder().encode(PASSPHRASE);
|
||||||
const keyMaterial = await crypto.subtle.importKey('raw', passBytes, 'PBKDF2', false, ['deriveKey']);
|
const keyMaterial = await (crypto.subtle as any).importKey('raw', passBytes, 'PBKDF2', false, ['deriveKey']);
|
||||||
const key = await crypto.subtle.deriveKey(
|
const key = await crypto.subtle.deriveKey(
|
||||||
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
{ name: 'PBKDF2', salt: salt as any, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||||
keyMaterial,
|
keyMaterial,
|
||||||
{ name: 'AES-GCM', length: 256 },
|
{ name: 'AES-GCM', length: 256 },
|
||||||
true,
|
true,
|
||||||
@@ -27,7 +27,7 @@ export async function encryptData(data: unknown): Promise<Blob> {
|
|||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
const keyBytes = await deriveKeyBytes(salt);
|
const keyBytes = await deriveKeyBytes(salt);
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']);
|
const key = await crypto.subtle.importKey('raw', keyBytes as any, 'AES-GCM', false, ['encrypt']);
|
||||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, json);
|
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, json);
|
||||||
const payload = new Uint8Array(ciphertext);
|
const payload = new Uint8Array(ciphertext);
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ export async function decryptData(buffer: ArrayBuffer): Promise<unknown> {
|
|||||||
throw new Error('不支持的加密格式,请使用最新版本重新导出');
|
throw new Error('不支持的加密格式,请使用最新版本重新导出');
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['decrypt']);
|
const key = await crypto.subtle.importKey('raw', keyBytes as any, 'AES-GCM', false, ['decrypt']);
|
||||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, payload);
|
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, payload);
|
||||||
const jsonBytes = new Uint8Array(decrypted);
|
const jsonBytes = new Uint8Array(decrypted);
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
getMemoryCollectionId
|
getMemoryCollectionId
|
||||||
} from './vector-memory.js';
|
} from './vector-memory.js';
|
||||||
import { logMemory, logDebug, logWarn, logInfo } from './log-service.js';
|
import { logMemory, logDebug, logWarn, logInfo } from './log-service.js';
|
||||||
import type { MemoryEntry, MemorySearchResult, MemoryExtractionResult, ChatDB, OllamaAPI } from '../types.js';
|
import type { MemoryEntry, MemorySearchResult, MemoryExtractionResult } from '../types.js';
|
||||||
|
import type { ChatDB } from '../db/chat-db.js';
|
||||||
|
import type { OllamaAPI } from '../api/ollama.js';
|
||||||
|
|
||||||
const TYPE_ICONS: Record<string, string> = {
|
const TYPE_ICONS: Record<string, string> = {
|
||||||
fact: '📌',
|
fact: '📌',
|
||||||
@@ -686,10 +688,9 @@ ${conversationText.slice(0, 4000)}
|
|||||||
const response = await api.chat({
|
const response = await api.chat({
|
||||||
model,
|
model,
|
||||||
messages: [{ role: 'user', content: extractPrompt }],
|
messages: [{ role: 'user', content: extractPrompt }],
|
||||||
stream: false,
|
|
||||||
think: false,
|
think: false,
|
||||||
options: { num_ctx: 8192, temperature: 0.1 }
|
options: { num_ctx: 8192, temperature: 0.1 }
|
||||||
});
|
} as any);
|
||||||
|
|
||||||
const content = (response as { message?: { content?: string } })?.message?.content || '';
|
const content = (response as { message?: { content?: string } })?.message?.content || '';
|
||||||
const jsonMatch = content.match(new RegExp('```json\\s*([\\s\\S]*?)\\s*```')) || content.match(/\{[\s\S]*"entries"[\s\S]*\}/);
|
const jsonMatch = content.match(new RegExp('```json\\s*([\\s\\S]*?)\\s*```')) || content.match(/\{[\s\S]*"entries"[\s\S]*\}/);
|
||||||
@@ -715,7 +716,7 @@ ${conversationText.slice(0, 4000)}
|
|||||||
importance: Math.min(10, Math.max(1, entry.importance || 5)),
|
importance: Math.min(10, Math.max(1, entry.importance || 5)),
|
||||||
tags: entry.tags || [],
|
tags: entry.tags || [],
|
||||||
source: '自动提取',
|
source: '自动提取',
|
||||||
sessionId: currentSession?.id
|
sessionId: (currentSession as any)?.id
|
||||||
});
|
});
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export async function extractSkillsFromToolRecords(
|
|||||||
const chainNames = chain.map(s => s.name).join(' → ');
|
const chainNames = chain.map(s => s.name).join(' → ');
|
||||||
|
|
||||||
// 检查是否已有完全相同的技能链
|
// 检查是否已有完全相同的技能链
|
||||||
const existingSkills: Skill[] = await bridge.db.getAllSkills();
|
const existingSkills = await bridge.db.getAllSkills() as Skill[];
|
||||||
const duplicate = existingSkills.find(s => {
|
const duplicate = existingSkills.find(s => {
|
||||||
try {
|
try {
|
||||||
const existingChain: ToolChainStep[] = JSON.parse(s.tool_chain);
|
const existingChain: ToolChainStep[] = JSON.parse(s.tool_chain);
|
||||||
@@ -73,7 +73,29 @@ export async function extractSkillsFromToolRecords(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
// 已有相同技能,不重复创建
|
// v1.1: 重复链 → 更新参数提示(合并新知)+ 增加计数
|
||||||
|
try {
|
||||||
|
const existingChain: ToolChainStep[] = JSON.parse(duplicate.tool_chain);
|
||||||
|
let updated = false;
|
||||||
|
for (let i = 0; i < Math.min(chain.length, existingChain.length); i++) {
|
||||||
|
const newArgs = chain[i].args_hint.split(',').map(a => a.trim()).filter(Boolean);
|
||||||
|
const oldArgs = existingChain[i].args_hint.split(',').map(a => a.trim()).filter(Boolean);
|
||||||
|
const merged = [...new Set([...oldArgs, ...newArgs])];
|
||||||
|
const mergedHint = merged.join(', ');
|
||||||
|
if (mergedHint !== existingChain[i].args_hint) {
|
||||||
|
existingChain[i].args_hint = mergedHint;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
duplicate.tool_chain = JSON.stringify(existingChain);
|
||||||
|
duplicate.success_count++;
|
||||||
|
duplicate.updated_at = Date.now();
|
||||||
|
await bridge.db.saveSkill(duplicate);
|
||||||
|
logInfo(`技能参数优化: ${duplicate.name}`, `合并了新的参数提示`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
} catch { /* 更新失败不阻塞 */ }
|
||||||
logInfo(`技能已存在,跳过提取: ${duplicate.name}`);
|
logInfo(`技能已存在,跳过提取: ${duplicate.name}`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -113,20 +135,37 @@ export async function extractSkillsFromToolRecords(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 匹配用户消息与已有技能
|
* 匹配用户消息与已有技能。
|
||||||
* 返回匹配度最高的技能列表
|
* v1.1: 关键词 + 语义匹配融合,未使用技能衰减。
|
||||||
*/
|
*/
|
||||||
export async function matchSkills(userMessage: string, limit = 3): Promise<Skill[]> {
|
export async function matchSkills(userMessage: string, limit = 3): Promise<Skill[]> {
|
||||||
const bridge = window.metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (!bridge?.db?.getAllSkills) return [];
|
if (!bridge?.db?.getAllSkills) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allSkills: Skill[] = await bridge.db.getAllSkills();
|
const allSkills = await bridge.db.getAllSkills() as any as Skill[];
|
||||||
if (allSkills.length === 0) return [];
|
if (allSkills.length === 0) return [];
|
||||||
|
|
||||||
const messageLower = userMessage.toLowerCase();
|
const messageLower = userMessage.toLowerCase();
|
||||||
const messageWords = new Set(messageLower.split(/[\s,,。!?、;:""''()\[\]【】]+/).filter(w => w.length > 1));
|
const messageWords = new Set(messageLower.split(/[\s,,。!?、;:""''()\[\]【】]+/).filter(w => w.length > 1));
|
||||||
|
|
||||||
|
// ── 语义匹配(如果 embedding 模型可用)──
|
||||||
|
let userEmbedding: number[] | null = null;
|
||||||
|
try {
|
||||||
|
const memEnabled = state.get<boolean>('memoryEnabled', false);
|
||||||
|
const embedModel = state.get<string>('embeddingModel', '');
|
||||||
|
if (memEnabled && embedModel) {
|
||||||
|
const api = state.get<any>(KEYS.API);
|
||||||
|
if (api?.embed) {
|
||||||
|
const result = await api.embed(embedModel, userMessage);
|
||||||
|
userEmbedding = result.embedding || result.embeddings?.[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* 语义匹配失败回退关键词 */ }
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const THIRTY_DAYS = 30 * 24 * 3600 * 1000;
|
||||||
|
|
||||||
const scored = allSkills.map(skill => {
|
const scored = allSkills.map(skill => {
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
|
||||||
@@ -134,24 +173,43 @@ export async function matchSkills(userMessage: string, limit = 3): Promise<Skill
|
|||||||
if (skill.trigger_keywords) {
|
if (skill.trigger_keywords) {
|
||||||
const kwSet = new Set(skill.trigger_keywords.toLowerCase().split(',').map(k => k.trim()));
|
const kwSet = new Set(skill.trigger_keywords.toLowerCase().split(',').map(k => k.trim()));
|
||||||
for (const kw of kwSet) {
|
for (const kw of kwSet) {
|
||||||
if (kw && messageLower.includes(kw)) score += 0.4;
|
if (kw && messageLower.includes(kw)) score += 0.3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 名称和描述匹配
|
// 名称和描述匹配
|
||||||
const nameWords = skill.name.toLowerCase().split(/[\s→\-]+/);
|
const nameWords = skill.name.toLowerCase().split(/[\s→\-]+/);
|
||||||
for (const w of nameWords) {
|
for (const w of nameWords) {
|
||||||
if (w.length > 1 && messageWords.has(w)) score += 0.2;
|
if (w.length > 1 && messageWords.has(w)) score += 0.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成功率加成
|
// 语义匹配:余弦相似度加权(主导分数)
|
||||||
|
if (userEmbedding && skill.summary) {
|
||||||
|
const skillEmbed = getCachedSkillEmbedding(skill.id);
|
||||||
|
if (skillEmbed) {
|
||||||
|
const sim = cosineSimilarity(userEmbedding, skillEmbed);
|
||||||
|
score += sim * 0.4; // 语义匹配主导
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功率加权
|
||||||
const total = skill.success_count + skill.fail_count;
|
const total = skill.success_count + skill.fail_count;
|
||||||
if (total > 0) score *= (0.5 + 0.5 * (skill.success_count / total));
|
if (total > 0) score *= (0.5 + 0.5 * (skill.success_count / total));
|
||||||
|
|
||||||
|
// ── v1.1 未使用技能衰减 ──
|
||||||
|
if (skill.last_used_at) {
|
||||||
|
const daysSinceUse = (now - skill.last_used_at) / (24 * 3600 * 1000);
|
||||||
|
if (daysSinceUse > 30) score *= 0.5; // 30天未用降权50%
|
||||||
|
if (daysSinceUse > 90) score *= 0.3; // 90天未用几乎忽略
|
||||||
|
}
|
||||||
|
|
||||||
return { skill, score };
|
return { skill, score };
|
||||||
});
|
});
|
||||||
|
|
||||||
return scored
|
// ── v1.1 技能链合并:相同前缀的链抽象为一个技能 ──
|
||||||
|
const merged = mergeSimilarSkills(scored);
|
||||||
|
|
||||||
|
return merged
|
||||||
.filter(s => s.score >= MATCH_THRESHOLD)
|
.filter(s => s.score >= MATCH_THRESHOLD)
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
@@ -161,6 +219,98 @@ export async function matchSkills(userMessage: string, limit = 3): Promise<Skill
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 向量计算工具 ──
|
||||||
|
|
||||||
|
/** 技能 embedding 缓存(LRU 简单实现) */
|
||||||
|
const _skillEmbedCache = new Map<string, { embedding: number[]; time: number }>();
|
||||||
|
|
||||||
|
function getCachedSkillEmbedding(skillId: string): number[] | null {
|
||||||
|
const cached = _skillEmbedCache.get(skillId);
|
||||||
|
if (cached && Date.now() - cached.time < 300_000) return cached.embedding; // 5分钟有效
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheSkillEmbedding(skillId: string, embedding: number[]): void {
|
||||||
|
_skillEmbedCache.set(skillId, { embedding, time: Date.now() });
|
||||||
|
// 限制缓存大小
|
||||||
|
if (_skillEmbedCache.size > 200) {
|
||||||
|
const oldest = [..._skillEmbedCache.entries()].sort((a, b) => a[1].time - b[1].time)[0];
|
||||||
|
if (oldest) _skillEmbedCache.delete(oldest[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cosineSimilarity(a: number[], b: number[]): number {
|
||||||
|
if (!a || !b || a.length !== b.length) return 0;
|
||||||
|
let dot = 0, normA = 0, normB = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
dot += a[i] * b[i];
|
||||||
|
normA += a[i] * a[i];
|
||||||
|
normB += b[i] * b[i];
|
||||||
|
}
|
||||||
|
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
||||||
|
return denom === 0 ? 0 : dot / denom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 技能链合并 ──
|
||||||
|
|
||||||
|
interface ScoredSkill { skill: Skill; score: number; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.1 技能链合并:两个以上技能有相同前缀(前 2 步相同),
|
||||||
|
* 合并为一个抽象技能,分数取最高。
|
||||||
|
*/
|
||||||
|
function mergeSimilarSkills(scored: ScoredSkill[]): ScoredSkill[] {
|
||||||
|
if (scored.length < 2) return scored;
|
||||||
|
|
||||||
|
const result: ScoredSkill[] = [];
|
||||||
|
const used = new Set<number>();
|
||||||
|
|
||||||
|
for (let i = 0; i < scored.length; i++) {
|
||||||
|
if (used.has(i)) continue;
|
||||||
|
const group: ScoredSkill[] = [scored[i]];
|
||||||
|
|
||||||
|
// 找同前缀的
|
||||||
|
for (let j = i + 1; j < scored.length; j++) {
|
||||||
|
if (used.has(j)) continue;
|
||||||
|
if (getChainPrefix(scored[i].skill) === getChainPrefix(scored[j].skill)) {
|
||||||
|
group.push(scored[j]);
|
||||||
|
used.add(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.length >= 2) {
|
||||||
|
// 合并为一个抽象技能
|
||||||
|
const best = group.reduce((a, b) => a.score > b.score ? a : b);
|
||||||
|
const chainNames = group.map(g => {
|
||||||
|
try { return (JSON.parse(g.skill.tool_chain) as ToolChainStep[]).map(s => s.name).join('→'); }
|
||||||
|
catch { return g.skill.name; }
|
||||||
|
});
|
||||||
|
const mergedSkill: Skill = {
|
||||||
|
...best.skill,
|
||||||
|
name: `${getChainPrefix(best.skill)} → ...`,
|
||||||
|
description: `合并了 ${group.length} 个相似技能: ${chainNames.join(' | ')}`,
|
||||||
|
summary: best.skill.summary,
|
||||||
|
};
|
||||||
|
result.push({ skill: mergedSkill, score: best.score * 1.1 }); // 合并后略加分
|
||||||
|
used.add(i);
|
||||||
|
} else {
|
||||||
|
result.push(scored[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取技能链的前 2 步前缀(用于相似度判断) */
|
||||||
|
function getChainPrefix(skill: Skill): string {
|
||||||
|
try {
|
||||||
|
const chain: ToolChainStep[] = JSON.parse(skill.tool_chain);
|
||||||
|
return chain.slice(0, 2).map(s => s.name).join('→');
|
||||||
|
} catch {
|
||||||
|
return skill.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从匹配的技能构建 system prompt 上下文(Level 0:仅名称 + 描述索引)
|
* 从匹配的技能构建 system prompt 上下文(Level 0:仅名称 + 描述索引)
|
||||||
* 完整工具链通过 skill_view 工具按需加载(Level 1)
|
* 完整工具链通过 skill_view 工具按需加载(Level 1)
|
||||||
@@ -286,8 +436,8 @@ export async function listSkills(): Promise<Array<{
|
|||||||
}>> {
|
}>> {
|
||||||
const bridge = window.metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (!bridge?.db?.getAllSkills) return [];
|
if (!bridge?.db?.getAllSkills) return [];
|
||||||
const allSkills: Skill[] = await bridge.db.getAllSkills();
|
const allSkills: Skill[] = await bridge.db.getAllSkills() as any;
|
||||||
return allSkills.map(skill => {
|
return (allSkills as any[]).map((skill: any) => {
|
||||||
const total = skill.success_count + skill.fail_count;
|
const total = skill.success_count + skill.fail_count;
|
||||||
const rate = total > 0 ? Math.round(skill.success_count / total * 100) : 100;
|
const rate = total > 0 ? Math.round(skill.success_count / total * 100) : 100;
|
||||||
let chainPreview = '';
|
let chainPreview = '';
|
||||||
@@ -313,7 +463,7 @@ export async function viewSkill(skillName: string): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
const bridge = window.metonaDesktop;
|
const bridge = window.metonaDesktop;
|
||||||
if (!bridge?.db?.getAllSkills) return { success: false, error: '数据库不可用' };
|
if (!bridge?.db?.getAllSkills) return { success: false, error: '数据库不可用' };
|
||||||
const allSkills: Skill[] = await bridge.db.getAllSkills();
|
const allSkills: Skill[] = await bridge.db.getAllSkills() as any;
|
||||||
const skill = allSkills.find(s =>
|
const skill = allSkills.find(s =>
|
||||||
s.name.toLowerCase() === skillName.toLowerCase() ||
|
s.name.toLowerCase() === skillName.toLowerCase() ||
|
||||||
s.name.toLowerCase().includes(skillName.toLowerCase())
|
s.name.toLowerCase().includes(skillName.toLowerCase())
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
import { state, KEYS } from '../state/state.js';
|
import { state, KEYS } from '../state/state.js';
|
||||||
import { VectorStore } from './vector-store.js';
|
import { VectorStore } from './vector-store.js';
|
||||||
import { logMemory, logDebug, logError } from './log-service.js';
|
import { logMemory, logDebug, logError } from './log-service.js';
|
||||||
import type { MemoryEntry, OllamaAPI, VectorItem, SearchResult } from '../types.js';
|
import type { MemoryEntry, VectorItem, SearchResult } from '../types.js';
|
||||||
|
import type { OllamaAPI } from '../api/ollama.js';
|
||||||
|
|
||||||
const MEMORY_COLLECTION_NAME = '记忆向量索引';
|
const MEMORY_COLLECTION_NAME = '记忆向量索引';
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ export class VectorStore {
|
|||||||
|
|
||||||
if (saved && saved.version === this._indexVersion(vectors)) {
|
if (saved && saved.version === this._indexVersion(vectors)) {
|
||||||
if (!(saved.invertedLists instanceof Map)) {
|
if (!(saved.invertedLists instanceof Map)) {
|
||||||
saved.invertedLists = new Map(Object.entries(saved.invertedListsObj || {}));
|
saved.invertedLists = new Map(Object.entries(saved.invertedListsObj || {}).map(([k, v]) => [parseInt(k), v as string[]]));
|
||||||
}
|
}
|
||||||
this._indexCache.set(colId, saved);
|
this._indexCache.set(colId, saved);
|
||||||
return saved;
|
return saved;
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
* State - 轻量级响应式状态管理
|
* State - 轻量级响应式状态管理
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { StateKey, ChatSession, ChatDB, OllamaAPI } from '../types.js';
|
import type { StateKey, ChatSession } from '../types.js';
|
||||||
|
import type { ChatDB } from '../db/chat-db.js';
|
||||||
|
import type { OllamaAPI } from '../api/ollama.js';
|
||||||
|
|
||||||
function shallowEqual(a: unknown, b: unknown): boolean {
|
function shallowEqual(a: unknown, b: unknown): boolean {
|
||||||
if (a === b) return true;
|
if (a === b) return true;
|
||||||
@@ -45,7 +47,7 @@ class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update(key: string, updater: (old: unknown) => unknown): void {
|
update(key: string, updater: (old: any) => any): void {
|
||||||
const old = this._state[key];
|
const old = this._state[key];
|
||||||
const value = updater(old);
|
const value = updater(old);
|
||||||
this._state[key] = value;
|
this._state[key] = value;
|
||||||
|
|||||||
@@ -900,6 +900,75 @@ html, body {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 系统提示词折叠卡片 ── */
|
||||||
|
.system-prompt-card {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid rgba(155, 126, 216, 0.12);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(155, 126, 216, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-prompt-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.system-prompt-header:hover {
|
||||||
|
background: rgba(155, 126, 216, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-icon { font-size: 14px; flex-shrink: 0; }
|
||||||
|
.sp-title { font-weight: 600; color: #9B7ED8; white-space: nowrap; }
|
||||||
|
.sp-tokens {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--bg-layer);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sp-preview {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.sp-chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.sp-chevron.sp-expanded { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.system-prompt-body {
|
||||||
|
border-top: 1px solid rgba(155, 126, 216, 0.08);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.system-prompt-body pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
|
||||||
/* 打字光标 */
|
/* 打字光标 */
|
||||||
.typing-cursor {
|
.typing-cursor {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
Vendored
+4
@@ -51,6 +51,7 @@ export interface OllamaModelInfo {
|
|||||||
|
|
||||||
export interface OllamaModelDetail {
|
export interface OllamaModelDetail {
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
|
model_info?: Record<string, any>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ export interface ModelCaps {
|
|||||||
vision: boolean;
|
vision: boolean;
|
||||||
tools: boolean;
|
tools: boolean;
|
||||||
completion: boolean;
|
completion: boolean;
|
||||||
|
contextLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OllamaVersionResponse {
|
export interface OllamaVersionResponse {
|
||||||
@@ -111,6 +113,8 @@ export interface ChatMessage {
|
|||||||
toolCalls?: ToolCallRecord[];
|
toolCalls?: ToolCallRecord[];
|
||||||
/** 标记此消息为 LLM 压缩摘要生成 */
|
/** 标记此消息为 LLM 压缩摘要生成 */
|
||||||
compressed?: boolean;
|
compressed?: boolean;
|
||||||
|
/** 实际发送给模型的完整系统提示词(调试用) */
|
||||||
|
_systemPrompt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatSession {
|
export interface ChatSession {
|
||||||
|
|||||||
Reference in New Issue
Block a user