fix(chat): U2 消息模型与分发器对齐后端事件

- board_started 现在保存为结构化消息并渲染 BoardBannerCard
- board_concluded 现在追加 board_conclusion 结构化消息
- 扩展 IChatMessage.status 包含 error
- 移除 chat.ts 中的 any 类型(保留 handleWsMessage 遗留 TODO)
- BoardBannerCard v-for key 使用 name-index 组合避免重复
This commit is contained in:
chiguyong 2026-06-19 01:29:25 +08:00
parent a2c6af54b8
commit ff22946655
3 changed files with 207 additions and 13 deletions

View File

@ -38,16 +38,30 @@ export interface IChatMessage {
routing_method?: string
confidence?: number
task_id?: string
status?: 'completed' | 'pending'
status?: 'completed' | 'pending' | 'error'
tool_calls?: IToolCallData[]
thinking?: string
expert_id?: string
expert_name?: string
expert_color?: string
expert_avatar?: string
message_type?: 'chat' | 'handoff' | 'assist_request' | 'plan_update' | 'milestone' | 'board_speech' | 'board_summary' | 'board_conclusion'
message_type?:
| 'chat'
| 'handoff'
| 'assist_request'
| 'plan_update'
| 'milestone'
| 'board_started'
| 'board_speech'
| 'board_summary'
| 'board_conclusion'
| 'error'
board_round?: number
board_role?: 'moderator' | 'expert' | 'user' | 'summary'
plan_phases?: ITeamPlanPhase[]
error_detail?: string
board_started?: IBoardStartedData
board_conclusion?: IBoardConcludedData
}
/** Conversation with messages */
@ -218,6 +232,34 @@ export interface IBoardMessage {
timestamp: number
}
/** Expert template (matches backend GET /api/v1/experts response item) */
export interface IExpertTemplate {
name: string
description: string
is_builtin: boolean
avatar: string
color: string
persona: string
thinking_style: string
speaking_style: string
decision_framework: string
}
/** Experts list response (matches backend GET /api/v1/experts) */
export interface IExpertsResponse {
experts: IExpertTemplate[]
total: number
}
/** File upload response (matches backend POST /api/v1/chat/upload) */
export interface IUploadResponse {
filename: string
stored_name: string
content_type: string
size: number
download_url: string
}
/** API error */
export interface IApiError {
status: number

View File

@ -0,0 +1,137 @@
<template>
<div class="board-banner-card">
<div class="board-banner-card__bar" />
<div class="board-banner-card__body">
<div class="board-banner-card__title">
<span class="board-banner-card__icon">🏛</span>
<span>私董会 {{ topic }}</span>
</div>
<div class="board-banner-card__experts">
<span
v-for="(expert, idx) in experts"
:key="`${expert.name}-${idx}`"
class="board-banner-card__chip"
:class="{ 'board-banner-card__chip--moderator': expert.is_moderator }"
>
<span class="board-banner-card__chip-avatar">{{ expert.avatar }}</span>
<span>{{ expert.name }}</span>
</span>
</div>
<div class="board-banner-card__meta">
<span>轮次 {{ currentRound }} / {{ maxRounds }} </span>
<div class="board-banner-card__progress">
<div
class="board-banner-card__progress-fill"
:style="{ width: progressPercent + '%' }"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { IBoardExpert } from '@/api/types'
interface Props {
topic: string
experts: IBoardExpert[]
maxRounds: number
currentRound?: number
}
const props = withDefaults(defineProps<Props>(), {
currentRound: 1,
})
const progressPercent = computed(() => {
if (props.maxRounds <= 0) return 0
return Math.min((props.currentRound / props.maxRounds) * 100, 100)
})
</script>
<style scoped>
.board-banner-card {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.board-banner-card__bar {
height: 4px;
background: var(--accent-board);
}
.board-banner-card__body {
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.board-banner-card__title {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.board-banner-card__icon {
font-size: var(--font-lg);
}
.board-banner-card__experts {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.board-banner-card__chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: var(--accent-board-soft);
color: var(--accent-board);
border-radius: var(--radius-full);
font-size: var(--font-xs);
}
.board-banner-card__chip--moderator {
background: var(--accent-board);
color: var(--text-inverse);
}
.board-banner-card__chip-avatar {
font-size: 12px;
}
.board-banner-card__meta {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--font-xs);
color: var(--text-tertiary);
}
.board-banner-card__progress {
flex: 1;
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
}
.board-banner-card__progress-fill {
height: 100%;
background: var(--accent-board);
border-radius: 2px;
transition: width 0.3s ease;
}
</style>

View File

@ -31,7 +31,7 @@ export const useChatStore = defineStore('chat', () => {
// Board Meeting state (transient, only active during a board discussion)
const boardState = ref<{
topic: string
experts: Array<{ name: string; avatar: string; color: string; is_moderator: boolean }>
experts: Array<{ name: string; avatar: string; color: string; is_moderator: boolean; persona: string }>
max_rounds: number
current_round: number
status: 'discussing' | 'concluding' | 'completed' | 'dissolved'
@ -56,7 +56,7 @@ export const useChatStore = defineStore('chat', () => {
const data = await apiClient.getConversations()
// Normalize server response: backend returns {id, created_at, updated_at, message_count}
// but frontend IConversation expects {id, title, messages, created_at, updated_at}
conversations.value = data.map((conv: any) => ({
conversations.value = data.map((conv: IConversation) => ({
id: conv.id,
title: conv.title || '对话',
messages: Array.isArray(conv.messages) ? conv.messages : [],
@ -276,7 +276,7 @@ export const useChatStore = defineStore('chat', () => {
socket.onmessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data as string) as Record<string, any>
const data = JSON.parse(event.data as string) as Record<string, unknown>
console.log('[Chat WS] Received:', data.type, data)
handleWsMessage(data)
} catch (error) {
@ -403,6 +403,8 @@ export const useChatStore = defineStore('chat', () => {
return _teamStore
}
// TODO: refactor to WsServerMessage union to eliminate `any`.
// This function predates the current VI redesign and touches many legacy branches.
function handleWsMessage(data: Record<string, any>): void {
// Backend sends nested data: {type, data: {...}}
// Flatten for easier access
@ -700,6 +702,7 @@ export const useChatStore = defineStore('chat', () => {
avatar: e.avatar,
color: e.color,
is_moderator: e.is_moderator,
persona: e.persona,
})),
max_rounds: data.max_rounds,
current_round: 0,
@ -708,18 +711,18 @@ export const useChatStore = defineStore('chat', () => {
streamingSteps.value.push(
`私董会已开启: 主题「${data.topic}」, ${data.experts.length} 位专家, 最多 ${data.max_rounds}`
)
// Push a system-style message to indicate board start
// Push a structured banner message so the renderer can show BoardBannerCard
const conversationId = currentConversationId.value
if (conversationId) {
const startMsg: IChatMessage = {
id: generateId(),
role: 'assistant',
content: `🏛️ **私董会开始**\n\n**主题**: ${data.topic}\n**专家**: ${data.experts
.map((e) => `${e.avatar} ${e.name}${e.is_moderator ? ' (主持人)' : ''}`)
.join(', ')}\n****: ${data.max_rounds}`,
content: `🏛️ 私董会开始:${data.topic}`,
timestamp: new Date().toISOString(),
status: 'completed',
message_type: 'milestone',
message_type: 'board_started',
board_started: data,
board_round: 0,
}
appendMessage(conversationId, startMsg)
}
@ -789,9 +792,21 @@ export const useChatStore = defineStore('chat', () => {
streamingSteps.value.push(
`私董会结束: ${data.total_rounds} 轮讨论${data.error ? ' (异常)' : ''}`
)
// The final_answer event will carry the formatted conclusion,
// so we don't need to add a separate message here.
// The conclusion is already persisted by the backend.
// Push a structured conclusion message so the renderer can show BoardConclusionCard
const conversationId = currentConversationId.value
if (conversationId) {
const conclusionMsg: IChatMessage = {
id: generateId(),
role: 'assistant',
content: data.summary || '私董会已结束',
timestamp: new Date().toISOString(),
status: 'completed',
message_type: 'board_conclusion',
board_conclusion: data,
board_round: data.total_rounds,
}
appendMessage(conversationId, conclusionMsg)
}
// Clear board state after a short delay to allow UI to update
setTimeout(() => {
boardState.value = null