agent-Specialization/static/src/App.vue
JOJO be9592a2f4 fix(mobile): 审批面板自动关闭和交互问题
- 移除审批面板在空列表时自动关闭的逻辑
- 支持手动打开空审批面板
- 恢复点击遮罩层关闭面板功能
2026-04-12 13:42:09 +08:00

703 lines
28 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>
<div class="dropdown-list dropdown-list--models">
<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>
<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-if="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"
:current-permission-mode="currentPermissionMode"
:permission-menu-open="permissionMenuOpen"
:permission-options="permissionModeOptions"
:current-context-tokens="currentContextTokens"
:versioning-enabled="versioningEnabled"
@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="(val) => handleTokenPanelToggleClick(val)"
@compress-conversation="handleCompressConversationClick"
@toggle-approval-panel="handleApprovalPanelToggleClick"
@file-selected="handleFileSelected"
@pick-images="openImagePicker"
@pick-video="openVideoPicker"
@remove-image="handleRemoveImage"
@remove-video="handleRemoveVideo"
@open-review="openReviewDialog"
@toggle-permission-menu="togglePermissionMenu"
@change-permission-mode="changePermissionMode"
@open-versioning-dialog="openVersioningDialog"
/>
</div>
</main>
<div
v-if="!isMobileViewport"
class="resize-handle"
@mousedown="startResize('right', $event)"
></div>
<ToolApprovalPanel
v-if="!isMobileViewport"
:collapsed="rightCollapsed"
:width="rightWidth"
:approvals="pendingToolApprovals"
:deciding-approval-ids="decidingApprovalIds"
@switch-unrestricted="handleSwitchPermissionToUnrestricted"
@approve="approveToolApproval"
@reject="rejectToolApproval"
@close="rightCollapsed = true"
/>
</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 />
<BackgroundCommandDialog />
<transition name="overlay-fade">
<VersioningDialog
v-if="versioningDialogOpen"
:host-mode="versioningHostMode"
:enabled="versioningEnabled"
:loading="versioningLoading"
:items="versioningCheckpoints"
:selected-seq="versioningSelectedSeq"
:detail="versioningSelectedDetail"
:detail-loading="versioningDetailLoading"
:restoring="versioningRestoring"
:restore-mode="versioningRestoreMode"
:workspace-matched="versioningWorkspaceMatched"
@close="versioningDialogOpen = false"
@refresh="refreshVersioningDialog"
@toggle-enabled="toggleConversationVersioning"
@select="selectVersioningCheckpoint"
@update:restore-mode="versioningRestoreMode = $event"
@confirm="confirmVersioningRestore"
/>
</transition>
<TutorialOverlay v-if="tutorialStore.running" />
<NewUserTutorialPrompt
:visible="tutorialPromptVisible"
:username="tutorialPromptUsername"
:loading="tutorialPromptLoading"
@start="handleNewUserTutorialStart"
@skip="handleNewUserTutorialSkip"
/>
<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>
<div class="dropdown-list dropdown-list--models">
<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>
<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>
<transition name="mobile-panel-overlay">
<div
v-if="isMobileViewport && activeMobileOverlay === 'approval'"
class="mobile-panel-overlay mobile-panel-overlay--right mobile-approval-overlay"
@click.self="closeMobileOverlay('backdrop-click')"
>
<div class="mobile-panel-sheet mobile-panel-sheet--approval">
<ToolApprovalPanel
class="mobile-overlay-content"
:collapsed="false"
:width="Math.min(rightWidth || 420, 460)"
:approvals="pendingToolApprovals"
:deciding-approval-ids="decidingApprovalIds"
@switch-unrestricted="handleSwitchPermissionToUnrestricted"
@approve="approveToolApproval"
@reject="rejectToolApproval"
@close="closeMobileOverlay('approval-panel-close-btn')"
/>
</div>
</div>
</transition>
</template>
</AppShell>
</template>
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
import appOptions from './app';
import VideoPicker from './components/overlay/VideoPicker.vue';
import { useTutorialStore } from './stores/tutorial';
const VirtualMonitorSurface = defineAsyncComponent(
() => import('./components/chat/VirtualMonitorSurface.vue')
);
const tutorialStore = useTutorialStore();
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>