agent-Specialization/static/index.html

648 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Agent System</title>
<!-- Vue 3 CDN -->
<script src="https://unpkg.com/vue@3.3.4/dist/vue.global.prod.js"></script>
<!-- Socket.IO Client -->
<script src="/static/vendor/socket.io.min.js"></script>
<!-- Marked.js for Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked@9.1.6/marked.min.js"></script>
<!-- Prism.js for code highlighting -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
<!-- KaTeX for LaTeX (无defer同步加载) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js"></script>
<!-- Custom CSS -->
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div id="app">
<!-- Loading indicator -->
<div v-if="!isConnected && messages.length === 0" style="text-align: center; padding: 50px;">
<h2>正在连接服务器...</h2>
<p>如果长时间无响应,请刷新页面</p>
</div>
<!-- Main UI (只在连接后显示) -->
<template v-else>
<!-- 顶部状态栏 -->
<header class="header">
<div class="header-left">
<span class="logo">🤖 AI Agent</span>
<span class="agent-version" v-if="agentVersion">{{ agentVersion }}</span>
</div>
<div class="header-right">
<span class="thinking-mode">{{ thinkingMode ? '思考模式' : '快速模式' }}</span>
<span class="connection-status" :class="{ connected: isConnected }">
<span class="status-dot" :class="{ active: isConnected }"></span>
{{ isConnected ? '已连接' : '未连接' }}
</span>
</div>
</header>
<div class="main-container">
<!-- 新增:对话历史侧边栏(最左侧) -->
<aside class="conversation-sidebar" :class="{ collapsed: sidebarCollapsed }">
<div class="conversation-header">
<button @click="createNewConversation" class="new-conversation-btn" v-if="!sidebarCollapsed">
<span class="btn-icon">+</span>
<span class="btn-text">新建对话</span>
</button>
<button @click="toggleSidebar" class="toggle-sidebar-btn">
<span v-if="sidebarCollapsed"></span>
<span v-else></span>
</button>
</div>
<template v-if="!sidebarCollapsed">
<div class="conversation-search">
<input v-model="searchQuery"
@input="searchConversations"
placeholder="搜索对话..."
class="search-input">
</div>
<div class="conversation-list">
<div v-if="conversationsLoading" class="loading-conversations">
正在加载...
</div>
<div v-else-if="conversations.length === 0" class="no-conversations">
暂无对话记录
</div>
<div v-else>
<div v-for="conv in conversations"
:key="conv.id"
class="conversation-item"
:class="{ active: conv.id === currentConversationId }"
@click="loadConversation(conv.id)">
<div class="conversation-title">{{ conv.title }}</div>
<div class="conversation-meta">
<span class="conversation-time">{{ formatTime(conv.updated_at) }}</span>
<span class="conversation-counts">
{{ conv.total_messages }}条消息
<span v-if="conv.total_tools > 0"> · {{ conv.total_tools }}工具</span>
</span>
</div>
<div class="conversation-actions" @click.stop>
<button @click="deleteConversation(conv.id)"
class="conversation-action-btn delete-btn"
title="删除对话">
×
</button>
<button @click="duplicateConversation(conv.id)"
class="conversation-action-btn copy-btn"
title="复制对话">
</button>
</div>
</div>
</div>
<div v-if="hasMoreConversations" class="load-more">
<button @click="loadMoreConversations"
:disabled="loadingMoreConversations"
class="load-more-btn">
{{ loadingMoreConversations ? '载入中...' : '加载更多' }}
</button>
</div>
</div>
</template>
<template v-else>
<div class="conversation-collapsed-spacer"></div>
</template>
</aside>
<!-- 左侧文件树 -->
<aside class="sidebar left-sidebar" :style="{ width: leftWidth + 'px' }">
<div class="sidebar-header">
<div class="panel-menu-wrapper" ref="panelMenuWrapper">
<button class="sidebar-view-toggle"
@click.stop="togglePanelMenu"
title="切换侧边栏">
</button>
<transition name="fade">
<div class="panel-menu" v-if="panelMenuOpen">
<button type="button"
:class="{ active: panelMode === 'files' }"
@click.stop="selectPanelMode('files')"
title="项目文件">📁</button>
<button type="button"
:class="{ active: panelMode === 'todo' }"
@click.stop="selectPanelMode('todo')"
title="待办列表">{{ todoEmoji }}</button>
<button type="button"
:class="{ active: panelMode === 'subAgents' }"
@click.stop="selectPanelMode('subAgents')"
title="子智能体">🤖</button>
</div>
</transition>
</div>
<button class="sidebar-manage-btn"
@click="openGuiFileManager"
title="打开桌面式文件管理器">
管理
</button>
<h3>
<span v-if="panelMode === 'files'">{{ fileEmoji }} 项目文件</span>
<span v-else-if="panelMode === 'todo'">{{ todoEmoji }} 待办列表</span>
<span v-else>🤖 子智能体</span>
</h3>
</div>
<template v-if="panelMode === 'todo'">
<div class="todo-panel">
<div v-if="!todoList" class="todo-empty">
暂无待办列表
</div>
<div v-else>
<div class="todo-task"
v-for="task in (todoList.tasks || [])"
:key="task.index"
:class="{ done: task.status === 'done' }">
<span class="todo-task-title">task{{ task.index }}{{ task.title }}</span>
<span class="todo-task-status">{{ formatTaskStatus(task) }}</span>
</div>
<div class="todo-instruction">{{ todoList.instruction }}</div>
</div>
</div>
</template>
<template v-else-if="panelMode === 'subAgents'">
<div class="sub-agent-panel">
<div v-if="!subAgents.length" class="sub-agent-empty">
暂无运行中的子智能体
</div>
<div v-else class="sub-agent-cards">
<div class="sub-agent-card"
v-for="agent in subAgents"
:key="agent.task_id"
@click="openSubAgent(agent)">
<div class="sub-agent-header">
<span class="sub-agent-id">#{{
agent.agent_id }}</span>
<span class="sub-agent-status" :class="agent.status">{{ agent.status }}</span>
</div>
<div class="sub-agent-summary">{{ agent.summary }}</div>
<div class="sub-agent-tool" v-if="agent.last_tool">
当前:{{ agent.last_tool }}
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="file-tree" @contextmenu.prevent>
<file-node
v-for="node in fileTree"
:key="node.path"
:node="node"
:level="0"
:expanded-folders="expandedFolders"
@toggle-folder="toggleFolder"
@context-menu="showContextMenu"
></file-node>
</div>
</template>
</aside>
<!-- 左侧拖拽手柄 -->
<div class="resize-handle" @mousedown="startResize('left', $event)"></div>
<!-- 中间聊天区域 -->
<main class="chat-container">
<!-- 当前对话信息栏 -->
<div class="current-conversation-info" v-if="currentConversationTitle">
<span class="conversation-title-display">{{ currentConversationTitle }}</span>
<span class="conversation-stats">
<span class="message-count">{{ messages.length }}条消息</span>
</span>
</div>
<!-- Token区域包装器 -->
<div class="token-wrapper" v-if="currentConversationId">
<!-- Token统计显示面板 -->
<div class="token-display-panel" :class="{ collapsed: tokenPanelCollapsed }">
<div class="token-panel-content">
<div class="token-stats">
<div class="token-item">
<span class="token-label">当前上下文</span>
<span class="token-value current">{{ formatTokenCount(currentContextTokens || 0) }}</span>
</div>
<div class="token-separator"></div>
<div class="token-item">
<span class="token-label">累计输入</span>
<span class="token-value input">{{ formatTokenCount(currentConversationTokens.cumulative_input_tokens || 0) }}</span>
</div>
<div class="token-item">
<span class="token-label">累计输出</span>
<span class="token-value output">{{ formatTokenCount(currentConversationTokens.cumulative_output_tokens || 0) }}</span>
</div>
</div>
</div>
</div>
<!-- 独立的切换按钮 -->
<button @click="toggleTokenPanel" class="token-toggle-btn" :class="{ collapsed: tokenPanelCollapsed }">
<span v-if="!tokenPanelCollapsed"></span>
<span v-else></span>
</button>
</div>
<div class="messages-area" ref="messagesArea">
<div v-for="(msg, index) in messages" :key="index" class="message-block">
<!-- 用户消息 -->
<div v-if="msg.role === 'user'" class="user-message">
<div class="message-header">👤 用户</div>
<div class="message-text">{{ msg.content }}</div>
</div>
<!-- AI消息 -->
<div v-else-if="msg.role === 'assistant'" class="assistant-message">
<div class="message-header">🤖 AI Assistant</div>
<!-- 按顺序显示所有actions -->
<div v-for="(action, actionIndex) in msg.actions"
:key="action.id"
class="action-item"
:class="{
'streaming-content': action.streaming,
'completed-tool': action.type === 'tool' && !action.streaming,
'immediate-show': action.streaming || action.type === 'text'
}">
<!-- 思考块 -->
<div v-if="action.type === 'thinking'"
class="collapsible-block thinking-block"
:class="{ expanded: expandedBlocks.has(`${index}-thinking-${actionIndex}`) }">
<div class="collapsible-header" @click="toggleBlock(`${index}-thinking-${actionIndex}`)">
<div class="arrow"></div>
<div class="status-icon">
<span class="thinking-icon" :class="{ 'thinking-animation': action.streaming }">🧠</span>
</div>
<span class="status-text">{{ action.streaming ? '正在思考...' : '思考过程' }}</span>
</div>
<div class="collapsible-content">
<div class="content-inner thinking-content">
{{ action.content }}
</div>
</div>
<div v-if="action.streaming" class="progress-indicator"></div>
</div>
<!-- 文本输出块 -->
<div v-else-if="action.type === 'text'" class="text-output">
<div class="text-content"
:class="{ 'streaming-text': action.streaming }">
<!-- 流式实时渲染markdown但不包装代码块 -->
<div v-if="action.streaming" v-html="renderMarkdown(action.content, true)"></div>
<!-- 完成:完整渲染包含代码块包装 -->
<div v-else v-html="renderMarkdown(action.content, false)"></div>
</div>
</div>
<!-- 系统提示块 -->
<div v-else-if="action.type === 'system'" class="system-action">
<div class="system-action-content">
{{ action.content }}
</div>
</div>
<!-- 追加内容占位 -->
<div v-else-if="action.type === 'append_payload'"
class="append-placeholder"
:class="{ 'append-error': action.append?.success === false }">
<div class="append-placeholder-content">
<template v-if="action.append?.success !== false">
✏️ 已写入 {{ action.append?.path || '目标文件' }} 的追加内容(内容已保存至文件)
</template>
<template v-else>
❌ 向 {{ action.append?.path || '目标文件' }} 写入失败,内容已截获供后续修复。
</template>
<div class="append-meta" v-if="action.append">
<span v-if="action.append.lines !== null && action.append.lines !== undefined">
· 行数 {{ action.append.lines }}
</span>
<span v-if="action.append.bytes !== null && action.append.bytes !== undefined">
· 字节 {{ action.append.bytes }}
</span>
</div>
<div class="append-warning" v-if="action.append?.forced">
⚠️ 未检测到结束标记,请根据提示继续补充。
</div>
</div>
</div>
<!-- 修改内容占位 -->
<div v-else-if="action.type === 'modify_payload'" class="modify-placeholder">
<div class="modify-placeholder-content">
🛠️ 已对 {{ action.modify?.path || '目标文件' }} 执行补丁
<div class="modify-meta" v-if="action.modify">
<span v-if="action.modify.total !== null && action.modify.total !== undefined">
· 共 {{ action.modify.total }} 处
</span>
<span v-if="action.modify.completed && action.modify.completed.length">
· 已完成 {{ action.modify.completed.length }} 处
</span>
<span v-if="action.modify.failed && action.modify.failed.length">
· 未完成 {{ action.modify.failed.length }} 处
</span>
</div>
<div class="modify-warning" v-if="action.modify?.forced">
⚠️ 未检测到结束标记,系统已在流结束时执行补丁。
</div>
<div class="modify-warning" v-if="action.modify?.failed && action.modify.failed.length">
⚠️ 未完成的序号:{{ action.modify.failed.map(f => f.index || f).join('、') || action.modify.failed.join('、') }},请根据提示重新输出。
</div>
</div>
</div>
<!-- 工具块(修复版) -->
<div v-else-if="action.type === 'tool'"
class="collapsible-block tool-block"
:class="{
expanded: expandedBlocks.has(`${index}-tool-${actionIndex}`),
processing: action.tool.status === 'preparing' || action.tool.status === 'running',
completed: action.tool.status === 'completed'
}">
<div class="collapsible-header" @click="toggleBlock(`${index}-tool-${actionIndex}`)">
<div class="arrow"></div>
<div class="status-icon">
<!-- 修复传递完整的tool对象 -->
<span class="tool-icon"
:class="getToolAnimationClass(action.tool)">
{{ getToolIcon(action.tool) }}
</span>
</div>
<span class="status-text">
{{ getToolStatusText(action.tool) }}
</span>
<span class="tool-desc">{{ getToolDescription(action.tool) }}</span>
</div>
<div class="collapsible-content">
<div class="content-inner">
<!-- 根据工具类型显示不同内容 -->
<div v-if="action.tool.name === 'web_search' && action.tool.result">
<div class="search-meta">
<div><strong>搜索内容:</strong>{{ action.tool.result.query || action.tool.arguments.query }}</div>
<div><strong>主题:</strong>{{ formatSearchTopic(action.tool.result.filters || {}) }}</div>
<div><strong>时间范围:</strong>{{ formatSearchTime(action.tool.result.filters || {}) }}</div>
<div><strong>结果数量:</strong>{{ action.tool.result.total_results }}</div>
</div>
<div v-if="action.tool.result.results && action.tool.result.results.length" class="search-result-list">
<div v-for="item in action.tool.result.results" :key="item.url || item.index" class="search-result-item">
<div class="search-result-title">{{ item.title || '无标题' }}</div>
<div class="search-result-url"><a v-if="item.url" :href="item.url" target="_blank">{{ item.url }}</a><span v-else>无可用链接</span></div>
</div>
</div>
<div v-else class="search-empty">未返回详细的搜索结果。</div>
</div>
<div v-else-if="action.tool.name === 'run_python' && action.tool.result">
<div class="code-block">
<div class="code-label">代码:</div>
<pre><code class="language-python">{{ action.tool.result.code || action.tool.arguments.code }}</code></pre>
</div>
<div v-if="action.tool.result.output" class="output-block">
<div class="output-label">输出:</div>
<pre>{{ action.tool.result.output }}</pre>
</div>
</div>
<div v-else>
<pre>{{ JSON.stringify(action.tool.result || action.tool.arguments, null, 2) }}</pre>
</div>
</div>
</div>
<!-- 修复只在running状态显示进度条 -->
<!-- 只在流式消息期间且工具运行时显示进度条 -->
<div v-if="streamingMessage && (action.tool.status === 'preparing' || action.tool.status === 'running')"
class="progress-indicator"></div>
</div>
</div>
</div>
<!-- 系统消息 -->
<div v-else class="system-message">
{{ msg.content }}
</div>
</div>
</div>
<div class="scroll-lock-toggle" :class="{ locked: autoScrollEnabled && !userScrolling }">
<button @click="toggleScrollLock" class="scroll-lock-btn">
<svg v-if="autoScrollEnabled && !userScrolling" viewBox="0 0 24 24" aria-hidden="true" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 11h8a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z" />
<path d="M9 11V8a3 3 0 0 1 6 0v3" />
</svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v12" />
<path d="M7 13l5 5 5-5" />
</svg>
</button>
</div>
<!-- 输入区域 -->
<div class="input-area">
<div class="input-wrapper">
<textarea
v-model="inputMessage"
@keydown.enter.ctrl="sendMessage"
placeholder="输入消息... (Ctrl+Enter 发送)"
class="message-input"
:disabled="!isConnected || streamingMessage"
rows="3">
</textarea>
<div class="input-actions">
<div class="upload-control">
<input type="file"
ref="fileUploadInput"
class="file-input-hidden"
@change="handleFileSelected">
<button type="button"
class="btn upload-btn"
@click="triggerFileUpload"
:disabled="!isConnected || uploading">
{{ uploading ? '上传中...' : '上传文件' }}
</button>
</div>
<div class="tool-dropdown" ref="toolDropdown">
<button type="button"
class="btn tool-btn"
@click="toggleToolMenu"
:disabled="!isConnected || toolSettingsLoading">
工具
</button>
<transition name="settings-menu">
<div class="settings-menu tool-menu" v-if="toolMenuOpen">
<div class="tool-menu-status" v-if="toolSettingsLoading">
正在同步工具状态...
</div>
<div v-else-if="toolSettings.length === 0" class="tool-menu-empty">
暂无可控工具
</div>
<div v-else class="tool-menu-list">
<div v-for="category in toolSettings"
:key="category.id"
class="tool-category-item"
:class="{ disabled: !category.enabled }">
<span class="tool-category-label">
<span class="tool-category-icon">{{ toolCategoryEmoji(category.id) }}</span>
{{ category.label }}
</span>
<button type="button"
class="menu-btn tool-category-toggle"
@click="updateToolCategory(category.id, !category.enabled)"
:disabled="streamingMessage || !isConnected || toolSettingsLoading">
{{ category.enabled ? '禁用' : '启用' }}
</button>
</div>
</div>
</div>
</transition>
</div>
<button @click="handleSendOrStop"
:disabled="!isConnected || (!inputMessage.trim() && !streamingMessage)"
:class="['btn', streamingMessage ? 'stop-btn' : 'send-btn']">
{{ streamingMessage ? '停止' : '发送' }}
</button>
<div class="settings-dropdown" ref="settingsDropdown">
<button type="button"
class="btn settings-btn"
@click="toggleSettings"
:disabled="!isConnected">
设置
</button>
<transition name="settings-menu">
<div class="settings-menu" v-if="settingsOpen">
<button type="button"
class="menu-btn focus-entry"
@click="toggleFocusPanel"
:disabled="streamingMessage || !isConnected">
{{ rightCollapsed ? '展开聚焦面板' : '折叠聚焦面板' }}
</button>
<button type="button"
class="menu-btn mode-entry"
@click="toggleThinkingMode"
:disabled="streamingMessage || !isConnected">
{{ thinkingMode ? '快速模式' : '思考模式' }}
</button>
<button type="button"
class="menu-btn compress-entry"
@click="compressConversation"
:disabled="compressing || streamingMessage || !isConnected">
{{ compressing ? '压缩中...' : '压缩' }}
</button>
</div>
</transition>
</div>
</div>
</div>
</div>
</main>
<!-- 右侧拖拽手柄 -->
<div class="resize-handle" @mousedown="startResize('right', $event)"></div>
<!-- 右侧聚焦文件 -->
<aside class="sidebar right-sidebar"
:class="{ collapsed: rightCollapsed }"
:style="{ width: rightCollapsed ? '0px' : rightWidth + 'px' }">
<div class="sidebar-header">
<h3>👁️ 聚焦文件 ({{ Object.keys(focusedFiles).length }}/3)</h3>
</div>
<div class="focused-files" v-if="!rightCollapsed">
<div v-if="Object.keys(focusedFiles).length === 0" class="no-files">
暂无聚焦文件
</div>
<div v-else class="file-tabs">
<div v-for="(file, path) in focusedFiles" :key="path" class="file-tab">
<div class="tab-header">
<span class="file-name">{{ path.split('/').pop() }}</span>
<span class="file-size">{{ (file.size / 1024).toFixed(1) }}KB</span>
</div>
<div class="file-content">
<pre><code :class="getLanguageClass(path)">{{ file.content }}</code></pre>
</div>
</div>
</div>
</div>
</aside>
</div>
</template>
<div class="context-menu"
v-if="contextMenu.visible"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
@click.stop>
<button v-if="contextMenu.node && contextMenu.node.type === 'file'"
@click.stop="downloadFile(contextMenu.node.path)">
下载文件
</button>
<button v-if="contextMenu.node && contextMenu.node.type === 'folder'"
:disabled="!contextMenu.node.path"
@click.stop="downloadFolder(contextMenu.node.path)">
下载压缩包
</button>
</div>
</div>
<script>
// 全局复制代码块函数
function copyCodeBlock(blockId) {
const codeElement = document.querySelector(`[data-code-id="${blockId}"]`);
if (!codeElement) return;
const button = document.querySelector(`[data-code="${blockId}"]`);
// 如果正在显示"已复制"状态,忽略点击
if (button.classList.contains('copied')) return;
// 使用保存的原始代码内容
const codeContent = codeElement.dataset.originalCode || codeElement.textContent;
// 首次点击时保存原始图标
if (!button.dataset.originalIcon) {
button.dataset.originalIcon = button.textContent;
}
navigator.clipboard.writeText(codeContent).then(() => {
button.textContent = '✓';
button.classList.add('copied');
setTimeout(() => {
// 使用保存的原始图标恢复
button.textContent = button.dataset.originalIcon || '📋';
button.classList.remove('copied');
}, 2000);
}).catch(err => {
console.error('复制失败:', err);
// 即使失败也要恢复状态
button.textContent = button.dataset.originalIcon || '📋';
button.classList.remove('copied');
});
}
// 使用事件委托处理复制按钮点击
document.addEventListener('click', function(e) {
if (e.target.classList.contains('copy-code-btn')) {
const blockId = e.target.getAttribute('data-code');
if (blockId) copyCodeBlock(blockId);
}
});
</script>
<!-- 加载应用脚本 -->
<script src="/static/app.js"></script>
</body>
</html>