feat: improve application responsiveness
This commit is contained in:
+705
-46
@@ -20,6 +20,26 @@ const speechRecognitionMocks = vi.hoisted(() => ({
|
||||
startPcmRecording: vi.fn()
|
||||
}))
|
||||
|
||||
const lazyRouteMocks = vi.hoisted(() => {
|
||||
let pending: Promise<void> | undefined
|
||||
let releasePending: (() => void) | undefined
|
||||
return {
|
||||
suspendKnowledgeRoute(): void {
|
||||
pending = new Promise((resolve) => {
|
||||
releasePending = resolve
|
||||
})
|
||||
},
|
||||
releaseKnowledgeRoute(): void {
|
||||
releasePending?.()
|
||||
releasePending = undefined
|
||||
pending = undefined
|
||||
},
|
||||
async waitForKnowledgeRoute(): Promise<void> {
|
||||
await pending
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./speech-recognition', async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import('./speech-recognition')
|
||||
@@ -27,6 +47,11 @@ vi.mock('./speech-recognition', async (importOriginal) => ({
|
||||
startPcmRecording: speechRecognitionMocks.startPcmRecording
|
||||
}))
|
||||
|
||||
vi.mock('./KnowledgeWorkspace', async (importOriginal) => {
|
||||
await lazyRouteMocks.waitForKnowledgeRoute()
|
||||
return importOriginal<typeof import('./KnowledgeWorkspace')>()
|
||||
})
|
||||
|
||||
import App from './App'
|
||||
import { loadActivityRecords } from './activity-store'
|
||||
import { changeUiLocale } from './i18n'
|
||||
@@ -41,6 +66,7 @@ let fileSelectionProgressListener:
|
||||
| undefined
|
||||
let newConversationListener: (() => void) | undefined
|
||||
let maximizedChangedListener: ((maximized: boolean) => void) | undefined
|
||||
let beforeQuitListener: (() => Promise<void>) | undefined
|
||||
const removeMaximizedChangedListener = vi.fn()
|
||||
const run = vi.fn<DesktopApi['agent']['run']>()
|
||||
const modelProfileId = '00000000-0000-4000-8000-000000000001'
|
||||
@@ -76,6 +102,12 @@ const api: DesktopApi = {
|
||||
maximizedChangedListener = listener
|
||||
return removeMaximizedChangedListener
|
||||
}),
|
||||
onBeforeQuit: vi.fn((listener) => {
|
||||
beforeQuitListener = listener
|
||||
return () => {
|
||||
beforeQuitListener = undefined
|
||||
}
|
||||
}),
|
||||
clearLocalData: vi.fn(async () => {}),
|
||||
onNewConversation: vi.fn((listener) => {
|
||||
newConversationListener = listener
|
||||
@@ -289,6 +321,8 @@ const api: DesktopApi = {
|
||||
conversations: {
|
||||
list: vi.fn(async () => []),
|
||||
replace: vi.fn(async () => {}),
|
||||
saveLocal: vi.fn(async () => {}),
|
||||
deleteLocal: vi.fn(async () => true),
|
||||
onChanged: vi.fn(() => () => undefined)
|
||||
},
|
||||
workspace: {
|
||||
@@ -664,7 +698,21 @@ describe('App', () => {
|
||||
delete document.documentElement.dataset.theme
|
||||
document.documentElement.style.colorScheme = ''
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.conversations.list).mockReset().mockResolvedValue([])
|
||||
vi.mocked(api.conversations.replace)
|
||||
.mockReset()
|
||||
.mockResolvedValue()
|
||||
vi.mocked(api.conversations.saveLocal)
|
||||
.mockReset()
|
||||
.mockResolvedValue()
|
||||
vi.mocked(api.conversations.deleteLocal)
|
||||
.mockReset()
|
||||
.mockResolvedValue(true)
|
||||
vi.mocked(api.conversations.onChanged)
|
||||
.mockReset()
|
||||
.mockReturnValue(() => undefined)
|
||||
newConversationListener = undefined
|
||||
beforeQuitListener = undefined
|
||||
browserListener = undefined
|
||||
fileSelectionProgressListener = undefined
|
||||
maximizedChangedListener = undefined
|
||||
@@ -774,6 +822,122 @@ describe('App', () => {
|
||||
expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an accessible fallback while a lazy route loads', async () => {
|
||||
lazyRouteMocks.suspendKnowledgeRoute()
|
||||
try {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '知识库' })
|
||||
)
|
||||
|
||||
const loading = screen.getByRole('status', {
|
||||
name: '正在加载页面…'
|
||||
})
|
||||
expect(loading).toHaveAttribute('aria-live', 'polite')
|
||||
expect(loading).toHaveAttribute('aria-busy', 'true')
|
||||
await act(async () => lazyRouteMocks.releaseKnowledgeRoute())
|
||||
expect(
|
||||
await screen.findByRole('heading', {
|
||||
level: 1,
|
||||
name: '知识库'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('status', { name: '正在加载页面…' })
|
||||
).not.toBeInTheDocument()
|
||||
} finally {
|
||||
lazyRouteMocks.releaseKnowledgeRoute()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves title, message, and project filtering with deferred search', async () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000411',
|
||||
projectId,
|
||||
title: '标题里的 Alpha',
|
||||
updatedAt: 1_775_000_000_003,
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000412',
|
||||
role: 'assistant',
|
||||
content: '普通正文',
|
||||
createdAt: 1_775_000_000_003,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000413',
|
||||
projectId,
|
||||
title: '正文命中的会话',
|
||||
updatedAt: 1_775_000_000_002,
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000414',
|
||||
role: 'user',
|
||||
content: '这里包含 Beta Needle',
|
||||
createdAt: 1_775_000_000_002,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000415',
|
||||
projectId: '00000000-0000-4000-8000-000000000999',
|
||||
title: '其他项目里的 Alpha',
|
||||
updatedAt: 1_775_000_000_001,
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000416',
|
||||
role: 'user',
|
||||
content: 'Beta Needle',
|
||||
createdAt: 1_775_000_000_001,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
const { container } = render(<App />)
|
||||
const search = await screen.findByLabelText('搜索对话')
|
||||
const conversationList =
|
||||
container.querySelector<HTMLElement>('.conversation-list')
|
||||
if (!conversationList) {
|
||||
throw new Error('Missing conversation list')
|
||||
}
|
||||
expect(
|
||||
await within(conversationList).findByText('标题里的 Alpha')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(conversationList).queryByText('其他项目里的 Alpha')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(search, { target: { value: 'beta needle' } })
|
||||
expect(search).toHaveValue('beta needle')
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(conversationList).getByText('正文命中的会话')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(conversationList).queryByText('标题里的 Alpha')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(search, { target: { value: 'ALPHA' } })
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(conversationList).getByText('标题里的 Alpha')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(conversationList).queryByText('正文命中的会话')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(conversationList).queryByText('其他项目里的 Alpha')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Settings open when the interface language changes', async () => {
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
@@ -1225,6 +1389,69 @@ describe('App', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the latest 80 messages and preserves scroll when revealing earlier messages', async () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000421',
|
||||
projectId,
|
||||
title: '超长会话',
|
||||
updatedAt: 1_775_000_000_000,
|
||||
messages: Array.from({ length: 161 }, (_, index) => ({
|
||||
id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
|
||||
role: index % 2 === 0 ? ('user' as const) : ('assistant' as const),
|
||||
content: `历史消息 ${String(index).padStart(3, '0')}`,
|
||||
createdAt: 1_775_000_000_000 + index,
|
||||
state: 'complete' as const
|
||||
}))
|
||||
}
|
||||
])
|
||||
const { container } = render(<App />)
|
||||
|
||||
expect(await screen.findByText('历史消息 160')).toBeInTheDocument()
|
||||
expect(screen.queryByText('历史消息 080')).not.toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.message')).toHaveLength(80)
|
||||
const chat = container.querySelector<HTMLElement>('.chat')
|
||||
if (!chat) {
|
||||
throw new Error('Missing chat scroll container')
|
||||
}
|
||||
Object.defineProperties(chat, {
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
scrollHeight: {
|
||||
configurable: true,
|
||||
get: () => container.querySelectorAll('.message').length * 10
|
||||
},
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 125
|
||||
}
|
||||
})
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '加载更早的消息(还剩 81 条)'
|
||||
})
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.message')).toHaveLength(160)
|
||||
expect(screen.getByText('历史消息 001')).toBeInTheDocument()
|
||||
expect(screen.queryByText('历史消息 000')).not.toBeInTheDocument()
|
||||
expect(chat.scrollTop).toBe(925)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '加载更早的消息(还剩 1 条)'
|
||||
})
|
||||
)
|
||||
expect(container.querySelectorAll('.message')).toHaveLength(161)
|
||||
expect(screen.getByText('历史消息 000')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /加载更早的消息/u })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('历史消息 000').closest('article')
|
||||
).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps the reader position while a response continues below', async () => {
|
||||
render(<App />)
|
||||
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
|
||||
@@ -1335,6 +1562,68 @@ describe('App', () => {
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
)
|
||||
expect(api.conversations.deleteLocal).toHaveBeenCalledWith(
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a local conversation visible when deleting its persisted record fails', async () => {
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000431'
|
||||
const title = '删除失败时保留的本地会话'
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: conversationId,
|
||||
projectId,
|
||||
title,
|
||||
updatedAt: 1_775_000_000_000,
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000432',
|
||||
role: 'assistant',
|
||||
content: '需要保留的消息',
|
||||
createdAt: 1_775_000_000_000,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
vi.mocked(api.conversations.deleteLocal).mockRejectedValueOnce(
|
||||
new Error('delete failed')
|
||||
)
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByLabelText(`更多会话操作 ${title}`)
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: `删除对话 ${title}` })
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: `确认永久删除对话 ${title}`
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.conversations.deleteLocal).toHaveBeenCalledWith(
|
||||
conversationId
|
||||
)
|
||||
)
|
||||
expect(
|
||||
await screen.findByText('删除本地会话失败,已保留当前对话')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('需要保留的消息')).toBeInTheDocument()
|
||||
const dialog = screen.getByRole('alertdialog', {
|
||||
name: `确认永久删除对话 ${title}`
|
||||
})
|
||||
expect(
|
||||
dialog
|
||||
).toBeInTheDocument()
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
expect(
|
||||
await screen.findByLabelText(`更多会话操作 ${title}`)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a conversation when cancelling its active task fails', async () => {
|
||||
@@ -1576,6 +1865,155 @@ describe('App', () => {
|
||||
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
|
||||
})
|
||||
|
||||
it('persists only the changed conversation and streamed assistant message', async () => {
|
||||
const activeConversationId =
|
||||
'00000000-0000-4000-8000-000000000441'
|
||||
const activeMessageId =
|
||||
'00000000-0000-4000-8000-000000000442'
|
||||
const unrelatedConversationId =
|
||||
'00000000-0000-4000-8000-000000000443'
|
||||
const unrelatedMessageId =
|
||||
'00000000-0000-4000-8000-000000000444'
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: activeConversationId,
|
||||
projectId,
|
||||
title: '增量持久化会话',
|
||||
updatedAt: 1_775_000_000_002,
|
||||
messages: [
|
||||
{
|
||||
id: activeMessageId,
|
||||
role: 'assistant',
|
||||
content: '原有消息',
|
||||
createdAt: 1_775_000_000_002,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: unrelatedConversationId,
|
||||
projectId,
|
||||
title: '无关会话',
|
||||
updatedAt: 1_775_000_000_001,
|
||||
messages: [
|
||||
{
|
||||
id: unrelatedMessageId,
|
||||
role: 'assistant',
|
||||
content: '不应重复保存',
|
||||
createdAt: 1_775_000_000_001,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
render(<App />)
|
||||
expect(await screen.findByText('原有消息')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '只更新当前会话' }
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
const request = run.mock.calls[0]?.[0]
|
||||
if (!request) {
|
||||
throw new Error('Missing request')
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalled()
|
||||
)
|
||||
vi.mocked(api.conversations.saveLocal).mockClear()
|
||||
|
||||
act(() => {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: 'text',
|
||||
delta: '流式增量'
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
const batch = vi
|
||||
.mocked(api.conversations.saveLocal)
|
||||
.mock.calls.at(-1)?.[0]
|
||||
expect(batch).toHaveLength(1)
|
||||
expect(batch?.[0]?.header.id).toBe(activeConversationId)
|
||||
expect(batch?.[0]?.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: '流式增量',
|
||||
state: 'streaming'
|
||||
})
|
||||
])
|
||||
expect(
|
||||
batch?.some(
|
||||
(entry) => entry.header.id === unrelatedConversationId
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
batch?.flatMap((entry) => entry.messages).some(
|
||||
(message) =>
|
||||
message.id === activeMessageId ||
|
||||
message.id === unrelatedMessageId ||
|
||||
message.role === 'user'
|
||||
)
|
||||
).toBe(false)
|
||||
},
|
||||
{ timeout: 2_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('flushes pending local conversation changes before quit', async () => {
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000445'
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: conversationId,
|
||||
projectId,
|
||||
title: '退出持久化会话',
|
||||
updatedAt: 1_775_000_000_000,
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000446',
|
||||
role: 'assistant',
|
||||
content: '已有内容',
|
||||
createdAt: 1_775_000_000_000,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
render(<App />)
|
||||
expect(await screen.findByText('已有内容')).toBeInTheDocument()
|
||||
vi.mocked(api.conversations.saveLocal).mockClear()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '退出前必须保存' }
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
expect(
|
||||
await screen.findByText('退出前必须保存')
|
||||
).toBeInTheDocument()
|
||||
if (!beforeQuitListener) {
|
||||
throw new Error('Missing before-quit persistence listener')
|
||||
}
|
||||
|
||||
await act(async () => beforeQuitListener?.())
|
||||
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
header: expect.objectContaining({ id: conversationId }),
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
content: '退出前必须保存',
|
||||
state: 'complete'
|
||||
})
|
||||
])
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('replaces the direct-model thinking status with real reasoning', async () => {
|
||||
render(<App />)
|
||||
|
||||
@@ -1773,9 +2211,9 @@ describe('App', () => {
|
||||
await waitFor(
|
||||
() => {
|
||||
const persistedMessages = vi
|
||||
.mocked(api.conversations.replace)
|
||||
.mock.calls.flatMap(([conversations]) =>
|
||||
conversations.flatMap((conversation) => conversation.messages)
|
||||
.mocked(api.conversations.saveLocal)
|
||||
.mock.calls.flatMap(([batch]) =>
|
||||
batch.flatMap((conversation) => conversation.messages)
|
||||
)
|
||||
const persisted = persistedMessages
|
||||
.filter((message) => message.role === 'assistant')
|
||||
@@ -1864,12 +2302,12 @@ describe('App', () => {
|
||||
expect(screen.getByText('向量模型未配置')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
const snapshots = vi
|
||||
.mocked(api.conversations.replace)
|
||||
.mock.calls.at(-1)?.[0]
|
||||
.mocked(api.conversations.saveLocal)
|
||||
.mock.calls.flatMap(([batch]) => batch)
|
||||
expect(
|
||||
snapshots?.some(
|
||||
(conversation) =>
|
||||
conversation.knowledgeRetrievalMode === 'always'
|
||||
conversation.header.knowledgeRetrievalMode === 'always'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
@@ -2007,7 +2445,7 @@ describe('App', () => {
|
||||
expect(anchorClick).toHaveBeenCalledOnce()
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(api.conversations.replace).toHaveBeenCalledWith(
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
messages: expect.arrayContaining([
|
||||
@@ -2844,14 +3282,14 @@ describe('App', () => {
|
||||
setTimeout(resolve, 550)
|
||||
})
|
||||
)
|
||||
expect(api.conversations.replace).not.toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
projectId: channelProject.id,
|
||||
remote: undefined
|
||||
})
|
||||
])
|
||||
)
|
||||
const savedChannelHeaders = vi
|
||||
.mocked(api.conversations.saveLocal)
|
||||
.mock.calls.flatMap(([batch]) => batch.map((entry) => entry.header))
|
||||
expect(
|
||||
savedChannelHeaders.some(
|
||||
(header) => header.projectId === channelProject.id
|
||||
)
|
||||
).toBe(false)
|
||||
expect(screen.getAllByText('尚无远程会话').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开设置' }))
|
||||
@@ -3447,10 +3885,238 @@ describe('App', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('persists metadata-only retrieval and Runtime changes without rewriting messages', async () => {
|
||||
const conversationId =
|
||||
'00000000-0000-4000-8000-000000000451'
|
||||
const existingMessageId =
|
||||
'00000000-0000-4000-8000-000000000452'
|
||||
const libraryId = '11111111-1111-4111-8111-111111111111'
|
||||
const secondProfileId =
|
||||
'00000000-0000-4000-8000-000000000453'
|
||||
const settings = await api.settings.getRuntime()
|
||||
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
|
||||
...settings,
|
||||
modelProfiles: [
|
||||
...settings.modelProfiles,
|
||||
{
|
||||
id: secondProfileId,
|
||||
name: '仅元数据模型',
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
modelName: 'qwen3',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKeyConfigured: false,
|
||||
credentialSource: 'none'
|
||||
}
|
||||
]
|
||||
})
|
||||
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({
|
||||
libraries: [
|
||||
{
|
||||
id: libraryId,
|
||||
name: '产品知识',
|
||||
description: '',
|
||||
storageMode: 'managed',
|
||||
graphEnabled: false,
|
||||
graphStrategy: 'rules',
|
||||
sourceCount: 1,
|
||||
documentCount: 1,
|
||||
indexedDocumentCount: 1
|
||||
}
|
||||
],
|
||||
sources: [],
|
||||
documents: [],
|
||||
graphNodes: [],
|
||||
graphRelations: [],
|
||||
evidence: []
|
||||
})
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: conversationId,
|
||||
projectId,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: modelProfileId
|
||||
},
|
||||
knowledgeRetrievalMode: 'auto',
|
||||
title: '元数据会话',
|
||||
updatedAt: 1_775_000_000_000,
|
||||
messages: [
|
||||
{
|
||||
id: existingMessageId,
|
||||
role: 'assistant',
|
||||
content: '现有消息不应重写',
|
||||
createdAt: 1_775_000_000_000,
|
||||
state: 'complete'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
vi.mocked(api.agent.getStatus).mockImplementation(
|
||||
async (selection) => ({
|
||||
id: 'model',
|
||||
label:
|
||||
selection?.provider === 'model' &&
|
||||
selection.profileId === secondProfileId
|
||||
? 'qwen3'
|
||||
: 'sonnet-5',
|
||||
available: true,
|
||||
supportsToolExecution: true,
|
||||
detail: 'Ready'
|
||||
})
|
||||
)
|
||||
render(<App />)
|
||||
|
||||
const knowledgeScope = await screen.findByRole('button', {
|
||||
name: '选择知识库,本次已启用 1 个'
|
||||
})
|
||||
fireEvent.click(knowledgeScope)
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole('group', { name: '知识检索方式' })
|
||||
).getByRole('button', { name: '每次先检索' })
|
||||
)
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
|
||||
{
|
||||
header: expect.objectContaining({
|
||||
id: conversationId,
|
||||
knowledgeRetrievalMode: 'always'
|
||||
}),
|
||||
messages: []
|
||||
}
|
||||
]),
|
||||
{ timeout: 2_000 }
|
||||
)
|
||||
|
||||
vi.mocked(api.conversations.saveLocal).mockClear()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /sonnet-5/u })
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('menuitemradio', {
|
||||
name: /^仅元数据模型.*qwen3$/u
|
||||
})
|
||||
)
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
|
||||
{
|
||||
header: expect.objectContaining({
|
||||
id: conversationId,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: secondProfileId
|
||||
},
|
||||
knowledgeRetrievalMode: 'always'
|
||||
}),
|
||||
messages: []
|
||||
}
|
||||
]),
|
||||
{ timeout: 2_000 }
|
||||
)
|
||||
const savedMessages = vi
|
||||
.mocked(api.conversations.saveLocal)
|
||||
.mock.calls.flatMap(([batch]) =>
|
||||
batch.flatMap((entry) => entry.messages)
|
||||
)
|
||||
expect(
|
||||
savedMessages.some((message) => message.id === existingMessageId)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('migrates legacy startup conversations with replace when SQLite has no local conversation', async () => {
|
||||
const legacyConversation = {
|
||||
id: '00000000-0000-4000-8000-000000000461',
|
||||
title: '待迁移旧会话',
|
||||
updatedAt: 1_775_000_000_000,
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000462',
|
||||
role: 'assistant' as const,
|
||||
content: '旧版浏览器存储消息',
|
||||
createdAt: 1_775_000_000_000,
|
||||
state: 'complete' as const
|
||||
}
|
||||
]
|
||||
}
|
||||
localStorage.setItem(
|
||||
'goodbuddy.conversations.v1',
|
||||
JSON.stringify([legacyConversation])
|
||||
)
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
await screen.findByText('旧版浏览器存储消息')
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() =>
|
||||
expect(api.conversations.replace).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
...legacyConversation,
|
||||
projectId
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(api.conversations.saveLocal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let remote rows displace legacy local conversations during migration', async () => {
|
||||
const legacyConversations = [0, 1].map((index) => ({
|
||||
id: `00000000-0000-4000-8000-${String(470 + index).padStart(12, '0')}`,
|
||||
title: `待迁移本地会话 ${index}`,
|
||||
updatedAt: 1_775_000_000_000 + index,
|
||||
messages: [
|
||||
{
|
||||
id: `00000000-0000-4000-8001-${String(470 + index).padStart(12, '0')}`,
|
||||
role: 'assistant' as const,
|
||||
content: `待迁移消息 ${index}`,
|
||||
createdAt: 1_775_000_000_000 + index,
|
||||
state: 'complete' as const
|
||||
}
|
||||
]
|
||||
}))
|
||||
localStorage.setItem(
|
||||
'goodbuddy.conversations.v1',
|
||||
JSON.stringify(legacyConversations)
|
||||
)
|
||||
const remoteProjectId =
|
||||
'00000000-0000-4000-8000-000000000499'
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce(
|
||||
Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `00000000-0000-4000-8002-${String(index).padStart(12, '0')}`,
|
||||
projectId: remoteProjectId,
|
||||
remote: {
|
||||
channel: 'weixin' as const,
|
||||
accountDisplay: `远程联系人 ${index}`,
|
||||
conversationType: 'direct' as const
|
||||
},
|
||||
title: `远程会话 ${index}`,
|
||||
updatedAt: 1_775_000_100_000 + index,
|
||||
messages: []
|
||||
}))
|
||||
)
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.conversations.saveLocal).toHaveBeenCalled()
|
||||
)
|
||||
expect(api.conversations.replace).not.toHaveBeenCalled()
|
||||
const migrated =
|
||||
vi.mocked(api.conversations.saveLocal).mock.calls[0]?.[0] ?? []
|
||||
expect(migrated.map((conversation) => conversation.header.id)).toEqual(
|
||||
expect.arrayContaining(
|
||||
legacyConversations.map((conversation) => conversation.id)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a legacy Auto conversation without silently persisting a replacement', async () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000020',
|
||||
projectId,
|
||||
runtimeSelection: { provider: 'auto' },
|
||||
title: '旧自动对话',
|
||||
updatedAt: 1,
|
||||
@@ -3468,18 +4134,10 @@ describe('App', () => {
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /默认模型.*sonnet-5/u })
|
||||
await screen.findByRole('button', { name: /自动.*sonnet-5/u })
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() =>
|
||||
expect(api.conversations.replace).toHaveBeenLastCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: '00000000-0000-4000-8000-000000000020',
|
||||
runtimeSelection: { provider: 'auto' }
|
||||
})
|
||||
])
|
||||
)
|
||||
)
|
||||
expect(api.conversations.replace).not.toHaveBeenCalled()
|
||||
expect(api.conversations.saveLocal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a removed model selection visible until the user replaces it', async () => {
|
||||
@@ -3488,6 +4146,7 @@ describe('App', () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000022',
|
||||
projectId,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: removedProfileId
|
||||
@@ -3507,19 +4166,14 @@ describe('App', () => {
|
||||
])
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.conversations.replace).toHaveBeenLastCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: '00000000-0000-4000-8000-000000000022',
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: removedProfileId
|
||||
}
|
||||
})
|
||||
])
|
||||
)
|
||||
)
|
||||
expect(
|
||||
await screen.findByText('旧消息')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /模型配置不可用/u })
|
||||
).toBeInTheDocument()
|
||||
expect(api.conversations.replace).not.toHaveBeenCalled()
|
||||
expect(api.conversations.saveLocal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps model Runtime selection scoped to its conversation', async () => {
|
||||
@@ -3614,14 +4268,17 @@ describe('App', () => {
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(api.conversations.replace).toHaveBeenLastCalledWith(
|
||||
expect(api.conversations.saveLocal).toHaveBeenLastCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: '第二模型对话',
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: secondProfileId
|
||||
}
|
||||
header: expect.objectContaining({
|
||||
title: '第二模型对话',
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: secondProfileId
|
||||
}
|
||||
}),
|
||||
messages: expect.any(Array)
|
||||
})
|
||||
])
|
||||
),
|
||||
@@ -4855,7 +5512,9 @@ describe('App', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: '新建笔记' })
|
||||
).toBeInTheDocument()
|
||||
expect(api.magicNotes.list).toHaveBeenCalled()
|
||||
await waitFor(() =>
|
||||
expect(api.magicNotes.list).toHaveBeenCalled()
|
||||
)
|
||||
expect(
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
+639
-114
@@ -38,8 +38,13 @@ import {
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Component,
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
@@ -84,10 +89,13 @@ import type {
|
||||
AssistantExpert,
|
||||
AssistantTask,
|
||||
TokenUsageSummary,
|
||||
ConversationMessage,
|
||||
ConversationSnapshot,
|
||||
ConversationAttachment,
|
||||
ConversationMessageBlock,
|
||||
ConversationToolActivity,
|
||||
LocalConversationHeader,
|
||||
LocalConversationSaveBatch,
|
||||
ProjectCreateInput,
|
||||
InteractiveWorkMode,
|
||||
ProjectChannel,
|
||||
@@ -109,13 +117,10 @@ import {
|
||||
upsertActivityRecord,
|
||||
type ActivityRecord
|
||||
} from './activity-store'
|
||||
import { KnowledgeWorkspace } from './KnowledgeWorkspace'
|
||||
import {
|
||||
KnowledgeCitationDialog,
|
||||
type KnowledgeCitationContextView
|
||||
} from './KnowledgeCitationDialog'
|
||||
import { HeartbeatCenter } from './HeartbeatCenter'
|
||||
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import {
|
||||
DestructiveConfirmActions,
|
||||
@@ -133,7 +138,6 @@ import {
|
||||
type PendingSidebarApproval,
|
||||
type SidebarArtifact
|
||||
} from './RightAssistantSidebar'
|
||||
import { SettingsPanel } from './SettingsPanel'
|
||||
import type { SettingsCategoryId } from './settings-categories'
|
||||
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
|
||||
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
|
||||
@@ -158,6 +162,30 @@ import type {
|
||||
import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts'
|
||||
import { ReleaseNotesDialog } from './ReleaseNotesDialog'
|
||||
|
||||
const KnowledgeWorkspace = lazy(async () => {
|
||||
const module = await import('./KnowledgeWorkspace')
|
||||
return { default: module.KnowledgeWorkspace }
|
||||
})
|
||||
|
||||
const HeartbeatCenter = lazy(async () => {
|
||||
const module = await import('./HeartbeatCenter')
|
||||
return { default: module.HeartbeatCenter }
|
||||
})
|
||||
|
||||
const MagicNotesWorkspace = lazy(async () => {
|
||||
const module = await import('./MagicNotesWorkspace')
|
||||
return { default: module.MagicNotesWorkspace }
|
||||
})
|
||||
|
||||
const SettingsPanel = lazy(async () => {
|
||||
const module = await import('./SettingsPanel')
|
||||
return { default: module.SettingsPanel }
|
||||
})
|
||||
|
||||
const messageRenderBatchSize = 80
|
||||
const conversationPersistenceIntervalMs = 500
|
||||
const conversationSearchSnapshotDelayMs = 250
|
||||
|
||||
type AppNotification = {
|
||||
id: string
|
||||
message: string
|
||||
@@ -206,6 +234,58 @@ function appNotificationReducer(
|
||||
return [...errors, ...transient]
|
||||
}
|
||||
|
||||
function RouteLoadingStatus({
|
||||
label
|
||||
}: {
|
||||
label: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
aria-busy="true"
|
||||
aria-label={label}
|
||||
aria-live="polite"
|
||||
className="route-loading-status"
|
||||
role="status"
|
||||
>
|
||||
<LoaderCircle aria-hidden="true" size={20} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
class RouteErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false }
|
||||
|
||||
static getDerivedStateFromError(): { failed: boolean } {
|
||||
return { failed: true }
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
return this.state.failed ? this.props.fallback : this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function RouteLoadError({
|
||||
message,
|
||||
reloadLabel
|
||||
}: {
|
||||
message: string
|
||||
reloadLabel: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="route-load-error" role="alert">
|
||||
<CircleAlert aria-hidden="true" size={20} />
|
||||
<strong>{message}</strong>
|
||||
<button onClick={() => window.location.reload()} type="button">
|
||||
{reloadLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppNotificationItem({
|
||||
notification,
|
||||
dispatch
|
||||
@@ -735,6 +815,14 @@ function loadConversations(
|
||||
}
|
||||
}
|
||||
|
||||
function hasConversationMigrationStorage(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(storageKey) !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function loadActiveProjectId(): string | undefined {
|
||||
try {
|
||||
return localStorage.getItem(activeProjectStorageKey) || undefined
|
||||
@@ -803,31 +891,91 @@ function isConversation(value: unknown): value is Conversation {
|
||||
function toConversationSnapshots(
|
||||
conversations: Conversation[]
|
||||
): ConversationSnapshot[] {
|
||||
return conversations.slice(0, 100).map((conversation) => ({
|
||||
return conversations
|
||||
.filter((conversation) => !conversation.remote)
|
||||
.slice(0, 100)
|
||||
.map((conversation) => ({
|
||||
id: conversation.id,
|
||||
projectId: conversation.projectId,
|
||||
runtimeSelection: conversation.runtimeSelection,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
title: conversation.title,
|
||||
updatedAt: conversation.updatedAt,
|
||||
messages: conversation.messages
|
||||
.slice(-500)
|
||||
.map(toConversationMessage)
|
||||
}))
|
||||
}
|
||||
|
||||
function toConversationMessage(message: Message): ConversationMessage {
|
||||
return {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
reasoning: message.reasoning,
|
||||
blocks: message.blocks,
|
||||
createdAt: message.createdAt,
|
||||
state: message.state,
|
||||
status: message.status,
|
||||
tools: message.tools,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||
artifactIds: message.artifactIds,
|
||||
attachments: message.attachments
|
||||
}
|
||||
}
|
||||
|
||||
function toLocalConversationHeader(
|
||||
conversation: Conversation
|
||||
): LocalConversationHeader {
|
||||
return {
|
||||
id: conversation.id,
|
||||
projectId: conversation.projectId,
|
||||
runtimeSelection: conversation.runtimeSelection,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
remote: conversation.remote,
|
||||
title: conversation.title,
|
||||
updatedAt: conversation.updatedAt,
|
||||
messages: conversation.messages.slice(-500).map((message) => ({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
reasoning: message.reasoning,
|
||||
blocks: message.blocks,
|
||||
createdAt: message.createdAt,
|
||||
state: message.state,
|
||||
status: message.status,
|
||||
tools: message.tools,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||
artifactIds: message.artifactIds,
|
||||
attachments: message.attachments
|
||||
}))
|
||||
}))
|
||||
updatedAt: conversation.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
function createLocalConversationSaveBatch(
|
||||
conversations: readonly Conversation[],
|
||||
persisted: ReadonlyMap<string, Conversation>,
|
||||
deletingConversationIds: ReadonlySet<string>
|
||||
): {
|
||||
batch: LocalConversationSaveBatch
|
||||
acknowledgements: Conversation[]
|
||||
} {
|
||||
const batch: LocalConversationSaveBatch = []
|
||||
const acknowledgements: Conversation[] = []
|
||||
for (const conversation of conversations) {
|
||||
if (
|
||||
conversation.remote ||
|
||||
deletingConversationIds.has(conversation.id) ||
|
||||
persisted.get(conversation.id) === conversation
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const previous = persisted.get(conversation.id)
|
||||
const previousMessages = new Map(
|
||||
previous?.messages.map((message) => [message.id, message]) ?? []
|
||||
)
|
||||
batch.push({
|
||||
header: toLocalConversationHeader(conversation),
|
||||
messages: conversation.messages
|
||||
.filter(
|
||||
(message) => previousMessages.get(message.id) !== message
|
||||
)
|
||||
.slice(-500)
|
||||
.map(toConversationMessage)
|
||||
})
|
||||
acknowledgements.push(conversation)
|
||||
if (batch.length === 100) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return { batch, acknowledgements }
|
||||
}
|
||||
|
||||
function mergeArtifacts(
|
||||
@@ -1307,6 +1455,9 @@ function App(): React.JSX.Element {
|
||||
tRef.current = t
|
||||
}, [t])
|
||||
const locale = i18n.resolvedLanguage === 'en-US' ? 'en-US' : 'zh-CN'
|
||||
const conversationMigrationStoragePresent = useRef(
|
||||
hasConversationMigrationStorage()
|
||||
)
|
||||
const [conversations, setConversations] = useState(() =>
|
||||
loadConversations(
|
||||
t('conversation.greeting'),
|
||||
@@ -1316,6 +1467,14 @@ function App(): React.JSX.Element {
|
||||
const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '')
|
||||
const activeConversationIdRef = useRef(activeId)
|
||||
const conversationsRef = useRef(conversations)
|
||||
const persistedLocalConversationsRef = useRef(
|
||||
new Map<string, Conversation>()
|
||||
)
|
||||
const conversationPersistenceQueueRef =
|
||||
useRef<Promise<void>>(Promise.resolve())
|
||||
const conversationPersistencePausedRef = useRef(false)
|
||||
const deletingLocalConversationIdsRef = useRef(new Set<string>())
|
||||
const flushConversationPersistenceAfterRenderRef = useRef(false)
|
||||
const [unreadConversationIds, setUnreadConversationIds] = useState<
|
||||
Set<string>
|
||||
>(() => new Set())
|
||||
@@ -1509,6 +1668,9 @@ function App(): React.JSX.Element {
|
||||
useState<ProjectChannel>()
|
||||
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery)
|
||||
const [searchConversationSnapshot, setSearchConversationSnapshot] =
|
||||
useState(conversations)
|
||||
const [conversationActionsId, setConversationActionsId] = useState('')
|
||||
const [confirmingConversationId, setConfirmingConversationId] =
|
||||
useState('')
|
||||
@@ -1591,6 +1753,7 @@ function App(): React.JSX.Element {
|
||||
const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>(
|
||||
loadActivityRecords
|
||||
)
|
||||
const activityRecordsRef = useRef(activityRecords)
|
||||
const activeRuns = useRef(new Map<string, ActiveRun>())
|
||||
const preparingConversations = useRef(new Set<string>())
|
||||
const hydratingArtifactIds = useRef(new Set<string>())
|
||||
@@ -1599,6 +1762,17 @@ function App(): React.JSX.Element {
|
||||
const scrollRef = useRef<HTMLElement>(null)
|
||||
const chatPinnedToBottomRef = useRef(true)
|
||||
const chatScrollContextRef = useRef(`${view}:${activeId}`)
|
||||
const prependScrollPositionRef = useRef<{
|
||||
conversationId: string
|
||||
scrollHeight: number
|
||||
scrollTop: number
|
||||
} | undefined>(undefined)
|
||||
const finalRevealedMessageIdRef = useRef<string | undefined>(undefined)
|
||||
const messageArticleRefs = useRef(new Map<string, HTMLElement>())
|
||||
const [visibleMessageWindow, setVisibleMessageWindow] = useState(() => ({
|
||||
conversationId: activeId,
|
||||
count: messageRenderBatchSize
|
||||
}))
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -1678,10 +1852,21 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}, [closeNarrowSidebar, narrowWindow, sidebarOpen])
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
conversationsRef.current = conversations
|
||||
}, [conversations])
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return
|
||||
}
|
||||
const timeout = window.setTimeout(
|
||||
() => setSearchConversationSnapshot(conversations),
|
||||
conversationSearchSnapshotDelayMs
|
||||
)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [conversations, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
projectsRef.current = projects
|
||||
}, [projects])
|
||||
@@ -1801,6 +1986,70 @@ function App(): React.JSX.Element {
|
||||
() => conversations.find((conversation) => conversation.id === activeId),
|
||||
[activeId, conversations]
|
||||
)
|
||||
const visibleMessageCount =
|
||||
visibleMessageWindow.conversationId === activeId
|
||||
? visibleMessageWindow.count
|
||||
: messageRenderBatchSize
|
||||
const visibleMessageStartIndex = Math.max(
|
||||
0,
|
||||
(activeConversation?.messages.length ?? 0) - visibleMessageCount
|
||||
)
|
||||
const visibleMessages =
|
||||
activeConversation?.messages.slice(visibleMessageStartIndex) ?? []
|
||||
const hiddenMessageCount = visibleMessageStartIndex
|
||||
|
||||
const revealEarlierMessages = useCallback((): void => {
|
||||
const scrollContainer = scrollRef.current
|
||||
if (scrollContainer) {
|
||||
prependScrollPositionRef.current = {
|
||||
conversationId: activeId,
|
||||
scrollHeight: scrollContainer.scrollHeight,
|
||||
scrollTop: scrollContainer.scrollTop
|
||||
}
|
||||
}
|
||||
const currentCount =
|
||||
visibleMessageWindow.conversationId === activeId
|
||||
? visibleMessageWindow.count
|
||||
: messageRenderBatchSize
|
||||
if (
|
||||
activeConversation &&
|
||||
currentCount + messageRenderBatchSize >=
|
||||
activeConversation.messages.length
|
||||
) {
|
||||
finalRevealedMessageIdRef.current =
|
||||
activeConversation.messages[0]?.id
|
||||
}
|
||||
setVisibleMessageWindow({
|
||||
conversationId: activeId,
|
||||
count: currentCount + messageRenderBatchSize
|
||||
})
|
||||
}, [activeConversation, activeId, visibleMessageWindow])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previous = prependScrollPositionRef.current
|
||||
if (!previous) {
|
||||
return
|
||||
}
|
||||
prependScrollPositionRef.current = undefined
|
||||
if (previous.conversationId !== activeId) {
|
||||
return
|
||||
}
|
||||
const scrollContainer = scrollRef.current
|
||||
if (!scrollContainer) {
|
||||
return
|
||||
}
|
||||
scrollContainer.scrollTop =
|
||||
previous.scrollTop +
|
||||
(scrollContainer.scrollHeight - previous.scrollHeight)
|
||||
const finalRevealedMessageId = finalRevealedMessageIdRef.current
|
||||
finalRevealedMessageIdRef.current = undefined
|
||||
if (finalRevealedMessageId) {
|
||||
messageArticleRefs.current
|
||||
.get(finalRevealedMessageId)
|
||||
?.focus({ preventScroll: true })
|
||||
}
|
||||
}, [activeId, visibleMessageWindow])
|
||||
|
||||
const activeRuntimeSelection = useMemo(
|
||||
() =>
|
||||
activeConversation?.runtimeSelection ??
|
||||
@@ -2050,8 +2299,11 @@ function App(): React.JSX.Element {
|
||||
[activeProjectId, projects]
|
||||
)
|
||||
const filteredConversations = useMemo(() => {
|
||||
const query = searchQuery.trim().toLocaleLowerCase()
|
||||
return conversations.filter(
|
||||
const query = deferredSearchQuery.trim().toLocaleLowerCase()
|
||||
const candidates = query
|
||||
? searchConversationSnapshot
|
||||
: conversations
|
||||
return candidates.filter(
|
||||
(conversation) =>
|
||||
(!activeProjectId ||
|
||||
conversation.projectId === activeProjectId) &&
|
||||
@@ -2063,7 +2315,13 @@ function App(): React.JSX.Element {
|
||||
message.content.toLocaleLowerCase().includes(query)
|
||||
))
|
||||
)
|
||||
}, [activeProject, activeProjectId, conversations, searchQuery])
|
||||
}, [
|
||||
activeProject,
|
||||
activeProjectId,
|
||||
conversations,
|
||||
deferredSearchQuery,
|
||||
searchConversationSnapshot
|
||||
])
|
||||
const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>(
|
||||
() =>
|
||||
conversations.flatMap((conversation) =>
|
||||
@@ -2866,6 +3124,7 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
})
|
||||
activeRuns.current.delete(event.requestId)
|
||||
flushConversationPersistenceAfterRenderRef.current = true
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -2892,25 +3151,86 @@ function App(): React.JSX.Element {
|
||||
viewRef.current = view
|
||||
}, [view])
|
||||
|
||||
const persistLocalConversationChanges = useCallback((): void => {
|
||||
const operation = conversationPersistenceQueueRef.current.then(
|
||||
async () => {
|
||||
if (conversationPersistencePausedRef.current) {
|
||||
return
|
||||
}
|
||||
const { batch, acknowledgements } =
|
||||
createLocalConversationSaveBatch(
|
||||
conversationsRef.current,
|
||||
persistedLocalConversationsRef.current,
|
||||
deletingLocalConversationIdsRef.current
|
||||
)
|
||||
if (batch.length === 0) {
|
||||
return
|
||||
}
|
||||
await window.goodbuddy.conversations.saveLocal(batch)
|
||||
for (const conversation of acknowledgements) {
|
||||
persistedLocalConversationsRef.current.set(
|
||||
conversation.id,
|
||||
conversation
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
conversationPersistenceQueueRef.current =
|
||||
operation.catch(() => undefined)
|
||||
void operation.catch(() => {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: tRef.current(
|
||||
'notices.conversationPersistenceFailed'
|
||||
),
|
||||
dedupeKey: 'conversation-persistence'
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationStoreReady) {
|
||||
return
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
void window.goodbuddy.conversations
|
||||
.replace(toConversationSnapshots(conversations))
|
||||
.catch(() => {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: tRef.current(
|
||||
'notices.conversationPersistenceFailed'
|
||||
),
|
||||
dedupeKey: 'conversation-persistence'
|
||||
})
|
||||
})
|
||||
}, 500)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [conversationStoreReady, conversations])
|
||||
persistLocalConversationChanges()
|
||||
const interval = window.setInterval(
|
||||
persistLocalConversationChanges,
|
||||
conversationPersistenceIntervalMs
|
||||
)
|
||||
return () => {
|
||||
window.clearInterval(interval)
|
||||
persistLocalConversationChanges()
|
||||
}
|
||||
}, [conversationStoreReady, persistLocalConversationChanges])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!conversationStoreReady ||
|
||||
!flushConversationPersistenceAfterRenderRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
flushConversationPersistenceAfterRenderRef.current = false
|
||||
persistLocalConversationChanges()
|
||||
}, [
|
||||
conversationStoreReady,
|
||||
conversations,
|
||||
persistLocalConversationChanges
|
||||
])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
window.goodbuddy.app.onBeforeQuit(async () => {
|
||||
saveActivityRecords(activityRecordsRef.current)
|
||||
if (!conversationStoreReady) {
|
||||
return
|
||||
}
|
||||
flushConversationPersistenceAfterRenderRef.current = false
|
||||
persistLocalConversationChanges()
|
||||
await conversationPersistenceQueueRef.current
|
||||
}),
|
||||
[conversationStoreReady, persistLocalConversationChanges]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationStoreReady) {
|
||||
@@ -2993,9 +3313,20 @@ function App(): React.JSX.Element {
|
||||
}, [conversationStoreReady])
|
||||
|
||||
useEffect(() => {
|
||||
saveActivityRecords(activityRecords)
|
||||
activityRecordsRef.current = activityRecords
|
||||
const timeout = window.setTimeout(() => {
|
||||
saveActivityRecords(activityRecords)
|
||||
}, 250)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [activityRecords])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
saveActivityRecords(activityRecordsRef.current)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void Promise.all([
|
||||
@@ -3016,14 +3347,34 @@ function App(): React.JSX.Element {
|
||||
setWorkMode(
|
||||
normalizeInteractiveWorkMode(project.defaultWorkMode)
|
||||
)
|
||||
let nextConversations: Conversation[] =
|
||||
persistedConversations.length > 0
|
||||
? persistedConversations
|
||||
: migrationConversations.current.map((conversation) =>
|
||||
conversation.projectId || project.kind === 'channel'
|
||||
? conversation
|
||||
: { ...conversation, projectId: project.id }
|
||||
const persistedLocalConversations =
|
||||
persistedConversations.filter(
|
||||
(conversation) => !conversation.remote
|
||||
)
|
||||
const persistedConversationIds = new Set(
|
||||
persistedConversations.map((conversation) => conversation.id)
|
||||
)
|
||||
const shouldMigrateLocalStorage =
|
||||
conversationMigrationStoragePresent.current ||
|
||||
persistedConversations.length === 0
|
||||
const migratedLocalConversations =
|
||||
shouldMigrateLocalStorage
|
||||
? migrationConversations.current
|
||||
.filter(
|
||||
(conversation) =>
|
||||
!conversation.remote &&
|
||||
!persistedConversationIds.has(conversation.id)
|
||||
)
|
||||
.map((conversation) =>
|
||||
conversation.projectId || project.kind === 'channel'
|
||||
? conversation
|
||||
: { ...conversation, projectId: project.id }
|
||||
)
|
||||
: []
|
||||
let nextConversations: Conversation[] = [
|
||||
...persistedConversations,
|
||||
...migratedLocalConversations
|
||||
]
|
||||
let projectConversation = nextConversations.find(
|
||||
(conversation) =>
|
||||
conversation.projectId === project.id &&
|
||||
@@ -3041,17 +3392,71 @@ function App(): React.JSX.Element {
|
||||
...nextConversations
|
||||
]
|
||||
}
|
||||
if (persistedConversations.length === 0) {
|
||||
await window.goodbuddy.conversations.replace(
|
||||
toConversationSnapshots(nextConversations)
|
||||
const acknowledgedLocalConversations = new Map(
|
||||
persistedLocalConversations.map((conversation) => [
|
||||
conversation.id,
|
||||
conversation
|
||||
])
|
||||
)
|
||||
if (
|
||||
nextConversations.some(
|
||||
(conversation) =>
|
||||
!conversation.remote &&
|
||||
acknowledgedLocalConversations.get(conversation.id) !==
|
||||
conversation
|
||||
)
|
||||
) {
|
||||
if (persistedConversations.length === 0) {
|
||||
const migratedSnapshots =
|
||||
toConversationSnapshots(nextConversations)
|
||||
await window.goodbuddy.conversations.replace(
|
||||
migratedSnapshots
|
||||
)
|
||||
const migratedSnapshotIds = new Set(
|
||||
migratedSnapshots.map((conversation) => conversation.id)
|
||||
)
|
||||
for (const conversation of nextConversations) {
|
||||
if (migratedSnapshotIds.has(conversation.id)) {
|
||||
acknowledgedLocalConversations.set(
|
||||
conversation.id,
|
||||
conversation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
const migration = createLocalConversationSaveBatch(
|
||||
nextConversations,
|
||||
acknowledgedLocalConversations,
|
||||
new Set()
|
||||
)
|
||||
if (migration.batch.length === 0) {
|
||||
break
|
||||
}
|
||||
await window.goodbuddy.conversations.saveLocal(
|
||||
migration.batch
|
||||
)
|
||||
for (const conversation of migration.acknowledgements) {
|
||||
acknowledgedLocalConversations.set(
|
||||
conversation.id,
|
||||
conversation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
persistedLocalConversationsRef.current =
|
||||
acknowledgedLocalConversations
|
||||
setConversations(nextConversations)
|
||||
setActiveId(projectConversation?.id ?? '')
|
||||
localStorage.removeItem(storageKey)
|
||||
try {
|
||||
localStorage.removeItem(storageKey)
|
||||
conversationMigrationStoragePresent.current = false
|
||||
} catch {
|
||||
// The SQLite migration has already completed successfully.
|
||||
}
|
||||
setConversationStoreReady(true)
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
@@ -3857,6 +4262,27 @@ function App(): React.JSX.Element {
|
||||
setDeletingConversationId('')
|
||||
return
|
||||
}
|
||||
const deletingConversation = conversations.find(
|
||||
(conversation) => conversation.id === conversationId
|
||||
)
|
||||
if (deletingConversation && !deletingConversation.remote) {
|
||||
deletingLocalConversationIdsRef.current.add(conversationId)
|
||||
try {
|
||||
await conversationPersistenceQueueRef.current
|
||||
await window.goodbuddy.conversations.deleteLocal(conversationId)
|
||||
persistedLocalConversationsRef.current.delete(conversationId)
|
||||
} catch {
|
||||
deletingLocalConversationIdsRef.current.delete(conversationId)
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: t(
|
||||
'notices.deleteConversationPersistenceFailed'
|
||||
)
|
||||
})
|
||||
setDeletingConversationId('')
|
||||
return
|
||||
}
|
||||
}
|
||||
setConfirmingConversationId('')
|
||||
setDeletingConversationId('')
|
||||
if (conversationActionsId === conversationId) {
|
||||
@@ -3882,6 +4308,8 @@ function App(): React.JSX.Element {
|
||||
const remaining = conversations.filter(
|
||||
(conversation) => conversation.id !== conversationId
|
||||
)
|
||||
conversationsRef.current = remaining
|
||||
deletingLocalConversationIdsRef.current.delete(conversationId)
|
||||
const projectRemaining = remaining.filter(
|
||||
(conversation) => conversation.projectId === activeProjectId
|
||||
)
|
||||
@@ -4726,54 +5154,63 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const clearLocalData = async (): Promise<void> => {
|
||||
for (const requestId of activeRuns.current.keys()) {
|
||||
await window.goodbuddy.agent.cancel(requestId)
|
||||
conversationPersistencePausedRef.current = true
|
||||
try {
|
||||
await conversationPersistenceQueueRef.current
|
||||
for (const requestId of activeRuns.current.keys()) {
|
||||
await window.goodbuddy.agent.cancel(requestId)
|
||||
}
|
||||
activeRuns.current.clear()
|
||||
for (const attachment of attachments) {
|
||||
await window.goodbuddy.context.remove(attachment.id)
|
||||
}
|
||||
for (const library of knowledgeSnapshot.libraries) {
|
||||
await window.goodbuddy.knowledge.deleteLibrary(library.id)
|
||||
}
|
||||
await window.goodbuddy.app.clearLocalData()
|
||||
const conversation = createConversation(
|
||||
activeProjectId || undefined,
|
||||
runtimeSettings
|
||||
? getProjectDefaultRuntimeSelection(
|
||||
activeProject,
|
||||
runtimeSettings
|
||||
)
|
||||
: undefined,
|
||||
t('conversation.greeting')
|
||||
)
|
||||
conversationsRef.current = [conversation]
|
||||
persistedLocalConversationsRef.current.clear()
|
||||
setConversations([conversation])
|
||||
setActiveId(conversation.id)
|
||||
setActivityRecords([])
|
||||
setAssistantTasks([])
|
||||
setTokenUsage(emptyTokenUsage)
|
||||
setAssistantArtifacts([])
|
||||
setAssistantMemories([])
|
||||
setAssistantSchedules([])
|
||||
setAssistantHeartbeats([])
|
||||
setHeartbeatEntries([])
|
||||
setHeartbeatRuns([])
|
||||
setKnowledgeSnapshot({
|
||||
libraries: [],
|
||||
sources: [],
|
||||
documents: [],
|
||||
graphNodes: [],
|
||||
graphRelations: [],
|
||||
evidence: []
|
||||
})
|
||||
setEnabledKnowledgeLibraryIds([])
|
||||
updateAttachments([])
|
||||
setInput('')
|
||||
setView('chat')
|
||||
notify({
|
||||
tone: 'success',
|
||||
message: t('notices.localDataCleared')
|
||||
})
|
||||
} finally {
|
||||
conversationPersistencePausedRef.current = false
|
||||
persistLocalConversationChanges()
|
||||
}
|
||||
activeRuns.current.clear()
|
||||
for (const attachment of attachments) {
|
||||
await window.goodbuddy.context.remove(attachment.id)
|
||||
}
|
||||
for (const library of knowledgeSnapshot.libraries) {
|
||||
await window.goodbuddy.knowledge.deleteLibrary(library.id)
|
||||
}
|
||||
await window.goodbuddy.app.clearLocalData()
|
||||
const conversation = createConversation(
|
||||
activeProjectId || undefined,
|
||||
runtimeSettings
|
||||
? getProjectDefaultRuntimeSelection(
|
||||
activeProject,
|
||||
runtimeSettings
|
||||
)
|
||||
: undefined,
|
||||
t('conversation.greeting')
|
||||
)
|
||||
setConversations([conversation])
|
||||
setActiveId(conversation.id)
|
||||
setActivityRecords([])
|
||||
setAssistantTasks([])
|
||||
setTokenUsage(emptyTokenUsage)
|
||||
setAssistantArtifacts([])
|
||||
setAssistantMemories([])
|
||||
setAssistantSchedules([])
|
||||
setAssistantHeartbeats([])
|
||||
setHeartbeatEntries([])
|
||||
setHeartbeatRuns([])
|
||||
setKnowledgeSnapshot({
|
||||
libraries: [],
|
||||
sources: [],
|
||||
documents: [],
|
||||
graphNodes: [],
|
||||
graphRelations: [],
|
||||
evidence: []
|
||||
})
|
||||
setEnabledKnowledgeLibraryIds([])
|
||||
updateAttachments([])
|
||||
setInput('')
|
||||
setView('chat')
|
||||
notify({
|
||||
tone: 'success',
|
||||
message: t('notices.localDataCleared')
|
||||
})
|
||||
}
|
||||
|
||||
const isRunning =
|
||||
@@ -5161,7 +5598,7 @@ function App(): React.JSX.Element {
|
||||
{filteredConversations.length === 0 && (
|
||||
<p className="conversation-empty">
|
||||
{activeProject?.kind === 'channel' &&
|
||||
!searchQuery.trim()
|
||||
!deferredSearchQuery.trim()
|
||||
? t('conversation.noRemote')
|
||||
: t('conversation.noMatches')}
|
||||
</p>
|
||||
@@ -5382,10 +5819,33 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
|
||||
<div className="message-list">
|
||||
{activeConversation?.messages.map((message, messageIndex) => (
|
||||
<article
|
||||
{hiddenMessageCount > 0 && (
|
||||
<button
|
||||
className="load-earlier-messages"
|
||||
onClick={revealEarlierMessages}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.loadEarlierMessages', {
|
||||
count: hiddenMessageCount
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
{activeConversation &&
|
||||
visibleMessages.map((message, visibleMessageIndex) => {
|
||||
const messageIndex =
|
||||
visibleMessageStartIndex + visibleMessageIndex
|
||||
return (
|
||||
<article
|
||||
className={`message message--${message.role}`}
|
||||
key={message.id}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
messageArticleRefs.current.set(message.id, element)
|
||||
} else {
|
||||
messageArticleRefs.current.delete(message.id)
|
||||
}
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="message__avatar">
|
||||
{message.role === 'assistant' ? (
|
||||
@@ -5931,8 +6391,9 @@ function App(): React.JSX.Element {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
{showScrollToBottom && (
|
||||
@@ -6624,11 +7085,41 @@ function App(): React.JSX.Element {
|
||||
</PageShell>
|
||||
) : view === 'magic-notes' && magicNotesEnabled ? (
|
||||
<PageShell variant="master-detail">
|
||||
<MagicNotesWorkspace onNotify={notify} />
|
||||
<RouteErrorBoundary
|
||||
key="magic-notes"
|
||||
fallback={
|
||||
<RouteLoadError
|
||||
message={t('route.loadFailed')}
|
||||
reloadLabel={t('route.reload')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<RouteLoadingStatus label={t('route.loading')} />
|
||||
}
|
||||
>
|
||||
<MagicNotesWorkspace onNotify={notify} />
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
</PageShell>
|
||||
) : view === 'knowledge' ? (
|
||||
<PageShell variant="master-detail">
|
||||
<KnowledgeWorkspace
|
||||
<RouteErrorBoundary
|
||||
key="knowledge"
|
||||
fallback={
|
||||
<RouteLoadError
|
||||
message={t('route.loadFailed')}
|
||||
reloadLabel={t('route.reload')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<RouteLoadingStatus label={t('route.loading')} />
|
||||
}
|
||||
>
|
||||
<KnowledgeWorkspace
|
||||
documents={knowledgeSnapshot.documents}
|
||||
evidence={knowledgeSnapshot.evidence}
|
||||
graphNodes={knowledgeSnapshot.graphNodes}
|
||||
@@ -6908,11 +7399,27 @@ function App(): React.JSX.Element {
|
||||
selectedLibraryId={knowledgeSnapshot.selectedLibraryId}
|
||||
sources={knowledgeSnapshot.sources}
|
||||
tasks={knowledgeSnapshot.tasks}
|
||||
/>
|
||||
/>
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
</PageShell>
|
||||
) : view === 'heartbeat' ? (
|
||||
<PageShell variant="dashboard">
|
||||
<HeartbeatCenter
|
||||
<RouteErrorBoundary
|
||||
key="heartbeat"
|
||||
fallback={
|
||||
<RouteLoadError
|
||||
message={t('route.loadFailed')}
|
||||
reloadLabel={t('route.reload')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<RouteLoadingStatus label={t('route.loading')} />
|
||||
}
|
||||
>
|
||||
<HeartbeatCenter
|
||||
configs={assistantHeartbeats}
|
||||
currentProjectName={activeProject?.name}
|
||||
entries={heartbeatEntries}
|
||||
@@ -6930,10 +7437,26 @@ function App(): React.JSX.Element {
|
||||
onUseFollowUpTask={useHeartbeatTask}
|
||||
runs={heartbeatRuns}
|
||||
tasks={assistantTasks}
|
||||
/>
|
||||
/>
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
</PageShell>
|
||||
) : view === 'settings' ? (
|
||||
<SettingsPanel
|
||||
<RouteErrorBoundary
|
||||
key="settings"
|
||||
fallback={
|
||||
<RouteLoadError
|
||||
message={t('route.loadFailed')}
|
||||
reloadLabel={t('route.reload')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<RouteLoadingStatus label={t('route.loading')} />
|
||||
}
|
||||
>
|
||||
<SettingsPanel
|
||||
appearanceTheme={appearanceTheme}
|
||||
heartbeats={assistantHeartbeats}
|
||||
initialCategory={settingsInitialCategory}
|
||||
@@ -6972,7 +7495,9 @@ function App(): React.JSX.Element {
|
||||
onSetHeartbeatPaused={setHeartbeatPaused}
|
||||
open
|
||||
presentation="page"
|
||||
/>
|
||||
/>
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
) : (
|
||||
<PageShell variant="dashboard">
|
||||
<ActivityPanel
|
||||
|
||||
@@ -105,6 +105,24 @@ describe('activity-store', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('drops the oldest records until the stored JSON fits the load bound', () => {
|
||||
const records = Array.from(
|
||||
{ length: MAX_ACTIVITY_RECORDS },
|
||||
(_, index) => ({
|
||||
...makeRecord(index),
|
||||
detail: 'x'.repeat(4_000)
|
||||
})
|
||||
)
|
||||
|
||||
expect(saveActivityRecords(records)).toBe(true)
|
||||
const serialized = localStorage.getItem(ACTIVITY_STORAGE_KEY)
|
||||
expect(serialized?.length).toBeLessThanOrEqual(2_000_000)
|
||||
const restored = loadActivityRecords()
|
||||
expect(restored.length).toBeGreaterThan(0)
|
||||
expect(restored.length).toBeLessThan(MAX_ACTIVITY_RECORDS)
|
||||
expect(restored[0]?.id).toBe('activity-0')
|
||||
})
|
||||
|
||||
it('reports rejected writes without throwing', () => {
|
||||
const rejectingStorage = {
|
||||
setItem: () => {
|
||||
|
||||
@@ -278,8 +278,9 @@ export function loadActivityRecords(
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists at most 500 schema-valid records. Returns false if storage is
|
||||
* unavailable or rejects the write.
|
||||
* Persists as many of the newest schema-valid records as fit within the
|
||||
* bounded storage payload. Returns false if storage is unavailable or rejects
|
||||
* the write.
|
||||
*/
|
||||
export function saveActivityRecords(
|
||||
records: readonly ActivityRecord[],
|
||||
@@ -289,19 +290,32 @@ export function saveActivityRecords(
|
||||
return false
|
||||
}
|
||||
|
||||
const safeRecords: ActivityRecord[] = []
|
||||
const serializedRecords: string[] = []
|
||||
let serializedLength = 2
|
||||
for (const record of records) {
|
||||
const safeRecord = parseActivityRecord(record)
|
||||
if (safeRecord) {
|
||||
safeRecords.push(safeRecord)
|
||||
const serializedRecord = JSON.stringify(safeRecord)
|
||||
const nextLength =
|
||||
serializedLength +
|
||||
serializedRecord.length +
|
||||
(serializedRecords.length > 0 ? 1 : 0)
|
||||
if (nextLength > MAX_STORED_JSON_LENGTH) {
|
||||
break
|
||||
}
|
||||
serializedRecords.push(serializedRecord)
|
||||
serializedLength = nextLength
|
||||
}
|
||||
if (safeRecords.length === MAX_ACTIVITY_RECORDS) {
|
||||
if (serializedRecords.length === MAX_ACTIVITY_RECORDS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(safeRecords))
|
||||
storage.setItem(
|
||||
ACTIVITY_STORAGE_KEY,
|
||||
`[${serializedRecords.join(',')}]`
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
|
||||
@@ -49,6 +49,11 @@ export const app = {
|
||||
activity: 'Tasks & Activity',
|
||||
pendingSuggestions: '{{count}} pending suggestions'
|
||||
},
|
||||
route: {
|
||||
loading: 'Loading page…',
|
||||
loadFailed: 'The page component failed to load. Reload the app.',
|
||||
reload: 'Reload'
|
||||
},
|
||||
sidebar: {
|
||||
label: 'Main sidebar',
|
||||
newConversation: 'New conversation',
|
||||
@@ -226,6 +231,7 @@ export const app = {
|
||||
}
|
||||
},
|
||||
retry: 'Edit and send again',
|
||||
loadEarlierMessages: 'Load earlier messages ({{count}} remaining)',
|
||||
scrollToBottom: 'Scroll to bottom',
|
||||
status: {
|
||||
responseTruncated: 'The response was too long and was truncated locally',
|
||||
@@ -419,6 +425,8 @@ export const app = {
|
||||
'Channel conversations are created automatically when the client receives a new message',
|
||||
deleteConversationCancelFailed:
|
||||
'Could not stop the running task, so the conversation was not deleted',
|
||||
deleteConversationPersistenceFailed:
|
||||
'Could not delete the local conversation, so it was kept',
|
||||
deletedConversationBrowserCloseFailed:
|
||||
'Failed to close the browser for the deleted conversation',
|
||||
conversationCopied: 'Conversation copied to the clipboard',
|
||||
|
||||
@@ -45,6 +45,11 @@ export const app = {
|
||||
activity: '任务与活动',
|
||||
pendingSuggestions: '{{count}} 条待处理建议'
|
||||
},
|
||||
route: {
|
||||
loading: '正在加载页面…',
|
||||
loadFailed: '页面组件加载失败,请重新加载应用',
|
||||
reload: '重新加载'
|
||||
},
|
||||
sidebar: {
|
||||
label: '主侧栏',
|
||||
newConversation: '新建对话',
|
||||
@@ -219,6 +224,7 @@ export const app = {
|
||||
}
|
||||
},
|
||||
retry: '重新编辑并发送',
|
||||
loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)',
|
||||
scrollToBottom: '到底部',
|
||||
status: {
|
||||
responseTruncated: '回答过长,已在本地截断显示',
|
||||
@@ -395,6 +401,8 @@ export const app = {
|
||||
'通道项目的会话由客户端收到新消息后自动创建',
|
||||
deleteConversationCancelFailed:
|
||||
'停止会话中的运行任务失败,尚未删除对话',
|
||||
deleteConversationPersistenceFailed:
|
||||
'删除本地会话失败,已保留当前对话',
|
||||
deletedConversationBrowserCloseFailed: '关闭已删除对话的浏览器失败',
|
||||
conversationCopied: '对话已复制到剪贴板',
|
||||
clipboardUnavailable: '无法访问剪贴板,请检查系统权限',
|
||||
|
||||
@@ -3126,7 +3126,30 @@ button > svg {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.load-earlier-messages {
|
||||
align-self: center;
|
||||
min-height: var(--control-height);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.load-earlier-messages:hover {
|
||||
border-color: var(--accent-selected);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.load-earlier-messages:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.message {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-block-size: auto 160px;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
padding: 17px 8px;
|
||||
@@ -3138,6 +3161,56 @@ button > svg {
|
||||
grid-template-columns: minmax(0, 1fr) 30px;
|
||||
}
|
||||
|
||||
.route-loading-status {
|
||||
display: flex;
|
||||
min-height: min(360px, 50vh);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.route-loading-status > svg {
|
||||
animation: route-loading-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.route-load-error {
|
||||
display: flex;
|
||||
min-height: min(360px, 50vh);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-5);
|
||||
gap: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.route-load-error > svg {
|
||||
color: var(--danger-solid);
|
||||
}
|
||||
|
||||
.route-load-error > button {
|
||||
min-height: var(--control-height);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.route-load-error > button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@keyframes route-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.message__avatar {
|
||||
display: grid;
|
||||
width: 29px;
|
||||
|
||||
Reference in New Issue
Block a user