feat: v0.11.3 - 工具卡片三态转换 + 流式进度监控 + 默认工具页签
- 工具卡片完整生命周期: 准备中 -> 执行中 -> 完成 - onToolCallPrepare 流式阶段提前显示工具意图 - 工作空间监控定时器: AI处理中提示 - 日志面板流式进度: 每5秒输出, 15秒无新内容警告 - 提示栏按状态分组显示所有工具 - write_file 写入前增加开始日志 - 工具页签新卡片始终自动滚动到底部 - 默认页签切换为工具页签
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-v0.11.2-E8734A?style=flat-square" alt="version">
|
||||
<img src="https://img.shields.io/badge/version-v0.11.3-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/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">
|
||||
@@ -245,7 +245,7 @@ npm start
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||
```
|
||||
|
||||
产出:`release/Metona Ollama Setup v0.11.2.exe`
|
||||
产出:`release/Metona Ollama Setup v0.11.3.exe`
|
||||
|
||||
## 🛠️ 常用命令
|
||||
|
||||
@@ -481,7 +481,7 @@ npm start
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||
```
|
||||
|
||||
Output: `release/Metona Ollama Setup v0.11.2.exe`
|
||||
Output: `release/Metona Ollama Setup v0.11.3.exe`
|
||||
|
||||
## 🛠️ Common Commands
|
||||
|
||||
|
||||
+3
-3
@@ -91,7 +91,7 @@ npm run dist # NSIS 安装包
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `release/Metona Ollama Setup v0.11.2.exe` | NSIS 安装包(可选目录、创建快捷方式) |
|
||||
| `release/Metona Ollama Setup v0.11.3.exe` | NSIS 安装包(可选目录、创建快捷方式) |
|
||||
|
||||
> 仅提供 NSIS 安装包。
|
||||
|
||||
@@ -103,7 +103,7 @@ npm run dist # NSIS 安装包
|
||||
TOKEN="your_access_token"
|
||||
curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases?access_token=$TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag_name":"v0.11.2","name":"v0.11.2","body":"Release notes","target_commitish":"master"}'
|
||||
-d '{"tag_name":"v0.11.3","name":"v0.11.3","body":"Release notes","target_commitish":"master"}'
|
||||
```
|
||||
|
||||
### 上传附件
|
||||
@@ -112,7 +112,7 @@ curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/release
|
||||
TOKEN="your_access_token"
|
||||
RELEASE_ID="<release_id>"
|
||||
curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \
|
||||
-H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.2.exe"
|
||||
-H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.3.exe"
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Metona Ollama Desktop — 开发规范
|
||||
|
||||
> 版本: v0.11.2 | 更新: 2026-06-05 | 维护: 项目团队
|
||||
> 版本: v0.11.3 | 更新: 2026-06-05 | 维护: 项目团队
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "metona-ollama-desktop",
|
||||
"version": "0.11.2",
|
||||
"version": "0.11.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "metona-ollama-desktop",
|
||||
"version": "0.11.2",
|
||||
"version": "0.11.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sql.js": "^1.11.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "metona-ollama-desktop",
|
||||
"version": "0.11.2",
|
||||
"version": "0.11.3",
|
||||
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
||||
"main": "dist/main/main.js",
|
||||
"author": "thzxx",
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ export function createMenu(): void {
|
||||
dialog.showMessageBox(mainWindow!, {
|
||||
type: 'info',
|
||||
title: '关于 Metona Ollama',
|
||||
message: 'Metona Ollama Desktop v0.11.2',
|
||||
message: 'Metona Ollama Desktop v0.11.3',
|
||||
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
|
||||
icon: getIconPath()
|
||||
});
|
||||
|
||||
@@ -196,6 +196,7 @@ export async function handleWriteFile(params: { path: string; content: string; e
|
||||
}
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
sendLog('info', `✏️ write_file 开始`, `${filePath} (${contentLength}B, ${writeMode})`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
if (writeMode === 'append') {
|
||||
|
||||
@@ -261,7 +261,7 @@ function renderToolCallCard(tc: ToolCallRecord): string {
|
||||
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
|
||||
};
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: '⏳ 等待确认', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
|
||||
pending: '📝 准备中…', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
|
||||
};
|
||||
|
||||
const icon = icons[tc.name] || '🔧';
|
||||
@@ -428,8 +428,9 @@ export function updateToolCallCardInPlaceholder(tc: ToolCallRecord): void {
|
||||
if (!currentPlaceholder) return;
|
||||
const container = currentPlaceholder.querySelector('.tool-calls-container');
|
||||
if (!container) return;
|
||||
// 找到第一个同名且状态为 running 的卡片
|
||||
const existing = container.querySelector(`.tool-call-card[data-tool-name="${tc.name}"].tool-call-running`) as HTMLElement;
|
||||
// 找到第一个同名且状态为 running 的卡片(CSS.escape 防特殊字符)
|
||||
const escapedName = CSS.escape(tc.name);
|
||||
const existing = container.querySelector(`.tool-call-card[data-tool-name="${escapedName}"].tool-call-running`) as HTMLElement;
|
||||
if (existing) {
|
||||
const cardDiv = document.createElement('div');
|
||||
cardDiv.innerHTML = renderToolCallCard(tc);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder, resetCurrentPlaceholder
|
||||
} from './chat-area.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { addToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath } from './workspace-panel.js';
|
||||
import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js';
|
||||
import { ChatDB } from '../db/chat-db.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import { runAgentLoop } from '../services/agent-engine.js';
|
||||
@@ -352,11 +352,26 @@ async function handleRetry(): Promise<void> {
|
||||
return { role: m.role, content, ...(m.images?.length && { images: m.images }) };
|
||||
});
|
||||
|
||||
let retryMonitor: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
try {
|
||||
let retryContent = '';
|
||||
let retryThinkContent = '';
|
||||
let retryIterations = 0;
|
||||
|
||||
// ── 监控定时器 ──
|
||||
retryMonitor = setInterval(() => {
|
||||
if (!hasActiveCards()) {
|
||||
showWorkingHint('⏳ AI 正在处理中...');
|
||||
}
|
||||
}, 2000);
|
||||
await runAgentLoop(userMsg.content || '', userMsg.images || [], historyMessages, {
|
||||
onToolCallPrepare: (call) => {
|
||||
addToolCard({
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: null, status: 'pending', timestamp: Date.now()
|
||||
});
|
||||
},
|
||||
onNewIteration: (toolCalls) => {
|
||||
if (retryContent || toolCalls) {
|
||||
const now = Date.now();
|
||||
@@ -382,7 +397,7 @@ async function handleRetry(): Promise<void> {
|
||||
onThinking: (thinking) => { retryThinkContent = thinking; updateLastAssistantMessage(retryContent, thinking, null, getSelectedModel()); },
|
||||
onContent: (content) => { retryContent = content; updateLastAssistantMessage(content, retryThinkContent || null, null, getSelectedModel()); },
|
||||
onToolCallStart: (call) => {
|
||||
addToolCard({
|
||||
startToolCard({
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: null, status: 'running', timestamp: Date.now()
|
||||
});
|
||||
@@ -441,6 +456,9 @@ async function handleRetry(): Promise<void> {
|
||||
} catch (err) {
|
||||
logError('重试失败', (err as Error).message);
|
||||
appendSystemMessage(`❌ 重试失败: ${(err as Error).message}`);
|
||||
} finally {
|
||||
if (retryMonitor) clearInterval(retryMonitor as unknown as number);
|
||||
clearWorkingHint();
|
||||
}
|
||||
|
||||
state.set(KEYS.IS_STREAMING, false);
|
||||
@@ -909,8 +927,23 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
||||
let lastIterationEvalCount = 0; // 上一轮迭代结束时的累计 token 数
|
||||
let lastIterationStartTime = Date.now(); // 当前迭代开始时间
|
||||
|
||||
// ── 监控定时器:流式输出期间持续显示工作提示 ──
|
||||
// 模型生成大参数 tool_call 可能需要几分钟,期间工作空间显示提示
|
||||
const monitorInterval = setInterval(() => {
|
||||
if (!hasActiveCards()) {
|
||||
showWorkingHint('⏳ AI 正在处理中...');
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
try {
|
||||
await runAgentLoop(text || (pendingFiles.length > 0 ? `请分析 ${pendingFiles.map(f => f.name).join(', ')}` : ''), images, historyMessages, {
|
||||
/** 模型正在生成 tool_call 参数中 — 提前在工作空间显示"准备中"卡片 */
|
||||
onToolCallPrepare: (call) => {
|
||||
addToolCard({
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: null, status: 'pending', timestamp: Date.now()
|
||||
});
|
||||
},
|
||||
onNewIteration: (toolCalls) => {
|
||||
// Agent Loop 新迭代开始:保存上一轮内容为独立消息,创建新 placeholder
|
||||
// 先移除旧 placeholder(防止被 renderMessages 当作已渲染消息计数)
|
||||
@@ -967,7 +1000,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
||||
updateLastAssistantMessage(assistantContent, thinkContent || null, null, getSelectedModel());
|
||||
},
|
||||
onToolCallStart: (call) => {
|
||||
addToolCard({
|
||||
startToolCard({
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: null, status: 'running', timestamp: Date.now()
|
||||
});
|
||||
@@ -1097,6 +1130,8 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
||||
appendSystemMessage(errMsg);
|
||||
}
|
||||
} finally {
|
||||
clearInterval(monitorInterval);
|
||||
clearWorkingHint();
|
||||
state.set(KEYS.IS_STREAMING, false);
|
||||
updateSendButton(false);
|
||||
state.set(KEYS.ABORT_CONTROLLER, null);
|
||||
|
||||
@@ -40,7 +40,7 @@ interface FileNode {
|
||||
}
|
||||
|
||||
let panelVisible = true; // 常驻显示,不可关闭
|
||||
let activeTab: 'terminal' | 'tools' | 'files' = 'terminal';
|
||||
let activeTab: 'terminal' | 'tools' | 'files' = 'tools';
|
||||
let terminal: TerminalSession | null = null; // 唯一终端
|
||||
let workspaceDir = '';
|
||||
let currentFileDir = '';
|
||||
@@ -85,7 +85,6 @@ export function clearTerminalExternal(): void {
|
||||
|
||||
/** 工具调用卡片列表 */
|
||||
let toolCards: ToolCallRecord[] = [];
|
||||
let toolAutoScroll = true;
|
||||
|
||||
/** 当前正在运行的 AI 命令(用于用户手动终止时通知 AI) */
|
||||
let currentAiCommand: string | null = null;
|
||||
@@ -764,33 +763,110 @@ function isTerminalBusy(): boolean {
|
||||
export function addToolCard(tc: ToolCallRecord): void {
|
||||
toolCards.push(tc);
|
||||
|
||||
// ── 立即追加 DOM 节点,不等待全量渲染 ──
|
||||
if (activeTab === 'tools') {
|
||||
appendToolCardDOM(tc);
|
||||
}
|
||||
|
||||
const isPending = tc.status === 'pending';
|
||||
if (tc.name === 'run_command') {
|
||||
// run_command:始终切到终端
|
||||
switchToTab('terminal');
|
||||
} else if (!isTerminalBusy()) {
|
||||
// 终端空闲时才切到工具页签
|
||||
} else if (!isTerminalBusy() && !isPending) {
|
||||
// 终端空闲且非 pending 时才切到工具页签
|
||||
// pending 卡片不切换 tab(准备中不应打断当前视图)
|
||||
switchToTab('tools');
|
||||
}
|
||||
// 终端忙碌时不切换,避免打断用户看命令输出
|
||||
}
|
||||
|
||||
/** 更新工具调用卡片状态(由外部调用) */
|
||||
/** 工具开始执行:将 pending 卡片升级为 running,或新增 running 卡片 */
|
||||
export function startToolCard(tc: ToolCallRecord): void {
|
||||
// 查找是否有同名 pending 卡片(由 onToolCallPrepare 提前创建)
|
||||
const pendingIdx = toolCards.findIndex(
|
||||
c => c.name === tc.name && c.status === 'pending'
|
||||
);
|
||||
if (pendingIdx !== -1) {
|
||||
// 升级 pending → running,增量更新 DOM
|
||||
toolCards[pendingIdx] = tc;
|
||||
if (activeTab === 'tools') {
|
||||
updateToolCardDOM(tc);
|
||||
}
|
||||
} else {
|
||||
// 没有提前创建的 pending 卡片,走正常追加流程
|
||||
addToolCard(tc);
|
||||
}
|
||||
updateToolsHint();
|
||||
}
|
||||
|
||||
/** 更新工具调用卡片状态(由外部调用)—— 增量更新 DOM,不触发全量重绘 */
|
||||
export function updateToolCard(tc: ToolCallRecord): void {
|
||||
// 找到最后一个同名且 running 的卡片
|
||||
// 找到最后一个同名且 running/pending 的卡片(pending 被 onToolCallStart 更新为 running)
|
||||
for (let i = toolCards.length - 1; i >= 0; i--) {
|
||||
if (toolCards[i].name === tc.name && toolCards[i].status === 'running') {
|
||||
if (toolCards[i].name === tc.name && (toolCards[i].status === 'running' || toolCards[i].status === 'pending')) {
|
||||
toolCards[i] = tc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果当前在工具页签,刷新显示
|
||||
// 增量更新 DOM:只替换这一个卡片的 DOM 节点,保留其他卡片不动
|
||||
if (activeTab === 'tools') {
|
||||
renderToolCalls();
|
||||
updateToolCardDOM(tc);
|
||||
}
|
||||
updateToolsHint();
|
||||
}
|
||||
|
||||
/** 将单个工具卡片追加到 #wsToolsOutput DOM 容器 */
|
||||
function appendToolCardDOM(tc: ToolCallRecord): void {
|
||||
const container = document.querySelector('#wsToolsOutput') as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
// 首次添加卡片时清除空闲状态占位
|
||||
if (container.querySelector('.ws-idle-state')) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = renderToolCard(tc);
|
||||
const card = wrapper.firstElementChild as HTMLElement;
|
||||
if (card) {
|
||||
card.dataset.toolName = tc.name;
|
||||
container.appendChild(card);
|
||||
// 新卡片出现时始终滚到底部(用户需要看到最新的工具状态)
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/** 增量更新工具卡片 DOM 节点(找到 pending/running 状态的卡片并替换) */
|
||||
function updateToolCardDOM(tc: ToolCallRecord): void {
|
||||
const container = document.querySelector('#wsToolsOutput') as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
// 查找同名且状态为 pending 或 running 的卡片 DOM 节点
|
||||
const runningCard = (
|
||||
container.querySelector(`.tool-call-card[data-tool-name="${escapeAttr(tc.name)}"].tool-call-running`) ||
|
||||
container.querySelector(`.tool-call-card[data-tool-name="${escapeAttr(tc.name)}"].tool-call-pending`)
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (runningCard) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = renderToolCard(tc);
|
||||
const newCard = wrapper.firstElementChild as HTMLElement;
|
||||
if (newCard) {
|
||||
newCard.dataset.toolName = tc.name;
|
||||
runningCard.replaceWith(newCard);
|
||||
}
|
||||
} else {
|
||||
// running 卡片不在 DOM 中(可能是 tab 切换后再切回或其他边缘情况)
|
||||
// 回退:追加新卡片
|
||||
appendToolCardDOM(tc);
|
||||
}
|
||||
}
|
||||
|
||||
/** CSS.escape 回退(兼容旧 Electron) */
|
||||
function escapeAttr(value: string): string {
|
||||
try { return CSS.escape(value); } catch { return value.replace(/["\\]/g, '\\$&'); }
|
||||
}
|
||||
|
||||
function clearToolCards(): void {
|
||||
toolCards = [];
|
||||
if (activeTab === 'tools') {
|
||||
@@ -802,17 +878,32 @@ function clearToolCards(): void {
|
||||
function updateToolsHint(): void {
|
||||
const hint = document.querySelector('#wsToolsHint') as HTMLElement;
|
||||
if (!hint) return;
|
||||
const running = toolCards.filter(c => c.status === 'running');
|
||||
if (running.length > 0) {
|
||||
hint.textContent = `⏳ 执行中: ${running.map(c => getToolDisplayName(c.name)).join(', ')}`;
|
||||
hint.classList.add('running');
|
||||
} else {
|
||||
const total = toolCards.length;
|
||||
const success = toolCards.filter(c => c.status === 'success').length;
|
||||
const error = toolCards.filter(c => c.status === 'error').length;
|
||||
hint.textContent = total > 0 ? `共 ${total} 个工具调用(✅${success} ❌${error})` : '等待工具调用...';
|
||||
if (toolCards.length === 0) {
|
||||
hint.textContent = '等待工具调用...';
|
||||
hint.classList.remove('running');
|
||||
return;
|
||||
}
|
||||
|
||||
const byStatus: Record<string, string[]> = {};
|
||||
for (const tc of toolCards) {
|
||||
const name = getToolDisplayName(tc.name);
|
||||
if (!byStatus[tc.status]) byStatus[tc.status] = [];
|
||||
byStatus[tc.status].push(name);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const order = ['pending', 'running', 'success', 'error', 'cancelled'];
|
||||
const icons: Record<string, string> = {
|
||||
pending: '📝', running: '🔄', success: '✅', error: '❌', cancelled: '🚫'
|
||||
};
|
||||
for (const s of order) {
|
||||
if (byStatus[s]?.length) {
|
||||
parts.push(`${icons[s] || ''} ${byStatus[s].join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
hint.textContent = parts.join(' · ');
|
||||
hint.classList.toggle('running', !!(byStatus.pending || byStatus.running));
|
||||
}
|
||||
|
||||
function renderToolCalls(): void {
|
||||
@@ -845,16 +936,10 @@ function renderToolCalls(): void {
|
||||
html += renderToolCard(tc);
|
||||
}
|
||||
container.innerHTML = html;
|
||||
|
||||
if (toolAutoScroll) {
|
||||
// 全量渲染后滚到底部
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
container.onscroll = () => {
|
||||
toolAutoScroll = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
|
||||
};
|
||||
}
|
||||
|
||||
function getToolDisplayName(name: string): string {
|
||||
const names: Record<string, string> = {
|
||||
read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录',
|
||||
@@ -892,8 +977,12 @@ function getToolIcon(name: string): string {
|
||||
function renderToolCard(tc: ToolCallRecord): string {
|
||||
const icon = getToolIcon(tc.name);
|
||||
const name = getToolDisplayName(tc.name);
|
||||
const statusClass = tc.status === 'running' ? 'tool-call-running'
|
||||
: tc.status === 'pending' ? 'tool-call-pending'
|
||||
: tc.status === 'error' ? 'tool-call-error'
|
||||
: '';
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: '⏳ 等待确认', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
|
||||
pending: '📝 准备中…', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
|
||||
};
|
||||
const status = statusLabels[tc.status] || tc.status;
|
||||
const args = tc.arguments || {};
|
||||
@@ -951,8 +1040,6 @@ function renderToolCard(tc: ToolCallRecord): string {
|
||||
}
|
||||
}
|
||||
|
||||
const statusClass = tc.status === 'running' ? 'tool-call-running' : tc.status === 'error' ? 'tool-call-error' : '';
|
||||
|
||||
return `<div class="tool-call-card ${statusClass}" data-tool-name="${escapeHtml(tc.name)}">
|
||||
<div class="tool-call-header">
|
||||
<span class="tool-call-icon">${icon}</span>
|
||||
@@ -977,3 +1064,25 @@ export function switchToTab(tab: 'terminal' | 'tools' | 'files'): void {
|
||||
renderPanel();
|
||||
}
|
||||
|
||||
/** 检查工作空间是否有 pending 或 running 的工具卡片 */
|
||||
export function hasActiveCards(): boolean {
|
||||
return toolCards.some(c => c.status === 'pending' || c.status === 'running');
|
||||
}
|
||||
|
||||
/** 在工具页签显示工作提示(AI 正在处理中,但还没有具体的工具调用) */
|
||||
export function showWorkingHint(text: string): void {
|
||||
// 只在工具页签空闲时显示提示,避免覆盖已有的工具卡片
|
||||
if (hasActiveCards()) return;
|
||||
if (activeTab !== 'tools') return;
|
||||
const hint = document.querySelector('#wsToolsHint') as HTMLElement;
|
||||
if (hint) {
|
||||
hint.textContent = text;
|
||||
hint.classList.add('running');
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除工作提示,恢复显示 updateToolsHint */
|
||||
export function clearWorkingHint(): void {
|
||||
updateToolsHint();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="header-left">
|
||||
<span class="logo">🦙</span>
|
||||
<span class="app-title">Metona Ollama</span>
|
||||
<span class="app-version">v0.11.2</span>
|
||||
<span class="app-version">v0.11.3</span>
|
||||
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
||||
<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"/>
|
||||
@@ -181,14 +181,14 @@
|
||||
<div class="ws-header">
|
||||
<span class="ws-title">🖥️ 工作空间</span>
|
||||
<div class="ws-tabs">
|
||||
<button class="ws-header-tab active" id="wsTabTerminal">💻 命令行</button>
|
||||
<button class="ws-header-tab" id="wsTabTools">🔧 工具</button>
|
||||
<button class="ws-header-tab" id="wsTabTerminal">💻 命令行</button>
|
||||
<button class="ws-header-tab active" id="wsTabTools">🔧 工具</button>
|
||||
<button class="ws-header-tab" id="wsTabFiles">📁 文件</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 终端 Tab -->
|
||||
<div class="ws-content" id="wsTerminalContent">
|
||||
<div class="ws-content" id="wsTerminalContent" style="display:none;">
|
||||
<div class="ws-term-output" id="wsTermOutput"></div>
|
||||
<div class="ws-term-toolbar">
|
||||
<span class="ws-term-hint" id="wsTermHint">等待 AI 执行命令...</span>
|
||||
@@ -209,7 +209,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 工具调用 Tab -->
|
||||
<div class="ws-content" id="wsToolsContent" style="display:none;">
|
||||
<div class="ws-content" id="wsToolsContent">
|
||||
<div class="ws-tools-output" id="wsToolsOutput"></div>
|
||||
<div class="ws-term-toolbar">
|
||||
<span class="ws-tools-hint" id="wsToolsHint">等待工具调用...</span>
|
||||
@@ -420,7 +420,7 @@
|
||||
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度自动检测</strong> — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置</li><li><strong>多模态</strong> — 上传图片,模型需支持 Vision 能力。图片自动压缩至合适分辨率,节省上下文</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
|
||||
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>44 个工具</strong>,分为 10 类:<ul><li><strong>文件系统</strong>(16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files / compress</li><li><strong>命令执行</strong>(1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>(2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git(17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>(9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话 & Skill</strong>(8 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read / skill_list / skill_view</li><li><strong>子代理</strong>(1 个):spawn_task</li><li><strong>系统工具</strong>(6 个):datetime / calculator / random / uuid / json_format / hash</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true)</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>new browser_wait 等待元素出现或定时等待</li><li>仅 <code>run_command</code> 支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code>、<code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code>、<code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环,上下文使用率>80%时自动缩减到3轮</li><li>独立工具自动<strong>并行执行</strong>(16个只读工具加入并行白名单),有依赖关系的工具串行执行</li></ul></div>
|
||||
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li><strong>增量提取</strong>:长对话中每 20 轮自动触发轻量级记忆提取,不等到对话结束</li><li>对话结束时自动触发完整记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>AI 可通过 memory 工具主动管理记忆</li><li>记忆写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>记忆存储在本地 SQLite,不会上传到任何服务器</li></ul></div>
|
||||
<div class="help-section"><h4>🤖 Agent Loop v0.11.2 增强</h4><ul><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认300s),Ollama 卡死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>Final Answer 智能检测</strong>:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新</li><li><strong>自动子任务拆解</strong>:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理</li><li><strong>旧工具结果自动截断</strong>:超过10轮的工具结果自动截断到500字符,控制上下文膨胀</li></ul></div>
|
||||
<div class="help-section"><h4>🤖 Agent Loop v0.11.3 增强</h4><ul><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认300s),Ollama 卡死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>Final Answer 智能检测</strong>:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新</li><li><strong>自动子任务拆解</strong>:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理</li><li><strong>旧工具结果自动截断</strong>:超过10轮的工具结果自动截断到500字符,控制上下文膨胀</li></ul></div>
|
||||
<div class="help-section"><h4>🔌 MCP(Model Context Protocol)</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}_{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div>
|
||||
<div class="help-section"><h4>🔍 SearXNG 元搜索引擎</h4><ul><li>点击顶部 🔍 按钮打开配置面板,可接入自部署的 SearXNG 实例</li><li><strong>JSON 模式</strong>:调用 SearXNG JSON API,聚合 70+ 引擎结果,结构化解析</li><li><strong>HTML 模式</strong>:获取原始搜索结果页面,交由 AI 自行分析提取信息</li><li>支持认证 Key(HTTP Header Authorization),保护私有实例</li><li>启用后替代内置四引擎方案;关闭即回退,无缝切换</li><li>所有参数(引擎、语言、安全搜索、时间范围等)均可独立配置</li></ul></div>
|
||||
<div class="help-section"><h4>📋 自定义文件(SOUL.md / AGENT.md / USER.md)</h4><ul><li>在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效</li><li><strong>SOUL.md</strong> — AI 身份、性格、行为准则(<strong>永远不可被压缩</strong>,注入为最高优先级系统提示词)</li><li><strong>AGENT.md</strong> — 工具调用规则、链式调用模式、核心约束(内置精简默认版,可通过工作空间覆盖)</li><li><strong>USER.md</strong> — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考</li><li>可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文</li><li>删除工作空间中的文件即可恢复为内置默认版本</li></ul></div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
|
||||
import { extractSkillsFromToolRecords, matchSkills, buildSkillContext } from './skill-manager.js';
|
||||
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, logStream } from './log-service.js';
|
||||
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
|
||||
import { generateId } from '../utils/utils.js';
|
||||
import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD, recordActualTokens } from './context-manager.js';
|
||||
@@ -328,6 +328,9 @@ function formatToolResultForModel(toolName: string, result: ToolResult): string
|
||||
export interface AgentCallbacks {
|
||||
onThinking: (text: string) => void;
|
||||
onContent: (text: string) => void;
|
||||
/** 模型正在流式生成 tool_call,工具名已知但参数还没生成完 */
|
||||
onToolCallPrepare?: (call: ToolCall) => void;
|
||||
/** 模型已完成 tool_call 生成,即将执行工具 */
|
||||
onToolCallStart: (call: ToolCall) => void;
|
||||
onToolCallResult: (name: string, result: ToolResult, call: ToolCall) => void;
|
||||
onToolCallError: (name: string, error: string, call: ToolCall) => void;
|
||||
@@ -708,8 +711,33 @@ Shell: ${osInfo.shell}
|
||||
}, STREAM_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
let progressTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
try {
|
||||
// 流式调用
|
||||
const streamStartTime = Date.now();
|
||||
let lastLoggedLen = 0;
|
||||
let lastProgressAt = 0;
|
||||
const PROGRESS_INTERVAL = 5000; // 每5秒输出一次流式进度
|
||||
|
||||
// ── 独立定时器:即使没有新 chunk 也会持续输出进度 ──
|
||||
progressTimer = setInterval(() => {
|
||||
const elapsed = Date.now() - streamStartTime;
|
||||
const grew = content.length - lastLoggedLen;
|
||||
const sinceLast = elapsed - lastProgressAt;
|
||||
if (sinceLast >= PROGRESS_INTERVAL) {
|
||||
lastProgressAt = elapsed;
|
||||
const sec = Math.round(elapsed / 1000);
|
||||
if (grew > 0) {
|
||||
lastLoggedLen = content.length;
|
||||
logStream(`流式输出中… ${content.length} 字 / ${sec}s${toolCalls.length > 0 ? ` [${toolCalls.length} 个工具调用]` : ''}`);
|
||||
} else if (elapsed > 15000 && content.length > 0) {
|
||||
// 超过 15 秒无新内容 → 可能模型卡住了
|
||||
logStream(`⚠️ 无新内容 ${Math.round(sinceLast / 1000)}s,当前 ${content.length} 字`);
|
||||
}
|
||||
}
|
||||
}, PROGRESS_INTERVAL);
|
||||
|
||||
await api.chatStream(
|
||||
{
|
||||
model,
|
||||
@@ -737,6 +765,15 @@ Shell: ${osInfo.shell}
|
||||
if (chunk.message?.tool_calls?.length) {
|
||||
for (const tc of chunk.message.tool_calls) {
|
||||
if (tc.function?.name) {
|
||||
// 首次看到工具名 → 提前在工作空间创建"准备中"卡片
|
||||
// 此时参数还没生成完,但用户可以知道 AI 打算调哪个工具
|
||||
const isNewTool = !toolCalls.some(existing => existing.function.name === tc.function.name);
|
||||
if (isNewTool && callbacks.onToolCallPrepare) {
|
||||
callbacks.onToolCallPrepare({
|
||||
type: 'function',
|
||||
function: { name: tc.function.name, arguments: tc.function.arguments || {} }
|
||||
});
|
||||
}
|
||||
toolCalls.push({
|
||||
type: 'function',
|
||||
function: {
|
||||
@@ -782,10 +819,12 @@ Shell: ${osInfo.shell}
|
||||
|
||||
// 流式调用成功完成,清除超时定时器
|
||||
if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; }
|
||||
if (progressTimer) clearInterval(progressTimer as unknown as number);
|
||||
|
||||
} catch (err) {
|
||||
// 清除超时定时器
|
||||
if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; }
|
||||
if (progressTimer) clearInterval(progressTimer as unknown as number);
|
||||
if (abortController.signal.aborted) {
|
||||
logInfo('流式调用已中止');
|
||||
// 保存工具记录到 state,供消费方的 catch 处理中止消息
|
||||
@@ -999,9 +1038,17 @@ Shell: ${osInfo.shell}
|
||||
toolResultCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
// ── 等待渲染帧:确保 onToolCallPrepare 创建的 pending 卡片已上屏 ──
|
||||
// 然后 onToolCallStart 将 pending 升级为 running
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
|
||||
callbacks.onToolCallStart(call);
|
||||
logToolStart(call.function.name, JSON.stringify(call.function.arguments));
|
||||
|
||||
// ── 确保 running 状态 DOM 已被浏览器渲染后再执行工具 ──
|
||||
// 网络工具(web_search/web_fetch)天然慢无需此处理,但文件 I/O 工具极快
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
|
||||
// 需要确认的工具(目前只有 run_command 在 confirm 模式下)
|
||||
if (needsConfirmation(call.function.name)) {
|
||||
const confirmed = await callbacks.onConfirmTool(call);
|
||||
|
||||
Reference in New Issue
Block a user