Procházet zdrojové kódy

桌面端新增聊天气泡预设与自定义外观

Samien před 1 měsícem
rodič
revize
2a0309d

+ 60 - 0
design-qa.md

@@ -1,3 +1,63 @@
+# 聊天气泡外观 · 三端设计验收(2026-09-21)
+
+## Findings
+
+- 没有未解决的 P0/P1/P2 问题。
+- 三套预设为「当前样式」「Grok Bot」「Codex」。Codex 的双方消息都保留填充气泡;Grok Bot 使用黑白灰。
+- 适用范围明确为当前设备上的所有会话。设计中的范围下拉改为静态说明;本次不提供账号同步或单会话覆盖。
+- 文字颜色自动选择可读前景色;不开放可能导致不可读组合的任意文字色。极端渐变增加遮罩以保持正文对比度。
+
+## Source visual truth / normalization
+
+- Source: `/Users/samien/git/yaoyao/docs/chat-appearance/approved-design.png`,1619 × 972 px,用户确认的桌面与移动端并排设计画板。
+- 按画板内桌面/手机内容区域比较,不拉伸整张画板;忽略画板标题、留白和设备系统栏。设计不是等比例设备截图,因此只比较对应布局与控件,不宣称逐像素一致。
+- 对比时将原图与实际截图放在同一工具输入中;深色和实际聊天属于补充状态,设计原图未提供其逐像素目标。
+
+## Required fidelity surfaces
+
+- 字体与排版:沿用产品/平台字体和原有正文层级;中文自然换行,长回复保留 Markdown 标题、列表和行内代码。
+- 间距与布局:桌面左侧设置、右侧预览;移动端预览在上、控制项在下,较长内容可以滚动。颜色选择与预设均有明显选中状态。
+- 颜色:Codex 浅色用户背景 #E4F3FC、蓝色正文,助手 #F1F1F1;Grok Bot 浅色用户 #090909、白字,助手浅灰。深色有独立配色;边框与尾巴、圆角可分别调节。
+- 图像:未新增装饰性位图;聊天头像和图标复用项目原组件,原生端使用系统控件。截图为运行结果,未重绘。
+- 文案:使用「聊天气泡」「预设样式」「自定义微调」「我的气泡」「助手气泡」;三套预设名称与用户确认一致。
+
+## Comparison history
+
+1. 桌面首轮发现形状项挤压底部操作区:压缩分组间距、调整操作栏,最终桌面截图可见圆角、边框、尾巴及恢复入口。
+2. iOS 自动同步十六进制字段时将预设变成自定义:增加相同值短路判断,UI 测试验证 Codex 选中、切换 Grok Bot 后撤销、重启保持。
+3. 检查发现 Bot 聊天使用独立渲染组件:三端同时接入普通聊天与 Bot 聊天,iOS 额外捕获实际 Bot 长回复的浅/深色截图。
+4. 自定义深色气泡可能影响嵌套代码卡片:保留技术卡片自身底色/文字语义,Markdown 正文随气泡配色。浏览器代码块与 iOS 长列表复核通过。
+5. 最终整理发现早期 Web 手机截图停在设置分类:重新通过「外观 → 聊天气泡」捕获实际 Grok Bot 页面,纠正验收附件。
+
+## Remaining scope / checklist
+
+- [x] 预设即时应用、自定义配色、圆角、边框、尾巴、撤销与恢复。
+- [x] 本地持久化、损坏数据回退、深浅色适配。
+- [x] 正式消息组件接入,相关自动化测试与运行截图检查。
+- [ ] 真机和发布包验收未执行;本轮使用浏览器及模拟器。
+
+## Implementation evidence / state
+
+- `docs/chat-appearance/web-desktop-codex.jpg`:1440 × 900 px,CSS viewport 1440 × 900,1×;浅色、Codex 已选、形状项展开。
+- `docs/chat-appearance/web-mobile-grok.jpg`:390 × 844 px,CSS viewport 390 × 844,1×;浅色、Grok Bot 已选、上方预览、下方控件。其余控件可滚动访问。
+- `docs/chat-appearance/web-chat-dark.jpg`:794 × 866 px 的实际 Bot 消息夹具,深色 Grok Bot;标题、列表、链接及代码块可读。
+- 入口夹具:`tests/fixtures/settings-design/index.html?view=bubbles`。消息夹具:`tests/fixtures/chat-appearance/index.html`,均使用正式组件和本地示例数据。
+- Full-view:桌面保持左右分栏并完整展示操作项;手机保持从预览到预设到自定义的顺序,无横向溢出。
+- Focused:预设卡、十六进制字段、色块、用户/助手气泡在原尺寸截图中可直接判读,无需额外裁切;实际消息截图单独验证长回复和代码块。
+
+## Verification
+
+- `npm run typecheck` 通过。
+- `npx vitest run tests/client/chatAppearance.test.ts tests/client/settingsCenterDialog.test.ts`:23 项通过。
+- 浏览器验证预设、移动端导航、深色、刷新持久化;控制台 error 日志为空。
+- 测试包含存储失败、坏数据、极端渐变、用户/助手独立编辑以及普通/Bot 消息在没有新消息时即时重新渲染。
+
+final result: passed
+
+---
+
+以下保留之前功能的验收记录。
+
 # Bot 设置与我的设置 · 第 3 组设计对照
 
 ## Findings

+ 9 - 0
docs/chat-appearance/README.md

@@ -0,0 +1,9 @@
+# 聊天气泡外观
+
+入口:设置 → 外观 → 聊天气泡。
+
+三套预设:当前样式(经典蓝灰)、Grok Bot(黑白灰)、Codex(浅蓝双气泡)。点击立即应用,也可分别调整用户/助手气泡的纯色或渐变、浅/深色配色、圆角、边框与尾巴。文字自动适配对比度;支持撤销上一步和恢复默认。
+
+设置只保存在当前设备,并应用到该设备的所有会话。普通聊天和 Bot 聊天使用同一设置;媒体、工具、错误与技术卡片继续使用自身样式。
+
+本目录包含实际运行截图;完整视觉和交互验收记录见仓库根目录 `design-qa.md`。

