feat(ui): scheme B neutral grayscale for board messages + assistant bubbles

expertIdentity.ts PALETTE -> neutral grayscale; useMessageRenderer.ts removes assistant fallback for board_* events; BoardRoundCard/MessageShell apply GitHub-style gray; chatStream.ts prefers event-provided moderator avatar/color; StickyModeHeader/Scene4/LoginView/types aligned.
This commit is contained in:
chiguyong 2026-07-02 21:26:22 +08:00
parent 32746652aa
commit 8188e8861d
9 changed files with 103 additions and 186 deletions

View File

@ -346,6 +346,8 @@ export interface IExpertSpeechData {
/** round_summary event payload */ /** round_summary event payload */
export interface IRoundSummaryData { export interface IRoundSummaryData {
moderator_name: string; moderator_name: string;
moderator_avatar?: string;
moderator_color?: string;
content: string; content: string;
round: number; round: number;
continue: boolean; continue: boolean;

View File

@ -37,7 +37,7 @@
class="sticky-mode-header__avatar" class="sticky-mode-header__avatar"
:class="{ 'sticky-mode-header__avatar--lead': expert.isLead }" :class="{ 'sticky-mode-header__avatar--lead': expert.isLead }"
type="button" type="button"
:style="{ borderColor: expert.color }" :style="{ background: expert.color, color: 'var(--text-inverse)', borderColor: expert.color }"
:aria-label="`查看 ${expert.name} 详情`" :aria-label="`查看 ${expert.name} 详情`"
:aria-expanded="openKey === expert.key" :aria-expanded="openKey === expert.key"
> >
@ -62,7 +62,7 @@
> >
<span <span
class="expert-list__avatar" class="expert-list__avatar"
:style="{ borderColor: expert.color }" :style="{ background: expert.color, color: 'var(--text-inverse)', borderColor: expert.color }"
>{{ expert.avatar }}</span> >{{ expert.avatar }}</span>
<span class="expert-list__name">{{ expert.name }}</span> <span class="expert-list__name">{{ expert.name }}</span>
<span v-if="expert.isLead" class="expert-list__tag">Lead</span> <span v-if="expert.isLead" class="expert-list__tag">Lead</span>
@ -153,11 +153,40 @@ const allExperts = computed<IExpertDisplay[]>(() => {
} }
if (mode.value === 'board') { if (mode.value === 'board') {
const list = chatStore.boardState?.experts ?? [] const list = chatStore.boardState?.experts ?? []
// 使 messages board_speech/
// round_summary expert_color MessageShell
// 退 boardState
// YAML color
const liveColorByName = new Map<string, string>()
const liveAvatarByName = new Map<string, string>()
const conv = chatStore.conversations.find(
(c: { id: string; messages?: unknown[] }) => c.id === chatStore.currentConversationId,
)
if (conv?.messages) {
// walk from latest to earliest to capture the most recent identity
for (let i = conv.messages.length - 1; i >= 0; i--) {
const m = conv.messages[i] as {
message_type?: string
expert_name?: string
expert_color?: string
expert_avatar?: string
}
if (
(m.message_type === 'board_speech' ||
m.message_type === 'board_summary') &&
m.expert_name &&
!liveColorByName.has(m.expert_name)
) {
if (m.expert_color) liveColorByName.set(m.expert_name, m.expert_color)
if (m.expert_avatar) liveAvatarByName.set(m.expert_name, m.expert_avatar)
}
}
}
return list.map((e: IBoardExpert, idx: number) => ({ return list.map((e: IBoardExpert, idx: number) => ({
key: `${e.name}-${idx}`, key: `${e.name}-${idx}`,
name: e.name, name: e.name,
avatar: e.avatar, avatar: liveAvatarByName.get(e.name) ?? e.avatar,
color: e.color, color: liveColorByName.get(e.name) ?? e.color,
persona: e.persona, persona: e.persona,
description: e.is_moderator ? '主持人' : undefined, description: e.is_moderator ? '主持人' : undefined,
isLead: false, isLead: false,

View File

@ -14,20 +14,20 @@ export interface ExpertIdentity {
color: string color: string
} }
/** Non-blue palette. Order is stable — do not reshuffle (would break identity). */ /** Neutral slate palette (GitHub/professional style). Order is stable — do not reshuffle. */
const PALETTE: ReadonlyArray<string> = [ const PALETTE: ReadonlyArray<string> = [
"#d97706", // amber 600 "#4b5563", // slate 600
"#059669", // emerald 600 "#6b7280", // gray 500
"#7c3aed", // violet 600 "#9ca3af", // gray 400
"#db2777", // pink 600 "#374151", // gray 800
"#0e7490", // cyan 700 "#1f2937", // gray 900
"#65a30d", // lime 600 "#1e40af", // blue 800 (deep, non-vivid)
"#c2410c", // orange 700 "#166534", // green 800
"#9333ea", // purple 600 "#7c2d12", // orange 900
"#0891b2", // sky 600 "#581c87", // purple 900
"#a16207", // yellow 700 "#155e75", // cyan 800
"#be185d", // rose 700 "#92400e", // amber 800
"#15803d", // green 700 "#78716c", // stone 500
] ]
/** djb2-style string hash — stable across JS engines and reloads. */ /** djb2-style string hash — stable across JS engines and reloads. */

View File

@ -55,19 +55,19 @@ export function resolveMessageType(message: IChatMessage): MessageViewType {
switch (message.message_type) { switch (message.message_type) {
case 'plan_update': case 'plan_update':
return 'team_plan' return 'team_plan'
// 2026-07-01: board_* events render as plain assistant bubbles (streaming). // 2026-07-02: 恢复 board_* 事件走专用卡片路径落地方案B
// The user's first @board/@team message already shows a structured card // (中性灰阶头像/名字/颜色徽章/对话气泡 + 左侧3px色条)。
// (UserBubble) with topic + expert count + expert list; rendering // UserBubble 仅渲染用户输入的 @board 指令文本卡片,
// dedicated cards (BoardBannerCard / BoardRoundCard / BoardConclusionCard) // BoardBannerCard 渲染后端回送的 board_started 事件 (含专家 chip + 进度条)
// for the subsequent board_started/board_speech/board_summary/ // 二者职责不同,不构成重复。
// board_conclusion events duplicates the banner and breaks the natural
// chat flow. Fall through to 'assistant' so the content streams
// inline.
case 'board_started': case 'board_started':
return 'board_banner'
case 'board_speech': case 'board_speech':
return 'board_speech'
case 'board_summary': case 'board_summary':
return 'board_summary'
case 'board_conclusion': case 'board_conclusion':
return 'assistant' return 'board_conclusion'
case 'debate_started': case 'debate_started':
return 'debate_started' return 'debate_started'
case 'debate_argument': case 'debate_argument':
@ -149,42 +149,34 @@ export function useMessageRenderer(message: IChatMessage) {
} }
} }
case 'board_speech': case 'board_speech': {
const roleTag = message.board_role === 'moderator' ? '主持' : '专家'
return { return {
type, type,
shell: { shell: {
name: message.expert_name || '专家', name: message.expert_name || '专家',
meta: message.board_round ? `${message.board_round}${message.board_role === 'moderator' ? ' · 主持' : ''}` : time, meta: message.board_round ? `${message.board_round} · ${roleTag}` : `${roleTag}`,
avatar: message.expert_avatar, avatar: message.expert_avatar,
color: message.expert_color, color: message.expert_color,
}, },
component: BoardRoundCard, component: BoardRoundCard,
props: { props: {
name: message.expert_name || '专家',
avatar: message.expert_avatar || '',
color: message.expert_color,
round: message.board_round,
role: message.board_role === 'moderator' ? 'moderator' : 'expert',
content: message.content || '', content: message.content || '',
}, },
} }
}
case 'board_summary': case 'board_summary':
return { return {
type, type,
shell: { shell: {
name: message.expert_name || '主持人', name: message.expert_name || '主持人',
meta: message.board_round ? `${message.board_round} 轮 · 小结` : time, meta: message.board_round ? `${message.board_round} 轮 · 小结` : '小结',
avatar: message.expert_avatar, avatar: message.expert_avatar,
color: message.expert_color, color: message.expert_color,
}, },
component: BoardRoundCard, component: BoardRoundCard,
props: { props: {
name: message.expert_name || '主持人',
avatar: message.expert_avatar || '',
color: message.expert_color,
round: message.board_round,
role: 'summary',
content: message.content || '', content: message.content || '',
}, },
} }

View File

@ -1,19 +1,6 @@
<template> <template>
<div class="board-round-card" :class="[`board-round-card--${role}`]"> <div class="board-round-card">
<div class="board-round-card__header"> <AssistantText :message="textMessage" />
<span
class="board-round-card__avatar"
:style="avatarStyle"
>
{{ avatar || name.charAt(0) }}
</span>
<span class="board-round-card__name">{{ name }}</span>
<span v-if="round" class="board-round-card__round"> {{ round }} </span>
<span v-if="roleTag" class="board-round-card__role">{{ roleTag }}</span>
</div>
<div class="board-round-card__content">
<AssistantText :message="textMessage" />
</div>
</div> </div>
</template> </template>
@ -23,37 +10,16 @@ import type { IChatMessage } from '@/api/types'
import AssistantText from './AssistantText.vue' import AssistantText from './AssistantText.vue'
interface Props { interface Props {
name: string
avatar?: string
color?: string
round?: number
role?: 'moderator' | 'expert' | 'summary'
content: string content: string
} }
const props = withDefaults(defineProps<Props>(), { const props = defineProps<Props>()
avatar: '',
role: 'expert',
})
const roleTag = computed(() => {
const tags: Record<string, string> = {
moderator: '主持',
expert: '专家',
summary: '小结',
}
return tags[props.role] || ''
})
const avatarStyle = computed(() => {
if (props.color) {
return { background: props.color }
}
return { background: 'var(--accent-board)' }
})
// B: BoardRoundCard Board
// AssistantText ///
// MessageShell header
const textMessage = computed<IChatMessage>(() => ({ const textMessage = computed<IChatMessage>(() => ({
id: `board-${props.name}-${props.round || 0}`, id: `board-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
role: 'assistant', role: 'assistant',
content: props.content, content: props.content,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@ -64,63 +30,5 @@ const textMessage = computed<IChatMessage>(() => ({
<style scoped> <style scoped>
.board-round-card { .board-round-card {
width: 100%; width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-left: 3px solid var(--accent-board);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.board-round-card--summary {
background: var(--accent-board-soft);
border-left-color: var(--accent-board);
}
.board-round-card__header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-color);
}
.board-round-card__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: var(--radius-full);
font-size: 12px;
color: var(--text-inverse);
}
.board-round-card__name {
font-size: var(--font-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.board-round-card__round {
font-size: var(--font-xs);
color: var(--text-tertiary);
}
.board-round-card__role {
margin-left: auto;
font-size: var(--font-xs);
padding: 1px 6px;
border-radius: var(--radius-sm);
background: var(--accent-board-soft);
color: var(--accent-board);
}
.board-round-card__content {
padding: var(--space-2) var(--space-3);
}
.board-round-card__content :deep(p) {
margin: 0;
} }
</style> </style>

View File

@ -20,13 +20,13 @@
</div> </div>
<div class="message-shell__body"> <div class="message-shell__body">
<div class="message-shell__header"> <div class="message-shell__header">
<!-- U4 R10: 专家身份 badge expert_color 高亮 expert_name流式期间及结束后均保留 --> <!-- 方案B: 专家名字始终是纯文本 (粗体)彩色身份由头像背景承担
不再渲染彩色 pill 名字徽章 -->
<span <span
v-if="expertName" v-if="expertName || name"
class="message-shell__expert-badge" class="message-shell__name"
:style="{ backgroundColor: expertColor || '#1890ff' }" :class="{ 'message-shell__name--expert': !!expertName }"
>{{ expertName }}</span> >{{ expertName || name }}</span>
<span v-else class="message-shell__name">{{ name }}</span>
<span v-if="meta" class="message-shell__meta">{{ meta }}</span> <span v-if="meta" class="message-shell__meta">{{ meta }}</span>
<span <span
v-if="streaming" v-if="streaming"
@ -152,20 +152,10 @@ withDefaults(defineProps<Props>(), {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
/* U4 R10: 专家身份 badge — 彩色 pill区别于普通 name 文本与 avatar */ /* 方案B: 专家名字 — 粗体文本 + 略深色,与普通 name 区分 (无 pill 背景) */
.message-shell__expert-badge { .message-shell__name--expert {
display: inline-flex; color: var(--text-primary);
align-items: center;
padding: 0 var(--space-2);
border-radius: var(--radius-full);
color: var(--text-inverse, #fff);
font-size: var(--font-xs);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
line-height: 1.6;
max-width: 12em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.message-shell__meta { .message-shell__meta {

View File

@ -14,37 +14,16 @@
/> />
</MessageShell> </MessageShell>
<MessageShell role="assistant" name="架构师老张" meta="第 1 轮" avatar="张" color="var(--accent-board)"> <MessageShell role="assistant" name="架构师老张" meta="第 1 轮 · 专家" avatar="张" color="var(--accent-board)">
<BoardRoundCard <BoardRoundCard :content="speech1.content" />
name="架构师老张"
avatar="张"
color="var(--accent-board)"
:round="1"
role="expert"
:content="speech1.content"
/>
</MessageShell> </MessageShell>
<MessageShell role="assistant" name="后端负责人小李" meta="第 1 轮" avatar="李" color="var(--accent-board)"> <MessageShell role="assistant" name="后端负责人小李" meta="第 1 轮 · 专家" avatar="李" color="var(--accent-board)">
<BoardRoundCard <BoardRoundCard :content="speech2.content" />
name="后端负责人小李"
avatar="李"
color="var(--accent-board)"
:round="1"
role="expert"
:content="speech2.content"
/>
</MessageShell> </MessageShell>
<MessageShell role="assistant" name="主持人" meta="第 1 轮 · 小结" avatar="主" color="var(--accent-board)"> <MessageShell role="assistant" name="主持人" meta="第 1 轮 · 小结" avatar="主" color="var(--accent-board)">
<BoardRoundCard <BoardRoundCard :content="summaryMessage.content" />
name="主持人"
avatar="主"
color="var(--accent-board)"
:round="1"
role="summary"
:content="summaryMessage.content"
/>
</MessageShell> </MessageShell>
<MessageShell role="assistant" name="主持人" meta="11:05"> <MessageShell role="assistant" name="主持人" meta="11:05">

View File

@ -1296,14 +1296,17 @@ export function dispatchWsEvent(
(c) => c.id === conversationId, (c) => c.id === conversationId,
); );
if (!conv) break; if (!conv) break;
// Stable identity for the moderator, just like expert_speech. // Stable identity for the moderator. Prefer the event's
// moderator_avatar/moderator_color (added in 2026-07-02 so persistence
// has the same identity) and fall back to the boardState snapshot
// captured at board_started.
const moderator = state.boardState.value?.experts.find( const moderator = state.boardState.value?.experts.find(
(e) => e.name === summaryData.moderator_name, (e) => e.name === summaryData.moderator_name,
); );
const identity = resolveExpertIdentity( const identity = resolveExpertIdentity(
summaryData.moderator_name, summaryData.moderator_name,
moderator?.avatar, summaryData.moderator_avatar || moderator?.avatar,
moderator?.color, summaryData.moderator_color || moderator?.color,
); );
const summaryMsg: IChatMessage = { const summaryMsg: IChatMessage = {
id: generateId(), id: generateId(),

View File

@ -181,6 +181,20 @@ onMounted(async () => {
font-weight: 600; font-weight: 600;
} }
/* token AntD ConfigProvider token tauri
不稳定这里兜底强制使用项目主色#1a1a1a 近黑避免蓝色兜底 */
.login-submit.ant-btn-primary,
.login-submit.ant-btn-primary:hover,
.login-submit.ant-btn-primary:focus {
background-color: var(--color-primary, #1a1a1a);
border-color: var(--color-primary, #1a1a1a);
color: var(--text-inverse, #ffffff);
}
.login-submit.ant-btn-primary:hover {
background-color: var(--color-primary-hover, #2f2f2f);
border-color: var(--color-primary-hover, #2f2f2f);
}
.login-remember { .login-remember {
margin-bottom: 16px; margin-bottom: 16px;
} }