From c6799cf6a8db5281789db04583fe180f46b9ce62 Mon Sep 17 00:00:00 2001 From: kilock <731052835@qq.com> Date: Sat, 16 May 2026 10:10:45 +0800 Subject: [PATCH 1/6] fix(web): restore ui surfaces --- src/apps/shared/src/assistantTurn.ts | 11 ++ src/apps/shared/src/desktop.ts | 10 ++ .../src/__tests__/advancedSettings.test.tsx | 44 +++++ src/apps/web/src/__tests__/appUI.test.tsx | 4 +- .../src/__tests__/appearanceStorage.test.ts | 18 +++ .../__tests__/assistantTurnSegments.test.ts | 23 +++ .../chatInputPersonaSelector.test.tsx | 44 +---- .../src/__tests__/chatPageLoading.test.tsx | 7 +- .../desktopChannelsSettings.test.tsx | 90 +++++++++++ .../__tests__/documentPanelPreview.test.tsx | 153 ++++-------------- src/apps/web/src/components/ChatInput.tsx | 16 +- src/apps/web/src/components/ChatView.tsx | 6 +- .../web/src/components/DesktopSettings.tsx | 6 +- .../web/src/components/DesktopTitleBar.tsx | 2 +- src/apps/web/src/components/SettingsModal.tsx | 4 +- src/apps/web/src/components/Sidebar.tsx | 18 +-- src/apps/web/src/components/WelcomePage.tsx | 2 +- .../components/settings/AdvancedSettings.tsx | 87 +++++++++- .../settings/AppearanceSettings.tsx | 11 +- .../settings/DesktopQQSettingsPanel.tsx | 70 +++++--- src/apps/web/src/index.css | 9 ++ src/apps/web/src/locales/en.ts | 2 + src/apps/web/src/locales/index.ts | 2 + src/apps/web/src/locales/zh.ts | 2 + src/apps/web/src/storage.ts | 31 +++- 25 files changed, 430 insertions(+), 242 deletions(-) diff --git a/src/apps/shared/src/assistantTurn.ts b/src/apps/shared/src/assistantTurn.ts index 8a3dc54e0..13ad59279 100644 --- a/src/apps/shared/src/assistantTurn.ts +++ b/src/apps/shared/src/assistantTurn.ts @@ -63,6 +63,17 @@ export function splitWorkGroup( const finalSegment = segments[lastTextIndex]! const finalText = finalSegment.type === 'text' ? finalSegment.content : null + if (lastTextIndex < segments.length - 1) { + const workGroupSegments = [ + ...segments.slice(0, lastTextIndex), + ...segments.slice(lastTextIndex + 1), + ] + return { + workGroup: workGroupSegments.length > 0 ? { durationMs, segments: workGroupSegments } : null, + finalText, + } + } + // Need at least 2 segments before final to justify a work group. // A single pre-final segment (text or cop) stays inline. if (lastTextIndex < 2) { diff --git a/src/apps/shared/src/desktop.ts b/src/apps/shared/src/desktop.ts index ba6c5fa18..4c24db5e9 100644 --- a/src/apps/shared/src/desktop.ts +++ b/src/apps/shared/src/desktop.ts @@ -360,8 +360,18 @@ export type DesktopExportSection = | 'themes' export type DesktopThemeExportPayload = { + themePreset?: string | null customThemeId: string | null customThemes: Record + backgroundImage?: { + dataUrl: string + name: string + mimeType: string + size: number + updatedAt: number + } | null + backgroundImageOpacity?: number | null + sidebarGrouping?: 'normal' | 'gtd' | null } export type DesktopExportOptions = { diff --git a/src/apps/web/src/__tests__/advancedSettings.test.tsx b/src/apps/web/src/__tests__/advancedSettings.test.tsx index 51e0a38cb..f7521e0dd 100644 --- a/src/apps/web/src/__tests__/advancedSettings.test.tsx +++ b/src/apps/web/src/__tests__/advancedSettings.test.tsx @@ -102,6 +102,7 @@ describe('AdvancedSettings', () => { return { ...actual, readLocaleFromStorage: vi.fn(() => 'zh'), + readGtdEnabled: vi.fn(() => false), writeLocaleToStorage: vi.fn(), } }) @@ -140,10 +141,16 @@ describe('AdvancedSettings', () => { })) vi.doMock('../contexts/AppearanceContext', () => ({ useAppearance: () => ({ + themePreset: 'default', + setThemePreset: vi.fn(), customThemeId: null, customThemes: {}, saveCustomTheme: vi.fn(), setActiveCustomTheme: vi.fn(), + backgroundImage: null, + setBackgroundImage: vi.fn(() => true), + backgroundImageOpacity: 40, + setBackgroundImageOpacity: vi.fn(), }), })) vi.doMock('@arkloop/shared', async () => { @@ -228,6 +235,7 @@ describe('AdvancedSettings', () => { return { ...actual, readLocaleFromStorage: vi.fn(() => 'zh'), + readGtdEnabled: vi.fn(() => true), writeLocaleToStorage: vi.fn(), } }) @@ -255,10 +263,22 @@ describe('AdvancedSettings', () => { })) vi.doMock('../contexts/AppearanceContext', () => ({ useAppearance: () => ({ + themePreset: 'background-image', + setThemePreset: vi.fn(), customThemeId: null, customThemes: {}, saveCustomTheme: vi.fn(), setActiveCustomTheme: vi.fn(), + backgroundImage: { + dataUrl: 'data:image/png;base64,Ymc=', + name: 'bg.png', + mimeType: 'image/png', + size: 2, + updatedAt: 1234, + }, + setBackgroundImage: vi.fn(() => true), + backgroundImageOpacity: 55, + setBackgroundImageOpacity: vi.fn(), }), })) vi.doMock('@arkloop/shared', async () => { @@ -311,5 +331,29 @@ describe('AdvancedSettings', () => { '自定义主题', ]) expect(exportDataBundle).not.toHaveBeenCalled() + + const exportLabel = exportButton!.textContent?.trim() + const confirmExportButton = Array.from(document.body.querySelectorAll('button')) + .filter((button) => button.textContent?.trim() === exportLabel) + .at(-1) + expect(confirmExportButton).toBeTruthy() + + await act(async () => { + confirmExportButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await flushEffects() + + expect(exportDataBundle).toHaveBeenCalledWith(expect.objectContaining({ + sections: expect.arrayContaining(['themes']), + themes: expect.objectContaining({ + themePreset: 'background-image', + backgroundImage: expect.objectContaining({ + dataUrl: 'data:image/png;base64,Ymc=', + name: 'bg.png', + }), + backgroundImageOpacity: 55, + sidebarGrouping: 'gtd', + }), + })) }) }) diff --git a/src/apps/web/src/__tests__/appUI.test.tsx b/src/apps/web/src/__tests__/appUI.test.tsx index 286288305..ba676d1fb 100644 --- a/src/apps/web/src/__tests__/appUI.test.tsx +++ b/src/apps/web/src/__tests__/appUI.test.tsx @@ -384,7 +384,7 @@ describe('DesktopTitleBar update entry', () => { await renderTitleBar(appUpdateState('available'), true) const titleBar = container.firstElementChild as HTMLElement | null - expect(titleBar?.style.paddingLeft).toBe('8px') + expect(titleBar?.style.paddingLeft).toBe('12px') expect(container.querySelector('button[title="Minimize"]')).toBeNull() }) @@ -394,7 +394,7 @@ describe('DesktopTitleBar update entry', () => { await renderTitleBar(appUpdateState('available'), true) const titleBar = container.firstElementChild as HTMLElement | null - expect(titleBar?.style.paddingLeft).toBe('8px') + expect(titleBar?.style.paddingLeft).toBe('12px') expect(container.querySelector('button[title="Minimize"]')).toBeNull() }) diff --git a/src/apps/web/src/__tests__/appearanceStorage.test.ts b/src/apps/web/src/__tests__/appearanceStorage.test.ts index efbe5c01e..3ae097252 100644 --- a/src/apps/web/src/__tests__/appearanceStorage.test.ts +++ b/src/apps/web/src/__tests__/appearanceStorage.test.ts @@ -4,9 +4,12 @@ import type { ThemeBackgroundImage } from '../themes/types' import { readBackgroundImageFromStorage, readBackgroundImageOpacityFromStorage, + readGtdEnabled, readThemePresetFromStorage, + subscribeGtdEnabled, writeBackgroundImageToStorage, writeBackgroundImageOpacityToStorage, + writeGtdEnabled, writeThemePresetToStorage, } from '../storage' @@ -105,4 +108,19 @@ describe('appearance storage', () => { writeThemePresetToStorage('background-image') expect(readThemePresetFromStorage()).toBe('background-image') }) + + it('同步 GTD 分组状态变更', () => { + const observed: boolean[] = [] + const unsubscribe = subscribeGtdEnabled((enabled) => observed.push(enabled)) + + writeGtdEnabled(true) + expect(readGtdEnabled()).toBe(true) + expect(observed).toEqual([true]) + + writeGtdEnabled(false) + expect(readGtdEnabled()).toBe(false) + expect(observed).toEqual([true, false]) + + unsubscribe() + }) }) diff --git a/src/apps/web/src/__tests__/assistantTurnSegments.test.ts b/src/apps/web/src/__tests__/assistantTurnSegments.test.ts index e53017985..4a6269092 100644 --- a/src/apps/web/src/__tests__/assistantTurnSegments.test.ts +++ b/src/apps/web/src/__tests__/assistantTurnSegments.test.ts @@ -7,6 +7,7 @@ import { finalizeAssistantTurnFoldState, foldAssistantTurnEvent, requestAssistantTurnThinkingBreak, + splitWorkGroup, } from '../assistantTurnSegments' import { normalizeAgentEventData, @@ -46,6 +47,28 @@ function th(content: string, seq: number, endedByEventSeq?: number) { return { kind: 'thinking' as const, content, seq, startedAtMs, endedAtMs } } +describe('splitWorkGroup', () => { + it('keeps final text visible when a tool segment follows it', () => { + const tailToolSegment = { + type: 'cop' as const, + title: null, + items: [{ + kind: 'call' as const, + call: { toolCallId: 'terminal_1', toolName: 'terminal_run', arguments: {} }, + seq: 2, + }], + } + + const split = splitWorkGroup([ + { type: 'text', content: 'done' }, + tailToolSegment, + ], 1200) + + expect(split.finalText).toBe('done') + expect(split.workGroup?.segments).toEqual([tailToolSegment]) + }) +}) + describe('buildAssistantTurnFromAgentEvents', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx b/src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx index 9e2702949..b6386b460 100644 --- a/src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx +++ b/src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx @@ -128,7 +128,7 @@ describe('ChatInput persona selector', () => { } }) - it('按动态列表循环切换并可从下拉选择人格', async () => { + it('不再从加号菜单加载动态人格,提交沿用已选人格', async () => { const onSubmit = vi.fn<(event: FormEvent, personaKey: string) => void>((event) => event.preventDefault()) const container = document.createElement('div') document.body.appendChild(container) @@ -148,45 +148,9 @@ describe('ChatInput persona selector', () => { await flushMicrotasks() }) - expect(mockedListSelectablePersonas).toHaveBeenCalledWith('token') - - const selectorButton = findButtonByText(container, 'Normal') - expect(selectorButton).not.toBeNull() - if (!selectorButton) return - - await act(async () => { - selectorButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - const searchMenuButton = Array.from(container.querySelectorAll('button')).find( - (button) => button !== selectorButton && button.textContent?.trim() === 'Search', - ) as HTMLButtonElement | null - expect(searchMenuButton).not.toBeNull() - if (!searchMenuButton) return - - await act(async () => { - searchMenuButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(findButtonByText(container, 'Search')).not.toBeNull() - - const searchSelectorButton = findButtonByText(container, 'Search') - expect(searchSelectorButton).not.toBeNull() - if (!searchSelectorButton) return - - await act(async () => { - searchSelectorButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - const menuNormalButton = Array.from(container.querySelectorAll('button')).find( - (button) => button !== searchSelectorButton && button.textContent?.trim() === 'Normal', - ) as HTMLButtonElement | null - expect(menuNormalButton).not.toBeNull() - if (!menuNormalButton) return - - await act(async () => { - menuNormalButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) + expect(mockedListSelectablePersonas).not.toHaveBeenCalled() + expect(findButtonByText(container, 'Normal')).toBeFalsy() + expect(findButtonByText(container, 'Search')).toBeFalsy() const form = container.querySelector('form') expect(form).not.toBeNull() diff --git a/src/apps/web/src/__tests__/chatPageLoading.test.tsx b/src/apps/web/src/__tests__/chatPageLoading.test.tsx index 5046d72b8..de5f84e09 100644 --- a/src/apps/web/src/__tests__/chatPageLoading.test.tsx +++ b/src/apps/web/src/__tests__/chatPageLoading.test.tsx @@ -1001,7 +1001,10 @@ describe('ChatPage loading state', () => { mockedReadMessageTerminalStatus.mockReturnValue(null) }) - afterEach(() => { + afterEach(async () => { + sseMock.clearEventListeners() + sseMock.events = [] + await flushMicrotasks() HTMLElement.prototype.scrollIntoView = originalScrollIntoView if (originalActEnvironment === undefined) { delete actEnvironment.IS_REACT_ACT_ENVIRONMENT @@ -3740,6 +3743,7 @@ describe('ChatPage loading state', () => { expect(container.textContent ?? '').toContain('streaming') expect(sseMock.reset).toHaveBeenCalled() expect(sseMock.connect).toHaveBeenCalled() + expect(sseMock.subscribeEvents).toHaveBeenCalled() }) await act(async () => { @@ -3792,6 +3796,7 @@ describe('ChatPage loading state', () => { root.render(renderTree()) await flushMicrotasks() await flushMicrotasks() + await flushAnimationFrames(2) }) await waitForAssertion(() => { diff --git a/src/apps/web/src/__tests__/desktopChannelsSettings.test.tsx b/src/apps/web/src/__tests__/desktopChannelsSettings.test.tsx index b650dc7c0..960b316b9 100644 --- a/src/apps/web/src/__tests__/desktopChannelsSettings.test.tsx +++ b/src/apps/web/src/__tests__/desktopChannelsSettings.test.tsx @@ -118,6 +118,9 @@ async function loadChannelsSubject() { updateChannel: vi.fn(), verifyChannel: vi.fn(), createChannelBindCode: vi.fn(), + listChannelBindings: vi.fn().mockResolvedValue([]), + updateChannelBinding: vi.fn(), + deleteChannelBinding: vi.fn(), unbindChannelIdentity: vi.fn(), isApiError: vi.fn(() => false), } @@ -510,6 +513,93 @@ describe('DesktopChannelsSettings', () => { expect(document.body.textContent).toContain('app-123') }) + it('可以保存 QQ OneBot 的 Bot 名称配置', async () => { + const { api, DesktopChannelsSettings, LocaleProvider } = await loadChannelsSubject() + const qqChannel = { + id: 'qq-1', + account_id: 'acc-1', + channel_type: 'qq', + persona_id: 'persona-1', + webhook_url: null, + is_active: true, + config_json: { + onebot_ws_url: 'ws://127.0.0.1:6098', + onebot_http_url: 'http://127.0.0.1:3000', + onebot_token: 'secret', + bot_name: 'Old Bot', + allowed_user_ids: ['10001'], + allowed_group_ids: ['20001'], + }, + has_credentials: true, + created_at: '2026-03-26T00:00:00Z', + updated_at: '2026-03-26T00:00:00Z', + } + vi.mocked(api.listChannels).mockResolvedValue([qqChannel]) + vi.mocked(api.listMyChannelIdentities).mockResolvedValue([]) + vi.mocked(api.listChannelPersonas).mockResolvedValue([ + { + id: 'persona-1', + persona_key: 'normal', + version: '1', + display_name: 'Normal', + source: 'project', + } as never, + ]) + vi.mocked(api.listLlmProviders).mockResolvedValue([]) + vi.mocked(api.listChannelBindings).mockResolvedValue([]) + vi.mocked(api.updateChannel).mockResolvedValue({ + ...qqChannel, + config_json: { + ...qqChannel.config_json, + bot_name: 'New Bot', + }, + }) + + await act(async () => { + root!.render( + + + , + ) + }) + await flushEffects() + + const qqOneBotTab = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('OneBot')) + expect(qqOneBotTab).toBeTruthy() + + await act(async () => { + qqOneBotTab!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await flushEffects() + + const botNameInput = Array.from(document.body.querySelectorAll('input')).find((input) => input.value === 'Old Bot') as HTMLInputElement + expect(botNameInput).toBeTruthy() + + await act(async () => { + setInputValue(botNameInput, 'New Bot') + }) + await flushEffects() + + const saveButton = Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.trim() === '保存') + await act(async () => { + saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await flushEffects() + + expect(api.updateChannel).toHaveBeenCalledWith('token', 'qq-1', { + persona_id: 'persona-1', + is_active: true, + config_json: { + onebot_ws_url: 'ws://127.0.0.1:6098', + onebot_http_url: 'http://127.0.0.1:3000', + onebot_token: 'secret', + bot_name: 'New Bot', + allowed_user_ids: ['10001'], + allowed_group_ids: ['20001'], + }, + }) + }) + it('可以创建飞书官方渠道并提交官方接入配置', async () => { const { api, DesktopChannelsSettings, LocaleProvider } = await loadChannelsSubject() vi.mocked(api.listChannels).mockResolvedValue([]) diff --git a/src/apps/web/src/__tests__/documentPanelPreview.test.tsx b/src/apps/web/src/__tests__/documentPanelPreview.test.tsx index d4972ead8..72d0b2b75 100644 --- a/src/apps/web/src/__tests__/documentPanelPreview.test.tsx +++ b/src/apps/web/src/__tests__/documentPanelPreview.test.tsx @@ -1,98 +1,35 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act } from 'react' -import { createRoot } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' -import { ResourcePreviewPanel } from '../components/resource-preview/ResourcePreviewPanel' -import { LocaleProvider } from '../contexts/LocaleContext' +import { PreviewResourceView } from '../components/resource-preview/PreviewResourceView' +import type { PreviewResource } from '../components/resource-preview/types' import type { ArtifactRef } from '../storage' -type URLWithObjectURL = typeof URL & { - createObjectURL?: (object: Blob) => string - revokeObjectURL?: (url: string) => void -} - -type GlobalWithActEnvironment = typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean -} - -const originalRAF = globalThis.requestAnimationFrame -const originalCAF = globalThis.cancelAnimationFrame - -function flushMicrotasks(): Promise { - return Promise.resolve() - .then(() => Promise.resolve()) - .then(() => Promise.resolve()) - .then(() => Promise.resolve()) -} - -async function flushPreviewWork(): Promise { - for (let i = 0; i < 8; i++) { - await flushMicrotasks() - await new Promise((resolve) => setTimeout(resolve, 0)) +vi.mock('../components/ArtifactHtmlPreview', async () => { + const { createElement } = await import('react') + return { + ArtifactHtmlPreview: ({ artifact }: { artifact: ArtifactRef }) => createElement('div', { + 'data-artifact-html-preview': artifact.key, + 'data-title': artifact.title ?? artifact.filename, + }), } -} +}) describe('ResourcePreviewPanel artifact preview', () => { - const urlWithObjectURL = URL as URLWithObjectURL - const actEnvironmentGlobal = globalThis as GlobalWithActEnvironment - const originalCreateObjectURL = urlWithObjectURL.createObjectURL - const originalRevokeObjectURL = urlWithObjectURL.revokeObjectURL - const originalFetch = globalThis.fetch - const originalActEnvironment = actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT - - beforeEach(() => { - actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT = true - globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { - cb(performance.now()) - return 0 - } - globalThis.cancelAnimationFrame = () => {} - urlWithObjectURL.createObjectURL = vi.fn(() => 'blob:artifact-preview') - urlWithObjectURL.revokeObjectURL = vi.fn() - globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url - if (url.endsWith('/doc.md')) { - return new Response('[预览](artifact:preview.html)', { - headers: { 'Content-Type': 'text/markdown' }, - }) - } - if (url.endsWith('/preview.html')) { - return new Response('ok', { - headers: { 'Content-Type': 'text/html' }, - }) - } - return new Response('not-found', { status: 404 }) - }) - }) - - afterEach(() => { - if (originalCreateObjectURL) { - urlWithObjectURL.createObjectURL = originalCreateObjectURL - } else { - Reflect.deleteProperty(urlWithObjectURL, 'createObjectURL') - } - if (originalRevokeObjectURL) { - urlWithObjectURL.revokeObjectURL = originalRevokeObjectURL - } else { - Reflect.deleteProperty(urlWithObjectURL, 'revokeObjectURL') - } - globalThis.fetch = originalFetch - if (originalActEnvironment === undefined) { - delete actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT - } else { - actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment - } - globalThis.requestAnimationFrame = originalRAF - globalThis.cancelAnimationFrame = originalCAF - vi.restoreAllMocks() - }) - - it('Markdown 文档中的 html artifact 应继续内联渲染', async () => { - const markdownArtifact: ArtifactRef = { - key: 'doc.md', + it('Markdown 文档中的 html artifact 应继续内联渲染', () => { + const markdownResource: PreviewResource = { + source: 'artifact', + ref: { + kind: 'artifact', + key: 'doc.md', + filename: 'doc.md', + mimeType: 'text/markdown', + size: 10, + }, filename: 'doc.md', + mimeType: 'text/markdown', size: 10, - mime_type: 'text/markdown', + text: '[预览](artifact:preview.html)', } const htmlArtifact: ArtifactRef = { key: 'preview.html', @@ -101,39 +38,15 @@ describe('ResourcePreviewPanel artifact preview', () => { mime_type: 'text/html', } - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - - await act(async () => { - root.render( - - {}} - /> - , - ) - }) - - await act(async () => { - await flushPreviewWork() - }) - - expect(globalThis.fetch).toHaveBeenCalledTimes(2) - expect(container.querySelector('iframe')).not.toBeNull() + const html = renderToStaticMarkup( + , + ) - act(() => { - root.unmount() - }) - container.remove() + expect(html).toContain('data-artifact-html-preview="preview.html"') + expect(html).toContain('data-title="preview.html"') }) }) diff --git a/src/apps/web/src/components/ChatInput.tsx b/src/apps/web/src/components/ChatInput.tsx index 1b2f36436..b3e08d0ef 100644 --- a/src/apps/web/src/components/ChatInput.tsx +++ b/src/apps/web/src/components/ChatInput.tsx @@ -763,16 +763,12 @@ export const ChatInput = forwardRef(function ChatInput({ return () => cancelAnimationFrame(id) }, [persistSelectedPersona, searchMode, selectedPersonaKey]) - // sync persona when appMode changes - useEffect(() => { - const id = requestAnimationFrame(() => { - if (appMode === 'work' && selectedPersonaKey !== WORK_PERSONA_KEY) { - persistSelectedPersona(WORK_PERSONA_KEY) - } else if (appMode !== 'work' && selectedPersonaKey === WORK_PERSONA_KEY) { - persistSelectedPersona(DEFAULT_PERSONA_KEY) - } - }) - return () => cancelAnimationFrame(id) + useLayoutEffect(() => { + if (appMode === 'work' && selectedPersonaKey !== WORK_PERSONA_KEY) { + persistSelectedPersona(WORK_PERSONA_KEY) + } else if (appMode !== 'work' && selectedPersonaKey === WORK_PERSONA_KEY) { + persistSelectedPersona(DEFAULT_PERSONA_KEY) + } }, [persistSelectedPersona, appMode, selectedPersonaKey]) const typewriterTarget = placeholder diff --git a/src/apps/web/src/components/ChatView.tsx b/src/apps/web/src/components/ChatView.tsx index c251b15f3..9a0b782d5 100644 --- a/src/apps/web/src/components/ChatView.tsx +++ b/src/apps/web/src/components/ChatView.tsx @@ -3434,7 +3434,7 @@ export const ChatView = memo(function ChatView() { ]) return ( -
+
{/* Chat column + right panel: starts below the desktop Chat/Work titlebar. */}
-
+
{/* 消息列表 */} {messageListArea} @@ -3466,7 +3466,7 @@ export const ChatView = memo(function ChatView() { left: 0, right: 0, zIndex: 10, - background: 'linear-gradient(to bottom, transparent 0%, var(--c-bg-page-gradient-stop, var(--c-bg-page)) 24px)', + background: 'linear-gradient(to bottom, transparent 0%, var(--c-chat-bg-gradient-stop, var(--c-bg-page-gradient-stop, var(--c-bg-page))) 24px)', transition: `padding ${rightPanelLayoutTransitionCss}`, } as React.CSSProperties} className="flex w-full flex-col items-center gap-2" diff --git a/src/apps/web/src/components/DesktopSettings.tsx b/src/apps/web/src/components/DesktopSettings.tsx index 39686ea18..62d8be366 100644 --- a/src/apps/web/src/components/DesktopSettings.tsx +++ b/src/apps/web/src/components/DesktopSettings.tsx @@ -585,7 +585,7 @@ export function DesktopSettings({ return ( <>
diff --git a/src/apps/web/src/components/DesktopTitleBar.tsx b/src/apps/web/src/components/DesktopTitleBar.tsx index 6af41e810..7227a5101 100644 --- a/src/apps/web/src/components/DesktopTitleBar.tsx +++ b/src/apps/web/src/components/DesktopTitleBar.tsx @@ -32,7 +32,7 @@ import { formatDesktopAppVersion } from '../desktopVersion' export const DESKTOP_TITLEBAR_HEIGHT = 44 const WINDOWS_TITLEBAR_HEIGHT = 44 const MAC_TITLEBAR_LEFT_PADDING = 76 -const DESKTOP_ICON_RAIL_LEFT_PADDING = 8 +const DESKTOP_ICON_RAIL_LEFT_PADDING = 12 type Props = { sidebarCollapsed: boolean diff --git a/src/apps/web/src/components/SettingsModal.tsx b/src/apps/web/src/components/SettingsModal.tsx index f190d6ad8..0940a8ea2 100644 --- a/src/apps/web/src/components/SettingsModal.tsx +++ b/src/apps/web/src/components/SettingsModal.tsx @@ -86,7 +86,7 @@ export function SettingsModal({ me, accessToken, initialTab = 'account', onClose onMouseDown={(e) => { if (e.target === e.currentTarget) onClose() }} >
{/* nav */}
diff --git a/src/apps/web/src/components/Sidebar.tsx b/src/apps/web/src/components/Sidebar.tsx index 5e2d456ff..4ba4b783d 100644 --- a/src/apps/web/src/components/Sidebar.tsx +++ b/src/apps/web/src/components/Sidebar.tsx @@ -39,7 +39,7 @@ import { readGtdSomedayThreadIds, writeGtdSomedayThreadIds, readGtdArchivedThreadIds, writeGtdArchivedThreadIds, readPinnedThreadIds, writePinnedThreadIds, - readGtdEnabled, readExpandedProjectPaths, writeExpandedProjectPaths, + readGtdEnabled, readExpandedProjectPaths, subscribeGtdEnabled, writeExpandedProjectPaths, clearThreadWorkFolder, readThreadWorkFolder, writeThreadWorkFolder, clearWorkFolder, writeWorkFolder, } from '../storage' @@ -60,7 +60,6 @@ const PROJECT_GROUP_SECONDARY_PAGE_SIZE = 2 const PROJECT_GROUP_LABEL_WEIGHT = 'var(--c-sidebar-thread-weight)' const SIDEBAR_ROW_TEXT_SIZE = '13.5px' const SIDEBAR_ROW_LINE_HEIGHT = '20px' -const GTD_ENABLED_STORAGE_KEY = 'arkloop:web:gtd_enabled' const DRAG_START_DISTANCE_PX = 3 const DRAG_LONG_PRESS_DELAY_MS = 180 const GTD_BUCKETS: readonly GtdBucket[] = ['inbox', 'todo', 'waiting', 'someday', 'archived'] @@ -1129,20 +1128,7 @@ export function Sidebar({ }, [deleteConfirmThreadId]) useEffect(() => { - const handler = (e: Event) => { - const enabled = (e as CustomEvent).detail - setGtdEnabled(enabled) - } - const storageHandler = (e: StorageEvent) => { - if (e.key !== GTD_ENABLED_STORAGE_KEY) return - setGtdEnabled(e.newValue === 'true') - } - window.addEventListener('arkloop:gtd-enabled-changed', handler) - window.addEventListener('storage', storageHandler) - return () => { - window.removeEventListener('arkloop:gtd-enabled-changed', handler) - window.removeEventListener('storage', storageHandler) - } + return subscribeGtdEnabled(setGtdEnabled) }, []) // 监听 work folder 变更,触发重新渲染 diff --git a/src/apps/web/src/components/WelcomePage.tsx b/src/apps/web/src/components/WelcomePage.tsx index fc8bcfd2c..e4d6c82d4 100644 --- a/src/apps/web/src/components/WelcomePage.tsx +++ b/src/apps/web/src/components/WelcomePage.tsx @@ -502,7 +502,7 @@ export function WelcomePage() { return (
-
+
{/* 顶部 header */}
{!isDesktop() && ( diff --git a/src/apps/web/src/components/settings/AdvancedSettings.tsx b/src/apps/web/src/components/settings/AdvancedSettings.tsx index 10f5e9eb1..f12573fb1 100644 --- a/src/apps/web/src/components/settings/AdvancedSettings.tsx +++ b/src/apps/web/src/components/settings/AdvancedSettings.tsx @@ -32,7 +32,8 @@ import type { MeDailyUsageItem, MeHourlyUsageItem, MeModelUsageItem, MeUsageSumm import { getMyDailyUsage, getMyHourlyUsage, getMyUsage, getMyUsageByModel } from '../../api' import { useAppearance } from '../../contexts/AppearanceContext' import { useLocale } from '../../contexts/LocaleContext' -import type { ThemeDefinition } from '../../themes/types' +import type { ThemeBackgroundImage, ThemeDefinition, ThemePreset } from '../../themes/types' +import { readGtdEnabled, writeGtdEnabled } from '../../storage' import { SettingsSection } from './_SettingsSection' import { SettingsSectionHeader } from './_SettingsSectionHeader' import { settingsInputCls } from './_SettingsInput' @@ -78,6 +79,7 @@ const DESKTOP_EXPORT_SECTIONS: DesktopExportSection[] = [ const AGENT_IMPORT_ITEM_KEYS = ['identity', 'skills', 'mcp', 'providers'] as const const AGENT_IMPORT_SOURCE_ORDER: ImportSourceKind[] = ['openclaw', 'hermes'] +const THEME_PRESETS: readonly ThemePreset[] = ['default', 'terra', 'github', 'nord', 'catppuccin', 'tokyo-night', 'retina-burn', 'background-image', 'custom'] function createDefaultAgentImportSelection(): Record { return { @@ -95,6 +97,37 @@ function createAgentImportSelections(sources: AgentImportDiscovery[]): AgentImpo }, {}) } +function isThemePreset(value: unknown): value is ThemePreset { + return typeof value === 'string' && THEME_PRESETS.includes(value as ThemePreset) +} + +function normalizeThemeBackgroundImage(value: unknown): ThemeBackgroundImage | null | undefined { + if (value === null) return null + if (!value || typeof value !== 'object') return undefined + const image = value as Record + if ( + typeof image.dataUrl !== 'string' || + typeof image.name !== 'string' || + typeof image.mimeType !== 'string' || + typeof image.size !== 'number' || + typeof image.updatedAt !== 'number' + ) { + return undefined + } + return { + dataUrl: image.dataUrl, + name: image.name, + mimeType: image.mimeType, + size: image.size, + updatedAt: image.updatedAt, + } +} + +function applySidebarGrouping(value: unknown): void { + if (value !== 'normal' && value !== 'gtd') return + writeGtdEnabled(value === 'gtd') +} + function formatUsd(value: number) { return `$${value.toFixed(4)}` } @@ -962,7 +995,18 @@ function DataPane({ onReloadOverview }: { onReloadOverview: () => Promise const ob = t.onboarding const api = getDesktopApi() const { addToast } = useToast() - const { customThemeId, customThemes, saveCustomTheme, setActiveCustomTheme } = useAppearance() + const { + themePreset, + setThemePreset, + customThemeId, + customThemes, + saveCustomTheme, + setActiveCustomTheme, + backgroundImage, + setBackgroundImage, + backgroundImageOpacity, + setBackgroundImageOpacity, + } = useAppearance() const [actionLoading, setActionLoading] = useState<'choose' | 'export' | 'import' | null>(null) const [actionError, setActionError] = useState('') const [exportDialogOpen, setExportDialogOpen] = useState(false) @@ -1034,8 +1078,12 @@ function DataPane({ onReloadOverview }: { onReloadOverview: () => Promise const result = await api.advanced.exportDataBundle({ sections: selectedSections, themes: { + themePreset, customThemeId, customThemes: selectedSections.includes('themes') ? customThemes : {}, + backgroundImage: selectedSections.includes('themes') ? backgroundImage : null, + backgroundImageOpacity: selectedSections.includes('themes') ? backgroundImageOpacity : null, + sidebarGrouping: selectedSections.includes('themes') ? (readGtdEnabled() ? 'gtd' : 'normal') : null, }, }) if (result.canceled) { @@ -1050,7 +1098,7 @@ function DataPane({ onReloadOverview }: { onReloadOverview: () => Promise } finally { setActionLoading(null) } - }, [api, addToast, customThemeId, customThemes, ds.advancedExportCanceled, ds.advancedExportDone, selectedSections, t.requestFailed]) + }, [api, addToast, backgroundImage, backgroundImageOpacity, customThemeId, customThemes, ds.advancedExportCanceled, ds.advancedExportDone, selectedSections, t.requestFailed, themePreset]) const handleImport = useCallback(async () => { if (!api?.advanced) return @@ -1062,15 +1110,38 @@ function DataPane({ onReloadOverview }: { onReloadOverview: () => Promise addToast(ds.advancedImportCanceled, 'neutral') return } - if (result.themes?.customThemes && typeof result.themes.customThemes === 'object') { - for (const value of Object.values(result.themes.customThemes)) { + const importedThemes = result.themes + if (importedThemes?.customThemes && typeof importedThemes.customThemes === 'object') { + for (const value of Object.values(importedThemes.customThemes)) { if (value && typeof value === 'object' && 'id' in value && typeof value.id === 'string') { saveCustomTheme(value as ThemeDefinition) } } - if (result.themes.customThemeId) { - setActiveCustomTheme(result.themes.customThemeId) + } + let importedBackground: ThemeBackgroundImage | null | undefined + if (importedThemes && 'backgroundImage' in importedThemes) { + importedBackground = normalizeThemeBackgroundImage(importedThemes.backgroundImage) + if (importedBackground === undefined) { + throw new Error(t.requestFailed) + } + } + if (importedBackground !== undefined && !setBackgroundImage(importedBackground)) { + throw new Error(t.requestFailed) + } + if (typeof importedThemes?.backgroundImageOpacity === 'number' && Number.isFinite(importedThemes.backgroundImageOpacity)) { + setBackgroundImageOpacity(importedThemes.backgroundImageOpacity) + } + applySidebarGrouping(importedThemes?.sidebarGrouping) + if (isThemePreset(importedThemes?.themePreset)) { + if (importedThemes.themePreset === 'custom' && importedThemes.customThemeId) { + setActiveCustomTheme(importedThemes.customThemeId) + } else if (importedThemes.themePreset === 'background-image') { + setThemePreset('background-image') + } else { + setThemePreset(importedThemes.themePreset) } + } else if (importedThemes?.customThemeId) { + setActiveCustomTheme(importedThemes.customThemeId) } addToast(ds.advancedImportDone, 'success') await onReloadOverview() @@ -1079,7 +1150,7 @@ function DataPane({ onReloadOverview }: { onReloadOverview: () => Promise } finally { setActionLoading(null) } - }, [api, addToast, ds.advancedImportCanceled, ds.advancedImportDone, onReloadOverview, saveCustomTheme, setActiveCustomTheme, t.requestFailed]) + }, [api, addToast, ds.advancedImportCanceled, ds.advancedImportDone, onReloadOverview, saveCustomTheme, setActiveCustomTheme, setBackgroundImage, setBackgroundImageOpacity, setThemePreset, t.requestFailed]) const selectedAgentImportSource = selectedAgentImport ? agentImportSources.find((source) => source.kind === selectedAgentImport) ?? null diff --git a/src/apps/web/src/components/settings/AppearanceSettings.tsx b/src/apps/web/src/components/settings/AppearanceSettings.tsx index 85b1873d1..079526439 100644 --- a/src/apps/web/src/components/settings/AppearanceSettings.tsx +++ b/src/apps/web/src/components/settings/AppearanceSettings.tsx @@ -1,11 +1,11 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import type { LucideIcon } from 'lucide-react' import { Monitor, Sun, Moon } from 'lucide-react' import type { Locale } from '../../locales' import type { Theme } from '@arkloop/shared/contexts/theme' import { useLocale } from '../../contexts/LocaleContext' import { useTheme } from '../../contexts/ThemeContext' -import { readGtdEnabled, writeGtdEnabled } from '../../storage' +import { readGtdEnabled, subscribeGtdEnabled, writeGtdEnabled } from '../../storage' import { FontSettings } from './FontSettings' import { ThemePresetPicker } from './ThemePresetPicker' import { ThemeColorEditor } from './ThemeColorEditor' @@ -267,6 +267,8 @@ export function SidebarGroupingPicker({ showLabel = true }: { showLabel?: boolea const [gtdEnabled, setGtdEnabled] = useState(() => readGtdEnabled()) const [hoveredValue, setHoveredValue] = useState(null) + useEffect(() => subscribeGtdEnabled(setGtdEnabled), []) + const options: { value: boolean; label: string; Preview: () => React.JSX.Element }[] = [ { value: false, label: t.sidebarGroupingNormal, Preview: NormalPreview }, { value: true, label: t.sidebarGroupingGtd, Preview: GtdPreview }, @@ -294,11 +296,6 @@ export function SidebarGroupingPicker({ showLabel = true }: { showLabel?: boolea if (gtdEnabled === value) return setGtdEnabled(value) writeGtdEnabled(value) - window.dispatchEvent(new CustomEvent('arkloop:gtd-enabled-changed', { detail: value })) - window.dispatchEvent(new StorageEvent('storage', { - key: 'arkloop:web:gtd_enabled', - newValue: String(value), - })) }} className="flex flex-col items-center gap-2" > diff --git a/src/apps/web/src/components/settings/DesktopQQSettingsPanel.tsx b/src/apps/web/src/components/settings/DesktopQQSettingsPanel.tsx index f4974508a..98336984f 100644 --- a/src/apps/web/src/components/settings/DesktopQQSettingsPanel.tsx +++ b/src/apps/web/src/components/settings/DesktopQQSettingsPanel.tsx @@ -67,6 +67,7 @@ export function DesktopQQSettingsPanel({ const [onebotWSUrl, setOnebotWSUrl] = useState((channel?.config_json?.onebot_ws_url as string | undefined) ?? '') const [onebotHTTPUrl, setOnebotHTTPUrl] = useState((channel?.config_json?.onebot_http_url as string | undefined) ?? '') const [onebotToken, setOnebotToken] = useState((channel?.config_json?.onebot_token as string | undefined) ?? '') + const [botName, setBotName] = useState((channel?.config_json?.bot_name as string | undefined) ?? '') const [autoLoginUin, setAutoLoginUin] = useState((channel?.config_json?.auto_login_uin as string | undefined) ?? '') const refreshBindings = useCallback(async () => { if (!channel?.id) { @@ -90,6 +91,7 @@ export function DesktopQQSettingsPanel({ setOnebotWSUrl((channel?.config_json?.onebot_ws_url as string | undefined) ?? '') setOnebotHTTPUrl((channel?.config_json?.onebot_http_url as string | undefined) ?? '') setOnebotToken((channel?.config_json?.onebot_token as string | undefined) ?? '') + setBotName((channel?.config_json?.bot_name as string | undefined) ?? '') setAutoLoginUin((channel?.config_json?.auto_login_uin as string | undefined) ?? '') }, [channel, personas]) @@ -124,6 +126,7 @@ export function DesktopQQSettingsPanel({ const persistedOnebotWSUrl = (channel?.config_json?.onebot_ws_url as string | undefined) ?? '' const persistedOnebotHTTPUrl = (channel?.config_json?.onebot_http_url as string | undefined) ?? '' const persistedOnebotToken = (channel?.config_json?.onebot_token as string | undefined) ?? '' + const persistedBotName = (channel?.config_json?.bot_name as string | undefined) ?? '' const persistedAutoLoginUin = (channel?.config_json?.auto_login_uin as string | undefined) ?? '' const dirty = useMemo(() => { if ((channel?.is_active ?? false) !== enabled) return true @@ -133,9 +136,11 @@ export function DesktopQQSettingsPanel({ if (onebotWSUrl !== persistedOnebotWSUrl) return true if (onebotHTTPUrl !== persistedOnebotHTTPUrl) return true if (onebotToken !== persistedOnebotToken) return true + if (botName !== persistedBotName) return true if (autoLoginUin !== persistedAutoLoginUin) return true return false }, [ + botName, channel, effectiveAllowedUserIDs, effectiveAllowedGroupIDs, @@ -150,6 +155,7 @@ export function DesktopQQSettingsPanel({ persistedOnebotWSUrl, persistedOnebotHTTPUrl, persistedOnebotToken, + persistedBotName, autoLoginUin, persistedAutoLoginUin, ]) @@ -203,6 +209,8 @@ export function DesktopQQSettingsPanel({ else delete configJSON.onebot_http_url if (onebotToken.trim()) configJSON.onebot_token = onebotToken.trim() else delete configJSON.onebot_token + if (botName.trim()) configJSON.bot_name = botName.trim() + else delete configJSON.bot_name if (autoLoginUin.trim()) configJSON.auto_login_uin = autoLoginUin.trim() else delete configJSON.auto_login_uin @@ -397,34 +405,46 @@ export function DesktopQQSettingsPanel({ onChange={(v) => { setOnebotToken(v); setSaved(false) }} /> - - { - setAllowedUserIDs((current) => current.filter((item) => item !== value)) - setSaved(false) - }} + + { setBotName(e.target.value); setSaved(false) }} + placeholder={ct.qqBotNamePlaceholder} + disabled={saving} + className={inputCls} /> + +
+ { + setAllowedUserIDs((current) => current.filter((item) => item !== value)) + setSaved(false) + }} + /> - - { - setAllowedGroupIDs((current) => current.filter((item) => item !== value)) - setSaved(false) - }} - /> + { + setAllowedGroupIDs((current) => current.filter((item) => item !== value)) + setSaved(false) + }} + /> +
diff --git a/src/apps/web/src/index.css b/src/apps/web/src/index.css index a340e11c8..e194002b0 100644 --- a/src/apps/web/src/index.css +++ b/src/apps/web/src/index.css @@ -92,6 +92,7 @@ button:not(:disabled) { --c-background-image-scrim-alpha: calc(0.18 + ((1 - var(--c-background-image-opacity)) * 0.34)); --c-background-image-vignette-alpha: 0.22; --c-bg-page-gradient-stop: var(--c-bg-page); + --c-chat-bg-gradient-stop: var(--c-bg-page-gradient-stop); --c-bg-sidebar: #242422; --c-bg-deep: #141413; --c-bg-sub: #1e1e1c; @@ -1683,6 +1684,14 @@ button:not(:disabled) { :root[data-background-image="custom"] .theme-surface-page .theme-surface-page { background-color: transparent; } + + :root[data-background-image="custom"] { + --c-chat-bg-gradient-stop: color-mix(in srgb, var(--c-bg-page) 42%, transparent); + } + + :root[data-background-image="custom"] .theme-chat-surface { + background-color: color-mix(in srgb, var(--c-bg-page) 42%, transparent); + } } .background-opacity-range::-webkit-slider-thumb { diff --git a/src/apps/web/src/locales/en.ts b/src/apps/web/src/locales/en.ts index 6e399ba50..90f07c05b 100644 --- a/src/apps/web/src/locales/en.ts +++ b/src/apps/web/src/locales/en.ts @@ -817,6 +817,8 @@ export const en: LocaleStrings = { qqOneBotHTTPUrlPlaceholder: 'http://127.0.0.1:3000', qqOneBotToken: 'Token', qqOneBotTokenPlaceholder: 'Access token', + qqBotName: 'Bot name', + qqBotNamePlaceholder: 'Chiffon', qqOneBotAutoFilled: 'Auto-filled from NapCat', qqExternalOneBotHint: 'Please deploy NapCat or another OneBot11 service externally, then fill in the connection info below', bindingsTitle: 'Linked accounts', diff --git a/src/apps/web/src/locales/index.ts b/src/apps/web/src/locales/index.ts index c04a7d0ea..9bd5a9971 100644 --- a/src/apps/web/src/locales/index.ts +++ b/src/apps/web/src/locales/index.ts @@ -818,6 +818,8 @@ export interface LocaleStrings { qqOneBotHTTPUrlPlaceholder: string qqOneBotToken: string qqOneBotTokenPlaceholder: string + qqBotName: string + qqBotNamePlaceholder: string qqOneBotAutoFilled: string qqExternalOneBotHint: string weixin: string diff --git a/src/apps/web/src/locales/zh.ts b/src/apps/web/src/locales/zh.ts index 84980cd55..9610c91e0 100644 --- a/src/apps/web/src/locales/zh.ts +++ b/src/apps/web/src/locales/zh.ts @@ -811,6 +811,8 @@ export const zh: LocaleStrings = { qqOneBotHTTPUrlPlaceholder: 'http://127.0.0.1:3000', qqOneBotToken: 'Token', qqOneBotTokenPlaceholder: '鉴权 Token', + qqBotName: 'Bot 名称', + qqBotNamePlaceholder: '草洛', qqOneBotAutoFilled: '已从 NapCat 自动填充', qqExternalOneBotHint: '当前平台不支持内置 QQ 管理,请自行部署 NapCat 或其他 OneBot11 服务后填写连接信息', bindingsTitle: '已关联账号', diff --git a/src/apps/web/src/storage.ts b/src/apps/web/src/storage.ts index 160dbfe07..b7fe09dbf 100644 --- a/src/apps/web/src/storage.ts +++ b/src/apps/web/src/storage.ts @@ -2482,12 +2482,13 @@ export function writeBackgroundImageOpacityToStorage(opacity: number): void { // -- Sidebar View & GTD -- -const GTD_ENABLED_KEY = 'arkloop:web:gtd_enabled' +export const GTD_ENABLED_STORAGE_KEY = 'arkloop:web:gtd_enabled' +const GTD_ENABLED_CHANGED_EVENT = 'arkloop:gtd-enabled-changed' export function readGtdEnabled(): boolean { if (!canUseLocalStorage()) return false try { - return localStorage.getItem(GTD_ENABLED_KEY) === 'true' + return localStorage.getItem(GTD_ENABLED_STORAGE_KEY) === 'true' } catch { return false } @@ -2496,10 +2497,34 @@ export function readGtdEnabled(): boolean { export function writeGtdEnabled(v: boolean): void { if (!canUseLocalStorage()) return try { - localStorage.setItem(GTD_ENABLED_KEY, String(v)) + localStorage.setItem(GTD_ENABLED_STORAGE_KEY, String(v)) + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(GTD_ENABLED_CHANGED_EVENT, { detail: v })) + } } catch { /* ignore */ } } +export function subscribeGtdEnabled(listener: (enabled: boolean) => void): () => void { + if (typeof window === 'undefined') return () => {} + + const customHandler = (event: Event) => { + const enabled = (event as CustomEvent).detail + listener(typeof enabled === 'boolean' ? enabled : readGtdEnabled()) + } + const storageHandler = (event: StorageEvent) => { + if (event.key !== GTD_ENABLED_STORAGE_KEY) return + listener(event.newValue === 'true') + } + + window.addEventListener(GTD_ENABLED_CHANGED_EVENT, customHandler) + window.addEventListener('storage', storageHandler) + + return () => { + window.removeEventListener(GTD_ENABLED_CHANGED_EVENT, customHandler) + window.removeEventListener('storage', storageHandler) + } +} + const GTD_INBOX_THREAD_IDS_KEY = 'arkloop:web:gtd_inbox_thread_ids' export function readGtdInboxThreadIds(): Set { From 2d92eb326553c854a49270d7386a731a705c5c6c Mon Sep 17 00:00:00 2001 From: kilock <731052835@qq.com> Date: Sat, 16 May 2026 10:54:02 +0800 Subject: [PATCH 2/6] fix(web): align chat mask edges --- src/apps/web/src/index.css | 2 +- src/apps/web/src/layouts/AppLayout.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apps/web/src/index.css b/src/apps/web/src/index.css index e194002b0..d77f6991c 100644 --- a/src/apps/web/src/index.css +++ b/src/apps/web/src/index.css @@ -1686,7 +1686,7 @@ button:not(:disabled) { } :root[data-background-image="custom"] { - --c-chat-bg-gradient-stop: color-mix(in srgb, var(--c-bg-page) 42%, transparent); + --c-chat-bg-gradient-stop: transparent; } :root[data-background-image="custom"] .theme-chat-surface { diff --git a/src/apps/web/src/layouts/AppLayout.tsx b/src/apps/web/src/layouts/AppLayout.tsx index 51702f7aa..369835137 100644 --- a/src/apps/web/src/layouts/AppLayout.tsx +++ b/src/apps/web/src/layouts/AppLayout.tsx @@ -67,7 +67,7 @@ const MainViewport = memo(function MainViewport({ }) return ( -
+
{notificationsOpen && ( From 95af49a94a8b9b59fc800a9999756f4be1d40c6b Mon Sep 17 00:00:00 2001 From: kilock <731052835@qq.com> Date: Sat, 16 May 2026 10:56:46 +0800 Subject: [PATCH 3/6] test(web): cover preview panel loading --- .../__tests__/documentPanelPreview.test.tsx | 121 ++++++++++++++---- 1 file changed, 94 insertions(+), 27 deletions(-) diff --git a/src/apps/web/src/__tests__/documentPanelPreview.test.tsx b/src/apps/web/src/__tests__/documentPanelPreview.test.tsx index 72d0b2b75..34b33d137 100644 --- a/src/apps/web/src/__tests__/documentPanelPreview.test.tsx +++ b/src/apps/web/src/__tests__/documentPanelPreview.test.tsx @@ -1,8 +1,9 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { PreviewResourceView } from '../components/resource-preview/PreviewResourceView' -import type { PreviewResource } from '../components/resource-preview/types' +import { ResourcePreviewPanel } from '../components/resource-preview/ResourcePreviewPanel' +import { LocaleProvider } from '../contexts/LocaleContext' import type { ArtifactRef } from '../storage' vi.mock('../components/ArtifactHtmlPreview', async () => { @@ -15,22 +16,71 @@ vi.mock('../components/ArtifactHtmlPreview', async () => { } }) +type GlobalWithActEnvironment = typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean +} + +function flushMicrotasks(): Promise { + return Promise.resolve() + .then(() => Promise.resolve()) + .then(() => Promise.resolve()) +} + +async function waitForAssertion(assertion: () => void): Promise { + let lastError: unknown + for (let i = 0; i < 20; i++) { + try { + assertion() + return + } catch (err) { + lastError = err + } + await act(async () => { + await flushMicrotasks() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + } + throw lastError +} + describe('ResourcePreviewPanel artifact preview', () => { - it('Markdown 文档中的 html artifact 应继续内联渲染', () => { - const markdownResource: PreviewResource = { - source: 'artifact', - ref: { - kind: 'artifact', - key: 'doc.md', - filename: 'doc.md', - mimeType: 'text/markdown', - size: 10, - }, - filename: 'doc.md', - mimeType: 'text/markdown', - size: 10, - text: '[预览](artifact:preview.html)', + const actEnvironmentGlobal = globalThis as GlobalWithActEnvironment + const originalFetch = globalThis.fetch + const originalActEnvironment = actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + if (url.endsWith('/doc.md')) { + return new Response('[Preview](artifact:preview.html)', { + headers: { 'Content-Type': 'text/markdown' }, + }) + } + return new Response('not-found', { status: 404 }) + }) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + globalThis.fetch = originalFetch + if (originalActEnvironment === undefined) { + delete actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT + } else { + actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment } + vi.restoreAllMocks() + }) + + it('loads markdown artifacts before rendering linked html artifacts inline', async () => { const htmlArtifact: ArtifactRef = { key: 'preview.html', filename: 'preview.html', @@ -38,15 +88,32 @@ describe('ResourcePreviewPanel artifact preview', () => { mime_type: 'text/html', } - const html = renderToStaticMarkup( - , - ) + await act(async () => { + root.render( + + {}} + /> + , + ) + }) + + await waitForAssertion(() => { + expect(container.querySelector('[data-artifact-html-preview="preview.html"]')).not.toBeNull() + }) - expect(html).toContain('data-artifact-html-preview="preview.html"') - expect(html).toContain('data-title="preview.html"') + expect(globalThis.fetch).toHaveBeenCalledTimes(1) + const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]! + expect(String(url)).toContain('/v1/artifacts/doc.md') + expect((init as RequestInit | undefined)?.headers).toEqual({ Authorization: 'Bearer token' }) }) }) From 083b874f7f40c2cb97a6f98cd22967e86c071a23 Mon Sep 17 00:00:00 2001 From: kilock <731052835@qq.com> Date: Sat, 16 May 2026 11:12:21 +0800 Subject: [PATCH 4/6] fix(web): preserve assistant turn order --- src/apps/shared/src/assistantTurn.ts | 35 ++++---- .../__tests__/assistantTurnSegments.test.ts | 42 +++++++++- src/apps/web/src/assistantTurnSegments.ts | 2 + src/apps/web/src/components/MessageList.tsx | 82 ++++++++++++------- 4 files changed, 112 insertions(+), 49 deletions(-) diff --git a/src/apps/shared/src/assistantTurn.ts b/src/apps/shared/src/assistantTurn.ts index 13ad59279..d1da75edd 100644 --- a/src/apps/shared/src/assistantTurn.ts +++ b/src/apps/shared/src/assistantTurn.ts @@ -37,15 +37,22 @@ export type WorkGroup = { segments: AssistantTurnSegment[] } +export type WorkGroupSplit = { + workGroup: WorkGroup | null + finalText: string | null + finalTextIndex: number + tailSegments: AssistantTurnSegment[] +} + /** * Split segments into work group (pre-final) and final text. - * The last text segment is the final answer; everything before it goes into the work group. + * The last text segment is the final answer; trailing segments stay after it. * Returns null workGroup when there's nothing meaningful to collapse. */ export function splitWorkGroup( segments: AssistantTurnSegment[], durationMs: number, -): { workGroup: WorkGroup | null; finalText: string | null } { +): WorkGroupSplit { // Find the index of the last text segment let lastTextIndex = -1 for (let i = segments.length - 1; i >= 0; i--) { @@ -57,33 +64,25 @@ export function splitWorkGroup( // No text segment at all if (lastTextIndex === -1) { - return { workGroup: null, finalText: null } + return { workGroup: null, finalText: null, finalTextIndex: -1, tailSegments: [] } } const finalSegment = segments[lastTextIndex]! const finalText = finalSegment.type === 'text' ? finalSegment.content : null - - if (lastTextIndex < segments.length - 1) { - const workGroupSegments = [ - ...segments.slice(0, lastTextIndex), - ...segments.slice(lastTextIndex + 1), - ] - return { - workGroup: workGroupSegments.length > 0 ? { durationMs, segments: workGroupSegments } : null, - finalText, - } - } + const preSegments = segments.slice(0, lastTextIndex) + const tailSegments = segments.slice(lastTextIndex + 1) // Need at least 2 segments before final to justify a work group. // A single pre-final segment (text or cop) stays inline. - if (lastTextIndex < 2) { - return { workGroup: null, finalText } + if (preSegments.length < 2) { + return { workGroup: null, finalText, finalTextIndex: lastTextIndex, tailSegments } } - const workGroupSegments = segments.slice(0, lastTextIndex) return { - workGroup: { durationMs, segments: workGroupSegments }, + workGroup: { durationMs, segments: preSegments }, finalText, + finalTextIndex: lastTextIndex, + tailSegments, } } diff --git a/src/apps/web/src/__tests__/assistantTurnSegments.test.ts b/src/apps/web/src/__tests__/assistantTurnSegments.test.ts index 4a6269092..3a4586f55 100644 --- a/src/apps/web/src/__tests__/assistantTurnSegments.test.ts +++ b/src/apps/web/src/__tests__/assistantTurnSegments.test.ts @@ -48,7 +48,7 @@ function th(content: string, seq: number, endedByEventSeq?: number) { } describe('splitWorkGroup', () => { - it('keeps final text visible when a tool segment follows it', () => { + it('keeps a trailing tool segment after final text', () => { const tailToolSegment = { type: 'cop' as const, title: null, @@ -65,7 +65,45 @@ describe('splitWorkGroup', () => { ], 1200) expect(split.finalText).toBe('done') - expect(split.workGroup?.segments).toEqual([tailToolSegment]) + expect(split.finalTextIndex).toBe(0) + expect(split.workGroup).toBeNull() + expect(split.tailSegments).toEqual([tailToolSegment]) + }) + + it('collapses pre-final work while preserving trailing segment order', () => { + const firstToolSegment = { + type: 'cop' as const, + title: null, + items: [{ + kind: 'call' as const, + call: { toolCallId: 'terminal_1', toolName: 'terminal_run', arguments: {} }, + seq: 2, + }], + } + const tailToolSegment = { + type: 'cop' as const, + title: null, + items: [{ + kind: 'call' as const, + call: { toolCallId: 'terminal_2', toolName: 'terminal_run', arguments: {} }, + seq: 4, + }], + } + + const split = splitWorkGroup([ + { type: 'text', content: 'before' }, + firstToolSegment, + { type: 'text', content: 'done' }, + tailToolSegment, + ], 1200) + + expect(split.finalText).toBe('done') + expect(split.finalTextIndex).toBe(2) + expect(split.workGroup?.segments).toEqual([ + { type: 'text', content: 'before' }, + firstToolSegment, + ]) + expect(split.tailSegments).toEqual([tailToolSegment]) }) }) diff --git a/src/apps/web/src/assistantTurnSegments.ts b/src/apps/web/src/assistantTurnSegments.ts index d1fd0267b..ae5ed38c0 100644 --- a/src/apps/web/src/assistantTurnSegments.ts +++ b/src/apps/web/src/assistantTurnSegments.ts @@ -16,6 +16,7 @@ import { type CopBlockItem, type TurnToolCallRef, type WorkGroup, + type WorkGroupSplit, } from '../../shared/src/assistantTurn' import { agentEventDataRecord, @@ -40,6 +41,7 @@ export { type CopBlockItem, type TurnToolCallRef, type WorkGroup, + type WorkGroupSplit, } function toAssistantTurnEventType(type: string): string { diff --git a/src/apps/web/src/components/MessageList.tsx b/src/apps/web/src/components/MessageList.tsx index a773bb4a2..81be90d8b 100644 --- a/src/apps/web/src/components/MessageList.tsx +++ b/src/apps/web/src/components/MessageList.tsx @@ -21,7 +21,7 @@ import { apiBaseUrl } from '@arkloop/shared/api' import type { AgentMessage } from '../agent-ui' import { copTimelinePayloadForSegment, type CopTimelinePayload, type TodoWriteRef } from '../copSegmentTimeline' import { buildResolvedPool, EMPTY_POOL, buildFallbackSegments } from '../copSubSegment' -import { assistantTurnPlainText, splitWorkGroup, type AssistantTurnSegment, type WorkGroup as WorkGroupType } from '../assistantTurnSegments' +import { assistantTurnPlainText, splitWorkGroup, type AssistantTurnSegment, type WorkGroup as WorkGroupType, type WorkGroupSplit } from '../assistantTurnSegments' import { WorkGroup } from './WorkGroup' import { resolveMessageSourcesForRender } from './chatSourceResolver' import { createThreadShare } from '../api' @@ -402,7 +402,9 @@ export const MessageList = memo(forwardRef( const messageWebFetches = msg.role === 'assistant' ? msgMeta?.webFetches : undefined const msgThinking = msg.role === 'assistant' ? msgMeta?.thinking : undefined const durationMs = historicalTurn?.durationMs ?? 0 - const workGroupSplit = hasAssistantTurn ? splitWorkGroup(historicalSegments, durationMs) : { workGroup: null as WorkGroupType | null, finalText: null as string | null } + const workGroupSplit: WorkGroupSplit = hasAssistantTurn + ? splitWorkGroup(historicalSegments, durationMs) + : { workGroup: null as WorkGroupType | null, finalText: null, finalTextIndex: -1, tailSegments: [] } const bubbleCallbacks = bubbleCallbacksByMessageId.get(msg.id) return (
( sources: resolvedSources ?? [], } - const renderSegment = (seg: AssistantTurnSegment, si: number, segments: AssistantTurnSegment[], isLive: boolean) => { + const renderSegment = ( + seg: AssistantTurnSegment, + si: number, + segments: AssistantTurnSegment[], + isLive: boolean, + originalIndex = si, + ) => { if (seg.type === 'text') { return ( ( .flatMap((entry) => entry.type === 'cop' ? copTimelinePayloadForSegment(entry, timelinePools).todoWrites ?? [] : []) - const payload = precomputed?.payloads.get(String(si)) ?? copTimelinePayloadForSegment(seg, timelinePools) - const histWidgets = precomputed?.histWidgetsMap.get(String(si)) ?? historicWidgetsForCop(seg, msgWidgetsRaw) + const payload = precomputed?.payloads.get(String(originalIndex)) ?? copTimelinePayloadForSegment(seg, timelinePools) + const histWidgets = precomputed?.histWidgetsMap.get(String(originalIndex)) ?? historicWidgetsForCop(seg, msgWidgetsRaw) const timelineTitleOverride = displayTerminalStatus != null ? currentRunCopHeaderOverride({ @@ -485,9 +493,9 @@ export const MessageList = memo(forwardRef( const entryComplete = !isLive const promotedNodes = [( ( )] return ( - + {promotedNodes} {histWidgets.map((w) => ( ( ) } - if (workGroupSplit.workGroup != null) { + const renderFinalText = () => ( + + ) + + if (workGroupSplit.workGroup != null || workGroupSplit.tailSegments.length > 0) { + const preSegments = workGroupSplit.finalTextIndex >= 0 + ? historicalSegments.slice(0, workGroupSplit.finalTextIndex) + : [] + const tailStartIndex = workGroupSplit.finalTextIndex + 1 return ( <> - - {workGroupSplit.workGroup.segments.map((seg, si) => - renderSegment(seg, si, workGroupSplit.workGroup!.segments, false) - )} - - {workGroupSplit.finalText != null && ( - + {workGroupSplit.workGroup != null ? ( + + {workGroupSplit.workGroup.segments.map((seg, si) => + renderSegment(seg, si, workGroupSplit.workGroup!.segments, false, si) + )} + + ) : ( + preSegments.map((seg, si) => + renderSegment(seg, si, historicalSegments, false, si) + ) )} + {workGroupSplit.finalText != null && renderFinalText()} + {workGroupSplit.tailSegments.map((seg, offset) => { + const originalIndex = tailStartIndex + offset + return renderSegment(seg, originalIndex, historicalSegments, false, originalIndex) + })} ) } From 55023a73e068216ca068bdd493bee3932c0600e3 Mon Sep 17 00:00:00 2001 From: kilock <731052835@qq.com> Date: Sat, 16 May 2026 14:48:06 +0800 Subject: [PATCH 5/6] fix(web): stabilize preview and import checks --- .../chatInputPersonaSelector.test.tsx | 17 +++++++-- .../__tests__/documentPanelPreview.test.tsx | 38 +++++++++++-------- .../components/settings/AdvancedSettings.tsx | 36 +++++++++++------- 3 files changed, 59 insertions(+), 32 deletions(-) diff --git a/src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx b/src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx index b6386b460..d49746494 100644 --- a/src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx +++ b/src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx @@ -148,14 +148,23 @@ describe('ChatInput persona selector', () => { await flushMicrotasks() }) - expect(mockedListSelectablePersonas).not.toHaveBeenCalled() - expect(findButtonByText(container, 'Normal')).toBeFalsy() - expect(findButtonByText(container, 'Search')).toBeFalsy() - const form = container.querySelector('form') expect(form).not.toBeNull() if (!form) return + const menuButton = form.querySelector('button[type="button"]') + expect(menuButton).not.toBeNull() + if (!menuButton) return + + await act(async () => { + menuButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushMicrotasks() + }) + + expect(mockedListSelectablePersonas).not.toHaveBeenCalled() + expect(findButtonByText(container, 'Normal')).toBeFalsy() + expect(findButtonByText(container, 'Search')).toBeFalsy() + await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) }) diff --git a/src/apps/web/src/__tests__/documentPanelPreview.test.tsx b/src/apps/web/src/__tests__/documentPanelPreview.test.tsx index 34b33d137..5f0927109 100644 --- a/src/apps/web/src/__tests__/documentPanelPreview.test.tsx +++ b/src/apps/web/src/__tests__/documentPanelPreview.test.tsx @@ -6,20 +6,13 @@ import { ResourcePreviewPanel } from '../components/resource-preview/ResourcePre import { LocaleProvider } from '../contexts/LocaleContext' import type { ArtifactRef } from '../storage' -vi.mock('../components/ArtifactHtmlPreview', async () => { - const { createElement } = await import('react') - return { - ArtifactHtmlPreview: ({ artifact }: { artifact: ArtifactRef }) => createElement('div', { - 'data-artifact-html-preview': artifact.key, - 'data-title': artifact.title ?? artifact.filename, - }), - } -}) - type GlobalWithActEnvironment = typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +const originalRAF = globalThis.requestAnimationFrame +const originalCAF = globalThis.cancelAnimationFrame + function flushMicrotasks(): Promise { return Promise.resolve() .then(() => Promise.resolve()) @@ -52,6 +45,11 @@ describe('ResourcePreviewPanel artifact preview', () => { beforeEach(() => { actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT = true + globalThis.requestAnimationFrame = (callback: FrameRequestCallback) => { + callback(performance.now()) + return 0 + } + globalThis.cancelAnimationFrame = () => {} container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -62,6 +60,11 @@ describe('ResourcePreviewPanel artifact preview', () => { headers: { 'Content-Type': 'text/markdown' }, }) } + if (url.endsWith('/preview.html')) { + return new Response('preview', { + headers: { 'Content-Type': 'text/html' }, + }) + } return new Response('not-found', { status: 404 }) }) }) @@ -77,6 +80,8 @@ describe('ResourcePreviewPanel artifact preview', () => { } else { actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment } + globalThis.requestAnimationFrame = originalRAF + globalThis.cancelAnimationFrame = originalCAF vi.restoreAllMocks() }) @@ -108,12 +113,15 @@ describe('ResourcePreviewPanel artifact preview', () => { }) await waitForAssertion(() => { - expect(container.querySelector('[data-artifact-html-preview="preview.html"]')).not.toBeNull() + expect(container.querySelector('iframe[title="preview.html"]')).not.toBeNull() }) - expect(globalThis.fetch).toHaveBeenCalledTimes(1) - const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]! - expect(String(url)).toContain('/v1/artifacts/doc.md') - expect((init as RequestInit | undefined)?.headers).toEqual({ Authorization: 'Bearer token' }) + expect(globalThis.fetch).toHaveBeenCalledTimes(2) + const [markdownUrl, markdownInit] = vi.mocked(globalThis.fetch).mock.calls[0]! + expect(String(markdownUrl)).toContain('/v1/artifacts/doc.md') + expect((markdownInit as RequestInit | undefined)?.headers).toEqual({ Authorization: 'Bearer token' }) + const [htmlUrl, htmlInit] = vi.mocked(globalThis.fetch).mock.calls[1]! + expect(String(htmlUrl)).toContain('/v1/artifacts/preview.html') + expect((htmlInit as RequestInit | undefined)?.headers).toEqual({ Authorization: 'Bearer token' }) }) }) diff --git a/src/apps/web/src/components/settings/AdvancedSettings.tsx b/src/apps/web/src/components/settings/AdvancedSettings.tsx index f12573fb1..317627be8 100644 --- a/src/apps/web/src/components/settings/AdvancedSettings.tsx +++ b/src/apps/web/src/components/settings/AdvancedSettings.tsx @@ -101,9 +101,13 @@ function isThemePreset(value: unknown): value is ThemePreset { return typeof value === 'string' && THEME_PRESETS.includes(value as ThemePreset) } -function normalizeThemeBackgroundImage(value: unknown): ThemeBackgroundImage | null | undefined { - if (value === null) return null - if (!value || typeof value !== 'object') return undefined +type ThemeBackgroundImageImport = + | { valid: true; value: ThemeBackgroundImage | null } + | { valid: false } + +function normalizeThemeBackgroundImage(value: unknown): ThemeBackgroundImageImport { + if (value === null) return { valid: true, value: null } + if (!value || typeof value !== 'object') return { valid: false } const image = value as Record if ( typeof image.dataUrl !== 'string' || @@ -112,14 +116,17 @@ function normalizeThemeBackgroundImage(value: unknown): ThemeBackgroundImage | n typeof image.size !== 'number' || typeof image.updatedAt !== 'number' ) { - return undefined + return { valid: false } } return { - dataUrl: image.dataUrl, - name: image.name, - mimeType: image.mimeType, - size: image.size, - updatedAt: image.updatedAt, + valid: true, + value: { + dataUrl: image.dataUrl, + name: image.name, + mimeType: image.mimeType, + size: image.size, + updatedAt: image.updatedAt, + }, } } @@ -1118,14 +1125,17 @@ function DataPane({ onReloadOverview }: { onReloadOverview: () => Promise } } } - let importedBackground: ThemeBackgroundImage | null | undefined + let hasImportedBackground = false + let importedBackground: ThemeBackgroundImage | null = null if (importedThemes && 'backgroundImage' in importedThemes) { - importedBackground = normalizeThemeBackgroundImage(importedThemes.backgroundImage) - if (importedBackground === undefined) { + const backgroundResult = normalizeThemeBackgroundImage(importedThemes.backgroundImage) + if (!backgroundResult.valid) { throw new Error(t.requestFailed) } + importedBackground = backgroundResult.value + hasImportedBackground = true } - if (importedBackground !== undefined && !setBackgroundImage(importedBackground)) { + if (hasImportedBackground && !setBackgroundImage(importedBackground)) { throw new Error(t.requestFailed) } if (typeof importedThemes?.backgroundImageOpacity === 'number' && Number.isFinite(importedThemes.backgroundImageOpacity)) { From da0ad0ed9adedd32f064c565cad51a025b182221 Mon Sep 17 00:00:00 2001 From: kilock <731052835@qq.com> Date: Sat, 16 May 2026 14:55:55 +0800 Subject: [PATCH 6/6] fix(web): use prompt text for qq bot name --- src/apps/web/src/locales/en.ts | 2 +- src/apps/web/src/locales/zh.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apps/web/src/locales/en.ts b/src/apps/web/src/locales/en.ts index 90f07c05b..f81963314 100644 --- a/src/apps/web/src/locales/en.ts +++ b/src/apps/web/src/locales/en.ts @@ -818,7 +818,7 @@ export const en: LocaleStrings = { qqOneBotToken: 'Token', qqOneBotTokenPlaceholder: 'Access token', qqBotName: 'Bot name', - qqBotNamePlaceholder: 'Chiffon', + qqBotNamePlaceholder: 'Enter bot name', qqOneBotAutoFilled: 'Auto-filled from NapCat', qqExternalOneBotHint: 'Please deploy NapCat or another OneBot11 service externally, then fill in the connection info below', bindingsTitle: 'Linked accounts', diff --git a/src/apps/web/src/locales/zh.ts b/src/apps/web/src/locales/zh.ts index 9610c91e0..82c8e5454 100644 --- a/src/apps/web/src/locales/zh.ts +++ b/src/apps/web/src/locales/zh.ts @@ -812,7 +812,7 @@ export const zh: LocaleStrings = { qqOneBotToken: 'Token', qqOneBotTokenPlaceholder: '鉴权 Token', qqBotName: 'Bot 名称', - qqBotNamePlaceholder: '草洛', + qqBotNamePlaceholder: '输入 Bot 名称', qqOneBotAutoFilled: '已从 NapCat 自动填充', qqExternalOneBotHint: '当前平台不支持内置 QQ 管理,请自行部署 NapCat 或其他 OneBot11 服务后填写连接信息', bindingsTitle: '已关联账号',