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:
@@ -298,6 +298,13 @@
|
||||
失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时
|
||||
显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠
|
||||
颜色或动效表达状态。
|
||||
- 左上角品牌区允许用户在“外观”设置中定制品牌名称、中英文副标题和单个 Logo。定制只
|
||||
影响该品牌区,不替换安装包名称、更新协议、Agent 标识或应用正文中的 GoodBuddy 产品
|
||||
身份。副标题按当前界面语言显示,留空时隐藏且不保留空行。
|
||||
- 品牌设置必须提供同布局预览、显式保存、默认 Logo 和整组恢复默认操作。自定义 Logo 仅
|
||||
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
|
||||
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
|
||||
单行截断,不能撑宽或挤压侧栏。
|
||||
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||
|
||||
### 6.9 助手工作栏
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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,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('图片格式不受支持')
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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('图片格式不受支持')
|
||||
})
|
||||
})
|
||||
@@ -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('图片格式不受支持')
|
||||
}
|
||||
Reference in New Issue
Block a user