feat(frontend): U5 前端辩论可视化

前端展示辩论过程,专家交锋有独立气泡样式,裁决结果清晰可见。

类型 (api/types.ts):
- WsServerMessage 新增 5 个辩论事件:debate_started / expert_argument /
  debate_round_summary / debate_resolved / team_intervention_ack
- IChatMessage.message_type 新增 4 个辩论消息类型
- IChatMessage 新增 7 个可选辩论字段(topic/round/decision 等)
- 新增 4 个数据接口

Chat Store (stores/chat.ts):
- 新增 debateState ref(topic/participants/round/status)
- WS switch 新增 5 个 case,复用 appendMessage/appendStep 模式
- 辩论结束 1s 后清空 debateState(与 board_concluded 一致)

渲染器 (useMessageRenderer.ts):
- MessageViewType + resolveMessageType 新增 4 个辩论视图类型
- useMessageRenderer 新增 4 个 render spec

新组件 (messages/):
- DebateBannerCard.vue — 辩论开始横幅(主题 + 参与专家 + 开场白)
- DebateArgumentCard.vue — 专家论点卡片(专家色边框 + 轮次标签)
- DebateSummaryCard.vue — 主持人轮次小结
- DebateConclusionCard.vue — 裁决卡片(按 decision 着色)

输入框 (ChatInput.vue):
- 团队模式下显示「辩论」按钮,点击弹出 prompt 输入主题
- 发送 /debate <topic> 命令(U4 WS 干预通道处理)

npm run typecheck 通过。
This commit is contained in:
chiguyong 2026-06-24 12:37:37 +08:00
parent c831e925b6
commit 49b483b933
10 changed files with 570 additions and 1 deletions

View File