binární
docs/chat-appearance/approved-design.png


binární
docs/chat-appearance/web-chat-dark.jpg


binární
docs/chat-appearance/web-desktop-codex.jpg


binární
docs/chat-appearance/web-mobile-grok.jpg


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 81 - 0
src/client/components/app/ChatAppearancePanel.vue


+ 17 - 6
src/client/components/app/SettingsCenterDialog.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { computed, nextTick, reactive, ref, watch } from 'vue'
 import type { Profile } from '@shared/types'
+import ChatAppearancePanel from '@/components/app/ChatAppearancePanel.vue'
 import AccountSecurityPanel from '@/components/app/AccountSecurityPanel.vue'
 import DesktopModePanel from '@/components/app/DesktopModePanel.vue'
 import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
@@ -23,6 +24,7 @@ type SettingsPage =
   | 'account-security'
   | 'account-mobile'
   | 'appearance'
+  | 'chat-appearance'
   | 'desktop-mode'
   | 'system-overview'
   | 'system-users'
@@ -116,12 +118,13 @@ const systemItems: NavigationItem[] = [
   { key: 'system-voice', label: '双流语音', icon: 'audio' },
   { key: 'system-update', label: '更新与回滚', icon: 'refresh' },
 ]
-const matchesSearch = (item: NavigationItem) => item.label.toLocaleLowerCase().includes(settingsQuery.value.trim().toLocaleLowerCase())
+const matchesSearch = (item: NavigationItem) => (item.key === 'appearance' ? `${item.label} 聊天气泡 Grok Bot Codex 颜色` : item.label).toLocaleLowerCase().includes(settingsQuery.value.trim().toLocaleLowerCase())
 const visibleAccounts = computed(() => accountItems.value.filter(matchesSearch))
 const visibleSystems = computed(() => systemItems.filter(matchesSearch))
 const visibleAgents = computed(() => agentItems.value.filter(matchesSearch))
 
 const allAllowedPages = computed(() => new Set<SettingsPage>([
+  'chat-appearance',
   ...agentItems.value.map(item => item.key),
   ...accountItems.value.map(item => item.key),
   ...(props.isAdmin ? systemItems.map(item => item.key) : []),
@@ -135,6 +138,7 @@ const activeTitle = computed(() => ({
   'account-security': '登录与安全',
   'account-mobile': '手机登录',
   appearance: '外观',
+  'chat-appearance': '聊天气泡',
   'desktop-mode': '运行模式',
   'system-overview': '系统概览',
   'system-users': '用户与权限',
@@ -149,6 +153,7 @@ const activeScope = computed(() => {
   if (activePage.value.startsWith('agent-')) return `正在设置:${profileTitle(props.activeProfile)} / ${props.activeProfile?.name || '未选择'}`
   if (activePage.value === 'account-profile') return '管理你的账号头像与服务器名称。'
   if (activePage.value.startsWith('account-')) return `当前账号:${accountName.value}${props.isAdmin ? ' · 管理员' : ''}`
+  if (activePage.value === 'chat-appearance') return '预设与颜色 · 自动保存到当前设备'
   if (activePage.value === 'appearance') return '选择当前浏览器的显示方式。'
   if (activePage.value === 'desktop-mode') return '选择这台电脑作为服务器或客户端。'
   return '全局设置 · 仅管理员'
@@ -192,6 +197,7 @@ function requestClose() {
 }
 
 function backToMenu() {
+  if (activePage.value === 'chat-appearance') { selectPage('appearance'); return }
   if (updateLocked.value || !confirmDiscard()) return
   dirtyPages[activePage.value] = false
   mobileDetailOpen.value = false
@@ -281,7 +287,7 @@ function requestModeSwitch() {
         <section
           ref="dialog"
           class="settings-center"
-          :class="{ 'settings-center--mobile-detail': mobileDetailOpen }"
+          :class="{ 'settings-center--mobile-detail': mobileDetailOpen, 'settings-center--bubbles': activePage === 'chat-appearance' }"
           role="dialog"
           aria-modal="true"
           aria-label="我的设置"
@@ -315,15 +321,15 @@ function requestModeSwitch() {
                 <nav>
                   <section v-if="visibleAccounts.length">
                     <h3>个人</h3>
-                    <button v-for="item in visibleAccounts" :key="item.key" type="button" :class="{ active: activePage === item.key }" :aria-current="activePage === item.key ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="18" /><span>{{ item.label }}</span></button>
+                    <button v-for="item in visibleAccounts" :key="item.key" type="button" :class="{ active: (activePage === item.key || (item.key === 'appearance' && activePage === 'chat-appearance')) }" :aria-current="(activePage === item.key || (item.key === 'appearance' && activePage === 'chat-appearance')) ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="18" /><span>{{ item.label }}</span></button>
                   </section>
                   <section v-if="isAdmin && visibleSystems.length">
                     <h3>管理</h3>
-                    <button v-for="item in visibleSystems" :key="item.key" type="button" :class="{ active: activePage === item.key }" :aria-current="activePage === item.key ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="18" /><span>{{ item.label }}</span><em v-if="item.key === 'system-voice'">全局</em></button>
+                    <button v-for="item in visibleSystems" :key="item.key" type="button" :class="{ active: (activePage === item.key || (item.key === 'appearance' && activePage === 'chat-appearance')) }" :aria-current="(activePage === item.key || (item.key === 'appearance' && activePage === 'chat-appearance')) ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="18" /><span>{{ item.label }}</span><em v-if="item.key === 'system-voice'">全局</em></button>
                   </section>
                   <section v-if="visibleAgents.length">
                     <h3>基础机器人</h3>
-                    <button v-for="item in visibleAgents" :key="item.key" type="button" :class="{ active: activePage === item.key }" :aria-current="activePage === item.key ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="18" /><span>{{ item.label }}</span></button>
+                    <button v-for="item in visibleAgents" :key="item.key" type="button" :class="{ active: (activePage === item.key || (item.key === 'appearance' && activePage === 'chat-appearance')) }" :aria-current="(activePage === item.key || (item.key === 'appearance' && activePage === 'chat-appearance')) ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="18" /><span>{{ item.label }}</span></button>
                   </section>
                   <section v-if="isAdmin && !settingsQuery"><button type="button" :disabled="updateLocked" @click="requestModeSwitch"><AppIcon :name="botMode ? 'chat' : 'users'" :size="18" /><span>{{ botMode ? '进入聊天模式' : '进入 Bot 模式' }}</span></button></section>
                   <p v-if="!visibleAccounts.length && (!isAdmin || !visibleSystems.length) && !visibleAgents.length" class="settings-search-empty">没有匹配的设置</p>
@@ -375,8 +381,10 @@ function requestModeSwitch() {
                       <component :is="themePreference === option[0] ? CheckmarkCircle : EllipseOutline" class="theme-choice-mark" aria-hidden="true"/>
                     </button>
                   </div>
+                  <button type="button" class="bubble-settings-entry" @click="selectPage('chat-appearance')"><AppIcon name="chat" :size="20"/><span><strong>聊天气泡</strong><small>预设、背景颜色与样式</small></span><span aria-hidden="true">›</span></button>
                   <p class="appearance-note"><AppIcon name="info" :size="16"/>更改仅影响当前浏览器。</p>
                 </section>
+                <ChatAppearancePanel v-else-if="activePage === 'chat-appearance'" :theme="theme" />
                 <SystemOverviewPanel v-else-if="activePage === 'system-overview' && isAdmin" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @navigate="selectPage" />
                 <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :profiles="profiles" :active="true" @dirty-change="setDirty('system-users', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :profiles="profiles" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />
@@ -412,6 +420,8 @@ function requestModeSwitch() {
 <style scoped>
 .settings-center-layer{position:fixed;z-index:300;inset:0;display:grid;place-items:center;padding:24px;background:var(--scrim);backdrop-filter:blur(3px)}
 .settings-center{display:flex;flex-direction:column;width:min(900px,calc(100vw - 48px));height:min(650px,calc(100dvh - 64px));overflow:hidden;border:1px solid var(--line);border-radius:14px;outline:0;background:var(--surface);box-shadow:0 24px 72px #0003;color:var(--text-primary)}
+.settings-center--bubbles{width:min(1180px,calc(100vw - 48px));height:min(900px,calc(100dvh - 64px))}
+.bubble-settings-entry{display:flex;align-items:center;gap:12px;text-align:left;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--text-primary);cursor:pointer}.bubble-settings-entry span:first-of-type{display:grid;gap:5px;flex:1}.bubble-settings-entry small{color:var(--text-secondary);font-size:12px}.bubble-settings-entry>span:last-child{font-size:24px}
 .settings-center__topbar{height:60px;box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;padding:8px 16px 8px 20px;border-bottom:1px solid var(--line)}
 .settings-center__topbar h2{margin:0;font-size:18px;font-weight:650;letter-spacing:-.02em}
 .settings-center__close,.mobile-back{display:grid;width:44px;height:44px;place-items:center;flex-shrink:0;padding:0;border:0;border-radius:9px;background:transparent;color:var(--text-secondary);cursor:pointer}
@@ -457,6 +467,7 @@ function requestModeSwitch() {
 .appearance-note{display:flex;align-items:flex-start;gap:8px;margin:2px 0 0;padding-top:16px;border-top:1px solid var(--line);color:var(--text-secondary);font-size:13px;line-height:1.6}
 .settings-content__scroll :deep(.account-security-panel>.panel-heading){display:none}.settings-content__scroll :deep(.account-security-panel){gap:20px}.settings-content__scroll :deep(.account-avatar-card),.settings-content__scroll :deep(.security-form),.settings-content__scroll :deep(.server-identity){padding:20px;border:0;border-radius:12px;background:var(--settings-panel)}.settings-content__scroll :deep(.security-form){gap:16px}.settings-content__scroll :deep(.field input){background:var(--surface)}
 .settings-center-fade-enter-active,.settings-center-fade-leave-active{transition:opacity 150ms ease}.settings-center-fade-enter-active .settings-center,.settings-center-fade-leave-active .settings-center{transition:transform 180ms var(--ease-out)}.settings-center-fade-enter-from,.settings-center-fade-leave-to{opacity:0}.settings-center-fade-enter-from .settings-center,.settings-center-fade-leave-to .settings-center{transform:translateY(6px) scale(.99)}
-@media(max-width:767px){.settings-center-layer{padding:0}.settings-center{width:100vw;height:100dvh;border:0;border-radius:0}.settings-center__topbar{display:none}.settings-center__body{display:block;height:100%}.settings-sidebar{display:grid;width:100%;height:100%;box-sizing:border-box;grid-template-rows:60px minmax(0,1fr);overflow:hidden;padding:0;border-right:0}.settings-sidebar__header{display:flex;align-items:center;justify-content:space-between;padding:0 12px 0 20px;border-bottom:1px solid var(--line)}.settings-sidebar__scroll{min-height:0;overflow-y:auto;padding:20px 16px max(24px,env(safe-area-inset-bottom))}.settings-center--mobile-detail .settings-sidebar{display:none}.settings-content{display:none;width:100%;height:100%}.settings-center--mobile-detail .settings-content{display:grid}.settings-content__header{min-height:60px;grid-template-columns:44px minmax(0,1fr) 44px;gap:4px;padding:8px 8px 8px 6px;border-bottom:1px solid var(--line)}.settings-content__header h3{font-size:17px}.settings-content__header p{display:none}.settings-center--mobile-detail .mobile-back,.settings-detail-close{display:grid}.settings-content__scroll{padding:20px 16px max(24px,env(safe-area-inset-bottom))}.settings-content--with-footer{grid-template-rows:auto minmax(0,1fr) calc(64px + env(safe-area-inset-bottom))}.settings-content__footer{padding:0 16px env(safe-area-inset-bottom)}.settings-content__footer button{flex:1}.appearance-panel{padding:14px;gap:14px}.theme-options{gap:8px}.theme-options button{padding:6px 6px 10px;gap:12px}.theme-options strong{font-size:12px}}
+@media(min-width:1000px){.settings-center--bubbles .settings-content__header{padding:14px 24px 10px}}
+@media(max-width:767px){.settings-center-layer{padding:0}.settings-center,.settings-center--bubbles{width:100vw;height:100dvh;border:0;border-radius:0}.settings-center__topbar{display:none}.settings-center__body{display:block;height:100%}.settings-sidebar{display:grid;width:100%;height:100%;box-sizing:border-box;grid-template-rows:60px minmax(0,1fr);overflow:hidden;padding:0;border-right:0}.settings-sidebar__header{display:flex;align-items:center;justify-content:space-between;padding:0 12px 0 20px;border-bottom:1px solid var(--line)}.settings-sidebar__scroll{min-height:0;overflow-y:auto;padding:20px 16px max(24px,env(safe-area-inset-bottom))}.settings-center--mobile-detail .settings-sidebar{display:none}.settings-content{display:none;width:100%;height:100%}.settings-center--mobile-detail .settings-content{display:grid}.settings-content__header{min-height:60px;grid-template-columns:44px minmax(0,1fr) 44px;gap:4px;padding:8px 8px 8px 6px;border-bottom:1px solid var(--line)}.settings-content__header h3{font-size:17px}.settings-content__header p{display:none}.settings-center--mobile-detail .mobile-back,.settings-detail-close{display:grid}.settings-content__scroll{padding:20px 16px max(24px,env(safe-area-inset-bottom))}.settings-content--with-footer{grid-template-rows:auto minmax(0,1fr) calc(64px + env(safe-area-inset-bottom))}.settings-content__footer{padding:0 16px env(safe-area-inset-bottom)}.settings-content__footer button{flex:1}.appearance-panel{padding:14px;gap:14px}.theme-options{gap:8px}.theme-options button{padding:6px 6px 10px;gap:12px}.theme-options strong{font-size:12px}}
 @media(prefers-reduced-motion:reduce){.settings-center-fade-enter-active,.settings-center-fade-leave-active,.settings-center-fade-enter-active .settings-center,.settings-center-fade-leave-active .settings-center{transition:none}}
 </style>

+ 8 - 3
src/client/components/messages/MessageTimeline.vue

@@ -1,4 +1,7 @@
 <script setup lang="ts">
+import { useChatAppearance } from '@/stores/chatAppearance'
+import { bubbleVariables } from '@/utils/chatAppearance'
+import '@/styles/chat-bubbles.css'
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import MessageFailureNotice from '@/components/messages/MessageFailureNotice.vue'
@@ -16,6 +19,8 @@ import { copyTextToClipboard } from '@/utils/clipboard'
 import { displayContentForMessage } from '@/utils/messageDisplay'
 import { formatMessageTime } from '@/utils/messageTime'
 
+const { appearance } = useChatAppearance()
+
 const props = withDefaults(defineProps<{
   messages: UiMessage[]
   title?: string
@@ -314,7 +319,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               </button>
             </div>
 
-            <div v-if="!messageFailure(message)?.replacesContent" class="message__content">
+            <div v-if="!messageFailure(message)?.replacesContent" class="message__content" :class="{ 'chat-bubble': (message.role === 'user' || message.role === 'assistant') && !!message.content.trim() }" :style="(message.role === 'user' || message.role === 'assistant') ? bubbleVariables(appearance[message.role], message.role) : undefined">
               <MarkdownContent
                 :file-profile="message.profile"
                 :content="displayContentForMessage(message.role, message.content)"
@@ -421,8 +426,8 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .message__content :deep(.code-block code) { font-size: 12px; }
 .message__content :deep(.file-link-card)::after { font-size: 10px; }
 .message--user { justify-content: flex-end; margin-top: 27px; }
-.message--user .message__body { max-width: min(610px, 76%); padding: 11px 15px; border-radius: 20px 20px 6px 20px; background: #eceff3; }
-.dark .message--user .message__body { background: #2d323a; }
+.message--user .message__body { max-width: min(610px, 76%); padding: 0; background: transparent; }
+
 .message--user .message__meta { display: none; }
 .message__delivery { display: flex; min-height: 18px; align-items: center; justify-content: flex-end; gap: 4px; margin: 5px 1px -4px; color: var(--text-muted); font-size: 10px; }.message__delivery--accepted { color: var(--success); }.message__delivery--failed, .message__delivery--unknown-receipt { color: var(--danger); }
 .message--system { justify-content: center; }.message--system .message__avatar, .message--system .message__meta { display: none; }.message--system .message__body { max-width: 82%; padding: 6px 10px; border: 1px solid var(--line); border-radius: 9px; color: var(--text-muted); text-align: center; }

+ 9 - 4
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -1,4 +1,7 @@
 <script setup lang="ts">
+import { useChatAppearance } from '@/stores/chatAppearance'
+import { bubbleVariables } from '@/utils/chatAppearance'
+import '@/styles/chat-bubbles.css'
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import MessageFailureNotice from '@/components/messages/MessageFailureNotice.vue'
@@ -19,6 +22,8 @@ import { copyTextToClipboard } from '@/utils/clipboard'
 import { displayContentForMessage } from '@/utils/messageDisplay'
 import { formatMessageTime } from '@/utils/messageTime'
 
+const { appearance } = useChatAppearance()
+
 type AvatarState = 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying'
 type HeaderAvatarMember = { name: string; avatar?: string; state?: AvatarState; activityKey?: number }
 
@@ -349,7 +354,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
         <article
           v-for="message in [row.message]"
           :key="message.id"
-          v-memo="[message, showTools, showAssistantIdentity, mentionSignature, agentAvatars[message.profile || ''], avatarState(message), copiedMessageId === message.id, copyFailedMessageId === message.id, readOnly, allowBranch]"
+          v-memo="[appearance, message, showTools, showAssistantIdentity, mentionSignature, agentAvatars[message.profile || ''], avatarState(message), copiedMessageId === message.id, copyFailedMessageId === message.id, readOnly, allowBranch]"
           :data-message-id="message.id"
           class="message"
           :class="[`message--${message.role}`, {
@@ -415,7 +420,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               </button>
             </div>
 
-            <div v-if="!messageFailure(message)?.replacesContent" class="message__content">
+            <div v-if="!messageFailure(message)?.replacesContent" class="message__content" :class="{ 'chat-bubble': !message.communication && (message.role === 'user' || message.role === 'assistant') && !!message.content.trim() }" :style="!message.communication && (message.role === 'user' || message.role === 'assistant') ? bubbleVariables(appearance[message.role], message.role) : undefined">
               <MarkdownContent
                 :stream-interval-ms="0"
                 :file-profile="message.profile"
@@ -530,8 +535,8 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .message__content :deep(.code-block code) { font-size: 12px; }
 .message__content :deep(.file-link-card)::after { font-size: 10px; }
 .message--user { justify-content: flex-end; margin-top: 27px; }
-.message--user .message__body { max-width: min(610px, 76%); padding: 11px 15px; border-radius: 20px 20px 6px 20px; background: #eceff3; }
-.dark .message--user .message__body { background: #2d323a; }
+.message--user .message__body { max-width: min(610px, 76%); padding: 0; background: transparent; }
+
 .message--user .message__meta { display: none; }
 .message__delivery { display: flex; min-height: 18px; align-items: center; justify-content: flex-end; gap: 4px; margin: 5px 1px -4px; color: var(--text-muted); font-size: 10px; }.message__delivery--accepted { color: var(--success); }.message__delivery--failed, .message__delivery--unknown-receipt { color: var(--danger); }
 .message--system { justify-content: center; }.message--system .message__avatar, .message--system .message__meta { display: none; }.message--system .message__body { width: min(640px, 100%); max-width: 92%; padding: 0; color: var(--text-muted); text-align: center; }

+ 32 - 0
src/client/stores/chatAppearance.ts

@@ -0,0 +1,32 @@
+import { ref, readonly } from 'vue'
+import { appearancePreset, normalizeAppearance, type ChatAppearance, type BubblePreset } from '@/utils/chatAppearance'
+
+export const CHAT_APPEARANCE_KEY = 'hermes-yaoyao:chat-appearance:v1'
+function load(): ChatAppearance {
+  try { return normalizeAppearance(JSON.parse(window.localStorage.getItem(CHAT_APPEARANCE_KEY) || 'null')) }
+  catch { return appearancePreset('current') }
+}
+const value = ref(load())
+const previous = ref<ChatAppearance | null>(null)
+const saveError = ref('')
+function save(next: ChatAppearance, undoable = true) {
+  const normalized = normalizeAppearance(next)
+  if (JSON.stringify(normalized) === JSON.stringify(value.value)) return
+  try {
+    window.localStorage.setItem(CHAT_APPEARANCE_KEY, JSON.stringify(normalized))
+    if (undoable) previous.value = JSON.parse(JSON.stringify(value.value))
+    value.value = normalized
+    saveError.value = ''
+  } catch { saveError.value = '无法保存外观设置,请检查浏览器存储空间。' }
+}
+if (typeof window !== 'undefined') window.addEventListener('storage', event => {
+  if (event.key === CHAT_APPEARANCE_KEY || event.key === null) { value.value = load(); previous.value = null }
+})
+export function useChatAppearance() {
+  return {
+    appearance: readonly(value), previous: readonly(previous), saveError: readonly(saveError),
+    update: save,
+    selectPreset: (preset: BubblePreset) => save(appearancePreset(preset)),
+    undo: () => { if (previous.value) { const old = previous.value; save(old, false); if (!saveError.value) previous.value = null } },
+  }
+}

+ 25 - 0
src/client/styles/chat-bubbles.css

@@ -0,0 +1,25 @@
+.chat-bubble {
+  --bubble-fill: var(--bubble-light);
+  --bubble-ink: var(--bubble-light-text);
+  box-sizing: border-box;
+  max-width: 100%;
+  padding: 11px 15px;
+  border: var(--bubble-border);
+  border-radius: var(--bubble-radius);
+  background: var(--bubble-fill);
+  color: var(--bubble-ink);
+  overflow-wrap: anywhere;
+}
+.dark .chat-bubble { --bubble-fill: var(--bubble-dark); --bubble-ink: var(--bubble-dark-text); }
+[data-bubble-theme="light"] .chat-bubble { --bubble-fill: var(--bubble-light); --bubble-ink: var(--bubble-light-text); }
+[data-bubble-theme="dark"] .chat-bubble { --bubble-fill: var(--bubble-dark); --bubble-ink: var(--bubble-dark-text); }
+.message__content.chat-bubble { width: fit-content; --text-primary: var(--bubble-ink); --text-secondary: var(--bubble-ink); --text-muted: var(--bubble-ink); }
+.message__content.chat-bubble .markdown-content { color: inherit; }
+.message--user .message__body:has(> .chat-bubble) { padding: 0; background: transparent; border-radius: 0; }
+/* Embedded cards and code keep their own surface and readable foreground. */
+.message__body { --bubble-card-ink: var(--text-primary); --bubble-card-muted: var(--text-secondary); }
+.message__content.chat-bubble :is(code, .code-block, th, .file-link-card, .media-load-error) {
+  --text-primary: var(--bubble-card-ink);
+  --text-secondary: var(--bubble-card-muted);
+  --text-muted: var(--bubble-card-muted);
+}

+ 98 - 0
src/client/utils/chatAppearance.ts

@@ -0,0 +1,98 @@
+export type BubbleRole = 'user' | 'assistant'
+export type BubblePreset = 'current' | 'grok' | 'codex'
+export interface BubbleStyle {
+  light: string
+  dark: string
+  lightEnd: string
+  darkEnd: string
+  lightText: string
+  darkText: string
+  gradient: boolean
+  radius: number
+  border: boolean
+  tail: boolean
+}
+export interface ChatAppearance {
+  version: 1
+  preset: BubblePreset | 'custom'
+  user: BubbleStyle
+  assistant: BubbleStyle
+}
+export const bubblePresets = [
+  { id: 'current', name: '当前样式', detail: '经典蓝灰' },
+  { id: 'grok', name: 'Grok Bot', detail: '黑白灰' },
+  { id: 'codex', name: 'Codex', detail: '浅蓝双气泡' },
+] as const
+export const bubbleSwatches = ['#090909', '#2868D7', '#26BFAE', '#9370DB', '#E56A92', '#F59742', '#A2A5AF']
+
+function style(light: string, dark: string, lightText: string, darkText: string, radius = 18, tail = false): BubbleStyle {
+  return { light, dark, lightEnd: light, darkEnd: dark, lightText, darkText, radius, tail, gradient: false, border: false }
+}
+export function appearancePreset(id: BubblePreset): ChatAppearance {
+  const assistant = style('#F1F1F1', '#262626', '#202124', '#F4F4F5', id === 'codex' ? 18 : 20, id === 'current')
+  const user = id === 'grok' ? style('#090909', '#5A5A5A', '#FFFFFF', '#FFFFFF', 20)
+    : id === 'codex' ? style('#E4F3FC', '#203C50', '#1E69A5', '#C1E5FF')
+      : style('#2868D7', '#2868D7', '#FFFFFF', '#FFFFFF', 20, true)
+  return { version: 1, preset: id, user, assistant }
+}
+export function validHex(value: unknown): value is string { return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) }
+export function luminance(hex: string): number {
+  const rgb = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255)
+    .map(v => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4)
+  return rgb[0]! * .2126 + rgb[1]! * .7152 + rgb[2]! * .0722
+}
+export function contrast(a: string, b: string): number {
+  const x = luminance(a), y = luminance(b)
+  return (Math.max(x, y) + .05) / (Math.min(x, y) + .05)
+}
+export function automaticText(start: string, end = start): string {
+  const white = Math.min(contrast(start, '#FFFFFF'), contrast(end, '#FFFFFF'))
+  const black = Math.min(contrast(start, '#141414'), contrast(end, '#141414'))
+  return white > black ? '#FFFFFF' : '#141414'
+}
+export function normalizeAppearance(value: unknown): ChatAppearance {
+  const fallback = appearancePreset('current')
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return fallback
+  const raw = value as Partial<ChatAppearance>
+  if (raw.version !== 1) return fallback
+  const preset = bubblePresets.find(p => p.id === raw.preset)
+  // Presets are canonical; ignore stale/custom fields stored alongside an id.
+  if (preset) return appearancePreset(preset.id)
+  if (raw.preset !== 'custom') return fallback
+  for (const role of ['user', 'assistant'] as const) {
+    const item = raw[role]
+    if (!item || !['light', 'dark', 'lightEnd', 'darkEnd'].every(k => validHex(item[k as keyof BubbleStyle]))) return fallback
+    const base = fallback[role]
+    fallback[role] = {
+      ...base, light: item.light.toUpperCase(), dark: item.dark.toUpperCase(),
+      lightEnd: item.lightEnd.toUpperCase(), darkEnd: item.darkEnd.toUpperCase(),
+      lightText: validHex(item.lightText) ? item.lightText : automaticText(item.light, item.gradient ? item.lightEnd : item.light),
+      darkText: validHex(item.darkText) ? item.darkText : automaticText(item.dark, item.gradient ? item.darkEnd : item.dark),
+      gradient: item.gradient === true, border: item.border === true, tail: item.tail === true,
+      radius: typeof item.radius === 'number' && Number.isFinite(item.radius) ? Math.max(4, Math.min(28, item.radius)) : base.radius,
+    }
+  }
+  fallback.preset = 'custom'
+  return fallback
+}
+export function bubbleColors(value: BubbleStyle, dark: boolean) {
+  const start = dark ? value.dark : value.light
+  const end = value.gradient ? dark ? value.darkEnd : value.lightEnd : start
+  const preferred = dark ? value.darkText : value.lightText
+  const text = Math.min(contrast(start, preferred), contrast(end, preferred)) >= 4.5 ? preferred : automaticText(start, end)
+  const needsScrim = Math.min(contrast(start, text), contrast(end, text)) < 4.5
+  const gradient = `linear-gradient(135deg, ${start}, ${end})`
+  // A contrasting scrim protects text even across extreme custom gradients.
+  const scrim = text === '#FFFFFF' ? 'rgba(0,0,0,.65)' : 'rgba(255,255,255,.8)'
+  return { text, background: needsScrim ? `linear-gradient(${scrim}, ${scrim}), ${gradient}` : value.gradient ? gradient : start }
+}
+export function bubbleVariables(value: BubbleStyle, role: BubbleRole): Record<string, string> {
+  const light = bubbleColors(value, false), dark = bubbleColors(value, true)
+  const radius = `${value.radius}px`
+  return {
+    '--bubble-light': light.background, '--bubble-dark': dark.background,
+    '--bubble-light-text': light.text, '--bubble-dark-text': dark.text,
+    '--bubble-radius': value.tail ? role === 'user' ? `${radius} ${radius} 4px ${radius}` : `${radius} ${radius} ${radius} 4px` : radius,
+    '--bubble-border': value.border ? '1px solid color-mix(in srgb, currentColor 24%, transparent)' : '1px solid transparent',
+  }
+}

+ 92 - 0
tests/client/chatAppearance.test.ts

@@ -0,0 +1,92 @@
+import { describe, expect, it, afterEach, beforeEach, vi } from 'vitest'
+import { nextTick } from 'vue'
+import MessageTimeline from '@/components/messages/MessageTimeline.vue'
+import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
+import { mount } from '@vue/test-utils'
+import { appearancePreset, bubbleColors, bubbleVariables, contrast, normalizeAppearance } from '@/utils/chatAppearance'
+import { useChatAppearance, CHAT_APPEARANCE_KEY } from '@/stores/chatAppearance'
+import ChatAppearancePanel from '@/components/app/ChatAppearancePanel.vue'
+
+beforeEach(() => {
+  const values = new Map<string, string>()
+  vi.stubGlobal('localStorage', {
+    getItem: (key: string) => values.get(key) ?? null,
+    setItem: (key: string, value: string) => { values.set(key, value) },
+    clear: () => values.clear(),
+  })
+  vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
+  HTMLElement.prototype.scrollTo = () => {}
+  useChatAppearance().selectPreset('current')
+})
+afterEach(() => { vi.restoreAllMocks(); useChatAppearance().selectPreset('current') })
+describe('chat bubble appearance', () => {
+  it.each([MessageTimeline, WorkspaceMessageTimeline])('updates rendered bubbles when a preset changes without replacing messages', async (component) => {
+    const store = useChatAppearance()
+    const wrapper = mount(component as typeof MessageTimeline, {
+      props: {identity: 'appearance', messages: [{id:'u',role:'user',content:'问题'}, {id:'a',role:'assistant',content:'长回复'}, {id:'s',role:'system',content:'系统信息'}]},
+      global: {stubs: {AgentAvatar:true, AppIcon:true, MarkdownContent:true}},
+    })
+    store.selectPreset('codex'); await nextTick()
+    expect(wrapper.findAll('.message__content.chat-bubble')).toHaveLength(2)
+    expect(wrapper.get('.message--user .chat-bubble').attributes('style')).toContain('#E4F3FC')
+    store.selectPreset('grok'); await nextTick()
+    expect(wrapper.get('.message--user .chat-bubble').attributes('style')).toContain('#090909')
+    expect(wrapper.find('.message--system .chat-bubble').exists()).toBe(false)
+    wrapper.unmount()
+  })
+  it('keeps both sides filled in Codex and Grok Bot presets with readable text', () => {
+    expect(appearancePreset('grok').user.light).toBe('#090909')
+    expect(appearancePreset('codex').user.light).toBe('#E4F3FC')
+    for (const id of ['current', 'grok', 'codex'] as const) for (const role of ['user', 'assistant'] as const) {
+      const style = appearancePreset(id)[role]
+      for (const dark of [false, true]) {
+        const colors = bubbleColors(style, dark)
+        expect(colors.background).not.toContain('transparent')
+        expect(contrast(colors.background, colors.text)).toBeGreaterThanOrEqual(4.5)
+      }
+    }
+  })
+  it('rejects corrupt persisted settings and clamps custom shapes', () => {
+    for (const data of [null, {version: 2, preset: 'grok'}, {version: 1, preset: 'custom', user: {}}]) expect(normalizeAppearance(data)).toEqual(appearancePreset('current'))
+    const custom = {...appearancePreset('codex'), preset: 'custom', user: {...appearancePreset('codex').user, radius: 300}}
+    expect(normalizeAppearance(custom).user.radius).toBe(28)
+  })
+  it('protects text against extreme custom gradients', () => {
+    const style = {...appearancePreset('codex').assistant, gradient: true, light: '#FFFFFF', lightEnd: '#000000'}
+    expect(bubbleColors(style, false).background).toContain('rgba')
+    expect(bubbleVariables({...style, tail:true}, 'assistant')['--bubble-radius']).toMatch(/4px$/)
+  })
+  it('persists preset selection and undo across store consumers', () => {
+    const a = useChatAppearance(), b = useChatAppearance()
+    a.selectPreset('grok'); a.selectPreset('codex')
+    expect(b.appearance.value.preset).toBe('codex')
+    expect(JSON.parse(window.localStorage.getItem(CHAT_APPEARANCE_KEY)! ).preset).toBe('codex')
+    b.selectPreset('codex') // Selecting the active preset must preserve the undo target.
+    b.undo()
+    expect(a.appearance.value.preset).toBe('grok')
+    expect(JSON.parse(window.localStorage.getItem(CHAT_APPEARANCE_KEY)!).preset).toBe('grok')
+  })
+  it('does not claim success when storage is unavailable', () => {
+    const store = useChatAppearance()
+    store.selectPreset('grok')
+    vi.spyOn(window.localStorage, 'setItem').mockImplementation(() => {throw new Error('quota')})
+    store.selectPreset('codex')
+    expect(store.appearance.value.preset).toBe('grok')
+    expect(store.saveError.value).toContain('无法保存')
+  })
+  it('edits the selected role and theme without changing the other role', async () => {
+    const store = useChatAppearance(); store.selectPreset('codex')
+    const wrapper = mount(ChatAppearancePanel, { global: {stubs: {AgentAvatar:true, AppIcon:true}} })
+    await wrapper.get('[aria-label="调整哪一方"] button:nth-child(2)').trigger('click')
+    await wrapper.get('[aria-label="预览深色气泡"]').trigger('click')
+    await wrapper.get('[aria-label="颜色十六进制值"]').setValue('#090909')
+    expect(store.appearance.value.assistant.dark).toBe('#090909')
+    expect(store.appearance.value.assistant.light).toBe('#F1F1F1')
+    expect(store.appearance.value.user.light).toBe('#E4F3FC')
+    expect(store.appearance.value.preset).toBe('custom')
+    await wrapper.get('[aria-label="颜色十六进制值"]').setValue('oops')
+    expect(wrapper.get('[role="alert"]').text()).toContain('六位颜色')
+    expect(store.appearance.value.assistant.dark).toBe('#090909')
+    wrapper.unmount()
+  })
+})

+ 1 - 0
tests/fixtures/chat-appearance/index.html

@@ -0,0 +1 @@
+<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>聊天气泡验证</title></head><body><div id="app"></div><script type="module" src="./main.ts"></script></body></html>

+ 26 - 0
tests/fixtures/chat-appearance/main.ts

@@ -0,0 +1,26 @@
+// Uses real chat renderers with local sample data only.
+import {createApp, defineComponent, h, ref} from 'vue'
+import {createPinia} from 'pinia'
+import MessageTimeline from '@/components/messages/MessageTimeline.vue'
+import WorkspaceMessageTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
+import ChatAppearancePanel from '@/components/app/ChatAppearancePanel.vue'
+import type {UiMessage} from '@/components/messages/types'
+import '@/styles/tokens.css'
+import '@/styles/global.css'
+const messages: UiMessage[] = [
+  {id:'user',role:'user',content:'帮我整理今天的工作重点。',status:'settled'},
+  {id:'assistant',role:'assistant',author:'夭夭助手',content:'当然,我们先聚焦三件事。\n\n## 今天的重点\n\n1. **梳理需求**,明确聊天气泡的颜色与样式。\n2. 确认桌面和手机上的阅读体验。\n3. 检查切换预设、恢复默认和重新打开应用。\n\n下面这段代码和说明也应保持清晰:\n\n```typescript\nconst theme = "codex"\nconsole.log(theme)\n```\n\n行内代码 `theme`、[文档链接](https://example.com) 和长段落都保持在同一个助手气泡内。',status:'settled'},
+]
+const App=defineComponent({setup(){
+  const dark=ref(false),settings=ref(false),bot=ref(true)
+  return ()=>h('main',{class:{dark:dark.value},style:'height:100dvh;background:var(--surface);color:var(--text-primary);display:flex;flex-direction:column'},[
+    h('header',{style:'padding:14px;display:flex;gap:12px;border-bottom:1px solid var(--line)'},[
+      h('button',{onClick:()=>settings.value=!settings.value},settings.value?'返回聊天':'聊天气泡设置'),
+      h('button',{onClick:()=>{dark.value=!dark.value;document.documentElement.classList.toggle('dark',dark.value)}},dark.value?'切换浅色':'切换深色'),
+      h('button',{onClick:()=>bot.value=!bot.value},bot.value?'Bot 模式':'普通聊天'),
+    ]),
+    settings.value?h('div',{style:'overflow:auto;padding:24px'},[h(ChatAppearancePanel,{theme:dark.value?'dark':'light'})])
+    :h(bot.value?WorkspaceMessageTimeline:MessageTimeline,{messages,identity:'bubble-fixture',readOnly:true,title:'气泡样式验证',style:'flex:1;min-height:0'}),
+  ])
+}})
+createApp(App).use(createPinia()).mount('#app')

+ 2 - 2
tests/fixtures/settings-design/main.ts

@@ -76,14 +76,14 @@ const App=defineComponent({setup(){
   const auth=useAuthStore()
   auth.user={id:'fixture-user',username:'samien',role:query.has('member')?'member':'admin'} as never
   auth.status='authenticated';auth.serverIdentity=identity
-  const opened=ref<'bot'|'personal'|'editor'|null>(query.get('view')==='editor'?'editor':['appearance','profile','desktop-mode'].includes(query.get('view')??'')?'personal':'bot')
+  const opened=ref<'bot'|'personal'|'editor'|null>(query.get('view')==='editor'?'editor':['bubbles','appearance','profile','desktop-mode'].includes(query.get('view')??'')?'personal':'bot')
   return()=>h('main',{style:'min-height:100dvh;background:var(--settings-panel);padding:18px;box-sizing:border-box'},[
     h('div',{style:'display:flex;gap:12px;align-items:center;font:13px var(--font-ui)'},[
       h('span','界面验证 · 示例数据'),h('button',{onClick:()=>opened.value='bot'},'Bot 设置'),h('button',{onClick:()=>opened.value='personal'},'我的设置'),h('button',{onClick:()=>opened.value='editor'},'编辑资料'),
     ]),
     opened.value==='bot'?h(BotSettingsDialog,{isAdmin:!query.has('member'),initialPage:query.get('view')==='computers'?'computers':'projects',onClose:()=>opened.value=null}):null,
     opened.value==='editor'?h(BotProfileDialog,{agent:agents[0],draft:{...agents[0],source:'["local","default"]'},sources:[{nodeId:'local',profile:'default',name:'丫头'}],profiles:[{name:'default',agentName:'丫头',isDefault:true}],agents,conversations,conversationId:'fixture-chat',isAdmin:!query.has('member'),knowledgeEnabled:true,onClose:()=>opened.value=null,onSave:()=>opened.value=null} as never):null,
-    h(SettingsCenterDialog,{open:opened.value==='personal',initialPage:query.get('view')==='desktop-mode'?'desktop-mode':query.get('view')==='profile'?'account-profile':'appearance',isAdmin:!query.has('member'),botMode:true,userName:'samien',theme:theme.value==='dark'?'dark':'light',themePreference:theme.value,profiles:[{name:'default',agentName:'默认机器人',isDefault:true}],activeProfile:{name:'default',agentName:'默认机器人',isDefault:true},onClose:()=>opened.value=null,'onSet-theme':setTheme}),
+    h(SettingsCenterDialog,{open:opened.value==='personal',initialPage:query.get('view')==='bubbles'?'chat-appearance':query.get('view')==='desktop-mode'?'desktop-mode':query.get('view')==='profile'?'account-profile':'appearance',isAdmin:!query.has('member'),botMode:true,userName:'samien',theme:theme.value==='dark'?'dark':'light',themePreference:theme.value,profiles:[{name:'default',agentName:'默认机器人',isDefault:true}],activeProfile:{name:'default',agentName:'默认机器人',isDefault:true},onClose:()=>opened.value=null,'onSet-theme':setTheme}),
   ])
 }})
 createApp(App).use(createPinia()).mount('#app')

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů