feat: v0.16.16 — 稳定性增强 + 性能优化 + 体验补全
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-v0.16.15-E8734A?style=flat-square" alt="version">
|
||||
<img src="https://img.shields.io/badge/version-v0.16.16-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.16.15.exe`
|
||||
产出:`release/Metona Ollama Setup v0.16.16.exe`
|
||||
|
||||
## 🛠️ 常用命令
|
||||
|
||||
@@ -485,7 +485,7 @@ npm start
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||
```
|
||||
|
||||
Output: `release/Metona Ollama Setup v0.16.15.exe`
|
||||
Output: `release/Metona Ollama Setup v0.16.16.exe`
|
||||
|
||||
## 🛠️ Common Commands
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "metona-ollama-desktop",
|
||||
"version": "0.16.15",
|
||||
"version": "0.16.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "metona-ollama-desktop",
|
||||
"version": "0.16.15",
|
||||
"version": "0.16.16",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "metona-ollama-desktop",
|
||||
"version": "0.16.15",
|
||||
"version": "0.16.16",
|
||||
"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.16.15',
|
||||
message: 'Metona Ollama Desktop v0.16.16',
|
||||
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
|
||||
icon: getIconPath()
|
||||
});
|
||||
|
||||
@@ -662,14 +662,76 @@ export function enableAutoScroll(): void {
|
||||
if (scrollBtnEl) scrollBtnEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// ── R38: 代码块复制按钮 ──
|
||||
// ── R38: 代码块复制按钮 + 语法高亮 ──
|
||||
|
||||
/** R38: 为代码块添加复制按钮 */
|
||||
/** 轻量级 JSON 语法高亮 — 检测 JSON 并高亮关键字、字符串、数字 */
|
||||
export function highlightJsonContent(text: string): string {
|
||||
const escaped = escapeHtml(text);
|
||||
|
||||
// 尝试 JSON 解析并格式化
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const formatted = JSON.stringify(parsed, null, 2);
|
||||
return formatJsonSyntax(escapeHtml(formatted));
|
||||
} catch {
|
||||
// 非 JSON,返回转义后的原文
|
||||
return escaped;
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON 语法着色 — key/string/number/boolean/null 分别着色 */
|
||||
function formatJsonSyntax(escaped: string): string {
|
||||
// 高亮 JSON key: "key":
|
||||
let result = escaped.replace(
|
||||
/"([^&]*?)"\s*:/g,
|
||||
'<span class="hl-json-key">"$1"</span>:'
|
||||
);
|
||||
// 高亮 string value: : "value" 或 [ "value"
|
||||
result = result.replace(
|
||||
/:\s*"([^&]*(?:&|<|>)?[^&]*)"/g,
|
||||
': <span class="hl-json-string">"$1"</span>'
|
||||
);
|
||||
result = result.replace(
|
||||
/\[\s*"([^&]*(?:&|<|>)?[^&]*)"/g,
|
||||
'[ <span class="hl-json-string">"$1"</span>'
|
||||
);
|
||||
// 高亮数字
|
||||
result = result.replace(
|
||||
/:\s*(-?\d+\.?\d*)/g,
|
||||
': <span class="hl-json-number">$1</span>'
|
||||
);
|
||||
// 高亮 boolean
|
||||
result = result.replace(
|
||||
/:\s*(true|false)/g,
|
||||
': <span class="hl-json-boolean">$1</span>'
|
||||
);
|
||||
// 高亮 null
|
||||
result = result.replace(
|
||||
/:\s*(null)/g,
|
||||
': <span class="hl-json-null">$1</span>'
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 为代码块添加复制按钮 + 语法高亮 */
|
||||
function addCodeBlockCopyButtons(scope?: ParentNode): void {
|
||||
const root = scope || messagesContainerEl;
|
||||
const codeBlocks = root.querySelectorAll('pre:not([data-copy-added])');
|
||||
for (const pre of codeBlocks) {
|
||||
pre.setAttribute('data-copy-added', 'true');
|
||||
|
||||
// 语法高亮:检测 JSON 并格式化
|
||||
const codeEl = pre.querySelector('code');
|
||||
const rawText = codeEl ? codeEl.textContent : pre.textContent;
|
||||
if (rawText && rawText.trim().startsWith('{')) {
|
||||
const highlighted = highlightJsonContent(rawText);
|
||||
if (codeEl) {
|
||||
codeEl.innerHTML = highlighted;
|
||||
} else {
|
||||
pre.innerHTML = `<code>${highlighted}</code>`;
|
||||
}
|
||||
}
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'code-copy-btn';
|
||||
btn.textContent = '📋 复制';
|
||||
@@ -741,6 +803,50 @@ function addMessageActions(): void {
|
||||
|
||||
// ── 导出功能 ──
|
||||
|
||||
/** 为工作空间面板的工具结果添加语法高亮和复制按钮 */
|
||||
export function addToolResultHighlighting(scope: ParentNode): void {
|
||||
const resultBlocks = scope.querySelectorAll('pre.tool-result-content:not([data-highlighted])');
|
||||
for (const pre of resultBlocks as NodeListOf<HTMLPreElement>) {
|
||||
pre.setAttribute('data-highlighted', 'true');
|
||||
|
||||
// 确保 pre 是相对定位
|
||||
if (getComputedStyle(pre).position === 'static') {
|
||||
pre.style.position = 'relative';
|
||||
}
|
||||
|
||||
// 语法高亮:检测 JSON 并格式化
|
||||
const rawText = pre.textContent || '';
|
||||
if (rawText.trim().startsWith('{') || rawText.trim().startsWith('[')) {
|
||||
const highlighted = highlightJsonContent(rawText);
|
||||
if (highlighted !== escapeHtml(rawText)) {
|
||||
pre.innerHTML = highlighted;
|
||||
}
|
||||
}
|
||||
|
||||
// 复制按钮
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'code-copy-btn';
|
||||
btn.textContent = '📋 复制';
|
||||
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:4px;cursor:pointer;opacity:0;transition:opacity 0.2s;z-index:10;';
|
||||
pre.addEventListener('mouseenter', () => { btn.style.opacity = '1'; });
|
||||
pre.addEventListener('mouseleave', () => { btn.style.opacity = '0'; });
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const text = pre.textContent || '';
|
||||
if (text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
btn.textContent = '✅ 已复制';
|
||||
setTimeout(() => { btn.textContent = '📋 复制'; }, 2000);
|
||||
}).catch(() => {
|
||||
btn.textContent = '❌ 失败';
|
||||
setTimeout(() => { btn.textContent = '📋 复制'; }, 2000);
|
||||
});
|
||||
}
|
||||
});
|
||||
pre.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
async function nativeSaveFile(defaultName: string, content: string): Promise<void> {
|
||||
const bridge = window.metonaDesktop;
|
||||
if (bridge) {
|
||||
@@ -802,3 +908,175 @@ export async function exportAsTxt(session: ChatSession): Promise<void> {
|
||||
await nativeSaveFile(`${session.title}.txt`, txt);
|
||||
logSession('导出', `${session.title}.txt`);
|
||||
}
|
||||
|
||||
// ── 对话内搜索 ──
|
||||
|
||||
let searchInputEl: HTMLInputElement;
|
||||
let searchBarEl: HTMLElement;
|
||||
let searchCountEl: HTMLElement;
|
||||
let searchMatches: HTMLElement[] = [];
|
||||
let searchCurrentIndex = -1;
|
||||
|
||||
/** 初始化对话内搜索功能 */
|
||||
export function initSearchBar(): void {
|
||||
searchBarEl = document.querySelector('#searchBar')!;
|
||||
searchInputEl = document.querySelector('#searchInput') as HTMLInputElement;
|
||||
searchCountEl = document.querySelector('#searchCount')!;
|
||||
|
||||
document.querySelector('#btnSearch')?.addEventListener('click', toggleSearchBar);
|
||||
|
||||
document.querySelector('#searchClose')?.addEventListener('click', closeSearchBar);
|
||||
document.querySelector('#searchPrev')?.addEventListener('click', () => navigateMatch(-1));
|
||||
document.querySelector('#searchNext')?.addEventListener('click', () => navigateMatch(1));
|
||||
|
||||
searchInputEl.addEventListener('input', performSearch);
|
||||
|
||||
// Ctrl+F 快捷键
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'f') {
|
||||
e.preventDefault();
|
||||
toggleSearchBar();
|
||||
}
|
||||
if (e.key === 'Escape' && searchBarEl.style.display !== 'none') {
|
||||
closeSearchBar();
|
||||
}
|
||||
if (searchBarEl.style.display !== 'none') {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
navigateMatch(e.shiftKey ? -1 : 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSearchBar(): void {
|
||||
if (searchBarEl.style.display === 'none') {
|
||||
searchBarEl.style.display = '';
|
||||
searchInputEl.focus();
|
||||
searchInputEl.select();
|
||||
} else {
|
||||
closeSearchBar();
|
||||
}
|
||||
}
|
||||
|
||||
function closeSearchBar(): void {
|
||||
searchBarEl.style.display = 'none';
|
||||
clearHighlights();
|
||||
searchInputEl.value = '';
|
||||
searchCountEl.textContent = '';
|
||||
searchMatches = [];
|
||||
searchCurrentIndex = -1;
|
||||
}
|
||||
|
||||
/** 执行搜索 */
|
||||
function performSearch(): void {
|
||||
clearHighlights();
|
||||
const query = searchInputEl.value.trim();
|
||||
|
||||
if (!query) {
|
||||
searchCountEl.textContent = '';
|
||||
searchMatches = [];
|
||||
searchCurrentIndex = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||
|
||||
// 在所有消息内容元素中搜索
|
||||
const contentEls = messagesContainerEl.querySelectorAll('.msg-content, .msg-thinking, .tool-result-content');
|
||||
searchMatches = [];
|
||||
|
||||
contentEls.forEach(el => {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
// 跳过 script/style 标签内的文本
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
if (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE') return NodeFilter.FILTER_REJECT;
|
||||
return node.textContent && regex.test(node.textContent) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
});
|
||||
|
||||
const textNodes: Text[] = [];
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
textNodes.push(node as Text);
|
||||
}
|
||||
|
||||
for (const textNode of textNodes) {
|
||||
const text = textNode.textContent || '';
|
||||
regex.lastIndex = 0;
|
||||
const parts: string[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
const highlight = document.createElement('span');
|
||||
highlight.className = 'search-highlight';
|
||||
highlight.textContent = match[0];
|
||||
parts.push(highlight.outerHTML);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.innerHTML = parts.join('');
|
||||
const highlights = wrapper.querySelectorAll('.search-highlight');
|
||||
textNode.replaceWith(...wrapper.childNodes);
|
||||
highlights.forEach(h => searchMatches.push(h as HTMLElement));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
searchCurrentIndex = searchMatches.length > 0 ? 0 : -1;
|
||||
updateSearchCount();
|
||||
|
||||
if (searchMatches.length > 0) {
|
||||
searchMatches[0].classList.add('search-highlight-current');
|
||||
searchMatches[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除所有搜索高亮 */
|
||||
function clearHighlights(): void {
|
||||
const highlights = messagesContainerEl.querySelectorAll('.search-highlight, .search-highlight-current');
|
||||
highlights.forEach(h => {
|
||||
const parent = h.parentNode;
|
||||
if (parent) {
|
||||
parent.replaceChild(document.createTextNode(h.textContent || ''), h);
|
||||
parent.normalize();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 导航到上一个/下一个匹配 */
|
||||
function navigateMatch(direction: number): void {
|
||||
if (searchMatches.length === 0) return;
|
||||
|
||||
// 移除当前高亮
|
||||
if (searchCurrentIndex >= 0) {
|
||||
searchMatches[searchCurrentIndex].classList.remove('search-highlight-current');
|
||||
}
|
||||
|
||||
searchCurrentIndex = (searchCurrentIndex + direction + searchMatches.length) % searchMatches.length;
|
||||
|
||||
searchMatches[searchCurrentIndex].classList.add('search-highlight-current');
|
||||
searchMatches[searchCurrentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
updateSearchCount();
|
||||
}
|
||||
|
||||
function updateSearchCount(): void {
|
||||
if (searchMatches.length === 0) {
|
||||
searchCountEl.textContent = searchInputEl.value.trim() ? '无匹配' : '';
|
||||
} else {
|
||||
searchCountEl.textContent = `${searchCurrentIndex + 1}/${searchMatches.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { logInfo, logError, logDebug } from '../services/log-service.js';
|
||||
import { escapeHtml, formatSize } from '../utils/utils.js';
|
||||
import { addToolResultHighlighting } from './chat-area.js';
|
||||
|
||||
// ── 工具卡片类型 ──
|
||||
interface ToolCallRecord {
|
||||
@@ -868,6 +869,7 @@ function appendToolCardDOM(tc: ToolCallRecord): void {
|
||||
if (card) {
|
||||
card.dataset.toolName = tc.name;
|
||||
container.appendChild(card);
|
||||
addToolResultHighlighting(card);
|
||||
}
|
||||
// 始终滚到底部
|
||||
scrollToolsToBottom(container);
|
||||
@@ -895,6 +897,7 @@ function updateToolCardDOM(tc: ToolCallRecord): void {
|
||||
if (newCard) {
|
||||
newCard.dataset.toolName = tc.name;
|
||||
runningCard.replaceWith(newCard);
|
||||
addToolResultHighlighting(newCard);
|
||||
// 卡片内容变化后可能变高(如出现执行结果),跟随滚动
|
||||
scrollToolsToBottom(container);
|
||||
}
|
||||
|
||||
+15
-1
@@ -28,7 +28,7 @@
|
||||
<div class="header-left">
|
||||
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
|
||||
<span class="app-title">Metona Ollama</span>
|
||||
<span class="app-version">v0.16.15</span>
|
||||
<span class="app-version">v0.16.16</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"/>
|
||||
@@ -46,6 +46,11 @@
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="btnSearch" title="对话内搜索 (Ctrl+F)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="btnMemory" title="Agent 记忆">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7z"/>
|
||||
@@ -81,6 +86,15 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ═══════════════ 对话内搜索条 ═══════════════ -->
|
||||
<div class="search-bar" id="searchBar" style="display:none;">
|
||||
<input type="text" id="searchInput" placeholder="搜索对话内容..." autocomplete="off" />
|
||||
<span class="search-count" id="searchCount"></span>
|
||||
<button class="search-nav-btn" id="searchPrev" title="上一个">▲</button>
|
||||
<button class="search-nav-btn" id="searchNext" title="下一个">▼</button>
|
||||
<button class="search-close-btn" id="searchClose" title="关闭">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════ 模型选择栏 ═══════════════ -->
|
||||
<div class="model-bar">
|
||||
<div class="model-bar-icon">
|
||||
|
||||
@@ -15,7 +15,7 @@ import { initToast, showToast } from './components/toast.js';
|
||||
import { initLightbox, closeLightbox } from './components/lightbox.js';
|
||||
import { initHeader, checkConnection } from './components/header.js';
|
||||
import { initModelBar, loadModels, setSelectedModel } from './components/model-bar.js';
|
||||
import { initChatArea, renderMessages, clearMessages, enableAutoScroll } from './components/chat-area.js';
|
||||
import { initChatArea, renderMessages, clearMessages, enableAutoScroll, initSearchBar } from './components/chat-area.js';
|
||||
import { initInputArea } from './components/input-area.js';
|
||||
import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js';
|
||||
import { initHistoryModal, closeHistoryModal } from './components/history-modal.js';
|
||||
@@ -304,6 +304,7 @@ async function init(): Promise<void> {
|
||||
initHeader();
|
||||
initModelBar();
|
||||
initChatArea();
|
||||
initSearchBar();
|
||||
initInputArea();
|
||||
initSettingsModal();
|
||||
initHistoryModal();
|
||||
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
checkCommandSafety,
|
||||
// R95: 按工具类型智能截断
|
||||
smartTruncateByToolType,
|
||||
// R116: 错误恢复建议
|
||||
getErrorRecoverySuggestions,
|
||||
formatErrorRecovery,
|
||||
} from './agent-safety.js';
|
||||
import { search, formatMemoryContext } from './memory-service.js';
|
||||
|
||||
@@ -43,6 +46,8 @@ import {
|
||||
AUTO_COMPRESS_THRESHOLD, recordActualTokens, predictContextOverflow, recordTokenUsage,
|
||||
// R91: 上下文压力分级评估
|
||||
getContextPressureLevel,
|
||||
// 统一上下文统计(替代多次独立计算)
|
||||
calculateContextStats,
|
||||
// R93: Token 预算追踪器
|
||||
recordBudgetUsage, setTokenBudgetNumCtx, resetTokenBudget,
|
||||
// R96: 消息角色压缩
|
||||
@@ -53,6 +58,10 @@ import {
|
||||
generateTokenReport, formatTokenReport,
|
||||
// R111: 自适应压缩策略选择
|
||||
chooseCompressionStrategy,
|
||||
// R123: 会话摘要持久化
|
||||
loadSessionSummaries, generateSessionSummary, saveSessionSummary, formatSessionSummariesForContext,
|
||||
// R125: Agent 状态检查点
|
||||
createCheckpoint, clearCheckpoints,
|
||||
} from './context-manager.js';
|
||||
import { executeHooks } from './hooks.js';
|
||||
import { recordIteration, recordToolCall, startSessionMetrics, endSessionMetrics } from './agent-metrics.js';
|
||||
@@ -1220,6 +1229,7 @@ async function handleInit(
|
||||
resetAllSafetyState(); // R51-R56: 重置所有安全状态
|
||||
resetTokenBudget(); // R93: 重置 Token 预算追踪
|
||||
setTokenBudgetNumCtx(getEffectiveNumCtx()); // R93: 设置当前预算 numCtx
|
||||
clearCheckpoints(); // R125: 清理上一轮会话的检查点
|
||||
|
||||
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
|
||||
// R55: 语义工具检索 — 根据用户查询过滤相关工具,减少 token 占用
|
||||
@@ -1248,6 +1258,20 @@ async function handleInit(
|
||||
if (isAborted()) { throw new DOMException('Aborted', 'AbortError'); }
|
||||
}
|
||||
|
||||
// R123: 注入历史会话摘要 — 为 AI 提供跨会话的上下文参考
|
||||
try {
|
||||
const historicalSummaries = loadSessionSummaries();
|
||||
if (historicalSummaries.length > 0) {
|
||||
const formatted = formatSessionSummariesForContext(historicalSummaries);
|
||||
if (formatted) {
|
||||
systemPromptParts.push(`<<<REFERENCE_DATA_START>>>
|
||||
${formatted}
|
||||
<<<REFERENCE_DATA_END>>>`);
|
||||
logInfo(`R123: 注入 ${historicalSummaries.length} 条历史会话摘要`);
|
||||
}
|
||||
}
|
||||
} catch { /* 历史摘要加载失败不影响主流程 */ }
|
||||
|
||||
// 注入工作空间上下文
|
||||
if (workspaceDir) {
|
||||
systemPromptParts.push(`【工作空间】
|
||||
@@ -1924,6 +1948,17 @@ async function handleExecuting(
|
||||
}
|
||||
}
|
||||
|
||||
// R116: 生成错误恢复建议的辅助函数
|
||||
const buildErrorWithRecovery = (errMsg: string): string => {
|
||||
try {
|
||||
const suggestion = getErrorRecoverySuggestions(call.function.name, errMsg);
|
||||
if (suggestion.suggestions.length > 0) {
|
||||
return formatErrorRecovery(suggestion);
|
||||
}
|
||||
} catch { /* 恢复建议生成失败不影响主流程 */ }
|
||||
return errMsg;
|
||||
};
|
||||
|
||||
// ── R78: 增强错误分类重试 — 使用 classifyError 区分瞬态/永久/安全错误 ──
|
||||
let lastError = '';
|
||||
let classifiedError: import('./agent-safety.js').ClassifiedError | null = null;
|
||||
@@ -1958,9 +1993,10 @@ async function handleExecuting(
|
||||
// 安全错误或永久错误:不重试
|
||||
if (!classifiedError.shouldRetry) {
|
||||
logWarn(`R78: 工具${classifiedError.class}错误(不重试): ${call.function.name}`, classifiedError.userMessage);
|
||||
const errWithRecovery = buildErrorWithRecovery(classifiedError.userMessage);
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: { success: false, error: classifiedError.userMessage },
|
||||
result: { success: false, error: errWithRecovery },
|
||||
status: 'error' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
}
|
||||
@@ -1973,9 +2009,10 @@ async function handleExecuting(
|
||||
if (errorSuggestion) {
|
||||
logWarn(`R97: ${errorSuggestion}`);
|
||||
}
|
||||
const errWithRecovery = buildErrorWithRecovery(classifiedError.userMessage);
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: { success: false, error: classifiedError.userMessage },
|
||||
result: { success: false, error: errWithRecovery },
|
||||
status: 'error' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
}
|
||||
@@ -1995,9 +2032,10 @@ async function handleExecuting(
|
||||
}
|
||||
}
|
||||
// P2 #6 修复:循环结束兆底返回
|
||||
const fallbackErr = buildErrorWithRecovery(lastError || '工具执行失败');
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: { success: false, error: lastError || '工具执行失败' }, status: 'error' as const, timestamp: Date.now()
|
||||
result: { success: false, error: fallbackErr }, status: 'error' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
};
|
||||
|
||||
@@ -2070,9 +2108,10 @@ async function handleObserving(
|
||||
// 中止检查 — P1 #4 修复:不在此处 transition,让主循环的 isAborted() 检查
|
||||
// 抛出 AbortError 触发 catch 块中的 onDone。持久化状态一致性由 persistLoopContext 处理。
|
||||
if (isAborted()) return;
|
||||
// R91: 上下文压力分级评估 — 根据压力等级选择压缩策略
|
||||
// 统一上下文统计(第一次计算,用于工具消息裁剪的压力等级判定)
|
||||
const numCtx = getEffectiveNumCtx();
|
||||
const pressureInfo = getContextPressureLevel(ctx.messages, numCtx);
|
||||
let ctxStats = calculateContextStats(ctx.messages, numCtx);
|
||||
const pressureInfo = ctxStats.pressureInfo;
|
||||
{
|
||||
// R91: 根据压力等级动态调整工具消息保留数量
|
||||
// 原子组裁剪:删除 tool 消息时同步处理其对应的 assistant(带 tool_calls),
|
||||
@@ -2138,14 +2177,13 @@ async function handleObserving(
|
||||
// 保存本轮工具调用
|
||||
ctx.prevToolCalls = [...ctx.toolCalls];
|
||||
|
||||
// R53: 主动上下文压缩 — 使用预测系统提前触发压缩
|
||||
// 统一上下文统计(第二次计算,裁剪后重新统计,复用于后续所有判断)
|
||||
ctxStats = calculateContextStats(ctx.messages, numCtx);
|
||||
|
||||
// R53: 主动上下文压缩 — 使用统一计算的趋势预测结果
|
||||
{
|
||||
const numCtx = getEffectiveNumCtx();
|
||||
const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
|
||||
recordTokenUsage(currentTokens, numCtx);
|
||||
const prediction = predictContextOverflow(numCtx);
|
||||
if (prediction.level === 'critical' || (prediction.level === 'warning' && prediction.turnsToOverflow <= 2)) {
|
||||
logWarn(`R53: 主动压缩触发 — ${prediction.message}`);
|
||||
if (ctxStats.compressDecision.shouldCompress && ctxStats.compressDecision.urgency === 'high') {
|
||||
logWarn(`R53: 主动压缩触发 — ${ctxStats.compressDecision.reason}`);
|
||||
transition(ctx, S.COMPRESSING);
|
||||
return;
|
||||
}
|
||||
@@ -2179,6 +2217,14 @@ async function handleObserving(
|
||||
} catch { /* 诊断失败不影响主流程 */ }
|
||||
}
|
||||
|
||||
// R125: 每 20 轮创建状态检查点 — 保存 Agent 运行状态快照,支持故障恢复
|
||||
if (ctx.loopCount > 0 && ctx.loopCount % 20 === 0) {
|
||||
try {
|
||||
const taskGoal = state.get<string>('_lastUserMessage', '') || ctx.messages.find(m => m.role === 'user')?.content || '';
|
||||
createCheckpoint(ctx.loopCount, ctx.state, ctx.messages, ctx.allToolRecords.length, taskGoal.slice(0, 200));
|
||||
} catch { /* 检查点创建失败不影响主流程 */ }
|
||||
}
|
||||
|
||||
// 记录本轮迭代度量
|
||||
recordIteration(ctx);
|
||||
|
||||
@@ -2212,10 +2258,8 @@ async function handleObserving(
|
||||
// 但如果上下文使用率已过高(>70%),跳过清理——压缩即将触发,
|
||||
// 此时清理会导致压缩 LLM 看不到 Plan Mode 进度等关键状态信息
|
||||
if (ctx.loopCount > 1 && ctx.loopCount % 10 === 0) {
|
||||
const numCtx = getEffectiveNumCtx();
|
||||
const usageRatio = numCtx > 0 ? estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx : 0;
|
||||
if (usageRatio > 0.7) {
|
||||
logInfo(`ephemeral 清理跳过: 上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 压缩即将触发`);
|
||||
if (ctxStats.usageRatio > 0.7) {
|
||||
logInfo(`ephemeral 清理跳过: 上下文使用率 ${(ctxStats.usageRatio * 100).toFixed(0)}%, 压缩即将触发`);
|
||||
} else {
|
||||
// C7: 保留含 Plan Mode 关键信息的 ephemeral 消息
|
||||
const PRESERVE_PATTERNS = [
|
||||
@@ -2244,10 +2288,9 @@ async function handleObserving(
|
||||
ctx.messages = mergeConsecutiveMessages(ctx.messages);
|
||||
}
|
||||
|
||||
// R98: 趋势感知压缩触发 — 结合趋势预测动态调整压缩阈值
|
||||
const compressDecision = getTrendAwareCompressThreshold(numCtx, ctx.messages);
|
||||
if (compressDecision.shouldCompress) {
|
||||
logWarn(`R98: 压缩触发 (${compressDecision.urgency}) — ${compressDecision.reason}`);
|
||||
// R98: 趋势感知压缩触发 — 复用统一计算的压缩决策
|
||||
if (ctxStats.compressDecision.shouldCompress) {
|
||||
logWarn(`R98: 压缩触发 (${ctxStats.compressDecision.urgency}) — ${ctxStats.compressDecision.reason}`);
|
||||
transition(ctx, S.COMPRESSING);
|
||||
return;
|
||||
}
|
||||
@@ -2716,6 +2759,15 @@ default:
|
||||
}
|
||||
clearPlanTracker();
|
||||
endSessionMetrics();
|
||||
// R123: 生成并保存会话摘要 — 供下一轮会话的 AI 参考
|
||||
try {
|
||||
if (ctx.loopCount > 0 && ctx.messages.length > 2) {
|
||||
const userMsg = ctx.messages.find(m => m.role === 'user')?.content || '';
|
||||
const totalTokens = ctx.totalEvalCount + ctx.totalPromptEvalCount;
|
||||
const summary = generateSessionSummary(userMsg, ctx.messages, ctx.allToolRecords, totalTokens);
|
||||
saveSessionSummary(summary);
|
||||
}
|
||||
} catch { /* 会话摘要保存失败不影响主流程 */ }
|
||||
cleanupAbortController();
|
||||
// P1-E6 修复:清理未执行的记忆提取 timer,避免新会话启动时旧提取污染
|
||||
while (_pendingMemoryTimers.length > 0) {
|
||||
|
||||
@@ -96,10 +96,7 @@ export function detectConsecutiveIdentical(minCount: number): { detected: boolea
|
||||
return { detected: false, toolName: '', count: 0 };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R56 已删除:目标对齐验证 — 关键词匹配粗糙,反复注入干扰 AI 判断
|
||||
// R63 已删除:工具调用速率限制 — 剥夺 AI 试错空间,误伤密集型任务
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R56/R63 已删除:目标对齐验证 + 速率限制
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R66-R67: 错误分类系统 — 区分瞬态/永久错误,指导重试策略
|
||||
@@ -272,9 +269,7 @@ function isAbsolute(p: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('/');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R87 已删除:工具熔断器 — 剥夺 AI 试错空间,连续失败可能是参数调试过程
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R87 已删除:工具熔断器
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R88: 工具结果元数据 — 为模型提供结果大小的上下文提示
|
||||
@@ -372,9 +367,7 @@ export function recordErrorPattern(toolName: string, errorMsg: string): string |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R104 已删除:工具结果去重 — 误伤轮询类任务(如反复 run_command 检查构建状态)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R104 已删除:工具结果去重
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R109: 工具参数消毒 — 防止通过工具参数注入恶意内容
|
||||
@@ -1141,9 +1134,7 @@ export function autoTuneMemorySearch(
|
||||
return { tuned: changes.length > 0, changes };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R119 已删除:工具优先级排序 — 强制重排可能打乱 AI 设计的执行顺序
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R119 已删除:工具优先级排序
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R124: 压缩上下文中工具引用解析 — 恢复被压缩的工具结果引用
|
||||
|
||||
@@ -998,6 +998,120 @@ export interface ContextPressureInfo {
|
||||
recommendedActions: string[]; // 建议的压缩动作
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一上下文统计 — 单次遍历消息列表,计算 token 总量、压力等级、压缩决策
|
||||
* 替代 shouldAutoCompress + getContextPressureLevel + getTrendAwareCompressThreshold 的重复计算
|
||||
*/
|
||||
export interface ContextStats {
|
||||
/** 包含 tool_calls/images 开销的完整 token 估算 */
|
||||
totalTokens: number;
|
||||
/** 仅消息内容的 token 估算(不含 tool_calls/images) */
|
||||
contentTokens: number;
|
||||
/** 上下文使用率 (0-1) */
|
||||
usageRatio: number;
|
||||
/** 消息条数 */
|
||||
messageCount: number;
|
||||
/** 压力等级信息 */
|
||||
pressureInfo: ContextPressureInfo;
|
||||
/** 趋势感知压缩决策 */
|
||||
compressDecision: { shouldCompress: boolean; reason: string; urgency: 'low' | 'medium' | 'high' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次遍历消息列表计算完整 token 数(含 tool_calls 和 images 开销)
|
||||
*/
|
||||
function calculateTotalTokens(messages: OllamaMessage[]): number {
|
||||
let totalTokens = 0;
|
||||
for (const m of messages) {
|
||||
totalTokens += estimateTokens(m.content || '');
|
||||
if (m.tool_calls?.length) {
|
||||
for (const tc of m.tool_calls) {
|
||||
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
|
||||
totalTokens += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20;
|
||||
}
|
||||
}
|
||||
if (m.images?.length) totalTokens += m.images.length * 100;
|
||||
}
|
||||
return totalTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一上下文统计 — 单次计算替代多次遍历
|
||||
*/
|
||||
export function calculateContextStats(
|
||||
messages: OllamaMessage[],
|
||||
numCtx: number,
|
||||
): ContextStats {
|
||||
// 单次遍历计算完整 token 数
|
||||
const totalTokens = calculateTotalTokens(messages);
|
||||
// 内容 token(不含 tool_calls/images 开销,供 recordTokenUsage 等使用)
|
||||
let contentTokens = 0;
|
||||
for (const m of messages) {
|
||||
contentTokens += estimateTokens(m.content || '');
|
||||
}
|
||||
|
||||
const usageRatio = numCtx > 0 ? totalTokens / numCtx : 0;
|
||||
const msgCount = messages.length;
|
||||
|
||||
// 记录当前迭代的 token 使用量(必须在 predictContextOverflow 之前调用)
|
||||
recordTokenUsage(contentTokens, numCtx);
|
||||
|
||||
// 压力等级计算(内联,避免重复遍历)
|
||||
let level: ContextPressureLevel;
|
||||
const actions: string[] = [];
|
||||
if (usageRatio > 0.7) {
|
||||
level = 'critical';
|
||||
actions.push('llm_compress', 'truncate_results', 'compact_old', 'merge_messages', 'clear_ephemeral');
|
||||
} else if (usageRatio > 0.5) {
|
||||
level = 'high';
|
||||
actions.push('truncate_results', 'compact_old', 'merge_messages');
|
||||
} else if (usageRatio > 0.3) {
|
||||
level = 'medium';
|
||||
actions.push('compact_old', 'clear_ephemeral');
|
||||
} else {
|
||||
level = 'low';
|
||||
if (msgCount > 60) actions.push('compact_old');
|
||||
}
|
||||
const pressureInfo: ContextPressureInfo = { level, tokenUsageRatio: usageRatio, messageCount: msgCount, recommendedActions: actions };
|
||||
|
||||
// 趋势感知压缩决策(复用已计算的 token 数,避免重复遍历)
|
||||
const baseThreshold = getAdaptiveCompressThreshold(numCtx);
|
||||
const prediction = predictContextOverflow(numCtx);
|
||||
let shouldCompress = false;
|
||||
let reason = '';
|
||||
let urgency: 'low' | 'medium' | 'high' = 'low';
|
||||
|
||||
if (prediction.level === 'critical' || (prediction.level === 'warning' && prediction.turnsToOverflow <= 2)) {
|
||||
shouldCompress = true;
|
||||
reason = `趋势预测触发: ${prediction.message}`;
|
||||
urgency = 'high';
|
||||
} else if (prediction.turnsToOverflow > 0 && prediction.turnsToOverflow <= 5 && usageRatio > baseThreshold * 0.8) {
|
||||
shouldCompress = true;
|
||||
reason = `趋势加速: ${prediction.turnsToOverflow} 轮后可能溢出,当前使用率 ${(usageRatio * 100).toFixed(0)}%`;
|
||||
urgency = 'medium';
|
||||
} else if (usageRatio > baseThreshold) {
|
||||
shouldCompress = true;
|
||||
reason = `标准阈值触发: 使用率 ${(usageRatio * 100).toFixed(0)}% > 阈值 ${(baseThreshold * 100).toFixed(0)}%`;
|
||||
urgency = usageRatio > 0.6 ? 'high' : 'medium';
|
||||
} else {
|
||||
const msgThreshold = getIncrementalCompressThresholdMessages(numCtx);
|
||||
if (msgCount >= msgThreshold) {
|
||||
shouldCompress = true;
|
||||
reason = `消息条数触发: ${msgCount} >= ${msgThreshold}`;
|
||||
urgency = 'low';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalTokens,
|
||||
contentTokens,
|
||||
usageRatio,
|
||||
messageCount: msgCount,
|
||||
pressureInfo,
|
||||
compressDecision: { shouldCompress, reason, urgency },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* R91: 评估当前上下文压力等级
|
||||
* - low (<30%): 无需压缩
|
||||
|
||||
@@ -9,7 +9,7 @@ import { OllamaAPI } from '../api/ollama.js';
|
||||
import { TOOL_DEFINITIONS } from './tool-registry.js';
|
||||
import { getEnabledToolDefinitions } from './tool-registry.js';
|
||||
import { logInfo, logWarn, logError } from './log-service.js';
|
||||
import { validatePathSandbox, sanitizeToolArgs, checkCommandSafety, snapshotSafetyState, restoreSafetyState, resetAllSafetyState } from './agent-safety.js';
|
||||
import { validatePathSandbox, sanitizeToolArgs, checkCommandSafety, snapshotSafetyState, restoreSafetyState, resetAllSafetyState, classifyError, calculateBackoff } from './agent-safety.js';
|
||||
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
|
||||
import type { ToolResult, ToolCall, ToolDefinition } from '../types.js';
|
||||
|
||||
@@ -133,31 +133,67 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
|
||||
let content = '';
|
||||
let toolCalls: Array<{ name: string; arguments: Record<string, unknown> }> = [];
|
||||
|
||||
try {
|
||||
await api.chatStream({
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
think: false,
|
||||
tools: tools as any,
|
||||
options: { num_ctx: numCtx, temperature: 0.3 }
|
||||
} as any, (chunk: any) => {
|
||||
if (chunk.message?.content) content += chunk.message.content;
|
||||
if (chunk.message?.tool_calls?.length) {
|
||||
for (const tc of chunk.message.tool_calls) {
|
||||
if (tc.function?.name && SUB_AGENT_TOOL_WHITELIST.has(tc.function.name)) {
|
||||
toolCalls.push({ name: tc.function.name, arguments: tc.function.arguments || {} });
|
||||
// LLM 调用重试循环 — 瞬态错误时指数退避重试,与主 Agent 一致
|
||||
const SUB_AGENT_API_MAX_RETRIES = 2;
|
||||
let llmSuccess = false;
|
||||
let llmLastError: Error | null = null;
|
||||
|
||||
for (let apiAttempt = 0; apiAttempt <= SUB_AGENT_API_MAX_RETRIES; apiAttempt++) {
|
||||
// 重试前重置本轮状态
|
||||
if (apiAttempt > 0) {
|
||||
content = '';
|
||||
toolCalls = [];
|
||||
const retryDelay = calculateBackoff(apiAttempt - 1, 1000);
|
||||
logWarn(`子 Agent API 重试 ${apiAttempt}/${SUB_AGENT_API_MAX_RETRIES}: ${retryDelay}ms 后重试`, llmLastError?.message || '');
|
||||
await new Promise(r => setTimeout(r, retryDelay));
|
||||
}
|
||||
|
||||
// 重试前检查中止信号
|
||||
if (subAgentAC.signal.aborted) break;
|
||||
|
||||
try {
|
||||
await api.chatStream({
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
think: false,
|
||||
tools: tools as any,
|
||||
options: { num_ctx: numCtx, temperature: 0.3 }
|
||||
} as any, (chunk: any) => {
|
||||
if (chunk.message?.content) content += chunk.message.content;
|
||||
if (chunk.message?.tool_calls?.length) {
|
||||
for (const tc of chunk.message.tool_calls) {
|
||||
if (tc.function?.name && SUB_AGENT_TOOL_WHITELIST.has(tc.function.name)) {
|
||||
toolCalls.push({ name: tc.function.name, arguments: tc.function.arguments || {} });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, subAgentAC);
|
||||
llmSuccess = true;
|
||||
break;
|
||||
} catch (err) {
|
||||
if (subAgentAC.signal.aborted) {
|
||||
logWarn('子 Agent LLM 调用被中止', `${loopCount} 轮`);
|
||||
return { success: true, content: '子任务执行已中止', loops: loopCount, duration: Date.now() - startTime, partial: true };
|
||||
}
|
||||
llmLastError = err as Error;
|
||||
const classified = classifyError((err as Error).message);
|
||||
// 永久错误或安全错误:不重试
|
||||
if (!classified.shouldRetry) {
|
||||
logError(`子 Agent 调用${classified.class}错误(不重试)`, (err as Error).message);
|
||||
return { success: false, error: classified.userMessage, loops: loopCount, duration: Date.now() - startTime };
|
||||
}
|
||||
// 未达最大重试次数则继续
|
||||
if (apiAttempt < SUB_AGENT_API_MAX_RETRIES) {
|
||||
continue;
|
||||
}
|
||||
}, subAgentAC);
|
||||
} catch (err) {
|
||||
if (subAgentAC.signal.aborted) {
|
||||
logWarn('子 Agent LLM 调用被中止', `${loopCount} 轮`);
|
||||
return { success: true, content: '子任务执行已中止', loops: loopCount, duration: Date.now() - startTime, partial: true };
|
||||
}
|
||||
logError('子 Agent 调用失败', (err as Error).message);
|
||||
return { success: false, error: (err as Error).message, loops: loopCount, duration: Date.now() - startTime };
|
||||
}
|
||||
|
||||
// 所有重试都失败
|
||||
if (!llmSuccess) {
|
||||
logError('子 Agent 调用失败(已达最大重试)', llmLastError?.message || '未知错误');
|
||||
return { success: false, error: llmLastError?.message || 'LLM 调用失败', loops: loopCount, duration: Date.now() - startTime };
|
||||
}
|
||||
|
||||
// 无工具调用 → 完成
|
||||
|
||||
@@ -4227,3 +4227,109 @@ html, body {
|
||||
font-size: 13px;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
/* ═══════════════ 对话内搜索条 ═══════════════ */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
z-index: 45;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-bar input[type="text"] {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--bg-mica);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-bar input[type="text"]:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.search-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-nav-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.search-nav-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.search-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.search-close-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 搜索高亮 */
|
||||
.search-highlight {
|
||||
background: rgba(255, 213, 79, 0.4);
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.search-highlight-current {
|
||||
background: rgba(255, 152, 0, 0.5);
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
/* ═══════════════ JSON 语法高亮 ═══════════════ */
|
||||
.hl-json-key {
|
||||
color: #9B7ED8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hl-json-string {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.hl-json-number {
|
||||
color: #D4A03C;
|
||||
}
|
||||
|
||||
.hl-json-boolean {
|
||||
color: #E8734A;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hl-json-null {
|
||||
color: #9E9E9E;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user