agent/static/index.html

899 lines
60 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">
<link rel="stylesheet" href="/static/easter-eggs/flood.css">
<link rel="stylesheet" href="/static/easter-eggs/snake.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>
<div class="main-container">
<!-- 新增:对话历史侧边栏(最左侧) -->
<aside class="conversation-sidebar" :class="{ collapsed: sidebarCollapsed }">
<div class="conversation-header" :class="{ 'collapsed-layout': sidebarCollapsed }">
<template v-if="sidebarCollapsed">
<div class="collapsed-header-buttons">
<button type="button"
class="collapsed-control-btn conversation-menu-btn"
title="展开对话记录"
@click="toggleSidebar">
<span class="sr-only">展开对话记录</span>
<span class="chat-icon" aria-hidden="true">
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 6.5c0-1.38 1.12-2.5 2.5-2.5h13c1.38 0 2.5 1.12 2.5 2.5v8.5c0 1.38-1.12 2.5-2.5 2.5h-5.6l-3.4 3.2.6-3.2H7.5c-1.38 0-2.5-1.12-2.5-2.5V6.5z"
stroke="currentColor"
stroke-width="1.7"
stroke-linejoin="round"/>
<path d="M9 9.5h10" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
<path d="M9 13h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
</svg>
</span>
</button>
<button type="button"
class="collapsed-control-btn quick-plus-btn"
title="快捷新建对话"
@click="createNewConversation">
<span class="sr-only">新建对话</span>
<span class="pencil-icon" aria-hidden="true">
<svg class="edit-svgIcon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<path d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1v32c0 8.8 7.2 16 16 16h32zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"></path>
</svg>
</span>
</button>
</div>
</template>
<template v-else>
<button @click="createNewConversation" class="new-conversation-btn">
<span class="btn-icon">+</span>
<span class="btn-text">新建对话</span>
</button>
<button @click="toggleSidebar" class="toggle-sidebar-btn">
<span v-if="sidebarCollapsed"
class="icon icon-md"
:style="iconStyle('menu')"
aria-hidden="true"></span>
<span v-else class="toggle-arrow" aria-hidden="true"></span>
</button>
</template>
</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>
<div class="conversation-personal-entry" :class="{ collapsed: sidebarCollapsed }">
<button type="button"
class="personal-page-btn"
:class="{ 'icon-only': sidebarCollapsed }"
title="个人页面"
@click="openPersonalPage">
<span class="sr-only">个人页面</span>
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true">
<path fill-rule="evenodd"
clip-rule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"></path>
</svg>
<span class="personal-label" v-if="!sidebarCollapsed">个人页面</span>
</button>
</div>
</aside>
<!-- 左侧文件树 -->
<aside class="sidebar left-sidebar" :style="{ width: leftWidth + 'px' }">
<div class="sidebar-status">
<div class="compact-status-card">
<div class="status-line">
<div class="status-brand">
<span class="icon icon-lg status-logo"
:style="iconStyle('bot')"
aria-hidden="true"></span>
<div class="brand-text">
<span class="brand-name">AI Agent</span>
<span class="agent-version" v-if="agentVersion">{{ agentVersion }}</span>
</div>
</div>
<div class="status-indicators">
<span class="mode-indicator"
:class="{ thinking: thinkingMode, fast: !thinkingMode }">
<transition name="mode-icon" mode="out-in">
<span class="icon icon-sm"
:style="iconStyle(thinkingMode ? 'brain' : 'zap')"
:key="thinkingMode ? 'brain' : 'zap'"
aria-hidden="true"></span>
</transition>
</span>
<span class="connection-dot"
:class="{ active: isConnected }"
:title="isConnected ? '已连接' : '未连接'"></span>
</div>
</div>
</div>
</div>
<div class="sidebar-panel-card-wrapper">
<div class="sidebar-panel-card">
<div class="sidebar-header">
<div class="panel-menu-wrapper" ref="panelMenuWrapper">
<button class="sidebar-view-toggle"
@click.stop="togglePanelMenu"
title="切换侧边栏">
<span class="icon icon-md"
:style="iconStyle('menu')"
aria-hidden="true"></span>
</button>
<transition name="fade">
<div class="panel-menu" v-if="panelMenuOpen">
<button type="button"
:class="{ active: panelMode === 'files' }"
@click.stop="selectPanelMode('files')"
title="项目文件">
<span class="icon icon-md"
:style="iconStyle('folder')"
aria-hidden="true"></span>
</button>
<button type="button"
:class="{ active: panelMode === 'todo' }"
@click.stop="selectPanelMode('todo')"
title="待办列表">
<span class="icon icon-md"
:style="iconStyle('stickyNote')"
aria-hidden="true"></span>
</button>
<button type="button"
:class="{ active: panelMode === 'subAgents' }"
@click.stop="selectPanelMode('subAgents')"
title="子智能体">
<span class="icon icon-md"
:style="iconStyle('bot')"
aria-hidden="true"></span>
</button>
</div>
</transition>
</div>
<button class="sidebar-manage-btn"
@click="openGuiFileManager"
title="打开桌面式文件管理器">
管理
</button>
<h3>
<span v-if="panelMode === 'files'" class="icon-label">
<span class="icon icon-sm"
:style="iconStyle('folder')"
aria-hidden="true"></span>
<span>项目文件</span>
</span>
<span v-else-if="panelMode === 'todo'" class="icon-label">
<span class="icon icon-sm"
:style="iconStyle('stickyNote')"
aria-hidden="true"></span>
<span>待办列表</span>
</span>
<span v-else class="icon-label">
<span class="icon icon-sm"
:style="iconStyle('bot')"
aria-hidden="true"></span>
<span>子智能体</span>
</span>
</h3>
</div>
<div class="sidebar-panel-content">
<div v-if="panelMode === 'todo'" 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 icon-label">
<span class="icon icon-sm"
:style="iconStyle(task.status === 'done' ? 'check' : 'checkbox')"
aria-hidden="true"></span>
<span>{{ task.status === 'done' ? '完成' : '未完成' }}</span>
</span>
</div>
<div class="todo-instruction">{{ todoList.instruction }}</div>
</div>
</div>
<div v-else-if="panelMode === 'subAgents'" 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>
<div v-else 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>
</div>
</div>
</div>
</aside>
<!-- 左侧拖拽手柄 -->
<div class="resize-handle" @mousedown="startResize('left', $event)"></div>
<!-- 中间聊天区域 -->
<main class="chat-container">
<div class="token-drawer" v-if="currentConversationId" :class="{ collapsed: tokenPanelCollapsed }">
<div class="token-display-panel">
<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-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>
</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 icon-label">
<span class="icon icon-sm"
:style="iconStyle('user')"
aria-hidden="true"></span>
<span>用户</span>
</div>
<div class="message-text">{{ msg.content }}</div>
</div>
<!-- AI消息 -->
<div v-else-if="msg.role === 'assistant'" class="assistant-message">
<div class="message-header icon-label">
<span class="icon icon-sm"
:style="iconStyle('bot')"
aria-hidden="true"></span>
<span>AI Assistant</span>
</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(action.blockId || `${index}-thinking-${actionIndex}`) }">
<div class="collapsible-header" @click="toggleBlock(action.blockId || `${index}-thinking-${actionIndex}`)">
<div class="arrow"></div>
<div class="status-icon">
<span class="thinking-icon" :class="{ 'thinking-animation': action.streaming }">
<span class="icon icon-sm"
:style="iconStyle('brain')"
aria-hidden="true"></span>
</span>
</div>
<span class="status-text">{{ action.streaming ? '正在思考...' : '思考过程' }}</span>
</div>
<div class="collapsible-content">
<div class="content-inner thinking-content"
:ref="`thinkingContent-${action.blockId || `${index}-thinking-${actionIndex}`}`"
@scroll="handleThinkingScroll(action.blockId || `${index}-thinking-${actionIndex}`, $event)"
style="max-height: 240px; overflow-y: auto;">
{{ 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">
<div class="icon-label append-status">
<span class="icon icon-sm"
:style="iconStyle('pencil')"
aria-hidden="true"></span>
<span>已写入 {{ action.append?.path || '目标文件' }} 的追加内容(内容已保存至文件)</span>
</div>
</template>
<template v-else>
<div class="icon-label append-status append-error-text">
<span class="icon icon-sm"
:style="iconStyle('x')"
aria-hidden="true"></span>
<span>向 {{ action.append?.path || '目标文件' }} 写入失败,内容已截获供后续修复。</span>
</div>
</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 icon-label" v-if="action.append?.forced">
<span class="icon icon-sm"
:style="iconStyle('triangleAlert')"
aria-hidden="true"></span>
<span>未检测到结束标记,请根据提示继续补充。</span>
</div>
</div>
</div>
<!-- 追加结果摘要 -->
<div v-else-if="action.type === 'append'"
class="append-placeholder"
:class="{ 'append-error': action.append?.success === false }">
<div class="append-placeholder-content">
<div class="icon-label append-status">
<span class="icon icon-sm"
:style="iconStyle('pencil')"
aria-hidden="true"></span>
<span>{{ action.append?.summary || '文件追加完成' }}</span>
</div>
<div class="append-meta" v-if="action.append">
<span>{{ action.append.path || '目标文件' }}</span>
<span v-if="action.append.lines">· 行数 {{ action.append.lines }}</span>
<span v-if="action.append.bytes">· 字节 {{ action.append.bytes }}</span>
</div>
<div class="append-warning icon-label" v-if="action.append?.forced">
<span class="icon icon-sm"
:style="iconStyle('triangleAlert')"
aria-hidden="true"></span>
<span>未检测到结束标记,请按提示继续补充。</span>
</div>
</div>
</div>
<!-- 修改内容占位 -->
<div v-else-if="action.type === 'modify_payload'" class="modify-placeholder">
<div class="modify-placeholder-content">
<span class="icon-label">
<span class="icon icon-sm"
:style="iconStyle('hammer')"
aria-hidden="true"></span>
<span>已对 {{ action.modify?.path || '目标文件' }} 执行补丁</span>
</span>
<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 icon-label" v-if="action.modify?.forced">
<span class="icon icon-sm"
:style="iconStyle('triangleAlert')"
aria-hidden="true"></span>
<span>未检测到结束标记,系统已在流结束时执行补丁。</span>
</div>
<div class="modify-warning icon-label" v-if="action.modify?.failed && action.modify.failed.length">
<span class="icon icon-sm"
:style="iconStyle('triangleAlert')"
aria-hidden="true"></span>
<span>未完成的序号:{{ action.modify.failed.map(f => f.index || f).join('、') || action.modify.failed.join('、') }},请根据提示重新输出。</span>
</div>
</div>
</div>
<!-- 修改结果摘要 -->
<div v-else-if="action.type === 'modify'" class="modify-placeholder">
<div class="modify-placeholder-content">
<div class="icon-label">
<span class="icon icon-sm"
:style="iconStyle('hammer')"
aria-hidden="true"></span>
<span>{{ action.modify?.summary || `已处理 ${action.modify?.path || '目标文件'}` }}</span>
</div>
<div class="modify-meta" v-if="action.modify">
<span v-if="action.modify.total">· 共 {{ action.modify.total }} 处</span>
<span v-if="action.modify.completed">· 完成 {{ action.modify.completed.length || action.modify.completed }} 处</span>
<span v-if="action.modify.failed">· 未完成 {{ action.modify.failed.length || action.modify.failed }} 处</span>
</div>
<div class="modify-warning icon-label" v-if="action.modify?.forced">
<span class="icon icon-sm"
:style="iconStyle('triangleAlert')"
aria-hidden="true"></span>
<span>未检测到结束标记,系统已自动处理。</span>
</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 icon icon-md"
:class="getToolAnimationClass(action.tool)"
:style="iconStyle(getToolIcon(action.tool))"
aria-hidden="true"></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">
<div class="collapsible-block system-block"
:class="{ expanded: expandedBlocks.has(`system-${index}`) }">
<div class="collapsible-header" @click="toggleBlock(`system-${index}`)">
<div class="arrow"></div>
<div class="status-icon">
<span class="tool-icon icon icon-md"
:style="iconStyle('info')"
aria-hidden="true"></span>
</div>
<span class="status-text">系统消息</span>
</div>
<div class="collapsible-content">
<div class="content-inner">
{{ msg.content }}
</div>
</div>
</div>
</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 compact-input-area">
<div class="stadium-input-wrapper" ref="stadiumShellOuter">
<div
class="stadium-shell"
ref="compactInputShell"
:class="{
'is-multiline': inputIsMultiline,
'is-focused': inputIsFocused,
'has-text': inputMessage.trim().length > 0
}">
<input type="file"
ref="fileUploadInput"
class="file-input-hidden"
@change="handleFileSelected">
<button type="button"
class="stadium-btn add-btn"
@click.stop="toggleQuickMenu"
:disabled="!isConnected">
+
</button>
<textarea
ref="stadiumInput"
v-model="inputMessage"
@input="handleInputChange"
@focus="handleInputFocus"
@blur="handleInputBlur"
@keydown.enter.ctrl="sendMessage"
placeholder="输入消息... (Ctrl+Enter 发送)"
class="stadium-input"
:disabled="!isConnected || streamingMessage"
rows="1">
</textarea>
<button type="button"
class="stadium-btn send-btn"
@click="handleSendOrStop"
:disabled="!isConnected || (!inputMessage.trim() && !streamingMessage)">
<span v-if="streamingMessage" class="stop-icon"></span>
<span v-else class="send-icon"></span>
</button>
</div>
<transition name="quick-menu">
<div class="quick-menu" v-if="quickMenuOpen" ref="quickMenu" @click.stop>
<button type="button"
class="menu-entry"
@click="handleQuickUpload"
:disabled="!isConnected || uploading">
{{ uploading ? '上传中...' : '上传文件' }}
</button>
<button type="button"
class="menu-entry has-submenu"
@click.stop="toggleToolMenu"
:disabled="!isConnected">
工具禁用
<span class="entry-arrow"></span>
</button>
<button type="button"
class="menu-entry"
@click="handleQuickModeToggle"
:disabled="streamingMessage || !isConnected">
{{ thinkingMode ? '快速模式' : '思考模式' }}
</button>
<button type="button"
class="menu-entry has-submenu"
@click.stop="toggleSettings"
:disabled="!isConnected">
设置
<span class="entry-arrow"></span>
</button>
<transition name="submenu-slide">
<div class="quick-submenu tool-submenu" v-if="toolMenuOpen">
<div class="submenu-status" v-if="toolSettingsLoading">
正在同步工具状态...
</div>
<div v-else-if="toolSettings.length === 0" class="submenu-empty">
暂无可控工具
</div>
<div v-else class="submenu-list">
<button v-for="category in toolSettings"
:key="category.id"
type="button"
class="menu-entry submenu-entry"
:class="{ disabled: !category.enabled }"
@click.stop="updateToolCategory(category.id, !category.enabled)"
:disabled="streamingMessage || !isConnected || toolSettingsLoading">
<span class="submenu-label icon-label">
<span class="icon icon-sm"
:style="iconStyle(toolCategoryIcon(category.id))"
aria-hidden="true"></span>
<span>{{ category.label }}</span>
</span>
<span class="entry-arrow">{{ category.enabled ? '禁用' : '启用' }}</span>
</button>
</div>
</div>
</transition>
<transition name="submenu-slide">
<div class="quick-submenu settings-submenu" v-if="settingsOpen">
<div class="submenu-list">
<button type="button"
class="menu-entry submenu-entry"
@click="openRealtimeTerminal"
:disabled="!isConnected">
实时终端
</button>
<button type="button"
class="menu-entry submenu-entry"
@click="toggleFocusPanel"
:disabled="!isConnected">
聚焦面板
</button>
<button type="button"
class="menu-entry submenu-entry"
@click="toggleTokenPanel"
:disabled="!currentConversationId">
用量统计
</button>
<button type="button"
class="menu-entry submenu-entry"
@click="compressConversation"
:disabled="compressing || streamingMessage || !isConnected">
{{ compressing ? '压缩中...' : '压缩对话' }}
</button>
</div>
</div>
</transition>
</div>
</transition>
</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 class="icon-label">
<span class="icon icon-sm"
:style="iconStyle('eye')"
aria-hidden="true"></span>
<span>聚焦文件 ({{ Object.keys(focusedFiles).length }}/3)</span>
</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>
<transition name="personal-page-fade">
<div class="personal-page-overlay"
v-if="personalPageVisible"
@click.self="closePersonalPage">
<div class="personal-page-card">
<h2>个人空间</h2>
<p>敬请期待,个人页面正在建设中。</p>
<button type="button"
class="personal-page-close"
@click="closePersonalPage">
返回工作区
</button>
</div>
</div>
</transition>
</template>
<div class="easter-egg-overlay"
v-show="easterEgg.active"
:class="{ active: easterEgg.active }"
aria-hidden="true"
ref="easterEggRoot">
</div>
<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.originalLabel) {
button.dataset.originalLabel = button.getAttribute('aria-label') || '复制代码';
}
navigator.clipboard.writeText(codeContent).then(() => {
button.classList.add('copied');
button.setAttribute('aria-label', '已复制');
setTimeout(() => {
button.classList.remove('copied');
button.setAttribute('aria-label', button.dataset.originalLabel);
}, 2000);
}).catch(err => {
console.error('复制失败:', err);
// 即使失败也要恢复状态
button.classList.remove('copied');
button.setAttribute('aria-label', button.dataset.originalLabel || '复制代码');
});
}
// 使用事件委托处理复制按钮点击
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/easter-eggs/registry.js"></script>
<script src="/static/easter-eggs/flood.js"></script>
<script src="/static/easter-eggs/snake.js"></script>
<!-- 加载应用脚本 -->
<script src="/static/app.js"></script>
</body>
</html>