@ -16,6 +16,7 @@ declare module 'vue' {
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
ACol: typeof import('ant-design-vue/es')['Col']
ACollapse: typeof import('ant-design-vue/es')['Collapse']
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
@ -64,12 +65,18 @@ declare module 'vue' {
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimePicker: typeof import('ant-design-vue/es/time-picker/dayjs')['TimePicker']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
BoardBannerCard: typeof import('./src/components/chat/messages/BoardBannerCard.vue')['default']
BoardConclusionCard: typeof import('./src/components/chat/messages/BoardConclusionCard.vue')['default']
BoardMeetingModal: typeof import('./src/components/chat/BoardMeetingModal.vue')['default']
BoardRoundCard: typeof import('./src/components/chat/messages/BoardRoundCard.vue')['default']
BoardStatusView: typeof import('./src/components/chat/BoardStatusView.vue')['default']
CalendarDrawer: typeof import('./src/components/calendar/CalendarDrawer.vue')['default']
CalendarGrid: typeof import('./src/components/calendar/CalendarGrid.vue')['default']
CalendarPanel: typeof import('./src/components/calendar/CalendarPanel.vue')['default']
CalendarTab: typeof import('./src/components/layout/tabs/CalendarTab.vue')['default']
CardView: typeof import('./src/components/calendar/CardView.vue')['default']
ChangePasswordPanel: typeof import('./src/components/settings/ChangePasswordPanel.vue')['default']
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
@ -80,8 +87,16 @@ declare module 'vue' {
ConditionNode: typeof import('./src/components/workflow/ConditionNode.vue')['default']
ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default']
DashboardOverview: typeof import('./src/components/evolution/DashboardOverview.vue')['default']
DebateArgumentCard: typeof import('./src/components/chat/messages/DebateArgumentCard.vue')['default']
DebateBannerCard: typeof import('./src/components/chat/messages/DebateBannerCard.vue')['default']
DebateConclusionCard: typeof import('./src/components/chat/messages/DebateConclusionCard.vue')['default']
DebateSummaryCard: typeof import('./src/components/chat/messages/DebateSummaryCard.vue')['default']
DocumentCard: typeof import('./src/components/chat/messages/DocumentCard.vue')['default']
DocumentPanel: typeof import('./src/components/chat/DocumentPanel.vue')['default']
DocumentUpload: typeof import('./src/components/kb/DocumentUpload.vue')['default']
ErrorCard: typeof import('./src/components/chat/messages/ErrorCard.vue')['default']
EventBadge: typeof import('./src/components/calendar/EventBadge.vue')['default']
EventEditor: typeof import('./src/components/calendar/EventEditor.vue')['default']
ExperiencePanel: typeof import('./src/components/evolution/ExperiencePanel.vue')['default']
ExperienceTimeline: typeof import('./src/components/evolution/ExperienceTimeline.vue')['default']
ExpertMessage: typeof import('./src/components/chat/ExpertMessage.vue')['default']
@ -91,7 +106,9 @@ declare module 'vue' {
FileTree: typeof import('./src/components/code/FileTree.vue')['default']
FlowCanvas: typeof import('./src/components/workflow/FlowCanvas.vue')['default']
IconNav: typeof import('./src/components/layout/IconNav.vue')['default']
InvitationManager: typeof import('./src/components/calendar/InvitationManager.vue')['default']
KnowledgeTab: typeof import('./src/components/layout/tabs/KnowledgeTab.vue')['default']
ListView: typeof import('./src/components/calendar/ListView.vue')['default']
MentionDropdown: typeof import('./src/components/chat/MentionDropdown.vue')['default']
MessageShell: typeof import('./src/components/chat/messages/MessageShell.vue')['default']
MetricsChart: typeof import('./src/components/evolution/MetricsChart.vue')['default']
@ -105,6 +122,7 @@ declare module 'vue' {
PlanVisualization: typeof import('./src/components/chat/PlanVisualization.vue')['default']
PropertyPanel: typeof import('./src/components/workflow/PropertyPanel.vue')['default']
QuadrantPanel: typeof import('./src/components/layout/QuadrantPanel.vue')['default']
ReminderConfig: typeof import('./src/components/calendar/ReminderConfig.vue')['default']
RightPanel: typeof import('./src/components/layout/RightPanel.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
@ -123,6 +141,7 @@ declare module 'vue' {
SourceConfig: typeof import('./src/components/kb/SourceConfig.vue')['default']
SplashScreen: typeof import('./src/components/layout/SplashScreen.vue')['default']
SplitPane: typeof import('./src/components/layout/SplitPane.vue')['default']
SyncSettings: typeof import('./src/components/calendar/SyncSettings.vue')['default']
SystemMonitorPanel: typeof import('./src/components/layout/SystemMonitorPanel.vue')['default']
SystemTab: typeof import('./src/components/layout/tabs/SystemTab.vue')['default']
TeamModal: typeof import('./src/components/chat/TeamModal.vue')['default']

View File

@ -58,6 +58,10 @@ export interface IChatMessage {
| 'board_speech'
| 'board_summary'
| 'board_conclusion'
| 'debate_started'
| 'debate_argument'
| 'debate_summary'
| 'debate_resolved'
| 'error'
board_round?: number
board_role?: 'moderator' | 'expert' | 'user' | 'summary'
@ -65,6 +69,13 @@ export interface IChatMessage {
error_detail?: string
board_started?: IBoardStartedData
board_conclusion?: IBoardConcludedData
debate_topic?: string
debate_round?: number
debate_decision?: string
debate_rationale?: string
debate_participants?: string[]
debate_opening?: string
debate_moderator?: string
}
/** Conversation with messages */
@ -132,6 +143,12 @@ export type WsServerMessage =
| { type: 'round_summary'; data: IRoundSummaryData }
| { type: 'user_intervention'; data: IUserInterventionData }
| { type: 'board_concluded'; data: IBoardConcludedData }
// Debate (U5) 事件
| { type: 'debate_started'; data: IDebateStartedData }
| { type: 'expert_argument'; data: IDebateArgumentData }
| { type: 'debate_round_summary'; data: IDebateRoundSummaryData }
| { type: 'debate_resolved'; data: IDebateResolvedData }
| { type: 'team_intervention_ack'; data: { content: string } }
// Calendar 事件 (KTD-10 — piggyback on chat WS)
| { type: 'calendar_event_created'; data: ICalendarEventCreatedData }
| { type: 'calendar_reminder'; data: ICalendarReminderData }
@ -225,6 +242,47 @@ export interface IBoardConcludedData {
error?: string
}
// ── Debate (U5) 模式类型 ──────────────────────────────────────────────
/** debate_started event payload */
export interface IDebateStartedData {
phase_id: string
phase_name: string
topic: string
participants: string[]
max_rounds: number
opening: string
}
/** expert_argument event payload */
export interface IDebateArgumentData {
phase_id: string
expert_id: string
expert_name: string
expert_color: string
content: string
round: number
topic: string
}
/** debate_round_summary event payload */
export interface IDebateRoundSummaryData {
phase_id: string
moderator_name: string
content: string
round: number
continue: boolean
}
/** debate_resolved event payload */
export interface IDebateResolvedData {
phase_id: string
phase_name: string
decision: 'adopt' | 'compromise' | 'shelve' | 'inconclusive'
conclusion: string
rationale: string
}
/** Board meeting status (matches backend BoardStatus enum) */
export type BoardStatus = 'forming' | 'discussing' | 'concluding' | 'completed' | 'dissolved'

View File

@ -78,6 +78,16 @@
<template #icon><TeamOutlined /></template>
私董会
</a-button>
<a-button
v-if="teamStore?.isTeamMode"
size="small"
:disabled="disabled && !teamStore?.isTeamMode"
@click="handleDebateClick"
class="chat-input__action-btn chat-input__action-btn--debate"
title="发起辩论"
>
<template #icon><CommentOutlined /></template>辩论
</a-button>
<a-button
size="small"
:disabled="disabled || fileUploading"
@ -129,12 +139,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, type Component } from 'vue'
import { Input as AInput, Button as AButton, Select as ASelect } from 'ant-design-vue'
import { SendOutlined, TeamOutlined, UsergroupAddOutlined, PaperClipOutlined, PoweroffOutlined } from '@ant-design/icons-vue'
import { SendOutlined, TeamOutlined, UsergroupAddOutlined, PaperClipOutlined, PoweroffOutlined, CommentOutlined } from '@ant-design/icons-vue'
import ContextPill from './ContextPill.vue'
import MentionDropdown from './MentionDropdown.vue'
import BoardMeetingModal from './BoardMeetingModal.vue'
import TeamModal from './TeamModal.vue'
import { useSkillsStore } from '@/stores/skills'
import { useTeamStore } from '@/stores/team'
import type { ISkillInfo } from '@/api/skills'
import { getDynamicBaseURL } from '@/api/base'
import { apiClient } from '@/api/client'
@ -230,6 +241,7 @@ const mentionQuery = ref('')
const mentionStartIndex = ref(-1)
const mentionPosition = ref({ left: 0 })
const skillsStore = useSkillsStore()
const teamStore = useTeamStore()
const skillSuggestions = computed<SkillSuggestion[]>(() => {
return (skillsStore.skills || []).map((s: ISkillInfo) => ({
@ -318,6 +330,14 @@ function handleTeamSubmit(command: string): void {
emit('send', command, selectedModel.value)
}
function handleDebateClick(): void {
// Prompt user for debate topic, then send as intervention
const topic = window.prompt('请输入辩论主题')
if (topic && topic.trim()) {
emit('send', `/debate ${topic.trim()}`, selectedModel.value)
}
}
function openFilePicker(): void {
fileInputRef.value?.click()
}
@ -563,6 +583,17 @@ function removePill(idx: number): void {
background: var(--accent-board-soft);
}
.chat-input__action-btn--debate {
color: #722ed1;
border-color: #d3adf7;
}
.chat-input__action-btn--debate:not(:disabled):hover {
color: #531dab;
border-color: #722ed1;
background: #f9f0ff;
}
.chat-input__action-btn--clear {
color: var(--text-tertiary);
}

View File

@ -6,6 +6,10 @@ import TeamPlanCard from '@/components/chat/messages/TeamPlanCard.vue'
import BoardBannerCard from '@/components/chat/messages/BoardBannerCard.vue'
import BoardRoundCard from '@/components/chat/messages/BoardRoundCard.vue'
import BoardConclusionCard from '@/components/chat/messages/BoardConclusionCard.vue'
import DebateBannerCard from '@/components/chat/messages/DebateBannerCard.vue'
import DebateArgumentCard from '@/components/chat/messages/DebateArgumentCard.vue'
import DebateSummaryCard from '@/components/chat/messages/DebateSummaryCard.vue'
import DebateConclusionCard from '@/components/chat/messages/DebateConclusionCard.vue'
import ErrorCard from '@/components/chat/messages/ErrorCard.vue'
export type MessageViewType =
@ -16,6 +20,10 @@ export type MessageViewType =
| 'board_speech'
| 'board_summary'
| 'board_conclusion'
| 'debate_started'
| 'debate_argument'
| 'debate_summary'
| 'debate_resolved'
| 'milestone'
| 'error'
@ -48,6 +56,14 @@ export function resolveMessageType(message: IChatMessage): MessageViewType {
return 'board_summary'
case 'board_conclusion':
return 'board_conclusion'
case 'debate_started':
return 'debate_started'
case 'debate_argument':
return 'debate_argument'
case 'debate_summary':
return 'debate_summary'
case 'debate_resolved':
return 'debate_resolved'
case 'milestone':
return 'milestone'
default:
@ -168,6 +184,83 @@ export function useMessageRenderer(message: IChatMessage) {
},
}
case 'debate_started':
return {
type,
shell: {
name: '辩论',
avatar: '⚖',
color: '#722ed1',
meta: message.debate_topic || '',
},
component: DebateBannerCard,
props: {
topic: message.debate_topic || '',
participants: message.debate_participants || [],
opening: message.debate_opening || message.content,
},
}
case 'debate_argument':
return {
type,
shell: {
name: message.expert_name || '专家',
avatar: (message.expert_name || '?')[0],
color: message.expert_color || '#722ed1',
meta: `辩论第${message.debate_round || 1}`,
},
component: DebateArgumentCard,
props: {
content: message.content,
round: message.debate_round || 1,
expertName: message.expert_name || '',
expertColor: message.expert_color || '#722ed1',
},
}
case 'debate_summary':
return {
type,
shell: {
name: message.expert_name || 'Lead',
avatar: (message.expert_name || 'L')[0],
color: '#722ed1',
meta: `${message.debate_round || 1}轮小结`,
},
component: DebateSummaryCard,
props: {
content: message.content,
round: message.debate_round || 1,
moderatorName: message.debate_moderator || message.expert_name || '',
},
}
case 'debate_resolved': {
const decisionLabels: Record<string, string> = {
adopt: '采纳',
compromise: '折中',
shelve: '搁置',
inconclusive: '未决',
}
const decision = message.debate_decision || 'inconclusive'
return {
type,
shell: {
name: '辩论裁决',
avatar: '⚖',
color: '#fa8c16',
meta: decisionLabels[decision] || decision,
},
component: DebateConclusionCard,
props: {
conclusion: message.content,
decision,
rationale: message.debate_rationale || '',
},
}
}
case 'error':
return {
type,

View File

@ -0,0 +1,46 @@
<template>
<div class="debate-argument" :style="{ borderLeftColor: expertColor }">
<div class="debate-argument__header">
<span class="debate-argument__name" :style="{ color: expertColor }">{{ expertName }}</span>
<a-tag color="purple">{{ round }}</a-tag>
</div>
<AssistantText :message="textMessage" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssistantText from './AssistantText.vue'
import type { IChatMessage } from '@/api/types'
const props = defineProps<{
content: string
round: number
expertName: string
expertColor: string
}>()
const textMessage = computed<IChatMessage>(() => ({
id: `debate-arg-${props.expertName}-${props.round}`,
role: 'assistant',
content: props.content,
timestamp: new Date().toISOString(),
status: 'completed',
}))
</script>
<style scoped>
.debate-argument {
padding: 8px 12px;
border-left: 3px solid #722ed1;
background: #fafafa;
border-radius: 4px;
}
.debate-argument__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.debate-argument__name { font-weight: 600; }
</style>

View File

@ -0,0 +1,53 @@
<template>
<div class="debate-banner">
<div class="debate-banner__icon"></div>
<div class="debate-banner__body">
<div class="debate-banner__title">辩论开始</div>
<div class="debate-banner__topic">{{ topic }}</div>
<div class="debate-banner__participants">
<span class="debate-banner__label">参与专家</span>
<a-tag v-for="p in participants" :key="p" color="purple">{{ p }}</a-tag>
</div>
<div v-if="opening" class="debate-banner__opening">
<AssistantText :message="openingMessage" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssistantText from './AssistantText.vue'
import type { IChatMessage } from '@/api/types'
const props = defineProps<{
topic: string
participants: string[]
opening: string
}>()
const openingMessage = computed<IChatMessage>(() => ({
id: 'debate-opening',
role: 'assistant',
content: props.opening,
timestamp: new Date().toISOString(),
status: 'completed',
}))
</script>
<style scoped>
.debate-banner {
display: flex;
gap: 12px;
padding: 12px 16px;
border-left: 3px solid #722ed1;
background: #f9f0ff;
border-radius: 4px;
}
.debate-banner__icon { font-size: 24px; }
.debate-banner__title { font-weight: 600; color: #722ed1; margin-bottom: 4px; }
.debate-banner__topic { font-size: 14px; margin-bottom: 8px; }
.debate-banner__participants { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
.debate-banner__label { font-size: 12px; color: #666; }
.debate-banner__opening { margin-top: 8px; }
</style>

View File

@ -0,0 +1,81 @@
<template>
<div class="debate-conclusion" :class="`debate-conclusion--${decision}`">
<div class="debate-conclusion__header">
<span class="debate-conclusion__icon">{{ decisionIcon }}</span>
<span class="debate-conclusion__decision">{{ decisionLabel }}</span>
</div>
<div class="debate-conclusion__body">
<AssistantText :message="textMessage" />
</div>
<div v-if="rationale" class="debate-conclusion__rationale">
<span class="debate-conclusion__rationale-label">理由</span>
<span>{{ rationale }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssistantText from './AssistantText.vue'
import type { IChatMessage } from '@/api/types'
const props = defineProps<{
conclusion: string
decision: string
rationale: string
}>()
const decisionLabels: Record<string, string> = {
adopt: '采纳',
compromise: '折中',
shelve: '搁置',
inconclusive: '未决',
}
const decisionIcons: Record<string, string> = {
adopt: '✓',
compromise: '⇄',
shelve: '○',
inconclusive: '?',
}
const decisionLabel = computed(() => decisionLabels[props.decision] || props.decision)
const decisionIcon = computed(() => decisionIcons[props.decision] || '?')
const textMessage = computed<IChatMessage>(() => ({
id: 'debate-conclusion',
role: 'assistant',
content: props.conclusion,
timestamp: new Date().toISOString(),
status: 'completed',
}))
</script>
<style scoped>
.debate-conclusion {
padding: 12px 16px;
border-left: 3px solid #fa8c16;
background: #fff7e6;
border-radius: 4px;
}
.debate-conclusion--adopt { border-left-color: #52c41a; background: #f6ffed; }
.debate-conclusion--compromise { border-left-color: #faad14; background: #fffbe6; }
.debate-conclusion--shelve { border-left-color: #bfbfbf; background: #f5f5f5; }
.debate-conclusion--inconclusive { border-left-color: #ff4d4f; background: #fff2f0; }
.debate-conclusion__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.debate-conclusion__icon { font-size: 18px; font-weight: bold; }
.debate-conclusion__decision { font-weight: 600; font-size: 14px; }
.debate-conclusion__body { margin-bottom: 8px; }
.debate-conclusion__rationale {
font-size: 12px;
color: #666;
padding-top: 8px;
border-top: 1px dashed #d9d9d9;
}
.debate-conclusion__rationale-label { font-weight: 600; }
</style>

View File

@ -0,0 +1,46 @@
<template>
<div class="debate-summary">
<div class="debate-summary__header">
<span class="debate-summary__moderator">{{ moderatorName }}</span>
<a-tag color="orange">{{ round }}轮小结</a-tag>
</div>
<AssistantText :message="textMessage" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssistantText from './AssistantText.vue'
import type { IChatMessage } from '@/api/types'
const props = defineProps<{
content: string
round: number
moderatorName: string
}>()
const textMessage = computed<IChatMessage>(() => ({
id: `debate-summary-${props.round}`,
role: 'assistant',
content: props.content,
timestamp: new Date().toISOString(),
status: 'completed',
}))
</script>
<style scoped>
.debate-summary {
padding: 8px 12px;
border-left: 3px solid #fa8c16;
background: #fff7e6;
border-radius: 4px;
margin-left: 16px;
}
.debate-summary__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.debate-summary__moderator { font-weight: 600; color: #fa8c16; }
</style>

View File

@ -5,5 +5,9 @@ export { default as TeamPlanCard } from './TeamPlanCard.vue'
export { default as BoardBannerCard } from './BoardBannerCard.vue'
export { default as BoardRoundCard } from './BoardRoundCard.vue'
export { default as BoardConclusionCard } from './BoardConclusionCard.vue'
export { default as DebateBannerCard } from './DebateBannerCard.vue'
export { default as DebateArgumentCard } from './DebateArgumentCard.vue'
export { default as DebateSummaryCard } from './DebateSummaryCard.vue'
export { default as DebateConclusionCard } from './DebateConclusionCard.vue'
export { default as ErrorCard } from './ErrorCard.vue'
export { default as FileAttachment } from './FileAttachment.vue'

View File

@ -10,6 +10,10 @@ import type {
IChatRequest,
WsClientMessage,
WsServerMessage,
IDebateStartedData,
IDebateArgumentData,
IDebateRoundSummaryData,
IDebateResolvedData,
} from '@/api/types'
function generateId(): string {
@ -148,6 +152,15 @@ export const useChatStore = defineStore('chat', () => {
const isBoardMode = computed(() => boardState.value !== null && boardState.value.status === 'discussing')
// Debate state (transient, only active during a debate collaboration)
const debateState = ref<{
topic: string
participants: string[]
current_round: number
max_rounds: number
status: 'debating' | 'resolved' | 'cancelled'
} | null>(null)
// --- Getters ---
const currentConversation = computed<IConversation | undefined>(() => {
return conversations.value.find((c) => c.id === currentConversationId.value)
@ -1199,6 +1212,130 @@ export const useChatStore = defineStore('chat', () => {
}, 1000)
break
}
// ── Debate events (U5) ──────────────────────────────────────────
case 'debate_started': {
const d = data.data as IDebateStartedData
debateState.value = {
topic: d.topic,
participants: d.participants,
current_round: 0,
max_rounds: d.max_rounds,
status: 'debating',
}
const sessionId = resolveIncomingConvId()
if (!sessionId) break
appendMessage(sessionId, {
id: generateId(),
role: 'assistant',
content: d.opening,
timestamp: new Date().toISOString(),
status: 'completed',
message_type: 'debate_started',
debate_topic: d.topic,
debate_participants: d.participants,
debate_opening: d.opening,
})
appendStep({ type: 'team_event', label: '辩论开始', detail: d.topic.slice(0, 40), status: 'success' }, sessionId)
break
}
case 'expert_argument': {
const d = data.data as IDebateArgumentData
debateState.value = {
...(debateState.value as NonNullable<typeof debateState.value>),
current_round: d.round,
}
const sessionId = resolveIncomingConvId()
if (!sessionId) break
appendMessage(sessionId, {
id: generateId(),
role: 'assistant',
content: d.content,
timestamp: new Date().toISOString(),
status: 'completed',
message_type: 'debate_argument',
expert_name: d.expert_name,
expert_color: d.expert_color,
debate_topic: d.topic,
debate_round: d.round,
})
appendStep({
type: 'team_event',
label: d.expert_name,
detail: `辩论第${d.round}`,
status: 'success',
}, sessionId)
break
}
case 'debate_round_summary': {
const d = data.data as IDebateRoundSummaryData
const sessionId = resolveIncomingConvId()
if (!sessionId) break
appendMessage(sessionId, {
id: generateId(),
role: 'assistant',
content: d.content,
timestamp: new Date().toISOString(),
status: 'completed',
message_type: 'debate_summary',
expert_name: d.moderator_name,
debate_round: d.round,
debate_moderator: d.moderator_name,
})
appendStep({
type: 'team_event',
label: d.moderator_name,
detail: `${d.round}轮小结`,
status: 'success',
}, sessionId)
break
}
case 'debate_resolved': {
const d = data.data as IDebateResolvedData
if (debateState.value) {
debateState.value = { ...debateState.value, status: 'resolved' }
}
const sessionId = resolveIncomingConvId()
if (!sessionId) break
appendMessage(sessionId, {
id: generateId(),
role: 'assistant',
content: d.conclusion,
timestamp: new Date().toISOString(),
status: 'completed',
message_type: 'debate_resolved',
debate_decision: d.decision,
debate_rationale: d.rationale,
})
appendStep({
type: 'team_event',
label: '辩论裁决',
detail: d.decision,
status: 'success',
}, sessionId)
// Clear debate state after 1 second (same pattern as board_concluded)
setTimeout(() => { debateState.value = null }, 1000)
break
}
case 'team_intervention_ack': {
// User intervention was accepted by the server — no message needed,
// just a step entry for visibility.
const d = data.data as { content: string }
const sessionId = resolveIncomingConvId()
if (!sessionId) break
appendStep({
type: 'team_event',
label: '用户干预',
detail: d.content.slice(0, 40),
status: 'success',
}, sessionId)
break
}
}
}
@ -1251,6 +1388,7 @@ export const useChatStore = defineStore('chat', () => {
pendingConversations,
streamingStepsByConv,
boardState,
debateState,
// Legacy aliases (derive from current conversation for backward compat).
// New code should use `isCurrentLoading` / `currentStreamingSteps` instead.
isLoading: isCurrentLoading,