mateclaw/mateclaw-ui/src/views/ChatConsole.vue

2870 lines
109 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>
<div class="mc-page-shell chat-console-shell">
<div class="mc-page-frame chat-console-frame">
<div class="chat-layout mc-surface-card">
<!-- 移动端会话面板遮罩 -->
<Transition name="fade">
<div v-if="isMobile && convPanelOpen" class="conv-backdrop" @click="convPanelOpen = false"></div>
</Transition>
<!-- 会话侧边栏 -->
<ConversationSidebar
:conversations="conversations"
:current-conversation-id="currentConversationId"
:agents="agents"
:selected-agent-id="selectedAgentId"
:collapsed="convPanelCollapsed"
:mobile-open="convPanelOpen"
:is-mobile="isMobile"
@select="selectConversation"
@new-chat="newConversation"
@agent-picked="onAgentPicked"
@toggle-collapse="toggleConvPanel"
@refresh="loadConversations"
@deleted="onConversationsDeleted"
/>
<!-- 主聊天区域 -->
<div
class="chat-area"
@dragenter.prevent="onDragEnter"
@dragover.prevent
@dragleave="onDragLeave"
@drop.prevent="onDrop"
>
<!-- 拖拽上传遮罩 -->
<Transition name="fade">
<div v-if="isDragging" class="drop-overlay">
<div class="drop-overlay__content">
<el-icon><UploadFilled /></el-icon>
<span>{{ $t('chat.dropToUpload') }}</span>
</div>
</div>
</Transition>
<!-- 头部 -->
<div class="chat-header">
<div class="chat-header-left">
<button v-if="isMobile" class="conv-toggle-btn" @click="convPanelOpen = !convPanelOpen" :title="$t('chat.conversations')">
<el-icon><ChatDotRound /></el-icon>
</button>
<div class="chat-stage-copy" v-if="currentAgent">
<div class="chat-stage-kicker">{{ $t('nav.chat') }}</div>
<!--
Header reads as "who is this employee" — name + tagline.
The runtime mode (ReAct / Plan-Execute) is technical jargon
to end users and lives in the badge tooltip instead, so the
header doesn't get polluted.
-->
<div
class="agent-badge"
:title="`${currentAgent.name}${currentAgentRuntimeMode ? ' · ' + currentAgentRuntimeMode : ''}`"
>
<span class="agent-badge-icon" :style="{ color: agentIconColor(currentAgent.icon) }"><SkillIcon :value="currentAgent.icon" :size="22" :fallback="'🤖'" /></span>
<div class="agent-badge-text">
<span class="agent-badge-name">{{ currentAgent.name }}</span>
</div>
<span class="status-dot" :class="connectionStatusClass" :title="connectionStatusLabel"></span>
</div>
</div>
<div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div>
</div>
<div class="chat-header-right">
<!-- Model selector Issue #81 v2 R3: always pass full providers + show-all-states
so unhealthy rows render as dimmed entries with status chips and a Fix
button instead of disappearing entirely. -->
<ModelSelector
:providers="providers"
:active-value="activeModelValue"
:active-label="activeModelLabel"
:saving="modelSaving"
:show-all-states="true"
:can-configure="canConfigureModels"
:empty-hint="modelSelectorEmptyHint"
@select="selectModel"
@navigate-fix="onModelSelectorFix"
/>
<!-- Overflow menu -->
<div class="header-overflow-wrap">
<button ref="headerBtnRef" class="header-btn" @click="headerMenuOpen = !headerMenuOpen" :title="$t('common.more')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<DropdownMenu
:open="headerMenuOpen"
:anchor="headerBtnRef"
:items="headerMenuItems"
@select="onHeaderMenuSelect"
@close="headerMenuOpen = false"
>
<template #item-icon="{ item }">
<el-icon v-if="item.key === 'config'"><Setting /></el-icon>
<el-icon v-else-if="item.key === 'clear'"><Delete /></el-icon>
</template>
</DropdownMenu>
</div>
</div>
</div>
<TeamWorkerBanner
v-if="workerRunContext"
:run-id="workerRunContext.runId"
:task-id="workerRunContext.taskId"
:team-id="workerRunContext.teamId"
:lead-conversation-id="workerRunContext.leadConversationId"
@navigate="router.push($event)"
/>
<!-- 使用组件化的 MessageList -->
<MessageList
ref="messageListRef"
:messages="messages"
:loading="isGenerating"
:assistant-icon="currentAgent?.icon || '🤖'"
:user-icon="userInitial"
:title="blockingPrompt ? modelPromptText.title : $t('app.title')"
:subtitle="blockingPrompt ? modelPromptText.desc : $t('chat.subtitle')"
:suggestions="blockingPrompt ? [] : suggestions"
:team-runs="teamRuns"
:expanded-team-run-id="teamRunRouteQuery.teamRunId || null"
:selected-team-task-id="teamRunRouteQuery.taskId || null"
:team-runs-has-more="Boolean(teamRunsNextCursor)"
:team-runs-loading-more="teamRunsLoadingMore"
:readonly="workerConversationReadOnly"
@regenerate="handleRegenerate"
@rewind="handleRewind"
@suggestion-click="sendSuggestion"
@toggle-thinking="handleToggleThinking"
@approve="handleApprove"
@approve-always="handleApproveAlways"
@deny="handleDeny"
@team-run-navigate="router.push($event)"
@team-runs-load-more="loadMoreTeamRuns"
>
<!-- Issue #81 v2 R2: blocking-only popup. Recoverable cases use the
non-blocking <RecoverableModelBanner> below instead. -->
<template v-if="blockingPrompt" #empty>
<div class="model-prompt">
<div class="model-prompt-title">{{ modelPromptText.title }}</div>
<div class="model-prompt-desc">{{ modelPromptText.desc }}</div>
<div class="model-prompt-actions">
<button class="btn-primary" @click="handlePrimaryAction">
{{ primaryActionLabel }}
</button>
<button
v-if="bestSwitchTarget"
class="btn-secondary"
@click="switchToBestTarget"
>
{{ $t('chat.promptAction.switchToModel', { name: bestSwitchTarget.label }) }}
</button>
</div>
</div>
</template>
</MessageList>
<!-- Issue #81: non-blocking banner — active provider is unhealthy but the
backend fallback chain has a LIVE provider to take over. -->
<RecoverableModelBanner
v-if="recoverablePrompt && activeProvider && bestFallbackName"
:provider-name="activeProvider.name"
:fallback-name="bestFallbackName"
@dismiss="recoverableDismissed = true"
/>
<!-- Cron job in-flight placeholder — visible while T2 hasn't committed
the assistant message yet. Populated by pollActivity → /cron-jobs/active-runs. -->
<div v-if="activeCronRuns.length > 0" class="cron-running-bar">
<div v-for="run in activeCronRuns" :key="run.runId" class="cron-running-item">
<span class="cron-running-spinner">🌀</span>
<span class="cron-running-text">
<strong>{{ run.jobName || $t('chat.cronRunning.fallbackName') }}</strong>
<span class="cron-running-meta">
· {{ $t('chat.cronRunning.executing') }}
<template v-if="run.startedAt"> · {{ elapsedLabel(run.startedAt) }}</template>
</span>
</span>
</div>
</div>
<!-- Terminal-state announcement after a goal completed or exhausted
in this conversation. Auto-dismisses when the user clicks × or
starts a new goal. -->
<GoalSystemLine
v-if="goalTerminalForCurrent && currentConversationId"
:variant="goalTerminalForCurrent.status"
:title="goalSystemLineTitle"
:detail="goalSystemLineDetail"
class="goal-system-line-slot"
@click.stop="onGoalSystemLineDismiss"
/>
<!-- Inline "set a goal?" invitation shown after the first assistant
reply when the conversation has no active goal and the user
hasn't dismissed it for this conv. -->
<GoalSetInlinePrompt
v-if="showGoalSetPrompt"
:conversation-id="currentConversationId"
:agent-id="String(selectedAgentId)"
:workspace-id="String(currentWorkspaceId || '1')"
:suggested-title="goalSuggestedTitle"
class="goal-set-prompt-slot"
@dismiss="onGoalPromptDismiss"
/>
<!-- 流式处理 Loading 栏(消息和输入框之间) -->
<StreamLoadingBar
:is-loading="isGenerating && !blockingPrompt"
:tool-count="toolCallCount"
:completion-tokens="currentGeneratingTokens"
:prompt-tokens="currentPromptTokens"
:phase="streamPhase"
:phase-info="phaseInfo"
:running-tool-name="currentRunningToolName"
:has-queued="hasQueued"
:lifecycle-stage="lifecycleStage"
:compact-status="compactStatus"
/>
<!-- Multimodal routing hint: shown when pending attachments require a
modality the primary model lacks. -->
<MultimodalRoutingHint
:attachments="pendingAttachments"
:capabilities="agentCapabilities"
/>
<!-- 上下文占用(估算):点击展开分项面板 -->
<div v-if="contextUsage" class="ctx-usage-row">
<ContextUsagePanel :usage="contextUsage" />
</div>
<!-- 使用组件化的 ChatInput -->
<ChatInput
ref="chatInputRef"
v-model="inputText"
:loading="isGenerating && !hasPendingApproval"
:disabled="blockingPrompt || !currentAgent || workerConversationReadOnly"
:skills-enabled="!!currentAgent && !currentAgent.skillsDisabled"
:placeholder="$t('chat.messagePlaceholder')"
:hint="currentRuntimeModel"
:attachments="pendingAttachments"
:uploading="uploadingAttachment"
:max-length="10240"
:pending-approval="activePendingApproval"
:stream-phase="streamPhase"
:queued-message="queuedMessage"
:queue-size="queueSize"
@submit="handleSendMessage"
@stop="handleStopStream"
@cancel-queued="handleCancelQueued"
@file-select="handleFileSelect"
@attachment-remove="removeAttachment"
@approve="handleApprove"
@approve-always="handleApproveAlways"
@deny="handleDeny"
:enable-talk-mode="!!selectedAgentId"
:thinking-enabled="thinkingEnabled"
:thinking-supported="currentModelSupportsThinking"
@toggle-thinking="thinkingEnabled = !thinkingEnabled"
@talk="showTalkMode = true"
/>
</div>
<!-- 运行总览侧栏:计划进度 + 子 Agent 实时状态 -->
<RunOverviewPanel
:messages="messages"
:is-generating="isGenerating"
:agent-type="currentAgent?.agentType"
/>
<!-- Talk Mode 覆盖层 -->
<TalkMode
v-if="showTalkMode"
:visible="showTalkMode"
:agent-id="selectedAgentId"
:conversation-id="currentConversationId"
@close="showTalkMode = false"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
let cachedAgents: import('@/types').Agent[] = []
</script>
<script setup lang="ts">
import { ElMessage } from 'element-plus/es/components/message/index'
import { ElMessageBox } from 'element-plus/es/components/message-box/index'
import { ref, computed, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { ChatDotRound, Delete, Setting, UploadFilled } from '@element-plus/icons-vue'
import { conversationApi, agentApi, modelApi, chatApi, cronJobApi, approvalApi } from '@/api/index'
import { copyToClipboard } from '@/utils/clipboard'
import { useFileDrop } from '@/composables/useFileDrop'
import { useIsMobile, useMediaQuery, BREAKPOINTS } from '@/composables/useBreakpoint'
import { useChat } from '@/composables/chat/useChat'
import { useTeamRuns } from '@/composables/chat/useTeamRuns'
import { useWorkerConversationGuard } from '@/composables/chat/useWorkerConversationGuard'
import { parseTeamMessageMetadata } from '@/composables/chat/messageMetadata'
import RunOverviewPanel from '@/components/chat/RunOverviewPanel.vue'
import { reconstructErrorInfo } from '@/types/chatError'
import { reconcileMessages, extractMessages } from '@/utils/messageReconcile'
import {
buildChatRouteQuery,
readLegacyWorkerRouteContext,
readTeamRunRouteQuery,
resolveConversationAgentSelection,
resolveRouteHydrationQuery,
} from '@/utils/chatRouteHydration'
import type { Conversation, Agent, ModelConfig, ProviderInfo, ActiveModelsInfo, ChatAttachment, MessageContentPart, Message, ToolCallMeta } from '@/types'
// 导入组件化组件
import MessageList from '@/components/chat/MessageList.vue'
import TeamWorkerBanner from '@/components/chat/TeamWorkerBanner.vue'
import RecoverableModelBanner from '@/components/chat/RecoverableModelBanner.vue'
import SkillIcon from '@/components/common/SkillIcon.vue'
import ConversationSidebar from '@/components/chat/ConversationSidebar.vue'
import DropdownMenu, { type DropdownMenuItem } from '@/components/common/DropdownMenu.vue'
import { agentIconColor } from '@/utils/agentIconColor'
import ChatInput from '@/components/chat/ChatInput.vue'
import MultimodalRoutingHint from '@/components/chat/MultimodalRoutingHint.vue'
import StreamLoadingBar from '@/components/chat/StreamLoadingBar.vue'
import ContextUsagePanel from '@/components/chat/ContextUsagePanel.vue'
import TalkMode from '@/components/chat/TalkMode.vue'
import ModelSelector from '@/components/chat/ModelSelector.vue'
import { useEChartsRenderer } from '@/composables/useEChartsRenderer'
import { useKatexRenderer } from '@/composables/useKatexRenderer'
import { useMermaidRenderer, handleMermaidDownload } from '@/composables/useMermaidRenderer'
import { useGoalStore } from '@/stores/useGoalStore'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { buildViewerModelProviders } from '@/utils/viewerModelProviders'
import GoalSetInlinePrompt from '@/components/goal/GoalSetInlinePrompt.vue'
import GoalSystemLine from '@/components/goal/GoalSystemLine.vue'
// ============ Talk Mode ============
const showTalkMode = ref(false)
// ============ 移动端 & 响应式状态 ============
const convPanelOpen = ref(false)
const convPanelCollapsed = ref(localStorage.getItem('mc-conv-collapsed') === 'true')
const userExplicitConvCollapse = ref(localStorage.getItem('mc-conv-collapsed') === 'true')
const isMobile = useIsMobile()
const compactViewport = useMediaQuery(BREAKPOINTS.compact)
// Leaving the mobile breakpoint closes the conversation drawer.
watch(isMobile, (mobile) => {
if (!mobile) convPanelOpen.value = false
})
// Auto-collapse the conversation panel on narrow desktop unless the user
// toggled it explicitly.
watch(compactViewport, (compact) => {
if (!userExplicitConvCollapse.value) convPanelCollapsed.value = compact
}, { immediate: true })
function toggleConvPanel() {
convPanelCollapsed.value = !convPanelCollapsed.value
userExplicitConvCollapse.value = convPanelCollapsed.value
localStorage.setItem('mc-conv-collapsed', String(convPanelCollapsed.value))
}
// ============ 配置和常量 ============
const suggestions = computed(() => {
const agent = currentAgent.value
// If agent has custom suggestions (stored as newline-separated string in description etc.)
const agentSuggestions = (agent as any)?.suggestions as string | undefined
if (agentSuggestions) {
const parsed = agentSuggestions.split('\n').filter(Boolean).slice(0, 4)
if (parsed.length) return parsed
}
// Agent-type-aware defaults
if (agent?.agentType === 'plan_execute') {
return [
t('chat.suggestionPlan1', '帮我制定一个完整的项目计划'),
t('chat.suggestionPlan2', '分步骤帮我完成一个复杂任务'),
t('chat.suggestionIntro'),
t('chat.suggestionWeather'),
]
}
return [
t('chat.suggestionIntro'),
t('chat.suggestionPoem'),
t('chat.suggestionCode'),
t('chat.suggestionWeather'),
]
})
// ============ 状态 ============
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const agents = ref<Agent[]>(cachedAgents.length > 0 ? [...cachedAgents] : [])
const conversations = ref<Conversation[]>([])
const selectedAgentId = ref<string | number>('')
const currentConversationId = ref<string>('')
const inputText = ref('')
const modelSaving = ref(false)
// Monotonic counter for in-flight setModel PUTs. The finally handler
// only clears modelSaving when its captured seq is still the latest, so a
// stale-finishing earlier PUT can't unlock the selector while a newer one
// is still in flight, and (crucially) switching conversations mid-PUT
// can't permanently lock the selector by leaving modelSaving stuck true.
let modelSaveSeq = 0
// Issue #81 v2 R2: split the single showModelPrompt boolean into two flags so
// the chat surface can either hard-block (blockingPrompt) or warn but let the
// backend fallback chain take over (recoverablePrompt). Driven by
// recomputePromptFlags() — see the watcher below.
const blockingPrompt = ref(false)
const recoverablePrompt = ref(false)
const recoverableDismissed = ref(false)
const defaultModel = ref<ModelConfig | null>(null)
const providers = ref<ProviderInfo[]>([])
// True when a viewer uses the credential-free provider projection. Provider
// config (API keys, base URLs, liveness) is admin-only, so viewers can switch
// models but prompt flags must still trust the active model's runtime state.
const providersUnavailable = ref(false)
// Mirror of /models/enabled (viewer-accessible). It supplies the model half of
// the safe picker projection and resolves the active label during hydration.
const enabledModels = ref<ModelConfig[]>([])
// The model the CURRENT conversation uses. Per-conversation — switching it
// never leaks into other conversations (see selectModel / applyConversationModel).
const activeModels = ref<ActiveModelsInfo | null>(null)
// Global default model — seeds the selector for conversations with no pin yet.
const globalDefaultModel = ref<{ providerId: string; model: string } | null>(null)
// True once the user manually picks a model for the CURRENT conversation.
// Guards the agent-capability re-seed (see the selectedAgentId watcher) from
// clobbering that pick during the window where the async capability fetch
// resolves after a fresh conversation was already seeded. Reset whenever we
// move to a different conversation.
const userPickedModel = ref(false)
const pendingAttachments = ref<ChatAttachment[]>([])
const uploadingAttachment = ref(false)
// Per-agent capability snapshot for the multimodal routing hint above the
// input box. Refetched whenever the active agent changes; cached locally to
// avoid an extra request per attachment change.
const agentCapabilities = ref<import('@/types').AgentCapabilities | null>(null)
// 思考模式:只有两个状态 — 开或关
const thinkingEnabled = ref(localStorage.getItem('mateclaw_thinking') !== 'off')
const thinkingLevel = computed(() => thinkingEnabled.value ? 'high' : 'off')
watch(thinkingEnabled, (v) => localStorage.setItem('mateclaw_thinking', v ? 'on' : 'off'))
// Dropdowns & menus
const headerMenuOpen = ref(false)
const headerBtnRef = ref<HTMLElement | null>(null)
const headerMenuItems = computed<DropdownMenuItem[]>(() => [
{ key: 'config', label: t('chat.configModel') },
{ key: 'sessions', label: t('chat.openSessions') },
{ divider: true },
{ key: 'clear', label: t('chat.clearMessages'), danger: true },
])
function onHeaderMenuSelect(item: DropdownMenuItem) {
if (item.key === 'config') goToModelSettings()
else if (item.key === 'sessions') router.push('/sessions')
else if (item.key === 'clear') clearMessages()
}
function onAgentPicked(value: string | number | null) {
if (value == null) return
if (String(value) !== String(selectedAgentId.value)) {
selectedAgentId.value = value
newConversation()
}
}
function selectModel(value: string) {
const [providerId, model] = value.split('::')
if (!providerId || !model) return
// Remember the explicit pick so a late-arriving agent-capability fetch
// doesn't re-seed over it on a not-yet-persisted conversation.
userPickedModel.value = true
// Per-conversation model: switching here only affects THIS conversation.
// We update the selector + the local list entry immediately so the UI is
// responsive, then persist the pin to the server right away IF the
// conversation already exists. Without the eager persist, IM channels
// (Feishu / DingTalk / WeCom …) keep using whatever the conversation row
// last had — they don't see the /chat/stream payload that the web path
// pins on send — so the user "switches model in the chat box" but the
// next IM inbound message still picks the old / default model.
//
// Snapshot the previous selection BEFORE the optimistic update so a
// failed PUT can roll the UI back instead of stranding the user with a
// model the backend isn't using.
const prevLlm = activeModels.value?.activeLlm
const prevActive: ActiveModelsInfo | null = prevLlm?.providerId && prevLlm?.model
? { activeLlm: { providerId: prevLlm.providerId, model: prevLlm.model } }
: null
const conv = conversations.value.find(c => c.conversationId === currentConversationId.value)
const prevConvProvider = conv?.modelProvider
const prevConvModel = conv?.modelName
activeModels.value = { activeLlm: { providerId, model } }
if (conv) {
conv.modelProvider = providerId
conv.modelName = model
}
// Only persist when the conversation is already in the server-side list.
// A brand-new chat (newConversation() generated a local id that hasn't
// been sent through /chat/stream yet) has no row to PUT against; that
// case still relies on the first /chat/stream call writing the pin.
if (conv && currentConversationId.value) {
const cid = currentConversationId.value
const mySeq = ++modelSaveSeq
modelSaving.value = true
conversationApi.setModel(cid, providerId, model)
.catch((e: any) => {
console.warn('[ChatConsole] Failed to persist model pin:', e)
mcToast.warning(t('chat.modelSaveFailed'))
// Roll back the visible selector + the cached conv pin so the UI
// doesn't keep claiming a model the backend isn't using. Only do
// it when the user is still on the same conversation AND hasn't
// picked yet another model — otherwise we'd corrupt the more
// recent state with this PUT's snapshot.
const liveConv = conversations.value.find(c => c.conversationId === cid)
if (liveConv && liveConv.modelProvider === providerId && liveConv.modelName === model) {
liveConv.modelProvider = prevConvProvider
liveConv.modelName = prevConvModel
}
if (currentConversationId.value !== cid) return
const stillShowingFailedPick =
activeModels.value?.activeLlm?.providerId === providerId
&& activeModels.value?.activeLlm?.model === model
if (!stillShowingFailedPick) return
activeModels.value = prevActive
})
.finally(() => {
// Only the LATEST in-flight PUT clears the saving flag. An earlier
// PUT finishing late must not flip saving to false while a newer
// one is still pending (the selector would unlock during a live
// request); and a conversation switch mid-PUT must not strand the
// flag at true forever (which would lock the selector across
// every conversation — the original bug this seq counter fixes).
if (mySeq === modelSaveSeq) {
modelSaving.value = false
}
})
}
}
/**
* The selected agent's model override, as a (provider, model) pair — or null
* when the agent has no usable override.
*
* Two sources, in order:
* 1. agentCapabilities — the backend-resolved pair from /agents/{id}/capabilities.
* Authoritative: it runs the same resolveModel the runtime uses (honouring
* disabled→default fallback and provider disambiguation). But it arrives
* via an async fetch that resolves AFTER the synchronous seeding on an
* agent switch / deep-link, and is lost entirely if that fetch fails.
* 2. currentAgent.modelName resolved against the enabled-model list — a
* SYNCHRONOUS fallback so an agent switch, a capability-fetch failure, or a
* not-yet-hydrated deep-link still honour the per-agent override instead of
* silently dropping to the global default (which handleSendMessage would
* then pin, re-introducing the very clobber this whole change fixes).
*
* enabledModels is viewer-accessible (/models/enabled) and loaded at mount, so
* the fallback works for viewers too.
*/
function agentSeedModel(): { providerId: string; model: string } | null {
const cap = agentCapabilities.value
if (cap?.providerId && cap?.modelName) {
return { providerId: cap.providerId, model: cap.modelName }
}
const name = currentAgent.value?.modelName
if (name) {
const hit = enabledModels.value.find(m => m.modelName === name)
if (hit?.provider) {
return { providerId: hit.provider, model: hit.modelName }
}
}
return null
}
/**
* Point the model selector at the model this conversation should run, honouring
* the same precedence the backend uses when it resolves the runtime model
* (see AgentGraphBuilder.resolveRuntimeBaseModel):
*
* conversation pin > selected agent's model override > global default
*
* The middle tier is the fix for the "I set the agent's model but chat used
* another one" bug: without it a fresh conversation seeded the global default,
* and handleSendMessage then PINNED that default onto the conversation row —
* which outranks the agent override and silently clobbered it.
*/
function applyConversationModel(conv?: Conversation | null) {
if (conv?.modelProvider && conv?.modelName) {
activeModels.value = { activeLlm: { providerId: conv.modelProvider, model: conv.modelName } }
return
}
const agentModel = agentSeedModel()
if (agentModel) {
activeModels.value = { activeLlm: { providerId: agentModel.providerId, model: agentModel.model } }
return
}
if (globalDefaultModel.value) {
activeModels.value = { activeLlm: { ...globalDefaultModel.value } }
}
}
/**
* After a poll refreshes the conversation list, the currently-open
* conversation may have drifted server-side: an admin may have rebound the
* channel to a different agent, or pinned a different model via another
* tab / API call. Pull the new server-side state into the local selector +
* agent header so the chat surface doesn't keep claiming the old binding.
*
* Skipped while a turn is generating — yanking the model / agent mid-stream
* would orphan the active SSE subscription.
*/
function reconcileCurrentConversation() {
if (!currentConversationId.value) return
if (isGenerating.value) return
// Don't fight an in-flight setModel write — the poll cycle may run BEFORE
// the PUT lands, in which case the server still reports the old pin and
// we'd flicker the UI back. Wait for the next tick.
if (modelSaving.value) return
const fresh = conversations.value.find(c => c.conversationId === currentConversationId.value)
if (!fresh) return
if (fresh.agentId != null && String(fresh.agentId) !== String(selectedAgentId.value)) {
selectedAgentId.value = fresh.agentId
}
const pickedProvider = activeModels.value?.activeLlm?.providerId
const pickedModel = activeModels.value?.activeLlm?.model
const serverHasPin = !!(fresh.modelProvider && fresh.modelName)
if (serverHasPin) {
if (fresh.modelProvider !== pickedProvider || fresh.modelName !== pickedModel) {
activeModels.value = { activeLlm: { providerId: fresh.modelProvider!, model: fresh.modelName! } }
}
} else if (pickedProvider || pickedModel) {
// Server-side pin was cleared (admin reset, model deleted, …) but the
// local selector still shows the old pick. Drop back to whatever the
// global default resolves to — applyConversationModel does the right
// thing when conv has no pin.
applyConversationModel(fresh)
}
}
// 拖拽上传 — useFileDrop owns the hover/counter state; the directory-aware
// payload handling (electron paths vs web FileSystem entries) stays here.
const { isDragging, onDragEnter, onDragLeave, onDrop } = useFileDrop(processDroppedItems)
async function processDroppedItems(e: DragEvent) {
if (workerConversationReadOnly.value) return
const dtFiles = Array.from(e.dataTransfer?.files || [])
const items = Array.from(e.dataTransfer?.items || [])
const electronDirs: File[] = []
const webDirEntries: FileSystemDirectoryEntry[] = []
const regularFiles: File[] = []
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry?.()
const file = dtFiles[i]
if (entry?.isDirectory) {
if ((file as any)?.path) {
// Electron: has absolute path
electronDirs.push(file)
} else {
// Web: need to recursively collect files
webDirEntries.push(entry as FileSystemDirectoryEntry)
}
} else if (file) {
regularFiles.push(file)
}
}
// Electron directories → record path reference
if (electronDirs.length) {
handleDirectoryAttach(electronDirs)
}
// Web directories → recursively collect files and upload
if (webDirEntries.length) {
const collected = await collectFilesFromEntries(webDirEntries)
if (collected.length) {
handleFileSelect(collected)
}
}
// Regular files → normal upload
if (regularFiles.length) {
handleFileSelect(regularFiles)
}
}
function handleDirectoryAttach(dirFiles: File[]) {
if (workerConversationReadOnly.value) return
if (!currentConversationId.value) {
newConversation()
}
for (const dir of dirFiles) {
const dirPath = (dir as any).path as string
pendingAttachments.value.push({
name: dirPath.split('/').pop() || dir.name,
size: 0,
url: '',
storedName: '',
path: dirPath,
contentType: 'inode/directory',
})
}
}
async function collectFilesFromEntries(dirEntries: FileSystemDirectoryEntry[]): Promise<File[]> {
const files: File[] = []
async function readDir(dir: FileSystemDirectoryEntry) {
const reader = dir.createReader()
let batch: FileSystemEntry[]
do {
batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
reader.readEntries(resolve, reject)
})
for (const entry of batch) {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => {
(entry as FileSystemFileEntry).file(resolve, reject)
})
files.push(file)
} else if (entry.isDirectory) {
await readDir(entry as FileSystemDirectoryEntry)
}
}
} while (batch.length > 0) // readEntries returns empty when done
}
for (const dir of dirEntries) {
await readDir(dir)
}
return files
}
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null)
// Post-render augmentations (ECharts, KaTeX, Mermaid) all watch the same
// MessageList container — placeholders emitted by useMarkdownRenderer get
// upgraded in place after Vue paints the rendered Markdown HTML.
const echartsContainerRef = computed(() => messageListRef.value?.$el as HTMLElement | null)
const { startObserving: startECharts, dispose: disposeECharts } = useEChartsRenderer(echartsContainerRef)
const { startObserving: startKatex, dispose: disposeKatex } = useKatexRenderer(echartsContainerRef)
const { startObserving: startMermaid, dispose: disposeMermaid } = useMermaidRenderer(echartsContainerRef)
// Last-attempt draft, restored into the input box when the SSE error event
// arrives async (sendChatMessage resolves on connect, the error fires later,
// so the catch in handleSendMessage cannot recover input by itself).
const pendingSendDraft = ref<{ input: string; attachments: any[] } | null>(null)
// 使用 useChat composable
const {
messages,
isGenerating,
streamPhase,
phaseInfo,
queuedMessage,
hasQueued,
queueSize,
heartbeat,
compactStatus,
contextUsage,
lifecycleStage,
sendMessage: sendChatMessage,
stopGeneration: stopChatGeneration,
cancelQueued,
reconnectStream: reconnectChatStream,
resetForNewConversation,
} = useChat({
baseUrl: '',
thinkingLevel,
onStreamEnd: async (meta) => {
// Restore the input/attachments if the turn ended in an error and the
// user hasn't typed something else in the meantime.
if (meta.reason === 'error' && pendingSendDraft.value) {
const draft = pendingSendDraft.value
if (!inputText.value) inputText.value = draft.input
if (pendingAttachments.value.length === 0) pendingAttachments.value = draft.attachments
}
if (meta.reason !== 'error') {
pendingSendDraft.value = null
}
// 流结束后刷新会话列表(更新 lastActiveTime / 标题等)
await loadConversations()
if (meta.conversationId && meta.conversationId === currentConversationId.value) {
// Skip DB refresh for awaiting_approval / interrupted / error:
// - awaiting_approval / interrupted: avoids overwriting local-only state
// or breaking message ordering.
// - error: the failed turn (e.g. SSE setup failure like "无权操作该会话")
// was never persisted, so refreshing would wipe the user's just-sent
// bubble and the failed assistant placeholder, leaving no trace of
// the attempt in the chat window.
if (meta.reason !== 'awaiting_approval'
&& meta.reason !== 'interrupted'
&& meta.reason !== 'error') {
await refreshCurrentConversationMessages(meta.conversationId)
}
}
},
})
const teamRunRouteQuery = computed(() => readTeamRunRouteQuery(route.query))
const metadataWorkerRunId = computed(() => {
for (let index = messages.value.length - 1; index >= 0; index -= 1) {
const metadata = parseTeamMessageMetadata(messages.value[index])
if (metadata.runId && metadata.taskId) return metadata.runId
}
return undefined
})
const linkedTeamRunId = computed(() => teamRunRouteQuery.value.teamRunId ?? metadataWorkerRunId.value)
const {
runs: teamRuns,
nextCursor: teamRunsNextCursor,
loadingMore: teamRunsLoadingMore,
loadMore: loadMoreTeamRuns,
} = useTeamRuns(currentConversationId, { linkedRunId: linkedTeamRunId })
const currentConversationKind = computed(() => conversations.value
.find(conversation => conversation.conversationId === currentConversationId.value)?.conversationKind)
const workerRouteHint = computed(() => Boolean(
teamRunRouteQuery.value.teamRunId
|| teamRunRouteQuery.value.taskId
|| currentConversationKind.value === 'team_worker'))
const workerGuard = useWorkerConversationGuard({
conversationId: currentConversationId,
workerHint: workerRouteHint,
load: async (conversationId) => {
if (isEphemeralConversation(conversationId)) return null
const query = teamRunRouteQuery.value
const response = await conversationApi.getTeamWorkerContext(conversationId, {
runId: query.teamRunId,
taskId: query.taskId,
})
return response.data ?? null
},
})
const workerRunContext = computed(() => workerGuard.context.value
?? readLegacyWorkerRouteContext(currentConversationId.value, route.query))
const workerConversationReadOnly = computed(() => workerGuard.readOnly.value)
// ============ 连接状态 ============
const connectionStatusClass = computed(() => {
if (isGenerating.value) return 'status-streaming'
if (streamPhase.value === 'failed') return 'status-error'
return 'status-idle'
})
const connectionStatusLabel = computed(() => {
if (isGenerating.value) return t('chat.status.streaming', 'Generating...')
if (streamPhase.value === 'failed') return t('chat.status.error', 'Disconnected')
return t('chat.status.idle', 'Ready')
})
// ============ 计算属性 ============
const currentAgent = computed(() => agents.value.find(a => String(a.id) === String(selectedAgentId.value)))
/** Human label for the agent's runtime mode — surfaces in the badge tooltip
* only, never in the visible header. */
const currentAgentRuntimeMode = computed(() => {
const a = currentAgent.value
if (!a) return ''
return a.agentType === 'react' ? t('agents.types.react') : t('agents.types.planExecute')
})
// Per-conversation last-viewed timestamp store (localStorage-backed, MVP).
// Keyed by conversationId. Updated when the user opens a conversation; the
// sidebar reads it (ConversationSidebar.hasUnread) to render the accent dot.
// Will move to a server-side table once we want cross-device read state.
const VIEWED_KEY_PREFIX = 'mc-conv-viewed:'
function markConversationViewed(conversationId: string | undefined, lastActiveTime?: string) {
if (!conversationId) return
const ts = lastActiveTime ? new Date(lastActiveTime).getTime() : Date.now()
try {
localStorage.setItem(VIEWED_KEY_PREFIX + conversationId, String(ts))
} catch {
// localStorage full / disabled — degrade silently; dot just stays on.
}
}
const currentRuntimeModel = computed(() => {
// Bind to the per-conversation active model — the same source the model
// selector reads — so the indicator updates the instant the user switches.
// Reading the global defaultModel here left the indicator frozen on the
// default while the selector moved.
const providerId = activeModels.value?.activeLlm?.providerId
const modelName = activeModels.value?.activeLlm?.model
if (providerId && modelName) {
const provider = providers.value.find((p) => p.id === providerId)
const all = provider ? [...(provider.models || []), ...(provider.extraModels || [])] : []
const hit = all.find((m) => m.id === modelName || m.name === modelName)
if (hit) return `${hit.name || hit.id} (${hit.id})`
// During initial hydration the safe provider projection may not be ready yet.
const em = enabledModels.value.find(
(m) => m.provider === providerId && (m.modelName === modelName || m.name === modelName)
)
if (em) return em.name ? `${em.name} (${em.modelName})` : em.modelName
return modelName
}
// No active model resolved yet — fall back to the global default, then the agent.
if (defaultModel.value?.name && defaultModel.value?.modelName) {
return `${defaultModel.value.name} (${defaultModel.value.modelName})`
}
return currentAgent.value?.modelName || 'default'
})
/**
* RFC-049 PR-1-UI: whether the active runtime model supports <em>any</em> form
* of deep thinking (OpenAI reasoning_effort / Kimi native / DeepSeek-Reasoner
* native / Anthropic extended thinking). Drives the enable/disable state of
* the thinking-depth toggle in ChatInput.
*
* Reads the broad capability (`supportsThinking`) from ProviderModelInfo,
* populated server-side in ModelInfoDTO. The narrow `supportsReasoningEffort`
* only covers OpenAI gpt-5/o1/o3/o4 and would wrongly gray out Kimi K2.x,
* DeepSeek-Reasoner, and Claude — all of which legitimately support thinking.
*/
const currentModelSupportsThinking = computed<boolean>(() => {
const providerId = activeModels.value?.activeLlm?.providerId
const modelName = activeModels.value?.activeLlm?.model
if (!providerId || !modelName) return false
const provider = providers.value.find((p) => p.id === providerId)
if (!provider) return false
const all = [...(provider.models || []), ...(provider.extraModels || [])]
const hit = all.find((m) => m.id === modelName || m.name === modelName)
return Boolean(hit?.supportsThinking)
})
const userInitial = computed(() => (localStorage.getItem('username') || 'U').charAt(0).toUpperCase())
const activeModelValue = computed(() => {
const providerId = activeModels.value?.activeLlm?.providerId
const model = activeModels.value?.activeLlm?.model
return providerId && model ? `${providerId}::${model}` : ''
})
const activeModelLabel = computed(() => {
if (!activeModelValue.value) return ''
const match = eligibleModels.value.find(m => m.value === activeModelValue.value)
if (match?.label) return match.label
// Fall back to the viewer-readable /models/enabled list while the safe
// provider projection is still hydrating, so the trigger never flashes the
// "配置模型" placeholder for an already active model.
const providerId = activeModels.value?.activeLlm?.providerId
const modelName = activeModels.value?.activeLlm?.model
if (!providerId || !modelName) return ''
const hit = enabledModels.value.find(m =>
m.provider === providerId && (m.modelName === modelName || m.name === modelName))
if (hit) return hit.name ? `${hit.name} (${hit.modelName})` : hit.modelName
return `${providerId} / ${modelName}`
})
const activeProvider = computed(() => {
const providerId = activeModels.value?.activeLlm?.providerId
return providerId ? providers.value.find((provider) => provider.id === providerId) || null : null
})
// Issue #81: liveness-aware popup state machine. modelPromptKind picks one of
// six branches; modelPromptText derives title + desc; primaryActionLabel +
// handlePrimaryAction map to the suggestedAction the backend computed.
type ModelPromptKind = 'no-active' | 'unconfigured' | 'removed' | 'cooldown' | 'unprobed' | 'no-models'
const modelPromptKind = computed<ModelPromptKind>(() => {
if (!activeModels.value?.activeLlm?.providerId) return 'no-active'
const p = activeProvider.value
if (!p) return 'no-active'
switch (p.liveness) {
case 'UNCONFIGURED': return 'unconfigured'
case 'REMOVED': return 'removed'
case 'COOLDOWN': return 'cooldown'
case 'UNPROBED': return 'unprobed'
case 'LIVE': return 'no-models'
default: return 'no-active'
}
})
const hintText = computed(() => {
const p = activeProvider.value
if (!p?.suggestedActionHintKey) return ''
return t(p.suggestedActionHintKey, (p.suggestedActionHintArgs || {}) as Record<string, unknown>)
})
const modelPromptText = computed<{ title: string; desc: string }>(() => {
const p = activeProvider.value
switch (modelPromptKind.value) {
case 'no-active':
return { title: t('chat.prompt.noActive.title'), desc: t('chat.prompt.noActive.desc') }
case 'unconfigured':
return {
title: t('chat.prompt.unconfigured.title', { name: p?.name || '' }),
desc: t('chat.prompt.unconfigured.desc', { fields: p?.missingFields || '', hint: hintText.value }),
}
case 'removed':
return {
title: t('chat.prompt.removed.title', { name: p?.name || '' }),
desc: p?.unavailableReason || t('chat.prompt.removed.descFallback'),
}
case 'cooldown':
return {
title: t('chat.prompt.cooldown.title', { name: p?.name || '' }),
desc: t('chat.prompt.cooldown.desc', {
seconds: Math.max(1, Math.ceil((p?.cooldownRemainingMs || 0) / 1000)),
}),
}
case 'unprobed':
return { title: t('chat.prompt.unprobed.title'), desc: t('chat.prompt.unprobed.desc') }
case 'no-models':
default:
return {
title: t('chat.prompt.noModels.title', { name: p?.name || '' }),
desc: t('chat.prompt.noModels.desc'),
}
}
})
const primaryActionLabel = computed(() => {
const action = activeProvider.value?.suggestedAction || 'configure_required_fields'
switch (action) {
case 'fill_base_url': return t('chat.promptAction.fillBaseUrl')
case 'fill_api_key': return t('chat.promptAction.fillApiKey')
case 'start_oauth': return t('chat.promptAction.startOAuth')
case 'test_connection': return t('chat.promptAction.testConnection')
case 'pull_model': return t('chat.promptAction.pullModel')
case 'wait_cooldown': return t('chat.promptAction.waitCooldown')
case 'reprobe': return t('chat.promptAction.reprobe')
case 'configure_required_fields':
default: return t('chat.goToModelSettings')
}
})
function handlePrimaryAction() {
goToModelSettings(activeProvider.value?.id)
}
/** First eligible model that is NOT the active one — what the secondary button switches to. */
const bestSwitchTarget = computed<{ value: string; label: string } | null>(() => {
for (const m of eligibleModels.value) {
if (m.value !== activeModelValue.value) return m
}
return null
})
/** First LIVE provider name — used by RecoverableModelBanner. */
const bestFallbackName = computed<string>(() => {
const target = bestSwitchTarget.value
if (!target) return ''
const [providerId] = target.value.split('::')
return providers.value.find(p => p.id === providerId)?.name || ''
})
function switchToBestTarget() {
const t = bestSwitchTarget.value
if (t) selectModel(t.value)
}
function onModelSelectorFix(provider: { id: string }) {
goToModelSettings(provider.id)
}
const availableProviders = computed(() =>
providers.value.filter((p) => p.available && [...(p.models || []), ...(p.extraModels || [])].length > 0)
)
const eligibleModels = computed(() => {
return availableProviders.value.flatMap((provider) => {
const allModels = [...(provider.models || []), ...(provider.extraModels || [])]
return allModels.map((model) => ({
value: `${provider.id}::${model.id}`,
label: `${provider.name} / ${model.name || model.id}`,
}))
})
})
// ============ 生命周期 ============
// Global shortcuts (Ctrl+K agents, Ctrl+N new chat) live in MainLayout so they
// work from any page; this view reacts to the dispatched event when mounted.
function handleChatShortcut(e: Event) {
const action = (e as CustomEvent).detail as 'newChat' | undefined
if (action === 'newChat') {
newConversation()
nextTick(() => chatInputRef.value?.focus?.())
}
}
// Cross-page hand-off from MainLayout's global shortcuts: read once on mount
// (before loadAgents triggers syncRouteState, which would wipe the action key)
// and apply after agents are loaded so the dropdown actually has something to show.
let pendingRouteAction: 'newChat' | '' = ''
function captureRouteAction() {
const action = route.query.action
if (action === 'newChat') {
pendingRouteAction = action
}
}
function applyPendingRouteAction() {
const action = pendingRouteAction
pendingRouteAction = ''
if (action === 'newChat') {
newConversation()
nextTick(() => chatInputRef.value?.focus?.())
}
}
// 轮询定时器:让 ChatConsole 能实时感知外部渠道WeChat/DingTalk/…)推进来的新消息,
// 无需 F5 即可看到侧栏列表更新和选中会话的消息/流状态。
let activityPollTimer: number | null = null
// Reentrancy guard: setInterval fires every ACTIVITY_POLL_MS regardless of
// whether the previous async pollActivity has finished. If one cycle runs long
// (slow reconnectStream / sluggish backend), unguarded ticks stack up and run
// concurrently, multiplying in-flight requests. This flag keeps one cycle at a
// time — late ticks become no-ops until the running cycle returns.
let activityPolling = false
const ACTIVITY_POLL_MS = 4000
// Cron progress placeholder: when a cron job is mid-run on the currently
// visible conversation (tasks_<wsId> / cron_<id>) the assistant bubble only
// appears after T2 commits, which can be 15 minutes for tool-heavy ReAct
// loops. activeCronRuns is filled by the same pollActivity tick so the user
// sees a "executing…" placeholder instead of staring at a blank screen.
interface ActiveCronRun {
runId: number | string
jobId: number | string
jobName?: string
triggerType?: string
conversationId?: string
startedAt?: string
}
const activeCronRuns = ref<ActiveCronRun[]>([])
function isCronConversation(cid: string | null | undefined): boolean {
return !!cid && (cid.startsWith('tasks_') || cid.startsWith('cron_'))
}
// True when a conversation id was minted client-side for a brand-new chat
// the user hasn't sent anything in yet, so the backend has never persisted
// (and therefore can't "own") it. Every per-conversation endpoint called with
// such an id is rejected with 403 "无权访问该会话" — the status/messages poller
// and the goal loader both skip the request entirely for these ids instead of
// spamming the console with ownership errors.
//
// Conservative by construction: an id counts as ephemeral only when it is
// absent from BOTH the loaded conversation list AND the local message buffer,
// so a freshly-persisted conversation is never mistaken for one. At worst a
// single stray 403 slips through during the persist race, which the leaf
// callers swallow.
function isEphemeralConversation(cid: string | null | undefined): boolean {
if (!cid) return true
// Server-managed virtual conversations (cron / scheduled tasks) always exist.
if (cid.startsWith('tasks_') || cid.startsWith('cron_')) return false
if (conversations.value.some((c) => c.conversationId === cid)) return false
return !messages.value.some((m: any) => m.conversationId === cid)
}
async function refreshActiveCronRuns(cid: string) {
if (!isCronConversation(cid)) {
activeCronRuns.value = []
return
}
try {
const res: any = await cronJobApi.activeRuns(cid)
if (currentConversationId.value !== cid) return
const next: ActiveCronRun[] = res?.data ?? []
const wasRunning = activeCronRuns.value.length > 0
activeCronRuns.value = next
// Transition from "had runs" to "no runs" → assistant bubble was just
// persisted by T2; fetch messages so it shows up without waiting for the
// next pollActivity tick to align.
if (wasRunning && next.length === 0) {
await refreshCurrentConversationMessages(cid)
}
} catch {
// Network blip — keep the previous state, next tick will retry.
}
}
// Reactive ticker so the elapsed label updates without depending on a poll.
const elapsedNow = ref(Date.now())
let elapsedTickTimer: number | null = null
function elapsedLabel(startedAt?: string): string {
if (!startedAt) return ''
const ms = elapsedNow.value - new Date(startedAt).getTime()
if (ms < 0 || !Number.isFinite(ms)) return ''
const sec = Math.floor(ms / 1000)
if (sec < 60) return `${sec}s`
const min = Math.floor(sec / 60)
const rem = sec % 60
return `${min}m${rem > 0 ? rem + 's' : ''}`
}
/**
* 判断当前消息列表的末尾是不是一条"本地仅有的失败气泡"。
* 典型场景SSE setup 阶段就抛错(如"无权操作该会话"
* 这次 turn 的 user / assistant 消息从未持久化进 DB。
* 数据库快照不知道它们存在pollActivity 的对齐会把它们冲掉。
*
* 识别条件:末尾 assistant 状态为 failed、带 errorInfo、且 id 不是 DB 数值 idclient uuid
*/
function hasLocalOnlyFailedTail(): boolean {
const last = messages.value[messages.value.length - 1] as any
if (!last || last.role !== 'assistant') return false
if (last.status !== 'failed') return false
if (!last.errorInfo) return false
return !/^\d+$/.test(String(last.id))
}
async function pollActivity() {
// 页面不可见时不轮询,避免切到别的标签还在空耗
if (typeof document !== 'undefined' && document.hidden) return
// Skip when the previous cycle is still running so slow polls can't stack.
if (activityPolling) return
activityPolling = true
try {
try {
await loadConversations()
reconcileCurrentConversation()
} catch {
// 静默失败,下一轮再试
}
// 自己没在生成时才刷新当前选中会话的消息 + 探测是否该接入流。
// 跳过尚未落库的临时会话(新建空会话、还没发消息):后端查不到会话行,
// getStatus / listMessages 一律 403,4s 轮询会把 console 刷爆(见 issue #408)。
if (currentConversationId.value
&& !isGenerating.value
&& streamPhase.value !== 'awaiting_approval'
&& !isEphemeralConversation(currentConversationId.value)) {
const cid = currentConversationId.value
try {
const statusRes: any = await conversationApi.getStatus(cid)
if (currentConversationId.value !== cid) return
const running = statusRes?.data?.streamStatus === 'running'
if (running) {
// 外部渠道正在跑:
// 1. 先从 DB 拉消息,把刚插入的 user 消息("你在干什么"之类)带进来,
// 否则只接入流的话前端只能看到 assistant content_delta看不到用户问题。
// 2. 再接入流,让后续 content_delta 实时累积到 assistant 气泡。
await refreshCurrentConversationMessages(cid)
if (currentConversationId.value !== cid || isGenerating.value) return
// Match selectConversation's behavior: a stale scroll-escape from an
// earlier upward scroll must not suppress follow-scroll on reconnect.
messageListRef.value?.resetScrollLock()
await reconnectStream(cid)
} else if (!hasLocalOnlyFailedTail()) {
// 不在跑:从 DB 对齐消息(新 user 消息 / 刚落库 assistant 会合并进来)。
// 但若末尾是本地失败气泡SSE setup 失败一类,后端从未持久化过),
// 就跳过对齐 —— 不然这次的 user/失败 assistant 会被 DB 快照覆盖掉,
// 用户除了上面的 toast 看不到任何痕迹。
await refreshCurrentConversationMessages(cid)
}
} catch {
// 忽略探测失败
}
// Cron progress placeholder — independent of streamStatus because cron
// runs use the non-streaming chat() path, so streamStatus stays idle.
await refreshActiveCronRuns(cid)
}
} finally {
activityPolling = false
}
}
onMounted(async () => {
captureRouteAction()
window.addEventListener('mc:chat-shortcut', handleChatShortcut)
document.addEventListener('click', handleCodeCopy)
startECharts()
startKatex()
startMermaid()
await Promise.all([loadAgents(), loadModelState(), loadConversations()])
await hydrateStateFromRoute()
applyPendingRouteAction()
activityPollTimer = window.setInterval(pollActivity, ACTIVITY_POLL_MS)
elapsedTickTimer = window.setInterval(() => {
if (activeCronRuns.value.length > 0) elapsedNow.value = Date.now()
}, 1000)
})
onBeforeUnmount(() => {
window.removeEventListener('mc:chat-shortcut', handleChatShortcut)
document.removeEventListener('click', handleCodeCopy)
disposeECharts()
disposeKatex()
disposeMermaid()
if (activityPollTimer !== null) {
clearInterval(activityPollTimer)
activityPollTimer = null
}
if (elapsedTickTimer !== null) {
clearInterval(elapsedTickTimer)
elapsedTickTimer = null
}
// Switching tabs / route changes / mouse-detach unmount this component, but the
// backend agent should keep running so the user can reconnect later. Use
// resetForNewConversation (front-end SSE disconnect only) instead of
// stopChatGeneration which would POST /stop and abort the in-flight turn.
resetForNewConversation()
// 释放所有附件的 ObjectURL防止内存泄漏
revokeAllPreviewUrls()
})
onDeactivated(() => {
if (activityPollTimer !== null) {
clearInterval(activityPollTimer)
activityPollTimer = null
}
if (elapsedTickTimer !== null) {
clearInterval(elapsedTickTimer)
elapsedTickTimer = null
}
document.removeEventListener('click', handleCodeCopy)
disposeECharts()
disposeKatex()
disposeMermaid()
revokeAllPreviewUrls()
resetForNewConversation()
})
onActivated(async () => {
document.addEventListener('click', handleCodeCopy)
startECharts()
startKatex()
startMermaid()
// Issue #538: ChatConsole is kept alive by the router (meta.keepAlive), so
// navigating away and back only fires onActivated, not onMounted — an
// agent created/edited/deleted elsewhere (e.g. the Employees page) never
// reached this component's own `agents` list otherwise, and stayed
// invisible in the picker until a full page reload forced a fresh mount.
await loadAgents()
activityPollTimer = window.setInterval(pollActivity, ACTIVITY_POLL_MS)
elapsedTickTimer = window.setInterval(() => {
if (activeCronRuns.value.length > 0) elapsedNow.value = Date.now()
}, 1000)
// 登出已改为 window.location.href刷新页面所以切回时不会有跨用户残留
if (currentConversationId.value && !isEphemeralConversation(currentConversationId.value)) {
try {
const statusRes: any = await conversationApi.getStatus(currentConversationId.value)
if (currentConversationId.value && statusRes.data?.streamStatus === 'running') {
await reconnectStream(currentConversationId.value)
}
} catch {
// 忽略
}
}
})
watch(() => route.query, () => {
// If a fresh action arrives (e.g. user re-fires Ctrl+K via the URL while
// the view is already alive), pick it up immediately.
captureRouteAction()
if (pendingRouteAction) applyPendingRouteAction()
void hydrateStateFromRoute()
})
watch([selectedAgentId, currentConversationId], () => {
syncRouteState()
})
// Load the active goal whenever the user switches conversation. The
// avatar ring listens on goalStore.activeGoalByConv[cid]; without this
// fetch the ring would only appear after an SSE event mutated the store.
const goalStore = useGoalStore()
const workspaceStore = useWorkspaceStore()
const currentWorkspaceId = computed(() => workspaceStore.currentWorkspaceId ?? '1')
const canConfigureModels = computed(() => workspaceStore.isGlobalAdmin)
const modelSelectorEmptyHint = computed(() => canConfigureModels.value
? undefined
: t('chat.noModelsAvailableContactAdmin'))
watch(currentConversationId, async (cid) => {
// Skip un-persisted conversations: a brand-new empty chat has no goal yet
// and the lookup would only 403 (Not the owner). The ring is hydrated by the
// goal_created SSE event once the first turn lands.
if (cid && !isEphemeralConversation(cid)) {
await goalStore.loadActiveForConversation(cid)
}
}, { immediate: true })
// Re-fetch the active goal when a turn finishes. A goal can be created or
// mutated mid-conversation — e.g. auto-derived server-side from a Plan-Execute
// plan, or completed by the agent — without the conversation id changing and
// without a goal_* SSE event reaching this client (skipped creation, missed
// event, reconnect). The per-conversation watch above only fires on switch, so
// this transition-to-idle refresh is what keeps the goal ring honest after
// every turn against the persisted truth.
watch(isGenerating, async (generating, wasGenerating) => {
if (wasGenerating && !generating
&& currentConversationId.value
&& !isEphemeralConversation(currentConversationId.value)) {
await goalStore.loadActiveForConversation(currentConversationId.value)
}
})
// Derive props for the inline prompt + system-line slots that sit
// between MessageList and ChatInput. The prompt shows only when:
// 1) there's a current conversation, agent, and at least one assistant
// reply (otherwise the prompt is premature);
// 2) there's no active goal (the ring already covers active state);
// 3) the user hasn't dismissed the prompt on this conv;
// 4) we're not mid-stream (don't pop suggestions while the agent
// is still typing).
const goalTerminalForCurrent = computed(() =>
currentConversationId.value
? goalStore.recentTerminal(currentConversationId.value)
: null,
)
const goalSystemLineTitle = computed(() => {
const t = goalTerminalForCurrent.value
if (!t) return ''
// The leading icon is owned by GoalSystemLine (✦ / ⚠) so we don't
// prepend one here — doing so produced "✦ 🎉 …" double-glyph titles.
return t.status === 'completed' ? `目标达成 · ${t.title}` : `这次的预算用完了 · ${t.title}`
})
const goalSystemLineDetail = computed(() => {
const t = goalTerminalForCurrent.value
if (!t) return ''
if (t.status === 'completed') {
return t.score != null
? `已完成 · final score ${t.score.toFixed(2)}`
: '已完成 · 总结已存入长期记忆'
}
// exhausted
if (t.reason === 'turn_budget') return '预算轮数用完。'
if (t.reason === 'llm_call_budget') return 'LLM 调用预算用完。'
return '预算耗尽。'
})
// True when the current conversation's message stream already contains a
// setGoal tool call — authoritative even before the goal_created SSE event
// updates the goal store.
const goalSetInStream = computed(() => {
return messages.value.some((m) => {
if (m.role !== 'assistant') return false
const tcs: any = (m as any).metadata?.toolCalls
return Array.isArray(tcs) && tcs.some((tc: any) => tc?.name === 'setGoal')
})
})
const showGoalSetPrompt = computed(() => {
if (!currentConversationId.value || !selectedAgentId.value) return false
if (isGenerating.value) return false
// Active goal? The ring covers that — no need for a prompt.
if (goalStore.activeGoal(currentConversationId.value)) return false
// A goal was already set this conversation via the setGoal tool — even if
// the goal_created/goal_evaluated SSE event hasn't updated the store yet
// (the turn can end a render frame before that event lands). Reading the
// stream directly closes that flash window: never offer to set a goal when
// the agent already set one here.
if (goalSetInStream.value) return false
// Recent terminal still showing? Let the user dismiss that first.
if (goalTerminalForCurrent.value) return false
if (goalStore.isPromptDismissed(currentConversationId.value)) return false
// Need at least one user → assistant exchange so the prompt has
// context to derive a suggested title from.
const hasAssistantReply = messages.value.some(m => m.role === 'assistant')
if (!hasAssistantReply) return false
// Heuristic: don't claim "this looks multi-turn" without evidence. Fire
// the prompt only when at least one signal of a real ongoing task is
// present. Without these the prompt fires after one-shot Q&A like
// "三句话告诉我 X" and the copy lies to the user.
const userTurns = messages.value.filter(m => m.role === 'user').length
if (userTurns >= 2) return true // multiple user messages = ongoing thread
// Single-turn case: only suggest if the agent did non-trivial work.
return messages.value.some(m => {
if (m.role !== 'assistant') return false
const md: any = (m as any).metadata
if (!md) return false
// Tool calls — strongest signal of real work.
if (Array.isArray(md.toolCalls) && md.toolCalls.length > 0) return true
// Plan steps — Plan-Execute agent ran a multi-step plan.
if (md.plan && Array.isArray(md.plan.steps) && md.plan.steps.length > 1) return true
// Multiple ReAct iterations / segments — agent looped.
if (Array.isArray(md.segments) && md.segments.length > 3) return true
return false
})
})
// Build a sensible default title from the conversation's first user
// message. The user can always edit later via the goal page.
const goalSuggestedTitle = computed(() => {
const firstUser = messages.value.find(m => m.role === 'user')
const raw = (firstUser?.content || '').trim()
if (!raw) return '新目标'
// 80 char clip mirrors GoalController.create validation.
return raw.length > 80 ? raw.slice(0, 77) + '...' : raw
})
function onGoalPromptDismiss() {
if (currentConversationId.value) {
goalStore.dismissPrompt(currentConversationId.value)
}
}
function onGoalSystemLineDismiss() {
if (currentConversationId.value) {
goalStore.clearRecentTerminal(currentConversationId.value)
}
}
// Refetch agent capabilities (modalities + sidecar config) on agent change so
// the multimodal routing hint above the input box can react synchronously when
// the user attaches an image / video.
watch(selectedAgentId, async (id) => {
if (!id) { agentCapabilities.value = null; return }
try {
const res: any = await agentApi.getCapabilities(id)
agentCapabilities.value = res.data || null
// The capability fetch is async and typically resolves AFTER the
// synchronous newConversation()/applyConversationModel() that ran on this
// same agent switch (which fell back to the global default because caps
// weren't loaded yet). Re-seed now that we know the agent's resolved model
// — but only for a conversation with no server-side pin and where the user
// hasn't manually picked a model, so we never clobber an explicit choice.
const conv = conversations.value.find(c => c.conversationId === currentConversationId.value)
const hasServerPin = !!(conv?.modelProvider && conv?.modelName)
if (!hasServerPin && !userPickedModel.value) {
applyConversationModel(conv)
}
} catch {
agentCapabilities.value = null
}
}, { immediate: true })
// ============ 方法 ============
async function loadAgents() {
try {
// Hide disabled agents from the picker — they cannot be chatted with
// (the chat endpoints reject disabled agents), so showing them invites
// a confusing failure path. The admin Agents view passes no filter.
const res: any = await agentApi.list({ enabled: true })
agents.value = res.data || []
if (agents.value.length > 0) cachedAgents = [...agents.value]
// 只有在 URL 没有指定 agentId 且当前无选中时,才默认选第一个
if (agents.value.length > 0 && !selectedAgentId.value && !route.query.agentId) {
selectedAgentId.value = agents.value[0].id
}
} catch (e) {
mcToast.error(t('chat.loadAgentsFailed'))
}
}
async function loadModelState() {
// /default + /active + /enabled are viewer-accessible and required to chat.
// /models (provider list) is admin-only because it returns API keys + base
// URLs. Viewers join /models/options with /models/enabled for the selector,
// but still skip the liveness banner because neither safe response exposes
// runtime diagnostics.
try {
const providerOptionsRequest = workspaceStore.isGlobalAdmin
? Promise.resolve({ data: [] })
: modelApi.listProviderOptions()
const [defaultRes, activeRes, enabledRes, providerOptionsRes]: any = await Promise.all([
modelApi.getDefault(),
modelApi.getActive(),
modelApi.listEnabled(),
providerOptionsRequest,
])
defaultModel.value = defaultRes.data || null
const ga = activeRes.data?.activeLlm
globalDefaultModel.value = ga?.providerId && ga?.model
? { providerId: ga.providerId, model: ga.model }
: null
// Seed the selector when no conversation has set it yet (fresh chat, or
// before a conversation is selected). A conversation that already has a
// model keeps it — selectConversation/applyConversationModel own that.
if (!activeModels.value && globalDefaultModel.value) {
activeModels.value = { activeLlm: { ...globalDefaultModel.value } }
}
enabledModels.value = enabledRes.data || []
if (!workspaceStore.isGlobalAdmin) {
providers.value = buildViewerModelProviders(
providerOptionsRes.data || [],
enabledModels.value,
)
// The safe projection intentionally has no liveness diagnostics. Trust
// the active model and let the runtime report a real call failure.
providersUnavailable.value = true
recomputePromptFlags()
return
}
} catch (e) {
mcToast.error(t('chat.loadModelFailed'))
blockingPrompt.value = true
recoverablePrompt.value = false
return
}
try {
const providersRes: any = await modelApi.listProviders()
providers.value = providersRes.data || []
providersUnavailable.value = false
} catch (e: any) {
if (e?.response?.status === 403) {
providers.value = []
providersUnavailable.value = true
} else {
// Non-403 failure is still a real problem worth surfacing.
mcToast.error(t('chat.loadModelFailed'))
}
}
recomputePromptFlags()
}
/**
* Issue #81 v2 R2: derive blocking / recoverable prompt flags from the current
* providers + active model snapshot. Called from loadModelState after every
* /providers refresh, and from a watcher when the user switches model. The
* runtime fallback chain in NodeStreamingChatHelper picks the first LIVE
* provider regardless of which one is "active", so as long as ANY provider is
* LIVE we should NOT block — we just hint with a banner.
*/
function recomputePromptFlags() {
const active = activeModels.value?.activeLlm
if (!active?.providerId || !active?.model) {
blockingPrompt.value = true
recoverablePrompt.value = false
recoverableDismissed.value = false
return
}
// Viewer-level users cannot read the provider list (admin-only because it
// returns API keys), so we can't compute liveness. Trust the active model
// and let the agent runtime surface any per-call failure instead of
// blocking the entire chat surface.
if (providersUnavailable.value) {
blockingPrompt.value = false
recoverablePrompt.value = false
recoverableDismissed.value = false
return
}
const ap = providers.value.find(p => p.id === active.providerId) || null
const apHasModels = ap
? ((ap.models?.length || 0) + (ap.extraModels?.length || 0)) > 0
: false
const activeUsable = ap?.liveness === 'LIVE' && apHasModels
if (activeUsable) {
blockingPrompt.value = false
recoverablePrompt.value = false
recoverableDismissed.value = false
return
}
const anyUsable = providers.value.some(p =>
p.liveness === 'LIVE'
&& ((p.models?.length || 0) + (p.extraModels?.length || 0)) > 0)
blockingPrompt.value = !anyUsable
recoverablePrompt.value = anyUsable && !recoverableDismissed.value
}
// Issue #81 v2 R2: keep blocking/recoverable in sync with the providers list
// and the active model selection without forcing every mutation site to call
// recomputePromptFlags() manually.
watch([providers, activeModels], recomputePromptFlags, { deep: true })
async function loadConversations() {
try {
const res: any = await conversationApi.list()
conversations.value = res.data || []
} catch (e) {
mcToast.error(t('chat.loadConversationsFailed'))
}
}
async function refreshCurrentConversationMessages(conversationId: string) {
if (!conversationId) return
if (isGenerating.value) return
if (streamPhase.value === 'awaiting_approval') return
try {
const res: any = await conversationApi.listMessages(conversationId)
// Stale guardawait 返回后确认仍是当前会话
if (currentConversationId.value !== conversationId) return
// 二次 isGenerating 检查:如果 await 期间用户已发新消息,不覆盖本地状态
if (isGenerating.value) return
const fetched = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg))
// 严格过滤:只保留 conversationId 完全匹配的本地消息orphan空 conversationId直接丢弃
const currentMessages = messages.value.filter(
(m: any) => m.conversationId === conversationId
)
messages.value = reconcileMessages(currentMessages, fetched)
} catch (e) {
console.warn('[ChatConsole] Failed to refresh current conversation messages:', e)
}
}
async function hydrateStateFromRoute() {
const { agentId, conversationId } = resolveRouteHydrationQuery({
routeAgentId: route.query.agentId ? String(route.query.agentId) : '',
routeConversationId: String(route.query.conversationId || ''),
agents: agents.value,
conversations: conversations.value,
})
if (agentId && agentId !== String(selectedAgentId.value)) {
selectedAgentId.value = agentId
}
if (conversationId && conversationId !== currentConversationId.value) {
const matchedConversation = conversations.value.find(conv => conv.conversationId === conversationId)
if (matchedConversation) {
await selectConversation(matchedConversation, agentId)
} else {
// 会话不在已加载列表中(可能来自 Sessions 页面跳转),尝试加载消息
currentConversationId.value = conversationId
messages.value = []
try {
const res: any = await conversationApi.listMessages(conversationId)
if (currentConversationId.value !== conversationId) return
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, true))
} catch {
// 消息加载失败,保持空
}
try {
if (currentConversationId.value !== conversationId) return
const statusRes: any = await conversationApi.getStatus(conversationId)
if (currentConversationId.value === conversationId && statusRes.data?.streamStatus === 'running') {
await reconnectStream(conversationId)
}
} catch {
// 忽略
}
}
}
// 如果仍然没选中 agent默认选第一个
if (!selectedAgentId.value && agents.value.length > 0) {
selectedAgentId.value = agents.value[0].id
}
}
function syncRouteState() {
const query = buildChatRouteQuery({
currentQuery: route.query,
agentId: selectedAgentId.value ? String(selectedAgentId.value) : undefined,
conversationId: currentConversationId.value || undefined,
})
router.replace({ path: '/chat', query })
}
async function selectConversation(conv: Conversation, routeAgentId = '') {
if (isMobile.value) convPanelOpen.value = false
// 切换到不同会话:只清理本地 UI/SSEresetForNewConversation 会 stream.disconnect + 清变量),
// 但不 POST /chat/{A}/stop —— 让 A 的后台 agent run 跑到完成。
// 用户之后回到 ApollActivity / selectConversation 的 /status 探测会自动 reconnect 接回实时流;
// 若 A 已完成refreshCurrentConversationMessages 会从 DB 拉完整结果。
// 点同一个会话则完全不 reset避免打断正在观察的流。
const switchingAway = currentConversationId.value !== conv.conversationId
if (switchingAway) {
resetForNewConversation()
messageListRef.value?.resetScrollLock()
}
currentConversationId.value = conv.conversationId
selectedAgentId.value = resolveConversationAgentSelection({
routeAgentId,
conversationAgentId: conv.agentId,
currentAgentId: selectedAgentId.value,
})
// Opening another conversation: its pin (or the agent/global fallback) is
// authoritative, so clear the previous conversation's manual-pick guard.
userPickedModel.value = false
// Restore this conversation's pinned model into the selector.
applyConversationModel(conv)
// Reset cron placeholder state up front; the immediate fetch below repopulates
// it for cron conversations so the user doesn't wait up to 4s for the next tick.
activeCronRuns.value = []
if (isCronConversation(conv.conversationId)) {
void refreshActiveCronRuns(conv.conversationId)
}
// Mark as read when opened — clears the unread dot on tasks_<wsId> after
// the user actually visits the cron output. localStorage-only for MVP;
// server-side last-viewed table is a future enhancement.
markConversationViewed(conv.conversationId, conv.lastActiveTime)
const requestedConvId = conv.conversationId
try {
const res: any = await conversationApi.listMessages(requestedConvId)
// Stale guardawait 返回后确认仍是当前会话,否则丢弃
if (currentConversationId.value !== requestedConvId) return
// 点同一个会话时,若已有 SSE 在跑就不要覆盖本地消息状态
if (switchingAway || !isGenerating.value) {
const convRunning = conv.streamStatus === 'running'
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, convRunning))
}
// Hydrate pending approvals恢复刷新后丢失的审批卡片RFC-067 §4.9
//
// Two-way reconciliation between the server's pending list and each
// message's metadata.pendingApproval:
// 1. Forward — server pending → align onto the message that already
// carries the same pendingId (so multi-pending convs don't have
// every banner overwrite the same row); fallback to last assistant
// only when no message has that id yet.
// 2. Reverse — local message metadata still says pending_approval but
// the server no longer lists that pendingId → flip to 'expired'
// locally. This closes the GC/timeout loop without requiring an
// extra server-side broadcast: the next refresh sees a clean state.
try {
const approvalRes: any = await chatApi.getPendingApprovals(requestedConvId)
if (currentConversationId.value !== requestedConvId) return
const pendingApprovals: any[] = approvalRes.data || []
// Index existing messages by their embedded pendingId (assistant only).
const indexById = new Map<string, Message>()
for (const m of messages.value) {
if (m.role !== 'assistant') continue
const pid = (m as any).metadata?.pendingApproval?.pendingId
if (typeof pid === 'string' && pid) indexById.set(pid, m)
}
// Forward direction: align server-known pending onto its owning message.
for (const pa of pendingApprovals) {
const enriched = {
pendingId: pa.pendingId,
toolName: pa.toolName,
arguments: pa.toolArguments,
reason: pa.reason,
status: 'pending_approval' as const,
findings: pa.findingsJson ? JSON.parse(pa.findingsJson) : undefined,
maxSeverity: pa.maxSeverity || undefined,
summary: pa.summary || undefined,
}
const target = indexById.get(pa.pendingId)
if (target) {
(target as any).metadata = {
...(target as any).metadata,
currentPhase: 'awaiting_approval',
pendingApproval: enriched,
}
} else {
// Fallback: no message in the loaded history claims this pendingId
// (typical when the assistant message hasn't been persisted yet —
// e.g., approval fired before doOnComplete). Append to the last
// assistant; same as pre-RFC behavior, but logged so a regression
// where multiple unmatched pendings collide is observable.
const assistantMessages = messages.value.filter(m => m.role === 'assistant')
const lastAssistant = assistantMessages[assistantMessages.length - 1]
if (lastAssistant) {
console.warn('[hydrate] pendingId %s has no owning message — falling back to last assistant', pa.pendingId)
;(lastAssistant as any).metadata = {
...(lastAssistant as any).metadata,
currentPhase: 'awaiting_approval',
pendingApproval: enriched,
}
}
}
}
// Reverse direction: any local pending_approval whose pendingId is not
// in the server's list got resolved (timeout / consume) without a UI
// event — flip to expired so MessageBubble hides the banner.
const serverIds = new Set<string>(pendingApprovals.map((p: any) => p.pendingId))
for (const m of messages.value) {
if (m.role !== 'assistant') continue
const meta = (m as any).metadata
const local = meta?.pendingApproval
if (local?.status === 'pending_approval'
&& local.pendingId
&& !serverIds.has(local.pendingId)) {
(m as any).metadata = {
...meta,
pendingApproval: { ...local, status: 'expired' },
}
}
}
} catch {
// hydration 失败不影响正常使用
}
// 决定是否重连 SSE
// - 快照 streamStatus==='running' → 直接重连
// - 否则探测实时状态(兜底处理:渠道消息进入后侧栏快照未刷新时,仍能接入运行中的流)
let shouldReconnect = conv.streamStatus === 'running'
if (!shouldReconnect) {
try {
const statusRes: any = await conversationApi.getStatus(requestedConvId)
if (currentConversationId.value !== requestedConvId) return
shouldReconnect = statusRes?.data?.streamStatus === 'running'
} catch {
// 探测失败不阻断主流程
}
}
if (currentConversationId.value === requestedConvId && shouldReconnect) {
await reconnectStream(requestedConvId)
}
} catch (e) {
mcToast.error(t('chat.loadMessagesFailed'))
}
}
function newConversation() {
// Creating a new chat is just local navigation. Keep any previous backend
// run alive so the user can return and reconnect to it later.
resetForNewConversation()
currentConversationId.value = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
messages.value = []
// A fresh conversation defers to the selected agent's model (then the global
// default) until the user explicitly picks one.
userPickedModel.value = false
applyConversationModel()
}
// The sidebar performs the delete API call(s) and emits the removed ids.
// Drop them from the local list and reset the chat area if the conversation
// currently open was among those deleted.
function onConversationsDeleted(ids: string[]) {
conversations.value = conversations.value.filter(c => !ids.includes(c.conversationId))
if (ids.includes(currentConversationId.value)) {
resetStreamingState()
messages.value = []
currentConversationId.value = ''
// Deleting the open conversation drops us back to a blank slate — clear the
// manual-pick guard so the next message (which mints a fresh conversation)
// seeds from the agent override again instead of inheriting the deleted
// conversation's pick. Mirrors newConversation()/selectConversation().
userPickedModel.value = false
}
}
async function clearMessages() {
if (!currentConversationId.value) return
try {
resetStreamingState()
await conversationApi.clearMessages(currentConversationId.value)
messages.value = []
} catch {
messages.value = []
}
}
// onModelChange removed — replaced by selectModel()
function goToModelSettings(providerId?: string) {
// Issue #81: when called with a providerId (e.g. from the unhealthy popup or
// ModelSelector's Fix button), pass it as a query param so a follow-up PR can
// scroll/focus the right card on the settings page. Today the consumer just
// ignores it; harmless meanwhile.
router.push({ path: '/settings/models', query: providerId ? { focus: providerId } : {} })
}
// ============ 计算属性:是否有待审批 ============
const hasPendingApproval = computed(() => {
return messages.value.some(
m => m.role === 'assistant' && (m as any).metadata?.pendingApproval?.status === 'pending_approval'
)
})
// 当前待审批的那条数据(传给 ChatInput 用于渲染审批栏)
const activePendingApproval = computed(() => {
const msg = messages.value.findLast(
m => m.role === 'assistant' && (m as any).metadata?.pendingApproval?.status === 'pending_approval'
)
return (msg as any)?.metadata?.pendingApproval ?? null
})
// 当前工具调用数
const toolCallCount = computed(() => {
const lastMsg = messages.value.findLast(m => m.role === 'assistant')
return lastMsg?.metadata?.toolCalls?.length ?? 0
})
// 当前正在执行的工具名称
const currentRunningToolName = computed(() => {
if (!isGenerating.value) return ''
const lastMsg = messages.value.findLast(m => m.role === 'assistant')
const metadata = lastMsg?.metadata
if (metadata?.runningToolName) return metadata.runningToolName
const runningTool = metadata?.toolCalls?.findLast((tc: any) => tc.status === 'running')
return runningTool?.name || heartbeat.value?.runningToolName || ''
})
// 当前正在生成的消息的 token 数据
const currentGeneratingTokens = computed(() => {
if (!isGenerating.value) return 0
// 找到最后一条 assistant 消息(可能仍在生成)
const lastMsg = messages.value.findLast(m => m.role === 'assistant')
// 返回 completionTokens从服务器响应中获取
return (lastMsg as any)?.completionTokens ?? 0
})
const currentPromptTokens = computed(() => {
if (!isGenerating.value) return 0
const lastMsg = messages.value.findLast(m => m.role === 'assistant')
return (lastMsg as any)?.promptTokens ?? 0
})
// ============ 消息发送和处理 ============
async function handleSendMessage(content: string) {
// 允许在等待审批时发送审批命令
const isApprovalCommand = /^\/(approve|deny)$/i.test(content.trim())
if ((!content && pendingAttachments.value.length === 0)
|| !selectedAgentId.value
|| blockingPrompt.value
|| workerConversationReadOnly.value) return
// 不再阻止运行中发送 — useChat 会自动走 interrupt/queue 路径
// 拦截 /approve 和 /deny 命令 —— 通过 SSE 流发送(和普通消息相同通道)
const trimmed = content.trim().toLowerCase()
if (trimmed === '/approve' || trimmed === '/deny') {
if (!currentConversationId.value) {
mcToast.warning('No active conversation')
inputText.value = ''
chatInputRef.value?.clear?.()
return
}
// 检查是否有 pending approval
const pendingMsg = messages.value.findLast(
m => m.role === 'assistant' && (m as any).metadata?.pendingApproval?.status === 'pending_approval'
)
if (!pendingMsg) {
mcToast.warning('No pending approval to process')
inputText.value = ''
chatInputRef.value?.clear?.()
return
}
// 乐观更新审批状态
const decision = trimmed === '/approve' ? 'approved' : 'denied'
;(pendingMsg as any).metadata.pendingApproval.status = decision
inputText.value = ''
chatInputRef.value?.clear?.()
// 通过正常 SSE 流发送复用聊天通道replay 结果实时流式推送)
try {
await sendChatMessage(trimmed, {
conversationId: currentConversationId.value,
agentId: selectedAgentId.value,
contentParts: [],
})
} catch (e: any) {
console.error('Approval stream failed:', e)
// 回滚乐观更新
;(pendingMsg as any).metadata.pendingApproval.status = 'pending_approval'
mcToast.error(e?.message || 'Approval failed')
}
return
}
if (!currentConversationId.value) {
currentConversationId.value = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
}
// Issue #81 v2 R2: only abort when there is genuinely no usable provider.
// If the active provider is unhealthy but another is LIVE, let the request
// through — NodeStreamingChatHelper's fallback walker will pick it up and
// emit a "warning" SSE delta which the input handler surfaces as a toast.
if (!activeModels.value?.activeLlm?.providerId || !activeModels.value?.activeLlm?.model) {
blockingPrompt.value = true
return
}
if (blockingPrompt.value) {
return
}
const outgoingAttachments = pendingAttachments.value.map((attachment) => ({ ...attachment }))
const contentParts = buildOutgoingParts(content, outgoingAttachments)
// 先暂存,发送成功后再清空(失败时恢复)
const savedInput = inputText.value
const savedAttachments = [...pendingAttachments.value]
// Stash for async-error recovery in onStreamEnd (sync catch can't reach this).
pendingSendDraft.value = { input: savedInput, attachments: savedAttachments }
inputText.value = ''
chatInputRef.value?.clear?.()
pendingAttachments.value = []
try {
await sendChatMessage(content, {
conversationId: currentConversationId.value,
agentId: selectedAgentId.value,
contentParts,
thinkingLevel: thinkingLevel.value,
modelProvider: activeModels.value?.activeLlm?.providerId,
modelName: activeModels.value?.activeLlm?.model,
attachments: outgoingAttachments.map(a => ({
type: 'file' as const,
fileUrl: a.url,
fileName: a.name,
storedName: a.storedName,
contentType: a.contentType,
fileSize: a.size,
path: a.path,
})),
})
// 发送成功后释放 ObjectURL
revokeAllPreviewUrls()
} catch (e) {
console.error('Send message failed:', e)
// 发送失败:恢复输入和附件,用户不丢失已上传的文件
if (!inputText.value) inputText.value = savedInput
if (pendingAttachments.value.length === 0) pendingAttachments.value = savedAttachments
}
}
function handleStopStream() {
stopChatGeneration()
}
async function handleRegenerate(message: Message) {
if (workerConversationReadOnly.value
|| isGenerating.value || !currentConversationId.value || !selectedAgentId.value) return
const idx = messages.value.indexOf(message)
if (idx >= 0) {
// The server drops the trailing assistant block and reuses the persisted
// seed user message (no duplicate user row) — mirror the truncation locally.
messages.value.splice(idx)
}
try {
await sendChatMessage('', {
conversationId: currentConversationId.value,
agentId: selectedAgentId.value,
contentParts: [],
thinkingLevel: thinkingLevel.value,
modelProvider: activeModels.value?.activeLlm?.providerId,
modelName: activeModels.value?.activeLlm?.model,
regenerate: true,
})
} catch (e: any) {
console.error('Regenerate failed:', e)
mcToast.error(e?.message || t('chat.regenerateFailed'))
}
}
async function handleRewind(message: Message) {
if (workerConversationReadOnly.value || isGenerating.value || !currentConversationId.value) return
const idx = messages.value.indexOf(message)
if (idx < 0) return
const count = messages.value.length - idx
try {
await ElMessageBox.confirm(
t('chat.rewindConfirm', { count }),
t('chat.rewindHere'),
{ type: 'warning' }
)
} catch {
return // user cancelled
}
try {
const res = await conversationApi.rewindMessage(
currentConversationId.value,
String(message.id)
)
messages.value.splice(idx)
// Sync the sidebar entry from the server-recomputed aggregates.
const data = res.data as { deletedCount: number; messageCount: number; lastMessage: string | null } | undefined
const conv = conversations.value.find(c => c.conversationId === currentConversationId.value)
if (conv && data) {
conv.lastMessage = data.lastMessage ?? ''
conv.messageCount = data.messageCount
}
} catch (e: any) {
mcToast.error(e?.message || t('chat.rewindFailed'))
}
}
function sendSuggestion(text: string) {
inputText.value = text
handleSendMessage(text)
}
function handleToggleThinking(message: import('@/types').Message, expanded: boolean) {
message.thinkingExpanded = expanded
}
// ============ 审批处理 ============
async function handleApprove(pendingId: string) {
if (!currentConversationId.value) return
await handleSendMessage('/approve')
}
async function handleDeny(pendingId: string) {
if (!currentConversationId.value) return
await handleSendMessage('/deny')
}
// Always-approve: create the matching grant first, then send /approve as usual.
// Failure to create the grant doesn't block the approval — we still forward
// /approve so the user's click isn't lost, just toast the error.
async function handleApproveAlways(
payload: { pendingId: string; scope: 'CONVERSATION' | 'AGENT' | 'USER' },
) {
if (!currentConversationId.value) return
const pa = activePendingApproval.value
if (!pa) return
// Resolve scope_id from the scope dimension.
let scopeId = ''
if (payload.scope === 'CONVERSATION') {
scopeId = currentConversationId.value
} else if (payload.scope === 'AGENT') {
scopeId = String(currentAgent.value?.id ?? '')
} else if (payload.scope === 'USER') {
// Login persists the id under 'userId' (see Login.vue); the old 'mc-user-id'
// key was never written, so USER-scope always-approve silently failed to
// resolve a scopeId and fell back to a one-shot /approve without a grant.
const me = localStorage.getItem('userId')
if (me) scopeId = me
}
if (!scopeId) {
ElMessage.error('Cannot resolve scope id for always-approve')
await handleSendMessage('/approve')
return
}
try {
const sev = pa.maxSeverity ?? 'LOW'
// Severity ceiling = at-or-above the current finding's severity. CRITICAL
// never enters this path (the backend rejects it), so HIGH covers the rest.
const ceiling = sev === 'HIGH' || sev === 'CRITICAL' ? 'HIGH'
: sev === 'MEDIUM' ? 'MEDIUM' : 'LOW'
const ruleId = pa.findings?.find((f: { ruleId?: string }) => !!f.ruleId)?.ruleId ?? null
await approvalApi.createGrant({
scopeType: payload.scope,
scopeId,
toolName: pa.toolName,
ruleId,
maxSeverity: ceiling,
grantKind: payload.scope === 'CONVERSATION' ? 'UNTIL_CONVERSATION_END' : 'ALWAYS',
note: `created from approval banner (${pa.toolName})`,
})
ElMessage.success(
t('chat.approveAlwaysCreated', { tool: pa.toolName }) as string,
)
} catch (e: any) {
ElMessage.error(e?.message || 'Failed to create auto-approve rule')
}
await handleSendMessage('/approve')
}
// 重连到运行中的流
async function reconnectStream(conversationId: string) {
try {
await reconnectChatStream(conversationId)
} catch (e) {
console.error('[ChatConsole] Reconnect failed:', e)
mcToast.warning(t('chat.reconnectFailed') || 'Stream reconnection failed')
}
}
function handleCancelQueued() {
cancelQueued()
}
// 简化版重置函数
function resetStreamingState() {
// 先通知后端停止旧流fire-and-forget再彻底清理前端状态
stopChatGeneration()
resetForNewConversation()
}
// ============ 附件处理 ============
async function handleFileSelect(files: File[]) {
if (workerConversationReadOnly.value) return
if (!currentConversationId.value) {
newConversation()
}
uploadingAttachment.value = true
try {
for (const file of files) {
const res: any = await chatApi.uploadFile(currentConversationId.value, file)
const data = res.data || {}
// 图片/视频使用本地 ObjectURL 预览(避免 /api/v1/chat/files/ 需要 JWT 认证导致加载失败)
const ct = data.contentType || file.type || ''
const isPreviewable = ct.startsWith('image/') || ct.startsWith('video/')
const previewUrl = isPreviewable ? URL.createObjectURL(file) : data.url
pendingAttachments.value.push({
name: data.fileName || file.name,
size: data.size || file.size,
url: data.url,
storedName: data.storedName,
path: data.path,
contentType: data.contentType || file.type,
previewUrl,
})
}
} catch (e) {
mcToast.error(t('chat.uploadFailed'))
} finally {
uploadingAttachment.value = false
}
}
function removeAttachment(key: string) {
// revoke 被移除附件的 ObjectURL防止内存泄漏
const removed = pendingAttachments.value.find(a => a.storedName === key || a.path === key)
if (removed?.previewUrl?.startsWith('blob:')) {
URL.revokeObjectURL(removed.previewUrl)
}
pendingAttachments.value = pendingAttachments.value.filter(
a => a.storedName !== key && a.path !== key
)
}
/** 释放所有 pending 附件的 ObjectURL */
function revokeAllPreviewUrls() {
for (const a of pendingAttachments.value) {
if (a.previewUrl?.startsWith('blob:')) {
URL.revokeObjectURL(a.previewUrl)
}
}
}
function buildOutgoingParts(text: string, attachments: ChatAttachment[]): MessageContentPart[] {
const parts: MessageContentPart[] = []
if (text) parts.push({ type: 'text', text })
for (const attachment of attachments) {
const ct = attachment.contentType || ''
const partType: MessageContentPart['type'] = ct.startsWith('video/') ? 'video'
: ct.startsWith('image/') ? 'image'
: 'file'
parts.push({
type: partType,
fileUrl: attachment.url,
fileName: attachment.name,
storedName: attachment.storedName,
contentType: attachment.contentType,
fileSize: attachment.size,
path: attachment.path,
})
}
return parts
}
// ============ 工具函数 ============
function normalizeMessage(raw: Message, preserveGeneratingStatus?: boolean): Message {
const msg: Message = { ...raw, contentParts: raw.contentParts ? [...raw.contentParts] : [] }
// 统一解析 metadata确保是对象而非 JSON 字符串
// 注意:后端 metadata 在 DB 中是 JSON 字符串Jackson 序列化时可能双重编码
if (typeof msg.metadata === 'string') {
try {
let parsed = JSON.parse(msg.metadata)
// 处理双重编码parse 后仍然是字符串的情况
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed) } catch { /* ignore */ }
}
msg.metadata = parsed
} catch { msg.metadata = {} as any }
}
// 保留后端返回的 token 字段MessageVO 新增)
if ((raw as any).promptTokens) msg.promptTokens = (raw as any).promptTokens
if ((raw as any).completionTokens) msg.completionTokens = (raw as any).completionTokens
if ((raw as any).cacheReadTokens) msg.cacheReadTokens = (raw as any).cacheReadTokens
if ((raw as any).cacheWriteTokens) msg.cacheWriteTokens = (raw as any).cacheWriteTokens
if ((raw as any).reasoningTokens) msg.reasoningTokens = (raw as any).reasoningTokens
if ((raw as any).runtimeModel) msg.runtimeModel = (raw as any).runtimeModel
if ((raw as any).runtimeProvider) msg.runtimeProvider = (raw as any).runtimeProvider
if (msg.contentParts.length === 0 && msg.content) {
if (msg.role === 'assistant') {
const parsed = parseThinkingContent(msg.content)
// thinkingLevel=off 时不展示 thinking 内容,直接剥离 <think> 标签
if (parsed.thinking && thinkingLevel.value !== 'off') {
msg.contentParts.push({ type: 'thinking', text: parsed.thinking })
}
if (parsed.content) msg.contentParts.push({ type: 'text', text: parsed.content })
msg.content = parsed.content
} else {
msg.contentParts.push({ type: 'text', text: msg.content })
}
}
// 从 tool_call contentParts 还原 metadata.toolCalls
const toolCallParts = msg.contentParts.filter(p => p.type === 'tool_call')
if (toolCallParts.length > 0) {
const toolCalls: ToolCallMeta[] = []
for (const part of toolCallParts) {
try {
const parsed = JSON.parse(part.text || '{}')
toolCalls.push({
name: parsed.name || '',
arguments: parsed.arguments,
result: parsed.result,
success: parsed.success,
// 历史消息中不应有 running 状态的工具调用(流已结束)
status: 'completed',
})
} catch {
// skip malformed tool_call parts
}
}
if (toolCalls.length > 0) {
msg.metadata = { ...msg.metadata, toolCalls }
}
msg.contentParts = msg.contentParts.filter(p => p.type !== 'tool_call')
}
// 历史消息的 metadata.toolCalls 也需要清理 running 状态
if (msg.metadata?.toolCalls) {
const cleaned = msg.metadata.toolCalls.map((tc: ToolCallMeta) => ({
...tc,
status: tc.status === 'running' ? 'completed' as const : tc.status,
}))
msg.metadata = { ...msg.metadata, toolCalls: cleaned }
}
if (!preserveGeneratingStatus && msg.status === 'generating') msg.status = 'failed'
// interrupted 是合法的历史状态interrupt-with-followup不映射为 stopped
if (!msg.status) msg.status = 'completed'
// 从 file/image/video contentParts 恢复 attachments历史消息 API 不返回单独的 attachments 字段)
const fileParts = msg.contentParts.filter(p => (p.type === 'file' || p.type === 'image' || p.type === 'video') && p.fileUrl)
if (fileParts.length > 0 && (!msg.attachments || msg.attachments.length === 0)) {
msg.attachments = fileParts.map(p => ({
name: p.fileName || 'unknown',
size: typeof p.fileSize === 'number' ? p.fileSize : Number(p.fileSize) || 0,
url: p.fileUrl!,
storedName: p.storedName || '',
path: p.path || '',
contentType: p.contentType,
}))
}
// 从持久化的 [错误] 文本重建 errorInfo使刷新后也能显示错误卡片
if (msg.role === 'assistant' && !msg.errorInfo) {
const text = msg.content || msg.contentParts?.find(p => p.type === 'text')?.text || ''
const rebuilt = reconstructErrorInfo(text)
if (rebuilt) {
msg.errorInfo = rebuilt
msg.status = 'failed'
}
}
return msg
}
function parseThinkingContent(raw: string): { content: string; thinking: string; hasThinking: boolean } {
if (!raw) return { content: '', thinking: '', hasThinking: false }
const normalized = raw.replace(/<thinking>/gi, '<think>').replace(/<\/thinking>/gi, '</think>')
const thinkingParts: string[] = []
const content = normalized.replace(/<think>([\s\S]*?)<\/think>/gi, (_, thinkingText: string) => {
const cleanText = thinkingText.trim()
if (cleanText) thinkingParts.push(cleanText)
return ''
}).trim()
return {
content,
thinking: thinkingParts.join('\n\n').trim(),
hasThinking: thinkingParts.length > 0,
}
}
function handleCodeCopy(e: MouseEvent) {
// Mermaid download button shares the same global click delegation. Handle
// it first so the SVG export beats the copy-button selector below if the
// user happens to click in an area where both ancestors are reachable.
if (handleMermaidDownload(e)) return
const btn = (e.target as HTMLElement).closest('.code-block__copy') as HTMLElement | null
if (!btn) return
// The copy button now sits inside <details><summary> for collapsible code
// blocks. Without preventDefault the click would also toggle the details
// open state — a regression introduced when we wrapped long blocks in
// <details>. stopPropagation guards against any future ancestor handlers.
e.preventDefault()
e.stopPropagation()
const encoded = btn.getAttribute('data-code')
if (!encoded) return
const code = decodeURIComponent(encoded)
copyToClipboard(code).then(() => {
btn.classList.add('copied')
const textEl = btn.querySelector('.code-block__copy-text')
if (textEl) textEl.textContent = t('chat.copied')
setTimeout(() => {
btn.classList.remove('copied')
if (textEl) textEl.textContent = t('chat.copy')
}, 1500)
}).catch(() => {
mcToast.error(t('chat.copyFailed'))
})
}
</script>
<style scoped>
.ctx-usage-row {
display: flex;
justify-content: flex-end;
padding: 0 4px 4px;
}
.cron-running-bar {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 16px;
margin: 0 12px;
background: var(--mc-warning-bg, rgba(255, 159, 67, 0.08));
border: 1px solid var(--mc-warning, rgba(255, 159, 67, 0.35));
border-radius: 10px;
color: var(--mc-text-primary);
font-size: 13px;
}
.cron-running-item {
display: flex;
align-items: center;
gap: 10px;
}
.cron-running-spinner {
display: inline-block;
font-size: 16px;
animation: cron-spin 1.6s linear infinite;
}
@keyframes cron-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.cron-running-text { line-height: 1.4; }
.cron-running-meta { color: var(--mc-text-secondary); margin-left: 4px; }
.chat-console-shell {
background: transparent;
min-height: 0;
height: 100%;
overflow: hidden;
}
.chat-console-frame {
height: min(calc(100vh - 28px), 100%);
min-height: 0;
overflow: hidden;
}
.chat-layout {
display: flex;
height: 100%;
overflow: hidden;
min-height: 0;
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(180deg, var(--mc-chat-header-bg), var(--mc-chat-bg));
position: relative;
min-height: 0;
}
/* 拖拽上传遮罩 */
.drop-overlay {
position: absolute;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(217, 119, 87, 0.06);
backdrop-filter: blur(2px);
}
.drop-overlay__content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px 60px;
border: 2px dashed var(--mc-primary, #D97757);
border-radius: 16px;
background: var(--mc-bg-elevated, #f8fafc);
color: var(--mc-primary, #D97757);
font-size: 16px;
font-weight: 500;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: linear-gradient(180deg, var(--mc-panel-raised), var(--mc-surface-overlay));
border-bottom: 1px solid var(--mc-border);
min-height: 52px;
backdrop-filter: blur(12px);
gap: 10px;
}
.chat-header-left {
display: flex;
align-items: center;
min-width: 0;
gap: 10px;
}
.chat-header-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.chat-stage-copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.chat-stage-kicker {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--mc-accent);
}
.agent-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: var(--mc-primary-bg);
border-radius: 999px;
max-width: 100%;
}
.agent-badge-icon {
display: flex;
align-items: center;
font-size: 14px;
}
.agent-badge-text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.agent-badge-name {
font-size: 13px;
font-weight: 600;
color: var(--mc-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.status-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-left: 2px;
transition: background 0.3s;
}
.status-idle { background: #34d399; box-shadow: 0 0 4px rgba(52, 211, 153, 0.5); }
.status-streaming { background: #fbbf24; box-shadow: 0 0 4px rgba(251, 191, 36, 0.5); animation: pulse-dot 1.2s infinite; }
.status-error { background: #f87171; box-shadow: 0 0 4px rgba(248, 113, 113, 0.5); }
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.no-agent-hint {
font-size: 13px;
color: var(--mc-text-tertiary);
}
/* Model selector */
/* Model selector styles moved to ModelSelector.vue */
/* Header overflow menu */
.header-overflow-wrap {
position: relative;
}
.header-btn {
width: 30px;
height: 30px;
border: 1px solid var(--mc-border);
background: var(--mc-panel-raised);
border-radius: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--mc-text-secondary);
transition: all 0.15s;
}
.header-btn:hover {
border-color: var(--mc-danger);
color: var(--mc-danger);
}
.model-prompt {
margin: 24px auto 0;
max-width: 540px;
padding: 20px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 16px;
text-align: center;
box-shadow: 0 8px 24px rgba(124, 63, 30, 0.06);
}
.model-prompt-title {
font-size: 18px;
font-weight: 700;
color: var(--mc-text-primary);
margin-bottom: 8px;
}
.model-prompt-desc {
font-size: 14px;
color: var(--mc-text-secondary);
line-height: 1.6;
margin-bottom: 16px;
}
.btn-primary {
padding: 8px 16px;
background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover));
color: white;
border: none;
border-radius: 12px;
font-size: 14px;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover {
background: var(--mc-primary-hover);
}
/* Issue #81: side-by-side primary + secondary actions in the model prompt. */
.model-prompt-actions {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.btn-secondary {
padding: 8px 14px;
background: transparent;
color: var(--mc-text-primary);
border: 1px solid var(--mc-border);
border-radius: 12px;
font-size: 14px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.btn-secondary:hover {
background: var(--mc-panel-raised);
border-color: var(--mc-primary);
}
/* ===== 移动端元素(桌面端隐藏) ===== */
.conv-backdrop {
display: none;
}
.conv-toggle-btn {
display: none;
}
/* ===== 移动端适配 ===== */
@media (max-width: 768px) {
.chat-console-shell {
padding: 0 !important;
height: 100dvh !important;
height: 100vh !important;
overflow: hidden !important;
min-height: 0 !important;
}
.chat-console-frame {
height: 100% !important;
min-height: 0 !important;
overflow: hidden !important;
border-radius: 0;
border: none;
}
.conv-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 99;
background: rgba(0, 0, 0, 0.3);
}
.conv-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
border-radius: 6px;
cursor: pointer;
color: var(--mc-text-secondary);
flex-shrink: 0;
transition: all 0.15s;
}
.conv-toggle-btn:hover {
border-color: var(--mc-primary);
color: var(--mc-primary);
}
.chat-header {
padding: 9px 12px;
gap: 8px;
}
.agent-badge {
padding: 4px 8px;
}
.chat-stage-kicker {
display: none;
}
.agent-badge-text {
display: none;
}
.model-select-trigger {
max-width: 160px;
}
.model-dropdown {
min-width: 200px;
}
.drop-overlay__content {
padding: 24px 32px;
font-size: 14px;
}
}
@media (max-width: 480px) {
.chat-header {
padding: 6px 8px;
min-height: 44px;
}
.chat-header-right {
gap: 4px;
}
.model-select-trigger {
max-width: 120px;
height: 30px;
padding: 0 8px;
font-size: 12px;
}
.header-btn {
width: 28px;
height: 28px;
}
}
</style>