feat(web): 优化聚合 tool use 展示与聊天背景设置 (#619)

This commit is contained in:
junes
2026-05-13 13:04:31 +08:00
committed by GitHub
parent 088a712f1e
commit 60af9835b4
15 changed files with 1449 additions and 77 deletions
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import {
getChatSurfaceColorPickerValue,
getToolGroupBackgroundPreference,
getUserMessageBackgroundPreference,
initializeChatSurfaceColors,
toPresetChatSurfaceColorPreference,
useChatSurfaceColors,
} from '@/hooks/useChatSurfaceColors'
describe('useChatSurfaceColors', () => {
beforeEach(() => {
localStorage.clear()
document.documentElement.removeAttribute('data-theme')
document.documentElement.style.removeProperty('--app-tool-group-bg')
document.documentElement.style.removeProperty('--app-chat-user-surface-bg')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('falls back to default when storage is missing or invalid', () => {
localStorage.setItem('hapi-tool-group-bg', 'preset:invalid')
localStorage.setItem('hapi-user-message-bg', 'custom:not-a-color')
expect(getToolGroupBackgroundPreference()).toBe('default')
expect(getUserMessageBackgroundPreference()).toBe('default')
})
it('stores preset and custom preferences using stable string values', () => {
const { result } = renderHook(() => useChatSurfaceColors())
act(() => {
result.current.setToolGroupBackground(toPresetChatSurfaceColorPreference('soft-blue'))
result.current.setUserMessageBackground('custom:#88cc44')
})
expect(localStorage.getItem('hapi-tool-group-bg')).toBe('preset:soft-blue')
expect(localStorage.getItem('hapi-user-message-bg')).toBe('custom:#88cc44')
expect(result.current.toolGroupBackground).toBe('preset:soft-blue')
expect(result.current.userMessageBackground).toBe('custom:#88cc44')
})
it('applies root css variables only for non-default preferences', () => {
const { result } = renderHook(() => useChatSurfaceColors())
expect(document.documentElement.style.getPropertyValue('--app-tool-group-bg')).toBe('')
expect(document.documentElement.style.getPropertyValue('--app-chat-user-surface-bg')).toBe('')
act(() => {
result.current.setToolGroupBackground('preset:soft-green')
result.current.setUserMessageBackground('custom:#88cc44')
})
expect(document.documentElement.style.getPropertyValue('--app-tool-group-bg')).toMatch(/^#/)
expect(document.documentElement.style.getPropertyValue('--app-chat-user-surface-bg')).toMatch(/^#/)
act(() => {
result.current.setToolGroupBackground('default')
result.current.setUserMessageBackground('default')
})
expect(document.documentElement.style.getPropertyValue('--app-tool-group-bg')).toBe('')
expect(document.documentElement.style.getPropertyValue('--app-chat-user-surface-bg')).toBe('')
})
it('reapplies stored values during initialization', () => {
localStorage.setItem('hapi-tool-group-bg', 'preset:soft-yellow')
localStorage.setItem('hapi-user-message-bg', 'custom:#88cc44')
initializeChatSurfaceColors()
expect(document.documentElement.style.getPropertyValue('--app-tool-group-bg')).toMatch(/^#/)
expect(document.documentElement.style.getPropertyValue('--app-chat-user-surface-bg')).toMatch(/^#/)
})
it('returns a valid picker value for default, preset, and custom preferences', () => {
expect(getChatSurfaceColorPickerValue('default')).toBe('#f2f4f6')
expect(getChatSurfaceColorPickerValue('preset:soft-blue')).toBe('#7db7ff')
expect(getChatSurfaceColorPickerValue('custom:#88cc44')).toBe('#88cc44')
})
})
+263
View File
@@ -0,0 +1,263 @@
import { useCallback, useEffect, useState } from 'react'
type ThemeMode = 'light' | 'dark'
type SurfaceKey = 'tool-group' | 'user-message'
export type ChatSurfaceColorPreset = 'default' | 'soft-blue' | 'soft-green' | 'soft-yellow'
export type ChatSurfaceColorPreference = 'default' | 'preset:soft-blue' | 'preset:soft-green' | 'preset:soft-yellow' | `custom:#${string}`
export const DEFAULT_CHAT_SURFACE_COLOR_PREFERENCE: ChatSurfaceColorPreference = 'default'
const TOOL_GROUP_BG_STORAGE_KEY = 'hapi-tool-group-bg'
const USER_MESSAGE_BG_STORAGE_KEY = 'hapi-user-message-bg'
const TOOL_GROUP_BG_CSS_VAR = '--app-tool-group-bg'
const USER_MESSAGE_BG_CSS_VAR = '--app-chat-user-surface-bg'
const DEFAULT_PICKER_COLOR = '#f2f4f6'
const PRESET_ACCENTS: Record<Exclude<ChatSurfaceColorPreset, 'default'>, string> = {
'soft-blue': '#7db7ff',
'soft-green': '#8fd19e',
'soft-yellow': '#f0d77a',
}
const THEME_BASES: Record<ThemeMode, Record<SurfaceKey, string>> = {
light: {
'tool-group': '#f2f4f6',
'user-message': '#f2f4f6',
},
dark: {
'tool-group': '#2b2f34',
'user-message': '#2b2f34',
},
}
let initialized = false
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof document !== 'undefined'
}
function safeGetItem(key: string): string | null {
if (!isBrowser()) return null
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function safeSetItem(key: string, value: string): void {
if (!isBrowser()) return
try {
localStorage.setItem(key, value)
} catch {
// Ignore storage errors
}
}
function safeRemoveItem(key: string): void {
if (!isBrowser()) return
try {
localStorage.removeItem(key)
} catch {
// Ignore storage errors
}
}
function isHexColor(value: string): boolean {
return /^#[0-9a-f]{6}$/i.test(value)
}
function normalizeHexColor(value: string): string | null {
const normalized = value.trim().toLowerCase()
return isHexColor(normalized) ? normalized : null
}
function parseChatSurfaceColorPreference(raw: string | null): ChatSurfaceColorPreference {
if (raw === 'default' || raw === 'preset:soft-blue' || raw === 'preset:soft-green' || raw === 'preset:soft-yellow') {
return raw
}
if (typeof raw === 'string' && raw.startsWith('custom:')) {
const normalized = normalizeHexColor(raw.slice('custom:'.length))
if (normalized) {
return `custom:${normalized}` as ChatSurfaceColorPreference
}
}
return DEFAULT_CHAT_SURFACE_COLOR_PREFERENCE
}
function getThemeMode(): ThemeMode {
if (!isBrowser()) return 'light'
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'
}
function hexToRgb(hex: string): [number, number, number] {
const normalized = hex.replace('#', '')
return [
Number.parseInt(normalized.slice(0, 2), 16),
Number.parseInt(normalized.slice(2, 4), 16),
Number.parseInt(normalized.slice(4, 6), 16),
]
}
function clampChannel(value: number): number {
return Math.max(0, Math.min(255, value))
}
function rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b]
.map((channel) => clampChannel(channel).toString(16).padStart(2, '0'))
.join('')}`
}
function mixHex(base: string, accent: string, ratio: number): string {
const [br, bg, bb] = hexToRgb(base)
const [ar, ag, ab] = hexToRgb(accent)
return rgbToHex(
Math.round(br + (ar - br) * ratio),
Math.round(bg + (ag - bg) * ratio),
Math.round(bb + (ab - bb) * ratio),
)
}
function getAccentColor(pref: ChatSurfaceColorPreference): string | null {
if (pref === 'default') return null
if (pref.startsWith('preset:')) {
return PRESET_ACCENTS[pref.slice('preset:'.length) as keyof typeof PRESET_ACCENTS] ?? null
}
return normalizeHexColor(pref.slice('custom:'.length))
}
function resolveSurfaceColor(pref: ChatSurfaceColorPreference, theme: ThemeMode, surface: SurfaceKey): string | null {
const accent = getAccentColor(pref)
if (!accent) return null
const base = THEME_BASES[theme][surface]
const ratio = pref.startsWith('custom:') ? (theme === 'dark' ? 0.22 : 0.34) : (theme === 'dark' ? 0.2 : 0.3)
return mixHex(base, accent, ratio)
}
function readStoredToolGroupBackground(): ChatSurfaceColorPreference {
return parseChatSurfaceColorPreference(safeGetItem(TOOL_GROUP_BG_STORAGE_KEY))
}
function readStoredUserMessageBackground(): ChatSurfaceColorPreference {
return parseChatSurfaceColorPreference(safeGetItem(USER_MESSAGE_BG_STORAGE_KEY))
}
function applyChatSurfaceVariables(): void {
if (!isBrowser()) return
const theme = getThemeMode()
const rootStyle = document.documentElement.style
const toolGroupColor = resolveSurfaceColor(readStoredToolGroupBackground(), theme, 'tool-group')
const userMessageColor = resolveSurfaceColor(readStoredUserMessageBackground(), theme, 'user-message')
if (toolGroupColor) rootStyle.setProperty(TOOL_GROUP_BG_CSS_VAR, toolGroupColor)
else rootStyle.removeProperty(TOOL_GROUP_BG_CSS_VAR)
if (userMessageColor) rootStyle.setProperty(USER_MESSAGE_BG_CSS_VAR, userMessageColor)
else rootStyle.removeProperty(USER_MESSAGE_BG_CSS_VAR)
}
function writePreference(key: string, value: ChatSurfaceColorPreference): void {
if (value === DEFAULT_CHAT_SURFACE_COLOR_PREFERENCE) {
safeRemoveItem(key)
} else {
safeSetItem(key, value)
}
applyChatSurfaceVariables()
}
export function getChatSurfaceColorPresetOptions(): ReadonlyArray<{ value: ChatSurfaceColorPreset; labelKey: string }> {
return [
{ value: 'default', labelKey: 'settings.chat.surfaceColor.default' },
{ value: 'soft-blue', labelKey: 'settings.chat.surfaceColor.softBlue' },
{ value: 'soft-green', labelKey: 'settings.chat.surfaceColor.softGreen' },
{ value: 'soft-yellow', labelKey: 'settings.chat.surfaceColor.softYellow' },
]
}
export function toPresetChatSurfaceColorPreference(preset: ChatSurfaceColorPreset): ChatSurfaceColorPreference {
return preset === 'default' ? 'default' : (`preset:${preset}` as ChatSurfaceColorPreference)
}
export function toCustomChatSurfaceColorPreference(value: string): ChatSurfaceColorPreference {
const normalized = normalizeHexColor(value) ?? DEFAULT_PICKER_COLOR
return `custom:${normalized}` as ChatSurfaceColorPreference
}
export function getToolGroupBackgroundPreference(): ChatSurfaceColorPreference {
return readStoredToolGroupBackground()
}
export function getUserMessageBackgroundPreference(): ChatSurfaceColorPreference {
return readStoredUserMessageBackground()
}
export function getChatSurfaceColorPickerValue(pref: ChatSurfaceColorPreference): string {
return getAccentColor(pref) ?? DEFAULT_PICKER_COLOR
}
export function initializeChatSurfaceColors(): void {
if (!isBrowser()) return
applyChatSurfaceVariables()
if (initialized) return
initialized = true
window.addEventListener('storage', applyChatSurfaceVariables)
const themeObserver = new MutationObserver(() => {
applyChatSurfaceVariables()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})
}
export function useChatSurfaceColors(): {
toolGroupBackground: ChatSurfaceColorPreference
userMessageBackground: ChatSurfaceColorPreference
setToolGroupBackground: (value: ChatSurfaceColorPreference) => void
setUserMessageBackground: (value: ChatSurfaceColorPreference) => void
} {
const [toolGroupBackground, setToolGroupBackgroundState] = useState<ChatSurfaceColorPreference>(getToolGroupBackgroundPreference)
const [userMessageBackground, setUserMessageBackgroundState] = useState<ChatSurfaceColorPreference>(getUserMessageBackgroundPreference)
useEffect(() => {
if (!isBrowser()) return
const onStorage = (event: StorageEvent) => {
if (event.key !== TOOL_GROUP_BG_STORAGE_KEY && event.key !== USER_MESSAGE_BG_STORAGE_KEY) {
return
}
setToolGroupBackgroundState(readStoredToolGroupBackground())
setUserMessageBackgroundState(readStoredUserMessageBackground())
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
}, [])
const setToolGroupBackground = useCallback((value: ChatSurfaceColorPreference) => {
setToolGroupBackgroundState(value)
writePreference(TOOL_GROUP_BG_STORAGE_KEY, value)
}, [])
const setUserMessageBackground = useCallback((value: ChatSurfaceColorPreference) => {
setUserMessageBackgroundState(value)
writePreference(USER_MESSAGE_BG_STORAGE_KEY, value)
}, [])
return {
toolGroupBackground,
userMessageBackground,
setToolGroupBackground,
setUserMessageBackground,
}
}