feat: add sidebar brand customization

The primary sidebar brand was fixed to the built-in name, subtitle, and logo. Appearance settings now let users preview and save a custom brand name, localized Chinese and English subtitles, and a validated local PNG, JPEG, or WebP logo, with default-logo and full-reset actions.

Customization remains device-local and limited to the top-left brand area; installer, update, Agent, and protocol identities remain GoodBuddy. The desktop package version is now 0.11.6.

Release note: 新增“外观 → 品牌定制”,可修改左上角品牌名称、中英文副标题和本地 Logo,并支持实时预览、恢复默认及本机持久化。
This commit is contained in:
mesalogo
2026-08-27 12:42:26 +08:00
parent 618e505b8b
commit 12463ad593
22 changed files with 1125 additions and 117 deletions
+7
View File
@@ -298,6 +298,13 @@
失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时
显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠
颜色或动效表达状态。
- 左上角品牌区允许用户在“外观”设置中定制品牌名称、中英文副标题和单个 Logo。定制只
影响该品牌区,不替换安装包名称、更新协议、Agent 标识或应用正文中的 GoodBuddy 产品
身份。副标题按当前界面语言显示,留空时隐藏且不保留空行。
- 品牌设置必须提供同布局预览、显式保存、默认 Logo 和整组恢复默认操作。自定义 Logo 仅
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
单行截断,不能撑宽或挤压侧栏。
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
### 6.9 助手工作栏
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.11.5",
"version": "0.11.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.11.5",
"version": "0.11.6",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.11.5",
"version": "0.11.6",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+1 -30
View File
@@ -1,4 +1,5 @@
import { extname } from 'node:path'
export { detectSupportedImage } from '../shared/image-media-type'
const mimeTypes: Readonly<Record<string, string>> = {
'.c': 'text/x-c',
@@ -40,33 +41,3 @@ export function mimeTypeFromFileName(
): string {
return mimeTypes[extname(name).toLocaleLowerCase()] ?? fallback
}
export function detectSupportedImage(data: Buffer): {
extension: 'jpg' | 'png' | 'webp'
mimeType: 'image/jpeg' | 'image/png' | 'image/webp'
} {
if (
data.byteLength >= 3 &&
data[0] === 0xff &&
data[1] === 0xd8 &&
data[2] === 0xff
) {
return { extension: 'jpg', mimeType: 'image/jpeg' }
}
if (
data.byteLength >= 8 &&
data.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
) {
return { extension: 'png', mimeType: 'image/png' }
}
if (
data.byteLength >= 12 &&
data.subarray(0, 4).toString('ascii') === 'RIFF' &&
data.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return { extension: 'webp', mimeType: 'image/webp' }
}
throw new Error('图片格式不受支持')
}
+67 -1
View File
@@ -18,6 +18,10 @@ import type {
} from '../../shared/contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import {
loadBrandingPreferences,
saveBrandingPreferences
} from './branding'
import type {
AssistantProject,
AssistantSchedule,
@@ -1343,6 +1347,33 @@ describe('App', () => {
).not.toBeInTheDocument()
})
it('loads customized branding into the primary sidebar', async () => {
expect(
saveBrandingPreferences({
name: '研发伙伴',
subtitleZhCN: '团队工作台',
subtitleEnUS: 'Team Workspace',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
})
).toBe(true)
render(<App />)
await screen.findByRole('heading', {
name: '今天想一起完成什么?'
})
const brand = document.querySelector('.brand')
expect(brand).not.toBeNull()
expect(within(brand as HTMLElement).getByText('研发伙伴'))
.toBeInTheDocument()
expect(within(brand as HTMLElement).getByText('团队工作台'))
.toBeInTheDocument()
expect(brand?.querySelector('img')).toHaveAttribute(
'src',
'data:image/png;base64,iVBORw0KGgo='
)
})
it('renders the unified user and Scheduled Task queue above the Composer', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000951'
@@ -1751,6 +1782,41 @@ describe('App', () => {
})
})
it('saves branding from Appearance and updates the sidebar immediately', async () => {
render(<App />)
fireEvent.click(
await screen.findByRole('button', {
name: /本地工作区/u
})
)
fireEvent.click(
await screen.findByRole('tab', { name: '外观' })
)
fireEvent.change(screen.getByLabelText('品牌名称'), {
target: { value: '研发伙伴' }
})
fireEvent.change(screen.getByLabelText('中文副标题'), {
target: { value: '团队工作台' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存品牌设置' })
)
expect(await screen.findByText('品牌设置已保存'))
.toBeInTheDocument()
const brand = document.querySelector('.brand')
expect(brand).not.toBeNull()
expect(within(brand as HTMLElement).getByText('研发伙伴'))
.toBeInTheDocument()
expect(within(brand as HTMLElement).getByText('团队工作台'))
.toBeInTheDocument()
expect(loadBrandingPreferences()).toMatchObject({
name: '研发伙伴',
subtitleZhCN: '团队工作台'
})
})
it('preloads a heavy route once across repeated pointer and focus intent', async () => {
render(<App />)
const activity = await screen.findByRole('button', {
@@ -8710,7 +8776,7 @@ describe('App', () => {
expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0)
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'描述你想生成的图片…'
'描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本'
)
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenCalled()
+43 -17
View File
@@ -180,6 +180,11 @@ import type { SettingsLeaveRequester } from './SettingsPanel'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
import {
loadBrandingPreferences,
saveBrandingPreferences
} from './branding'
import { BrandLockup } from './BrandLockup'
import {
applyAppearanceTheme,
loadAppearanceTheme,
@@ -2084,6 +2089,9 @@ function App(): React.JSX.Element {
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
const [appearanceTheme, setAppearanceTheme] =
useState<AppearanceTheme>(loadAppearanceTheme)
const [brandingPreferences, setBrandingPreferences] = useState(
loadBrandingPreferences
)
const [systemPrefersDark, setSystemPrefersDark] = useState(
() =>
typeof window.matchMedia === 'function' &&
@@ -2093,6 +2101,10 @@ function App(): React.JSX.Element {
appearanceTheme,
systemPrefersDark
)
const brandingSubtitle =
locale === 'en-US'
? brandingPreferences.subtitleEnUS
: brandingPreferences.subtitleZhCN
const toggleAppearanceTheme = useCallback((): void => {
setAppearanceTheme(
resolvedAppearanceTheme === 'dark' ? 'light' : 'dark'
@@ -7902,23 +7914,19 @@ function App(): React.JSX.Element {
ref={sidebarRef}
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
>
<div className="brand">
<div className="brand__mark">
<img
alt=""
aria-hidden="true"
src={
resolvedAppearanceTheme === 'dark'
? goodbuddyDarkIcon
: goodbuddyLightIcon
}
/>
</div>
<div className="brand__copy">
<strong>GoodBuddy</strong>
<span>{t('brand.subtitle')}</span>
</div>
</div>
<BrandLockup
className="brand"
copyClassName="brand__copy"
logo={
brandingPreferences.logoDataUrl ??
(resolvedAppearanceTheme === 'dark'
? goodbuddyDarkIcon
: goodbuddyLightIcon)
}
markClassName="brand__mark"
name={brandingPreferences.name}
subtitle={brandingSubtitle || undefined}
/>
<ProjectSwitcher
activeProjectId={activeProjectId}
@@ -10126,11 +10134,29 @@ function App(): React.JSX.Element {
>
<SettingsPanel
appearanceTheme={appearanceTheme}
brandingFallbackLogo={
resolvedAppearanceTheme === 'dark'
? goodbuddyDarkIcon
: goodbuddyLightIcon
}
brandingPreferences={brandingPreferences}
initialCategory={settingsInitialCategory}
initialChannel={settingsInitialChannel}
magicNotesEnabled={magicNotesEnabled}
remoteProjectsEnabled={remoteProjectsEnabled}
onAppearanceThemeChange={setAppearanceTheme}
onBrandingPreferencesChange={(preferences) => {
if (!saveBrandingPreferences(preferences)) {
return false
}
setBrandingPreferences(preferences)
notify({
tone: 'success',
message: t('notices.brandingSaved'),
dedupeKey: 'branding-saved'
})
return true
}}
onClearLocalData={clearLocalData}
onClose={() => {
setSettingsInitialCategory(undefined)
+33
View File
@@ -0,0 +1,33 @@
type BrandLockupProps = {
ariaLabel?: string
className: string
copyClassName: string
logo?: string
markClassName: string
name: string
role?: 'img'
subtitle?: string
}
export function BrandLockup({
ariaLabel,
className,
copyClassName,
logo,
markClassName,
name,
role,
subtitle
}: BrandLockupProps): React.JSX.Element {
return (
<div aria-label={ariaLabel} className={className} role={role}>
<div className={markClassName}>
{logo && <img alt="" aria-hidden="true" src={logo} />}
</div>
<div className={copyClassName}>
<strong>{name}</strong>
{subtitle && <span>{subtitle}</span>}
</div>
</div>
)
}
@@ -0,0 +1,268 @@
import { ImagePlus, RotateCcw, Save, Trash2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
brandingLogoMaximumBytes,
brandingNameMaximumLength,
brandingPreferencesEqual,
brandingSubtitleMaximumLength,
defaultBrandingPreferences,
isValidBrandingLogoDataUrl,
normalizeBrandingPreferences,
type BrandingPreferences
} from './branding'
import { BrandLockup } from './BrandLockup'
import {
loadImageDimensions,
readFileAsDataUrl
} from './file-data-url'
type BrandingSettingsSectionProps = {
preferences: BrandingPreferences
fallbackLogo?: string
onChange: (preferences: BrandingPreferences) => boolean
onDirtyChange?: (dirty: boolean) => void
}
export function BrandingSettingsSection({
preferences,
fallbackLogo,
onChange,
onDirtyChange
}: BrandingSettingsSectionProps): React.JSX.Element {
const { i18n, t } = useTranslation('settings')
const [draft, setDraft] = useState(preferences)
const [error, setError] = useState<string>()
const fileInputRef = useRef<HTMLInputElement>(null)
const logoReadRef = useRef<AbortController>(null)
const dirty = !brandingPreferencesEqual(draft, preferences)
useEffect(() => {
onDirtyChange?.(dirty)
}, [dirty, onDirtyChange])
useEffect(
() => () => {
logoReadRef.current?.abort()
},
[]
)
const save = (): void => {
const normalized = normalizeBrandingPreferences(draft)
if (!onChange(normalized)) {
setError(t('appearance.branding.errors.saveFailed'))
return
}
setDraft(normalized)
setError(undefined)
}
const selectLogo = async (file: File | undefined): Promise<void> => {
logoReadRef.current?.abort()
if (!file) {
return
}
if (
!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)
) {
setError(t('appearance.branding.errors.unsupportedLogo'))
return
}
if (file.size === 0 || file.size > brandingLogoMaximumBytes) {
setError(t('appearance.branding.errors.logoTooLarge'))
return
}
const controller = new AbortController()
logoReadRef.current = controller
try {
const dataUrl = await readFileAsDataUrl(
file,
file.type,
controller.signal
)
if (!isValidBrandingLogoDataUrl(dataUrl)) {
setError(t('appearance.branding.errors.invalidLogo'))
return
}
const { width, height } = await loadImageDimensions(
dataUrl,
controller.signal
)
if (
width <= 0 ||
height <= 0 ||
width > 4_096 ||
height > 4_096 ||
width * height > 16_000_000
) {
setError(t('appearance.branding.errors.invalidLogo'))
return
}
if (logoReadRef.current !== controller) {
return
}
setDraft((current) => ({
...current,
logoDataUrl: dataUrl
}))
setError(undefined)
} catch {
if (!controller.signal.aborted) {
setError(t('appearance.branding.errors.readLogoFailed'))
}
} finally {
if (logoReadRef.current === controller) {
logoReadRef.current = null
}
}
}
const previewSubtitle =
(i18n.resolvedLanguage === 'en-US'
? draft.subtitleEnUS
: draft.subtitleZhCN) || undefined
const previewLogo = draft.logoDataUrl ?? fallbackLogo
return (
<div className="settings-section appearance-settings branding-settings">
<div className="settings-section__title">
<ImagePlus aria-hidden="true" size={17} />
<div>
<strong>{t('appearance.branding.title')}</strong>
<small>{t('appearance.branding.description')}</small>
</div>
</div>
<BrandLockup
ariaLabel={t('appearance.branding.previewLabel')}
className="branding-settings__preview"
copyClassName="branding-settings__preview-copy"
logo={previewLogo}
markClassName="branding-settings__preview-mark"
name={draft.name || defaultBrandingPreferences.name}
role="img"
subtitle={previewSubtitle}
/>
<div className="branding-settings__fields">
<label className="field">
<span>{t('appearance.branding.fields.name')}</span>
<input
maxLength={brandingNameMaximumLength}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value
}))
}
value={draft.name}
/>
</label>
<label className="field">
<span>{t('appearance.branding.fields.subtitleZhCN')}</span>
<input
lang="zh-CN"
maxLength={brandingSubtitleMaximumLength}
onChange={(event) =>
setDraft((current) => ({
...current,
subtitleZhCN: event.target.value
}))
}
value={draft.subtitleZhCN}
/>
</label>
<label className="field">
<span>{t('appearance.branding.fields.subtitleEnUS')}</span>
<input
lang="en-US"
maxLength={brandingSubtitleMaximumLength}
onChange={(event) =>
setDraft((current) => ({
...current,
subtitleEnUS: event.target.value
}))
}
value={draft.subtitleEnUS}
/>
</label>
</div>
<div className="branding-settings__logo">
<input
accept="image/png,image/jpeg,image/webp"
aria-label={t('appearance.branding.logo.select')}
hidden
onChange={(event) => {
void selectLogo(event.target.files?.[0])
event.target.value = ''
}}
ref={fileInputRef}
type="file"
/>
<button
className="secondary-button"
onClick={() => fileInputRef.current?.click()}
type="button"
>
<ImagePlus aria-hidden="true" size={13} />
{t('appearance.branding.logo.select')}
</button>
{draft.logoDataUrl && (
<button
className="secondary-button"
onClick={() => {
logoReadRef.current?.abort()
setDraft((current) => ({
name: current.name,
subtitleZhCN: current.subtitleZhCN,
subtitleEnUS: current.subtitleEnUS
}))
setError(undefined)
}}
type="button"
>
<Trash2 aria-hidden="true" size={13} />
{t('appearance.branding.logo.useDefault')}
</button>
)}
<small>{t('appearance.branding.logo.help')}</small>
</div>
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
<div className="update-settings__actions">
<button
className="secondary-button"
disabled={brandingPreferencesEqual(
draft,
defaultBrandingPreferences
)}
onClick={() => {
logoReadRef.current?.abort()
setDraft({ ...defaultBrandingPreferences })
setError(undefined)
}}
type="button"
>
<RotateCcw aria-hidden="true" size={13} />
{t('appearance.branding.actions.restore')}
</button>
<button
className="primary-button"
disabled={!dirty}
onClick={save}
type="button"
>
<Save aria-hidden="true" size={13} />
{t('appearance.branding.actions.save')}
</button>
</div>
</div>
)
}
+2 -16
View File
@@ -24,6 +24,7 @@ import {
type FeedbackSubmissionErrorCode
} from '../../shared/feedback-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { loadImageDimensions } from './file-data-url'
type FeedbackDialogProps = {
appInfo: AppInfo
@@ -49,21 +50,6 @@ function formatScreenshotBytes(bytes: number): string {
: `${Math.max(1, Math.ceil(bytes / 1_024))} KB`
}
function imageDimensions(
objectUrl: string
): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () =>
resolve({
width: image.naturalWidth,
height: image.naturalHeight
})
image.onerror = () => reject(new Error('Image could not be decoded'))
image.src = objectUrl
})
}
export function FeedbackDialog({
appInfo,
onClose
@@ -174,7 +160,7 @@ export function FeedbackDialog({
}
objectUrl = URL.createObjectURL(file)
pendingScreenshotUrlsRef.current.add(objectUrl)
const { width, height } = await imageDimensions(objectUrl)
const { width, height } = await loadImageDimensions(objectUrl)
if (selection !== screenshotSelectionRef.current) {
pendingScreenshotUrlsRef.current.delete(objectUrl)
URL.revokeObjectURL(objectUrl)
+4 -40
View File
@@ -22,6 +22,7 @@ import {
magicNoteDataBytes,
type MagicNoteRichContent
} from '../../shared/magic-notes-contracts'
import { readFileAsDataUrl } from './file-data-url'
const supportedImageTypes = new Set([
'image/jpeg',
@@ -41,32 +42,6 @@ export type MagicNoteEditorProps = {
onParagraphCommit?: (content: MagicNoteRichContent) => void
}
function readFileAsDataUrl(
file: File,
mimeType: string,
readFailedMessage: string
): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error(readFailedMessage))
return
}
const separatorIndex = reader.result.indexOf(',')
if (separatorIndex < 0) {
reject(new Error(readFailedMessage))
return
}
resolve(
`data:${mimeType};base64,${reader.result.slice(separatorIndex + 1)}`
)
}
reader.onerror = () => reject(new Error(readFailedMessage))
reader.readAsDataURL(file)
})
}
function safeEmbeddedFileName(file: File): string {
const fallback = file.type.startsWith('video/')
? 'video'
@@ -273,9 +248,6 @@ export function MagicNoteEditor({
return
}
try {
const readFailedMessage = translateRef.current(
'editor.fileReadFailed'
)
const embeds = await Promise.all(
classified.map(async ({ file, kind, mimeType }) => ({
kind,
@@ -283,11 +255,7 @@ export function MagicNoteEditor({
name: safeEmbeddedFileName(file),
mimeType,
size: file.size,
dataUrl: await readFileAsDataUrl(
file,
mimeType,
readFailedMessage
)
dataUrl: await readFileAsDataUrl(file, mimeType)
}
}))
)
@@ -302,12 +270,8 @@ export function MagicNoteEditor({
index += 2
}
quill.setSelection(index, 0, 'silent')
} catch (error) {
onErrorRef.current(
error instanceof Error
? error.message
: translateRef.current('editor.fileReadFailed')
)
} catch {
onErrorRef.current(translateRef.current('editor.fileReadFailed'))
}
}
+110
View File
@@ -48,8 +48,21 @@ import {
} from './RuntimeCustomizationSection'
import { changeUiLocale } from './i18n'
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
import type { BrandingPreferences } from './branding'
const modelProfileId = '00000000-0000-4000-8000-000000000001'
const nativeImage = globalThis.Image
class DecodableBrandImage {
naturalWidth = 64
naturalHeight = 64
onload: (() => void) | null = null
onerror: (() => void) | null = null
set src(_value: string) {
queueMicrotask(() => this.onload?.())
}
}
const browserProfileId = '00000000-0000-4000-8000-000000000201'
const runtimeSettings: RuntimeSettings = {
provider: 'auto',
@@ -934,6 +947,10 @@ describe('SettingsPanel runtime files', () => {
afterEach(() => {
cleanup()
Object.defineProperty(globalThis, 'Image', {
configurable: true,
value: nativeImage
})
})
it('offers system, light, and dark appearance modes', async () => {
@@ -963,6 +980,99 @@ describe('SettingsPanel runtime files', () => {
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
})
it('customizes and restores the sidebar brand from Appearance', async () => {
const onBrandingPreferencesChange = vi.fn<
(preferences: BrandingPreferences) => boolean
>(() => true)
function BrandingHarness(): React.JSX.Element {
const [preferences, setPreferences] = useState({
name: 'GoodBuddy',
subtitleZhCN: '智能工作台',
subtitleEnUS: 'Intelligent Workspace'
})
return (
<SettingsPanel
{...heartbeatSettingsProps}
brandingFallbackLogo="default-logo.png"
brandingPreferences={preferences}
onBrandingPreferencesChange={(next) => {
onBrandingPreferencesChange(next)
setPreferences(next)
return true
}}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
}
render(<BrandingHarness />)
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(screen.getByRole('img', { name: '品牌效果预览' }))
.toHaveTextContent('GoodBuddy')
expect(screen.getByLabelText('品牌名称')).toHaveValue('GoodBuddy')
fireEvent.change(screen.getByLabelText('品牌名称'), {
target: { value: '研发伙伴' }
})
fireEvent.change(screen.getByLabelText('中文副标题'), {
target: { value: '团队工作台' }
})
fireEvent.change(screen.getByLabelText('英文副标题'), {
target: { value: 'Team Workspace' }
})
expect(screen.getByRole('img', { name: '品牌效果预览' }))
.toHaveTextContent('研发伙伴')
Object.defineProperty(globalThis, 'Image', {
configurable: true,
value: DecodableBrandImage
})
fireEvent.change(screen.getByLabelText('选择 Logo'), {
target: {
files: [
new File(
[
Uint8Array.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a
])
],
'brand.png',
{ type: 'image/png' }
)
]
}
})
await screen.findByRole('button', { name: '使用默认 Logo' })
fireEvent.click(
screen.getByRole('button', { name: '保存品牌设置' })
)
expect(onBrandingPreferencesChange).toHaveBeenCalledWith({
name: '研发伙伴',
subtitleZhCN: '团队工作台',
subtitleEnUS: 'Team Workspace',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
})
fireEvent.click(screen.getByRole('button', { name: '恢复默认' }))
expect(screen.getByLabelText('品牌名称')).toHaveValue('GoodBuddy')
expect(screen.getByLabelText('中文副标题')).toHaveValue(
'智能工作台'
)
})
it('configures direct model context compression with explicit token budgets', async () => {
render(
<SettingsPanel
+25
View File
@@ -76,6 +76,11 @@ import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { useUiLocale } from './i18n/UiLocaleProvider'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
import { BrandingSettingsSection } from './BrandingSettingsSection'
import {
defaultBrandingPreferences,
type BrandingPreferences
} from './branding'
type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
type AgentRuntimeType =
@@ -218,6 +223,11 @@ type SettingsPanelProps = {
onClearLocalData: () => Promise<void>
appearanceTheme?: AppearanceTheme
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
brandingPreferences?: BrandingPreferences
brandingFallbackLogo?: string
onBrandingPreferencesChange?: (
preferences: BrandingPreferences
) => boolean
magicNotesEnabled?: boolean
remoteProjectsEnabled?: boolean
onMagicNotesEnabledChange?: (enabled: boolean) => void
@@ -584,6 +594,9 @@ export function SettingsPanel({
onExpertsChanged = () => {},
appearanceTheme = 'system',
onAppearanceThemeChange = () => {},
brandingPreferences = defaultBrandingPreferences,
brandingFallbackLogo,
onBrandingPreferencesChange = () => false,
magicNotesEnabled = false,
remoteProjectsEnabled = false,
onMagicNotesEnabledChange = () => {},
@@ -598,6 +611,7 @@ export function SettingsPanel({
setPreference: setUiLocalePreference
} = useUiLocale()
const [settings, setSettings] = useState<RuntimeSettings>()
const [brandingDirty, setBrandingDirty] = useState(false)
const [provider, setProvider] =
useState<RuntimeSettingsInput['provider']>(
defaultRuntimeSettings.provider
@@ -927,11 +941,13 @@ export function SettingsPanel({
runtimeDraftDirty ||
speechModelSelectionDirty ||
runtimeCustomizationDirty ||
brandingDirty ||
platformFeaturesDirty ||
documentParsingDirty ||
sshHostsDirty
const navigationWouldLoseDraft =
runtimeCustomizationDirty ||
brandingDirty ||
platformFeaturesDirty ||
documentParsingDirty ||
sshHostsDirty
@@ -939,6 +955,7 @@ export function SettingsPanel({
runtimeCustomizationDirty &&
!runtimeDraftDirty &&
!speechModelSelectionDirty &&
!brandingDirty &&
!platformFeaturesDirty &&
!documentParsingDirty &&
!sshHostsDirty
@@ -1188,6 +1205,7 @@ export function SettingsPanel({
setSpeechModelDraftId(undefined)
setPersistedSpeechModelId(undefined)
setSpeechModelSelectionDirty(false)
setBrandingDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setSshHostsDirty(false)
@@ -1213,6 +1231,7 @@ export function SettingsPanel({
setSpeechModelSelectionDirty(false)
runtimeCustomizationRef.current?.discard()
setRuntimeCustomizationDirty(false)
setBrandingDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setSshHostsDirty(false)
@@ -2110,6 +2129,12 @@ export function SettingsPanel({
))}
</div>
</div>
<BrandingSettingsSection
fallbackLogo={brandingFallbackLogo}
onChange={onBrandingPreferencesChange}
onDirtyChange={setBrandingDirty}
preferences={brandingPreferences}
/>
</>
)}
{activeTab === 'platform-features' && (
+97
View File
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
brandingLogoMaximumBytes,
brandingNameMaximumLength,
defaultBrandingPreferences,
loadBrandingPreferences,
normalizeBrandingPreferences,
saveBrandingPreferences
} from './branding'
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
})
describe('branding preferences', () => {
it('uses defaults when no valid preference is stored', () => {
expect(loadBrandingPreferences()).toEqual(defaultBrandingPreferences)
localStorage.setItem('goodbuddy.branding.v1', '{"name":true}')
expect(loadBrandingPreferences()).toEqual(defaultBrandingPreferences)
})
it('normalizes text and accepts only bounded inline image data', () => {
expect(
normalizeBrandingPreferences({
name: ` ${'B'.repeat(brandingNameMaximumLength + 4)} `,
subtitleZhCN: ' 中文品牌 ',
subtitleEnUS: ' English brand ',
logoDataUrl: 'https://example.com/logo.png'
})
).toEqual({
name: 'B'.repeat(brandingNameMaximumLength),
subtitleZhCN: '中文品牌',
subtitleEnUS: 'English brand'
})
expect(
normalizeBrandingPreferences({
name: ' ',
subtitleZhCN: '',
subtitleEnUS: '',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
})
).toEqual({
name: 'GoodBuddy',
subtitleZhCN: '',
subtitleEnUS: '',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
})
})
it('persists and reloads valid preferences', () => {
const preferences = {
name: '团队助手',
subtitleZhCN: '研发工作台',
subtitleEnUS: 'Engineering Workspace',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
}
expect(saveBrandingPreferences(preferences)).toBe(true)
expect(loadBrandingPreferences()).toEqual(preferences)
})
it('rejects a logo whose bytes do not match its declared type', () => {
expect(
normalizeBrandingPreferences({
...defaultBrandingPreferences,
logoDataUrl: 'data:image/jpeg;base64,iVBORw0KGgo='
})
).toEqual(defaultBrandingPreferences)
})
it('rejects oversized encoded logos before decoding them', () => {
const decode = vi.spyOn(globalThis, 'atob')
expect(
normalizeBrandingPreferences({
...defaultBrandingPreferences,
logoDataUrl: `data:image/png;base64,${'A'.repeat(
Math.ceil((brandingLogoMaximumBytes * 4) / 3) + 8
)}`
})
).toEqual(defaultBrandingPreferences)
expect(decode).not.toHaveBeenCalled()
})
it('reports unavailable local storage without throwing', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked')
})
expect(
saveBrandingPreferences(defaultBrandingPreferences)
).toBe(false)
})
})
+140
View File
@@ -0,0 +1,140 @@
import { detectSupportedImage } from '../../shared/image-media-type'
export type BrandingPreferences = {
name: string
subtitleZhCN: string
subtitleEnUS: string
logoDataUrl?: string
}
export const defaultBrandingPreferences: BrandingPreferences = {
name: 'GoodBuddy',
subtitleZhCN: '智能工作台',
subtitleEnUS: 'Intelligent Workspace'
}
export const brandingNameMaximumLength = 32
export const brandingSubtitleMaximumLength = 48
export const brandingLogoMaximumBytes = 512 * 1024
const storageKey = 'goodbuddy.branding.v1'
const supportedLogoDataUrlPattern =
/^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/]+={0,2})$/iu
export function isValidBrandingLogoDataUrl(value: string): boolean {
const match = supportedLogoDataUrlPattern.exec(value)
if (!match) {
return false
}
try {
const encoded = match[2]!
if (
encoded.length >
Math.ceil((brandingLogoMaximumBytes * 4) / 3) + 4
) {
return false
}
const decoded = atob(encoded)
if (
decoded.length === 0 ||
decoded.length > brandingLogoMaximumBytes
) {
return false
}
const header = Uint8Array.from(
decoded
.slice(0, 12)
.split('')
.map((character) => character.charCodeAt(0))
)
return (
detectSupportedImage(header).mimeType === match[1]!.toLowerCase()
)
} catch {
return false
}
}
export function normalizeBrandingPreferences(
value: BrandingPreferences
): BrandingPreferences {
const name = value.name.trim()
const subtitleZhCN = value.subtitleZhCN.trim()
const subtitleEnUS = value.subtitleEnUS.trim()
return {
name:
name.length > 0
? name.slice(0, brandingNameMaximumLength)
: defaultBrandingPreferences.name,
subtitleZhCN: subtitleZhCN.slice(
0,
brandingSubtitleMaximumLength
),
subtitleEnUS: subtitleEnUS.slice(
0,
brandingSubtitleMaximumLength
),
...(value.logoDataUrl &&
isValidBrandingLogoDataUrl(value.logoDataUrl)
? { logoDataUrl: value.logoDataUrl }
: {})
}
}
export function loadBrandingPreferences(): BrandingPreferences {
try {
const value = localStorage.getItem(storageKey)
if (!value) {
return { ...defaultBrandingPreferences }
}
const parsed = JSON.parse(value) as unknown
if (
typeof parsed !== 'object' ||
parsed === null ||
typeof (parsed as Record<string, unknown>).name !== 'string' ||
typeof (parsed as Record<string, unknown>).subtitleZhCN !==
'string' ||
typeof (parsed as Record<string, unknown>).subtitleEnUS !==
'string'
) {
return { ...defaultBrandingPreferences }
}
const candidate = parsed as BrandingPreferences
return normalizeBrandingPreferences({
name: candidate.name,
subtitleZhCN: candidate.subtitleZhCN,
subtitleEnUS: candidate.subtitleEnUS,
...(typeof candidate.logoDataUrl === 'string'
? { logoDataUrl: candidate.logoDataUrl }
: {})
})
} catch {
return { ...defaultBrandingPreferences }
}
}
export function saveBrandingPreferences(
value: BrandingPreferences
): boolean {
try {
localStorage.setItem(
storageKey,
JSON.stringify(normalizeBrandingPreferences(value))
)
return true
} catch {
return false
}
}
export function brandingPreferencesEqual(
left: BrandingPreferences,
right: BrandingPreferences
): boolean {
return (
left.name === right.name &&
left.subtitleZhCN === right.subtitleZhCN &&
left.subtitleEnUS === right.subtitleEnUS &&
left.logoDataUrl === right.logoDataUrl
)
}
+82
View File
@@ -0,0 +1,82 @@
export function readFileAsDataUrl(
file: Blob,
mimeType: string,
signal?: AbortSignal
): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
const cleanup = (): void => {
signal?.removeEventListener('abort', abort)
reader.onload = null
reader.onerror = null
reader.onabort = null
}
const fail = (): void => {
cleanup()
reject(new Error('File could not be read'))
}
const abort = (): void => {
reader.abort()
}
if (signal?.aborted) {
fail()
return
}
signal?.addEventListener('abort', abort, { once: true })
reader.onload = () => {
if (typeof reader.result !== 'string') {
fail()
return
}
const separatorIndex = reader.result.indexOf(',')
if (separatorIndex < 0) {
fail()
return
}
const dataUrl =
`data:${mimeType};base64,${reader.result.slice(separatorIndex + 1)}`
cleanup()
resolve(dataUrl)
}
reader.onerror = fail
reader.onabort = fail
reader.readAsDataURL(file)
})
}
export function loadImageDimensions(
source: string,
signal?: AbortSignal
): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const image = new Image()
const cleanup = (): void => {
signal?.removeEventListener('abort', abort)
image.onload = null
image.onerror = null
}
const abort = (): void => {
cleanup()
image.src = ''
reject(new Error('Image loading was cancelled'))
}
if (signal?.aborted) {
abort()
return
}
signal?.addEventListener('abort', abort, { once: true })
image.onload = () => {
cleanup()
resolve({
width: image.naturalWidth,
height: image.naturalHeight
})
}
image.onerror = () => {
cleanup()
reject(new Error('Image could not be decoded'))
}
image.src = source
})
}
+1 -3
View File
@@ -2,9 +2,6 @@ import type { TranslationShape } from '../../resource-types'
import type { app as chineseApp } from '../zh-CN/app'
export const app = {
brand: {
subtitle: 'Intelligent Workspace'
},
notifications: {
success: 'Success',
error: 'Error',
@@ -567,6 +564,7 @@ export const app = {
}
},
notices: {
brandingSaved: 'Branding saved',
updateAvailable:
'GoodBuddy {{version}} is available. View it in About & Updates.',
remoteProjectActivated:
@@ -16,8 +16,8 @@ export const settings = {
categories: {
appearance: {
label: 'Appearance',
navigationDescription: 'Theme and interface language',
description: 'Theme and interface language'
navigationDescription: 'Theme, language, and branding',
description: 'Theme, language, and brand customization'
},
platformFeatures: {
label: 'Platform features',
@@ -879,6 +879,36 @@ export const settings = {
description: 'Use the English interface'
}
}
},
branding: {
title: 'Brand customization',
description:
'Customize the brand name, subtitle, and logo in the top-left corner',
previewLabel: 'Brand preview',
fields: {
name: 'Brand name',
subtitleZhCN: 'Chinese subtitle',
subtitleEnUS: 'English subtitle'
},
logo: {
select: 'Choose logo',
useDefault: 'Use default logo',
help:
'Supports PNG, JPEG, or WebP up to 4096 × 4096 and 512 KB'
},
actions: {
restore: 'Restore defaults',
save: 'Save branding'
},
errors: {
unsupportedLogo: 'Choose a PNG, JPEG, or WebP image',
logoTooLarge: 'The logo file cannot exceed 512 KB',
invalidLogo:
'The logo is invalid, has the wrong format, or is too large',
readLogoFailed: 'The logo could not be read. Choose it again.',
saveFailed:
'Branding could not be saved. Check local storage and try again.'
}
}
}
} satisfies TranslationShape<typeof chineseSettings>
+1 -3
View File
@@ -1,7 +1,4 @@
export const app = {
brand: {
subtitle: '智能工作台'
},
notifications: {
success: '成功',
error: '错误',
@@ -533,6 +530,7 @@ export const app = {
}
},
notices: {
brandingSaved: '品牌设置已保存',
updateAvailable: '发现 GoodBuddy {{version}},可在“关于与更新”中查看',
remoteProjectActivated: '远程项目 Agent 和 Runtime 已就绪',
remoteProjectActivationFailed:
@@ -12,8 +12,8 @@ export const settings = {
categories: {
appearance: {
label: '外观',
navigationDescription: '主题与界面语言',
description: '主题与界面语言'
navigationDescription: '主题、语言与品牌',
description: '主题、语言与品牌定制'
},
platformFeatures: {
label: '平台功能',
@@ -809,6 +809,33 @@ export const settings = {
description: 'Use the English interface'
}
}
},
branding: {
title: '品牌定制',
description: '定制左上角品牌名称、副标题与 Logo',
previewLabel: '品牌效果预览',
fields: {
name: '品牌名称',
subtitleZhCN: '中文副标题',
subtitleEnUS: '英文副标题'
},
logo: {
select: '选择 Logo',
useDefault: '使用默认 Logo',
help:
'支持 PNG、JPEG 或 WebP,尺寸不超过 4096 × 4096,文件不能超过 512 KB'
},
actions: {
restore: '恢复默认',
save: '保存品牌设置'
},
errors: {
unsupportedLogo: '请选择 PNG、JPEG 或 WebP 图片',
logoTooLarge: 'Logo 文件不能超过 512 KB',
invalidLogo: 'Logo 图片无效、格式不符或尺寸过大',
readLogoFailed: '读取 Logo 失败,请重新选择',
saveFailed: '保存品牌设置失败,请检查本地存储后重试'
}
}
}
} as const
+90
View File
@@ -2359,10 +2359,18 @@ textarea:focus-visible {
.brand__copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.brand__copy strong,
.brand__copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand__copy strong {
font-size: 15px;
font-weight: 600;
@@ -13736,6 +13744,84 @@ details.settings-section > :not(summary) + :not(summary) {
gap: 14px;
}
.branding-settings__preview {
display: flex;
align-items: center;
min-height: 76px;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
gap: var(--space-3);
}
.branding-settings__preview-mark {
display: grid;
width: 44px;
height: 44px;
flex: 0 0 44px;
overflow: hidden;
border: 1px solid var(--accent-selected);
border-radius: 12px;
background: var(--accent-subtle);
place-items: center;
}
.branding-settings__preview-mark img {
width: 100%;
height: 100%;
object-fit: cover;
}
.branding-settings__preview-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-1);
}
.branding-settings__preview-copy strong,
.branding-settings__preview-copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.branding-settings__preview-copy strong {
color: var(--text-primary);
font-size: var(--font-section-title);
}
.branding-settings__preview-copy span {
color: var(--text-muted);
font-size: var(--font-caption);
}
.branding-settings__fields {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.branding-settings__logo {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.branding-settings__logo button {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.branding-settings__logo small {
flex-basis: 100%;
color: var(--text-muted);
font-size: var(--font-caption);
}
.appearance-options {
display: grid;
gap: 10px;
@@ -14495,6 +14581,10 @@ details.settings-section > :not(summary) + :not(summary) {
grid-template-columns: 1fr;
}
.branding-settings__fields {
grid-template-columns: 1fr;
}
.workspace-picker {
flex-wrap: wrap;
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { detectSupportedImage } from './image-media-type'
describe('supported image detection', () => {
it.each([
{
bytes: [0xff, 0xd8, 0xff],
extension: 'jpg',
mimeType: 'image/jpeg'
},
{
bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
extension: 'png',
mimeType: 'image/png'
},
{
bytes: [
0x52,
0x49,
0x46,
0x46,
0,
0,
0,
0,
0x57,
0x45,
0x42,
0x50
],
extension: 'webp',
mimeType: 'image/webp'
}
])('detects $mimeType bytes', ({ bytes, extension, mimeType }) => {
expect(detectSupportedImage(Uint8Array.from(bytes))).toEqual({
extension,
mimeType
})
})
it('rejects unsupported bytes', () => {
expect(() =>
detectSupportedImage(Uint8Array.from([1, 2, 3]))
).toThrow('图片格式不受支持')
})
})
+44
View File
@@ -0,0 +1,44 @@
export type SupportedImageFormat = {
extension: 'jpg' | 'png' | 'webp'
mimeType: 'image/jpeg' | 'image/png' | 'image/webp'
}
function matchesAscii(
data: Uint8Array,
offset: number,
value: string
): boolean {
return [...value].every(
(character, index) =>
data[offset + index] === character.charCodeAt(0)
)
}
export function detectSupportedImage(
data: Uint8Array
): SupportedImageFormat {
if (
data.byteLength >= 3 &&
data[0] === 0xff &&
data[1] === 0xd8 &&
data[2] === 0xff
) {
return { extension: 'jpg', mimeType: 'image/jpeg' }
}
if (
data.byteLength >= 8 &&
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every(
(value, index) => data[index] === value
)
) {
return { extension: 'png', mimeType: 'image/png' }
}
if (
data.byteLength >= 12 &&
matchesAscii(data, 0, 'RIFF') &&
matchesAscii(data, 8, 'WEBP')
) {
return { extension: 'webp', mimeType: 'image/webp' }
}
throw new Error('图片格式不受支持')
}