agent-Specialization/static/src/App.vue

569 lines
23 KiB
Vue
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.

<template>
<AppShell :download-file="downloadFile" :download-folder="downloadFolder">
<div v-if="!isConnected && messages.length === 0" class="app-loading-state">
<div class="loading-animation" aria-hidden="true">
<!-- From Uiverse.io by Nawsome -->
<div class="boxes">
<div class="box">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div class="box">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div class="box">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div class="box">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
</div>
<div class="loading-copy">
<h2>加载中...</h2>
<p>正在连接服务器,请稍候</p>
<button type="button" class="loading-refresh-btn" @click="refreshCurrentPage">
刷新页面
</button>
</div>
</div>
<template v-else>
<div class="main-container">
<ConversationSidebar
v-if="!isMobileViewport"
:icon-style="iconStyle"
:format-time="formatTime"
:display-mode="chatDisplayMode"
:display-mode-disabled="displayModeSwitchDisabled"
@toggle="toggleSidebar"
@create="createNewConversation"
@search="handleSidebarSearchInput"
@search-submit="handleSidebarSearchSubmit"
@search-more="loadMoreSearchResults"
@select="loadConversation"
@load-more="loadMoreConversations"
@personal="openPersonalPage"
@delete="deleteConversation"
@duplicate="duplicateConversation"
@toggle-workspace="handleWorkspaceToggle"
@toggle-display-mode="handleDisplayModeToggle"
/>
<div v-if="!isMobileViewport" class="workspace-region">
<LeftPanel
ref="leftPanel"
class="workspace-panel"
:class="{ 'workspace-panel--collapsed': workspaceCollapsed }"
:width="leftWidth"
:collapsed="workspaceCollapsed"
:icon-style="iconStyle"
:agent-version="agentVersion"
:thinking-mode="thinkingMode"
:run-mode="resolvedRunMode"
:is-connected="isConnected"
:panel-menu-open="panelMenuOpen"
:panel-mode="panelMode"
:file-manager-disabled="policyUiBlocks.block_file_manager"
@toggle-panel-menu="togglePanelMenu"
@select-panel="selectPanelMode"
@open-file-manager="openGuiFileManager"
@toggle-thinking-mode="handleQuickModeToggle"
/>
<div
v-if="!workspaceCollapsed"
class="resize-handle"
@mousedown="startResize('left', $event)"
></div>
</div>
<main
class="chat-container"
:class="{
'chat-container--immersive': workspaceCollapsed,
'chat-container--mobile': isMobileViewport,
'chat-container--monitor': chatDisplayMode === 'monitor',
'has-title-ribbon': titleRibbonVisible
}"
>
<TokenDrawer
:visible="Boolean(currentConversationId)"
:collapsed="tokenPanelCollapsed"
@toggle="handleTokenPanelToggleClick"
:current-conversation-tokens="currentConversationTokens"
:current-context-tokens="currentContextTokens"
:container-status="containerStatus"
:container-net-rate="containerNetRate"
:project-storage="projectStorage"
:usage-quota="usageQuota"
:format-token-count="formatTokenCount"
:format-percentage="formatPercentage"
:format-bytes="formatBytes"
:format-quota-value="formatQuotaValue"
:format-reset-time="formatResetTime"
:format-rate="formatRate"
/>
<div
v-if="titleRibbonVisible"
class="conversation-ribbon"
ref="titleRibbon"
>
<button
type="button"
class="conversation-ribbon__selector"
data-tutorial="header-model-selector"
:class="{ open: headerMenuOpen }"
@click.stop="toggleHeaderMenu"
:disabled="!isConnected"
>
<span class="selector-label">
<span class="selector-model">{{ currentModelLabel }}</span>
<span class="selector-sep">·</span>
<span class="selector-mode">{{ headerRunModeLabel }}</span>
</span>
<svg viewBox="0 0 24 24" aria-hidden="true" class="selector-caret">
<path d="M7 10l5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<span class="conversation-ribbon__text">{{ titleTypingText || currentConversationTitle }}</span>
<transition name="header-menu">
<div
v-if="headerMenuOpen"
class="model-mode-dropdown"
ref="headerMenu"
>
<div class="dropdown-column" data-tutorial="header-model-options">
<div class="dropdown-title">模型</div>
<button
v-for="option in modelOptions"
:key="option.key"
type="button"
class="dropdown-item"
:class="{ active: option.key === currentModelKey, disabled: option.disabled }"
@click.stop="handleHeaderModelSelect(option.key, option.disabled)"
:disabled="streamingMessage || !isConnected || option.disabled"
>
<div class="item-label">{{ option.label }}</div>
<div class="item-desc">{{ option.description }}</div>
<span v-if="option.key === currentModelKey" class="item-check">✓</span>
</button>
</div>
<div class="dropdown-column" data-tutorial="header-runmode-options">
<div class="dropdown-title">运行模式</div>
<button
v-for="option in headerRunModeOptions"
:key="option.value"
type="button"
class="dropdown-item"
:class="{ active: option.value === resolvedRunMode }"
@click.stop="handleHeaderRunModeSelect(option.value)"
:disabled="streamingMessage || !isConnected"
>
<div class="item-label">{{ option.label }}</div>
<div class="item-desc">{{ option.desc }}</div>
<span v-if="option.value === resolvedRunMode" class="item-check">✓</span>
</button>
</div>
</div>
</transition>
</div>
<ChatArea
v-show="chatDisplayMode === 'chat'"
ref="messagesArea"
:messages="messages"
:icon-style="iconStyle"
:expanded-blocks="expandedBlocks"
:render-markdown="renderMarkdown"
:toggle-block="toggleBlock"
:handle-thinking-scroll="handleThinkingScroll"
:streaming-message="streamingMessage"
:get-tool-icon="getToolIcon"
:get-tool-status-text="getToolStatusText"
:get-tool-animation-class="getToolAnimationClass"
:get-tool-description="getToolDescription"
:format-search-topic="formatSearchTopic"
:format-search-time="formatSearchTime"
:format-search-domains="formatSearchDomains"
/>
<VirtualMonitorSurface v-show="chatDisplayMode === 'monitor'" />
<div v-if="blankHeroActive" class="blank-hero-overlay">
<span class="icon icon-lg" :style="iconStyle('bot')" aria-hidden="true"></span>
<p class="blank-hero-text">{{ blankWelcomeText }}</p>
</div>
<div
v-if="chatDisplayMode === 'chat'"
class="scroll-lock-toggle"
:class="{ locked: autoScrollEnabled && !userScrolling && isOutputActive() }"
>
<button @click="toggleScrollLock" class="scroll-lock-btn">
<svg
v-if="autoScrollEnabled && !userScrolling && isOutputActive()"
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="composer-container" :class="{ 'blank-hero-mode': composerHeroActive }">
<InputComposer
ref="inputComposer"
:input-message="inputMessage"
:input-is-multiline="inputIsMultiline"
:input-is-focused="inputIsFocused"
:is-connected="isConnected"
:streaming-message="composerBusy"
:input-locked="displayLockEngaged"
:uploading="uploading"
:media-uploading="mediaUploading"
:thinking-mode="thinkingMode"
:run-mode="resolvedRunMode"
:model-menu-open="modelMenuOpen"
:model-options="modelOptions"
:current-model-key="currentModelKey"
:quick-menu-open="quickMenuOpen"
:tool-menu-open="toolMenuOpen"
:mode-menu-open="modeMenuOpen"
:tool-settings="toolSettings"
:tool-settings-loading="toolSettingsLoading"
:settings-open="settingsOpen"
:compressing="compressing || compressionInProgress"
:current-conversation-id="currentConversationId"
:icon-style="iconStyle"
:tool-category-icon="toolCategoryIcon"
:selected-images="selectedImages"
:selected-videos="selectedVideos"
:block-upload="policyUiBlocks.block_upload"
:block-tool-toggle="policyUiBlocks.block_tool_toggle"
:block-realtime-terminal="policyUiBlocks.block_realtime_terminal"
:block-focus-panel="policyUiBlocks.block_focus_panel"
:block-token-panel="policyUiBlocks.block_token_panel"
:block-compress-conversation="policyUiBlocks.block_compress_conversation"
:block-conversation-review="policyUiBlocks.block_conversation_review"
@update:input-message="inputSetMessage"
@input-change="handleInputChange"
@input-focus="handleInputFocus"
@input-blur="handleInputBlur"
@toggle-quick-menu="toggleQuickMenu"
@send-message="sendMessage"
@send-or-stop="handleSendOrStop"
@quick-upload="handleQuickUpload"
@toggle-tool-menu="toggleToolMenu"
@toggle-mode-menu="toggleModeMenu"
@toggle-model-menu="toggleModelMenu"
@select-run-mode="handleModeSelect"
@select-model="handleModelSelect"
@toggle-settings="toggleSettings"
@update-tool-category="updateToolCategory"
@realtime-terminal="handleRealtimeTerminalClick"
@toggle-focus-panel="handleFocusPanelToggleClick"
@toggle-token-panel="handleTokenPanelToggleClick"
@compress-conversation="handleCompressConversationClick"
@file-selected="handleFileSelected"
@pick-images="openImagePicker"
@pick-video="openVideoPicker"
@remove-image="handleRemoveImage"
@remove-video="handleRemoveVideo"
@open-review="openReviewDialog"
/>
</div>
</main>
<div v-if="!isMobileViewport" class="resize-handle" @mousedown="startResize('right', $event)"></div>
</div>
<PersonalizationDrawer />
<transition name="overlay-fade">
<ImagePicker
v-if="imagePickerOpen"
:open="imagePickerOpen"
:entries="imageEntries"
:initial-selected="selectedImages"
:loading="imageLoading"
:uploading="mediaUploading"
@close="closeImagePicker"
@confirm="handleImagesConfirmed"
@local-files="handleLocalImageFiles"
/>
</transition>
<transition name="overlay-fade">
<VideoPicker
v-if="videoPickerOpen"
:open="videoPickerOpen"
:entries="videoEntries"
:initial-selected="selectedVideos"
:loading="videoLoading"
:uploading="mediaUploading"
@close="closeVideoPicker"
@confirm="handleVideosConfirmed"
@local-files="handleLocalVideoFiles"
/>
</transition>
<transition name="overlay-fade">
<ConversationReviewDialog
v-if="reviewDialogOpen"
:open="reviewDialogOpen"
:conversations="conversations"
:current-conversation-id="currentConversationId"
:selected-id="reviewSelectedConversationId"
:loading="conversationsLoading"
:loading-more="loadingMoreConversations"
:has-more="hasMoreConversations"
:submitting="reviewSubmitting"
:preview="reviewPreviewLines"
:preview-loading="reviewPreviewLoading"
:preview-error="reviewPreviewError"
:preview-limit="reviewPreviewLimit"
:send-to-model="reviewSendToModel"
:generated-path="reviewGeneratedPath"
@close="reviewDialogOpen = false"
@select="handleReviewSelect"
@load-more="loadMoreConversations"
@toggle-send="reviewSendToModel = $event"
@confirm="handleConfirmReview"
/>
</transition>
<SubAgentActivityDialog />
<TutorialOverlay />
<div
v-if="isMobileViewport"
class="mobile-panel-trigger"
:class="{ 'is-hidden': Boolean(activeMobileOverlay) }"
ref="mobilePanelTrigger"
>
<div class="mobile-panel-topbar">
<button type="button" class="mobile-panel-fab" data-tutorial="mobile-menu-trigger" aria-label="切换工作区" @click="toggleMobileOverlayMenu">
<img :src="mobilePanelIcon" alt="" aria-hidden="true" />
</button>
<button
type="button"
class="mobile-topbar-selector"
data-tutorial="header-model-selector-mobile"
:class="{ open: headerMenuOpen }"
@click.stop="toggleHeaderMenu"
:disabled="!isConnected"
>
<span class="mobile-selector-label">
<span class="mobile-selector-model">{{ currentModelLabel }}</span>
<span class="mobile-selector-sep">·</span>
<span class="mobile-selector-mode">{{ headerRunModeLabel }}</span>
</span>
</button>
<div class="mobile-topbar-title" :title="currentConversationTitle || '未命名对话'">
{{ currentConversationTitle || '未命名对话' }}
</div>
</div>
<transition name="mobile-panel-menu">
<div v-if="mobileOverlayMenuOpen" class="mobile-panel-menu mobile-panel-menu--dropdown">
<button
type="button"
class="dropdown-item mobile-menu-item"
data-tutorial="mobile-menu-conversation"
aria-label="对话记录"
@click="openMobileOverlay('conversation')"
>
<svg class="mobile-menu-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 5.5c0-1.38 1.12-2.5 2.5-2.5h11c1.38 0 2.5 1.12 2.5 2.5v7.5c0 1.38-1.12 2.5-2.5 2.5h-4.8l-2.9 2.7.5-2.7H6.5c-1.38 0-2.5-1.12-2.5-2.5V5.5z"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M8 8.5h8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
<path d="M8 11.5h5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
</svg>
<div class="item-label">对话记录</div>
</button>
<button
type="button"
class="dropdown-item mobile-menu-item"
data-tutorial="mobile-menu-workspace"
aria-label="工作文件"
@click="openMobileOverlay('workspace')"
>
<img class="mobile-menu-icon" :src="mobileMenuIcons.workspace" alt="" aria-hidden="true" />
<div class="item-label">工作文件</div>
</button>
<button
type="button"
class="dropdown-item mobile-menu-item mobile-menu-item--personal"
data-tutorial="mobile-menu-personal"
aria-label="个人空间"
@click="handleMobilePersonalClick"
>
<img class="mobile-menu-icon" :src="mobileMenuIcons.personal" alt="" aria-hidden="true" />
<div class="item-label">个人空间</div>
</button>
<button
type="button"
class="dropdown-item mobile-menu-item"
data-tutorial="mobile-menu-new-chat"
aria-label="新建对话"
@click="createNewConversation"
>
<img class="mobile-menu-icon" :src="mobileMenuIcons.newChat" alt="" aria-hidden="true" />
<div class="item-label">新建对话</div>
</button>
<button
v-if="isAppShell"
type="button"
class="dropdown-item mobile-menu-item mobile-menu-item--refresh"
aria-label="刷新页面"
@click="handleMobileRefreshClick"
>
<img class="mobile-menu-icon" :src="mobileMenuIcons.refresh" alt="" aria-hidden="true" />
<div class="item-label">刷新页面</div>
</button>
</div>
</transition>
<transition name="header-menu">
<div
v-if="headerMenuOpen && isMobileViewport"
class="model-mode-dropdown model-mode-dropdown--mobile"
ref="headerMenu"
>
<div class="dropdown-column" data-tutorial="header-model-options">
<div class="dropdown-title">模型</div>
<button
v-for="option in modelOptions"
:key="option.key"
type="button"
class="dropdown-item"
:class="{ active: option.key === currentModelKey, disabled: option.disabled }"
@click.stop="handleHeaderModelSelect(option.key, option.disabled)"
:disabled="streamingMessage || !isConnected || option.disabled"
>
<div class="item-label">{{ option.label }}</div>
<span v-if="option.key === currentModelKey" class="item-check">✓</span>
</button>
</div>
<div class="dropdown-column" data-tutorial="header-runmode-options">
<div class="dropdown-title">运行模式</div>
<button
v-for="option in headerRunModeOptions"
:key="option.value"
type="button"
class="dropdown-item"
:class="{ active: option.value === resolvedRunMode }"
@click.stop="handleHeaderRunModeSelect(option.value)"
:disabled="streamingMessage || !isConnected"
>
<div class="item-label">{{ option.label }}</div>
<span v-if="option.value === resolvedRunMode" class="item-check">✓</span>
</button>
</div>
</div>
</transition>
</div>
<transition name="mobile-panel-overlay">
<div
v-if="isMobileViewport && activeMobileOverlay === 'conversation'"
class="mobile-panel-overlay mobile-panel-overlay--left"
@click.self="closeMobileOverlay"
>
<div class="mobile-panel-sheet mobile-panel-sheet--conversation">
<ConversationSidebar
class="mobile-overlay-content"
:icon-style="iconStyle"
:format-time="formatTime"
:display-mode="chatDisplayMode"
:display-mode-disabled="displayModeSwitchDisabled"
:show-collapse-button="true"
collapse-button-variant="close"
@toggle="closeMobileOverlay"
@create="createNewConversation"
@search="handleSidebarSearchInput"
@search-submit="handleSidebarSearchSubmit"
@search-more="loadMoreSearchResults"
@select="handleMobileOverlaySelect($event)"
@load-more="loadMoreConversations"
@personal="openPersonalPage"
@delete="deleteConversation"
@duplicate="duplicateConversation"
@toggle-display-mode="handleDisplayModeToggle"
/>
</div>
</div>
</transition>
<transition name="mobile-panel-overlay">
<div
v-if="isMobileViewport && activeMobileOverlay === 'workspace'"
class="mobile-panel-overlay mobile-panel-overlay--left"
@click.self="closeMobileOverlay"
>
<div class="mobile-panel-sheet mobile-panel-sheet--workspace">
<button type="button" class="mobile-overlay-close" data-tutorial="mobile-workspace-close" aria-label="关闭面板" @click="closeMobileOverlay">
×
</button>
<LeftPanel
class="mobile-overlay-content"
:width="Math.min(leftWidth, 420)"
:icon-style="iconStyle"
:agent-version="agentVersion"
:thinking-mode="thinkingMode"
:is-connected="isConnected"
:panel-menu-open="panelMenuOpen"
:panel-mode="panelMode"
@toggle-panel-menu="togglePanelMenu"
@select-panel="selectPanelMode"
@open-file-manager="openGuiFileManager"
/>
</div>
</div>
</transition>
</template>
</AppShell>
</template>
<script setup lang="ts">
import appOptions from './app';
import VirtualMonitorSurface from './components/chat/VirtualMonitorSurface.vue';
import VideoPicker from './components/overlay/VideoPicker.vue';
const mobilePanelIcon = new URL('../icons/align-left.svg', import.meta.url).href;
const isAppShell =
typeof window !== 'undefined' &&
(new URLSearchParams(window.location.search).has('app_shell') || Boolean((window as any)?.AndroidThemeBridge));
const mobileMenuIcons = {
workspace: new URL('../icons/folder.svg', import.meta.url).href,
personal: new URL('../icons/user.svg', import.meta.url).href,
newChat: new URL('../icons/pencil.svg', import.meta.url).href,
refresh: new URL('../icons/refresh-cw.svg', import.meta.url).href
};
defineOptions(appOptions);
</script>