648 lines
41 KiB
HTML
648 lines
41 KiB
HTML
<!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>
|