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:
thzxx
2026-06-04 21:47:18 +08:00
parent b12837433b
commit db6cb8d2cc
27 changed files with 646 additions and 139 deletions
+47
View File
@@ -6,6 +6,7 @@ import { state, KEYS } from '../state/state.js';
import { logError, logSession } from '../services/log-service.js';
import { marked } from '../utils/marked-config.js';
import { escapeHtml, formatTime } from '../utils/utils.js';
import { estimateTokens } from '../services/context-manager.js';
import type { ChatSession, ChatMessage, ToolCallRecord } from '../types.js';
function getFileIcon(filename: string): string {
@@ -30,6 +31,31 @@ let scrollBtnEl: HTMLElement;
let autoScroll = true;
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 {
chatAreaEl = document.querySelector('#chatArea')!;
messagesContainerEl = document.querySelector('#messagesContainer')!;
@@ -118,6 +144,12 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void {
const safeContent = (msg.content != null) ? String(msg.content) : '';
if (msg.role === 'assistant') {
// ── 系统提示词折叠卡片(每条助理消息顶部展示)──
const sysPrompt = state.get<string>('_lastSystemPrompt', '');
if (sysPrompt) {
contentHtml += renderSystemPromptCard(sysPrompt);
}
if (msg.think) {
contentHtml += `
<div class="think-block">
@@ -273,6 +305,21 @@ export function updateLastAssistantMessage(
const loadingText = lastMsg.querySelector('.loading-text');
if (loadingDots) loadingDots.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) {
+41 -23
View File
@@ -12,7 +12,7 @@ import {
appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder, resetCurrentPlaceholder
} from './chat-area.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 { OllamaAPI } from '../api/ollama.js';
import { runAgentLoop } from '../services/agent-engine.js';
@@ -94,9 +94,7 @@ export function initInputArea(): void {
const bridge = getBridge();
if (!bridge) return;
const paths = await bridge.dialog.openFile({
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }],
properties: ['openFile'],
multiple: false
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }]
});
if (!paths || paths.length === 0) return;
await handleImagePaths(paths);
@@ -141,7 +139,7 @@ async function handleImagePaths(paths: string[]): Promise<void> {
for (const filePath of paths) {
const name = filePath.split(/[/\\]/).pop() || filePath;
try {
const result = await bridge.fs.readFileBase64(filePath);
const result = await bridge!.fs.readFileBase64(filePath);
if (!result.success) {
appendSystemMessage(`❌ 读取 ${name} 失败: ${result.error}`);
continue;
@@ -186,7 +184,7 @@ async function handleTextFilePaths(paths: string[]): Promise<void> {
continue;
}
try {
const result = await bridge.fs.readFile(filePath);
const result = await bridge!.fs.readFile(filePath);
if (!result.success) {
appendSystemMessage(`❌ 读取 ${name} 失败: ${result.error}`);
continue;
@@ -308,7 +306,7 @@ async function handleRetry(): Promise<void> {
...(retryThinkContent && { think: retryThinkContent }),
...(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()
}));
renderMessages();
@@ -353,7 +351,7 @@ async function handleRetry(): Promise<void> {
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
...(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()
}));
}
@@ -367,7 +365,7 @@ async function handleRetry(): Promise<void> {
...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
...(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()
}));
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;
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
...(isFirstMsg && {
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 thinkContent = '';
let finalStats: { eval_count?: number; prompt_eval_count?: number; total_duration?: number } | null = null;
let finalStats: any = null;
let modelName = '';
try {
@@ -697,6 +695,20 @@ export async function sendMessage(): Promise<void> {
};
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 记忆注入 ──
if (isMemoryEnabled()) {
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);
if (relevantMemories.length > 0) {
const memoryContext = buildMemoryContext(relevantMemories);
chatParams.system = memoryContext;
chatParams.system = (soulContent ? soulContent + '\n\n' : '') + memoryContext;
for (const m of relevantMemories) {
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) => {
if (chunk.message) {
@@ -752,9 +770,9 @@ export async function sendMessage(): Promise<void> {
timestamp: Date.now(),
...(modelName && { model: modelName }),
...(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,
messages: [...session.messages, assistantMsg],
updatedAt: Date.now()
@@ -800,7 +818,7 @@ export async function sendMessage(): Promise<void> {
...(thinkContent && { think: thinkContent }),
stopped: true
};
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, partialMsg],
updatedAt: Date.now()
@@ -829,7 +847,7 @@ export async function sendMessage(): Promise<void> {
const contentDiv = placeholder?.querySelector('.msg-content');
if (assistantContent && contentDiv) {
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, { role: 'assistant', content: assistantContent + '\n\n`[已中断]`', timestamp: Date.now() } as ChatMessage],
updatedAt: Date.now()
@@ -864,14 +882,14 @@ export async function sendMessage(): Promise<void> {
/** 更新当前会话消息中最近一个 'running' 状态的同名工具记录 */
function updateMessageToolRecord(toolName: string, status: 'success' | 'error', result: Record<string, unknown>): void {
let updated = false;
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => {
state.update(KEYS.CURRENT_SESSION, (session: any) => {
if (!session) return session;
const messages = [...session.messages];
// 从后往前找最近一条包含该工具 running 状态记录的消息
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
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) {
const updatedRecords = [...msg.toolCalls];
updatedRecords[idx] = { ...updatedRecords[idx], status, result };
@@ -919,7 +937,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
}
const isFirstMsg = currentSession.messages.length === 0;
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
...(isFirstMsg && {
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 }),
...(iterationDuration > 0 && { total_duration: iterationDuration }),
};
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, prevMsg],
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?.total_duration && { total_duration: loopStats.total_duration }),
};
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, lastMsg],
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?.total_duration && { total_duration: loopStats.total_duration }),
};
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, assistantMsg],
updatedAt: Date.now()
@@ -1133,7 +1151,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
...(abortToolRecords?.length && { toolCalls: abortToolRecords }),
stopped: true
};
state.update(KEYS.CURRENT_SESSION, (session: ChatSession | null) => ({
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session,
messages: [...session.messages, partialMsg],
updatedAt: Date.now()
+41 -4
View File
@@ -122,11 +122,22 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise<void>
const info = await api.showModel(m.name);
const caps = info.capabilities || [];
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, {
think: caps.includes('thinking'),
vision: caps.includes('vision'),
tools: caps.includes('tools'),
completion: isCompletion,
contextLength,
});
if (!isCompletion) {
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> {
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;
}
@@ -159,18 +174,40 @@ async function checkModelCapability(modelName: string): Promise<void> {
try {
const info = await api.showModel(modelName);
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'),
vision: capabilities.includes('vision'),
tools: capabilities.includes('tools'),
completion: capabilities.includes('completion'),
contextLength,
};
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);
} catch (err) {
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);
applyCapability(modelName, fallback);
}
+3 -13
View File
@@ -39,16 +39,6 @@ export function initSettingsModal(): void {
}, 500);
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 tempDisplay = document.querySelector('#tempValue')!;
tempSlider.addEventListener('input', () => {
@@ -250,9 +240,9 @@ export function initSettingsModal(): void {
const result = await bridge.workspace.setDir(newDir);
if (result.success) {
showToast('工作空间目录已更新', 'success');
logSetting('工作空间目录', result.dir);
logSetting('工作空间目录', result.dir!);
} else {
showToast(`设置失败: ${result.error}`, 'error');
showToast(`设置失败: ${result.error!}`, 'error');
// 恢复原值
const info = await bridge.workspace.getDir();
inputWorkspaceDir.value = info.dir;
@@ -401,7 +391,7 @@ async function importSessions(filePath: string): Promise<void> {
try {
const bridge = window.metonaDesktop;
const result = await bridge.fs.readFileBase64(filePath);
const result = await bridge!.fs.readFileBase64(filePath);
if (!result.success) {
showToast(`读取文件失败: ${result.error}`, 'error');
return;
+4 -4
View File
@@ -301,7 +301,7 @@ function renderBarChart(
/** 渲染全局柱状图(按会话) */
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 savedScrollLeft = barsEl?.scrollLeft ?? 0;
@@ -329,7 +329,7 @@ function renderGlobalChart(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 = '📋 会话汇总明细';
// 动态更新表头
@@ -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 savedScrollLeft = barsEl?.scrollLeft ?? 0;
@@ -423,7 +423,7 @@ function renderSessionView(): void {
renderBarChart(chartEl, chartData, '按轮次', undefined, savedScrollLeft);
// 表格标题
const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle');
const tableTitleEl = dashboardModalEl.querySelector('#tdTableTitle') as HTMLElement | null;
if (tableTitleEl) tableTitleEl.textContent = '📋 轮次明细(按时间倒序)';
// 动态更新表头
+1 -1
View File
@@ -7,7 +7,7 @@ import { state, KEYS } from '../state/state.js';
import { setToolEnabled, setRunCommandMode } from '../services/tool-registry.js';
import { showToast } from './toast.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;