diff --git a/src/apps/shared/src/assistantTurn.ts b/src/apps/shared/src/assistantTurn.ts index 8a3dc54e0..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,22 +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 + 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/shared/src/desktop.ts b/src/apps/shared/src/desktop.ts index 22e8d2ff1..c2914c8ea 100644 --- a/src/apps/shared/src/desktop.ts +++ b/src/apps/shared/src/desktop.ts @@ -362,8 +362,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..3a4586f55 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,66 @@ function th(content: string, seq: number, endedByEventSeq?: number) { return { kind: 'thinking' as const, content, seq, startedAtMs, endedAtMs } } +describe('splitWorkGroup', () => { + it('keeps a trailing tool segment after final text', () => { + 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.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]) + }) +}) + 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..d49746494 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,49 +148,22 @@ 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 form = container.querySelector('form') + expect(form).not.toBeNull() + if (!form) return - 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 + const menuButton = form.querySelector('button[type="button"]') + expect(menuButton).not.toBeNull() + if (!menuButton) return await act(async () => { - menuNormalButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + menuButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushMicrotasks() }) - const form = container.querySelector('form') - expect(form).not.toBeNull() - if (!form) return + 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__/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..5f0927109 100644 --- a/src/apps/web/src/__tests__/documentPanelPreview.test.tsx +++ b/src/apps/web/src/__tests__/documentPanelPreview.test.tsx @@ -1,16 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act } from 'react' -import { createRoot } from 'react-dom/client' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ResourcePreviewPanel } from '../components/resource-preview/ResourcePreviewPanel' import { LocaleProvider } from '../contexts/LocaleContext' 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 } @@ -22,42 +17,51 @@ 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)) +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', () => { - 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 + let container: HTMLDivElement + let root: Root beforeEach(() => { actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT = true - globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { - cb(performance.now()) + globalThis.requestAnimationFrame = (callback: FrameRequestCallback) => { + callback(performance.now()) return 0 } globalThis.cancelAnimationFrame = () => {} - urlWithObjectURL.createObjectURL = vi.fn(() => 'blob:artifact-preview') - urlWithObjectURL.revokeObjectURL = vi.fn() + 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('[预览](artifact:preview.html)', { + return new Response('[Preview](artifact:preview.html)', { headers: { 'Content-Type': 'text/markdown' }, }) } if (url.endsWith('/preview.html')) { - return new Response('ok', { + return new Response('preview', { headers: { 'Content-Type': 'text/html' }, }) } @@ -66,16 +70,10 @@ describe('ResourcePreviewPanel artifact preview', () => { }) afterEach(() => { - if (originalCreateObjectURL) { - urlWithObjectURL.createObjectURL = originalCreateObjectURL - } else { - Reflect.deleteProperty(urlWithObjectURL, 'createObjectURL') - } - if (originalRevokeObjectURL) { - urlWithObjectURL.revokeObjectURL = originalRevokeObjectURL - } else { - Reflect.deleteProperty(urlWithObjectURL, 'revokeObjectURL') - } + act(() => { + root.unmount() + }) + container.remove() globalThis.fetch = originalFetch if (originalActEnvironment === undefined) { delete actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT @@ -87,13 +85,7 @@ describe('ResourcePreviewPanel artifact preview', () => { vi.restoreAllMocks() }) - it('Markdown 文档中的 html artifact 应继续内联渲染', async () => { - const markdownArtifact: ArtifactRef = { - key: 'doc.md', - filename: 'doc.md', - size: 10, - mime_type: 'text/markdown', - } + it('loads markdown artifacts before rendering linked html artifacts inline', async () => { const htmlArtifact: ArtifactRef = { key: 'preview.html', filename: 'preview.html', @@ -101,20 +93,16 @@ 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() + await waitForAssertion(() => { + expect(container.querySelector('iframe[title="preview.html"]')).not.toBeNull() }) expect(globalThis.fetch).toHaveBeenCalledTimes(2) - expect(container.querySelector('iframe')).not.toBeNull() - - act(() => { - root.unmount() - }) - container.remove() + 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/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/ChatInput.tsx b/src/apps/web/src/components/ChatInput.tsx index b1ba06d38..9ddbcdb62 100644 --- a/src/apps/web/src/components/ChatInput.tsx +++ b/src/apps/web/src/components/ChatInput.tsx @@ -772,16 +772,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 dfdbcdd44..3e5f29a43 100644 --- a/src/apps/web/src/components/ChatView.tsx +++ b/src/apps/web/src/components/ChatView.tsx @@ -3502,7 +3502,7 @@ export const ChatView = memo(function ChatView() { ]) return ( -
+
{/* Chat column + right panel: starts below the desktop Chat/Work titlebar. */}
-
+
{/* 消息列表 */} {messageListArea} @@ -3534,7 +3534,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 03f3066ae..54060c5e8 100644 --- a/src/apps/web/src/components/DesktopSettings.tsx +++ b/src/apps/web/src/components/DesktopSettings.tsx @@ -578,7 +578,7 @@ export function DesktopSettings({ return ( <>
diff --git a/src/apps/web/src/components/DesktopTitleBar.tsx b/src/apps/web/src/components/DesktopTitleBar.tsx index cb5ac153a..e30132a62 100644 --- a/src/apps/web/src/components/DesktopTitleBar.tsx +++ b/src/apps/web/src/components/DesktopTitleBar.tsx @@ -35,7 +35,7 @@ import { useThreadLiveState } from '../contexts/thread-list' 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 const PINNED_MENU_OPEN_DELAY_MS = 70 const PINNED_MENU_GAP = 4 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) + })} ) } 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 5f64af85f..48a904092 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' @@ -61,7 +61,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'] @@ -1149,20 +1148,7 @@ export const Sidebar = memo(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 3e6469f55..3ee9c8530 100644 --- a/src/apps/web/src/components/WelcomePage.tsx +++ b/src/apps/web/src/components/WelcomePage.tsx @@ -559,7 +559,7 @@ export function WelcomePage() { return (
{ return { @@ -95,6 +97,44 @@ function createAgentImportSelections(sources: AgentImportDiscovery[]): AgentImpo }, {}) } +function isThemePreset(value: unknown): value is ThemePreset { + return typeof value === 'string' && THEME_PRESETS.includes(value as ThemePreset) +} + +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' || + typeof image.name !== 'string' || + typeof image.mimeType !== 'string' || + typeof image.size !== 'number' || + typeof image.updatedAt !== 'number' + ) { + return { valid: false } + } + return { + valid: true, + value: { + 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 +1002,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 +1085,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 +1105,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 +1117,41 @@ 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 hasImportedBackground = false + let importedBackground: ThemeBackgroundImage | null = null + if (importedThemes && 'backgroundImage' in importedThemes) { + const backgroundResult = normalizeThemeBackgroundImage(importedThemes.backgroundImage) + if (!backgroundResult.valid) { + throw new Error(t.requestFailed) + } + importedBackground = backgroundResult.value + hasImportedBackground = true + } + if (hasImportedBackground && !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 +1160,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 1d2a21508..ae13d3040 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; @@ -1678,6 +1679,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: 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/layouts/AppLayout.tsx b/src/apps/web/src/layouts/AppLayout.tsx index 011295e51..b667392bd 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 && ( diff --git a/src/apps/web/src/locales/en.ts b/src/apps/web/src/locales/en.ts index 6779258b4..6c1f243ec 100644 --- a/src/apps/web/src/locales/en.ts +++ b/src/apps/web/src/locales/en.ts @@ -826,6 +826,8 @@ export const en: LocaleStrings = { qqOneBotHTTPUrlPlaceholder: 'http://127.0.0.1:3000', qqOneBotToken: 'Token', qqOneBotTokenPlaceholder: 'Access token', + qqBotName: 'Bot name', + 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/index.ts b/src/apps/web/src/locales/index.ts index 7a8322f60..c4ba5a2d4 100644 --- a/src/apps/web/src/locales/index.ts +++ b/src/apps/web/src/locales/index.ts @@ -827,6 +827,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 e006e1991..f6d83e0f3 100644 --- a/src/apps/web/src/locales/zh.ts +++ b/src/apps/web/src/locales/zh.ts @@ -820,6 +820,8 @@ export const zh: LocaleStrings = { qqOneBotHTTPUrlPlaceholder: 'http://127.0.0.1:3000', qqOneBotToken: 'Token', qqOneBotTokenPlaceholder: '鉴权 Token', + qqBotName: 'Bot 名称', + qqBotNamePlaceholder: '输入 Bot 名称', qqOneBotAutoFilled: '已从 NapCat 自动填充', qqExternalOneBotHint: '当前平台不支持内置 QQ 管理,请自行部署 NapCat 或其他 OneBot11 服务后填写连接信息', bindingsTitle: '已关联账号', diff --git a/src/apps/web/src/storage.ts b/src/apps/web/src/storage.ts index 171c9f6ac..0c9141096 100644 --- a/src/apps/web/src/storage.ts +++ b/src/apps/web/src/storage.ts @@ -2486,12 +2486,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 } @@ -2500,10 +2501,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 { diff --git a/src/services/api/internal/http/catalogapi/tool_catalog_effective_mcp.go b/src/services/api/internal/http/catalogapi/tool_catalog_effective_mcp.go index 2ae5590c5..409f48ee9 100644 --- a/src/services/api/internal/http/catalogapi/tool_catalog_effective_mcp.go +++ b/src/services/api/internal/http/catalogapi/tool_catalog_effective_mcp.go @@ -469,4 +469,3 @@ func cloneToolCatalogItems(items []toolCatalogItem) []toolCatalogItem { copy(out, items) return out } -