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:
parent
a2c6af54b8
commit
ff22946655
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue