perf: reduce streaming render cost and refresh activity page on demand

- Segment streaming Markdown so completed blocks are reused and only the growing tail is re-parsed; fall back to whole-document parsing for document-wide constructs
- Memoize conversation sidebar rows and cache per-conversation derived activity, ordering and pending approvals
- Activity page loads on entry, refreshes every 15 s while visible, and has a single header refresh button
- Include pending settings, model download source, device sharing and knowledge gateway changes in the working tree
- Add Electron streaming input benchmark and regression tests
This commit is contained in:
mesalogo
2026-10-01 21:49:09 +08:00
parent 016f987026
commit c8f327fc4e
75 changed files with 3042 additions and 905 deletions
+15 -6
View File
@@ -959,23 +959,32 @@ describe('ActivityPanel', () => {
expect(screen.getByRole('row', { name: '直连模型 · gpt-sys 16 2 0 0 0% 18' })).toBeInTheDocument()
})
it('shows an empty system tab and refreshes usage on demand', async () => {
it('shows an empty system tab and refreshes from the header on demand', async () => {
let resolveRefresh!: () => void
const onRefreshTokenUsage = vi.fn(() => new Promise<void>((resolve) => { resolveRefresh = resolve }))
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} onRefreshTokenUsage={onRefreshTokenUsage}
const onRefresh = vi.fn(() => new Promise<void>((resolve) => { resolveRefresh = resolve }))
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} onRefresh={onRefresh}
records={[]} tokenUsage={makeTokenUsage()} />)
fireEvent.click(screen.getByRole('tab', { name: '系统任务' }))
expect(screen.getByText('暂无系统任务 Token 用量')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
expect(onRefreshTokenUsage).toHaveBeenCalledOnce()
const header = screen.getByRole('heading', { level: 1, name: '运行记录' }).closest('header')!
fireEvent.click(within(header).getByRole('button', { name: '刷新' }))
expect(onRefresh).toHaveBeenCalledOnce()
const busy = screen.getByRole('button', { name: '正在刷新' })
expect(busy).toBeDisabled()
fireEvent.click(busy)
expect(onRefreshTokenUsage).toHaveBeenCalledOnce()
expect(onRefresh).toHaveBeenCalledOnce()
resolveRefresh()
expect(await screen.findByRole('button', { name: '刷新' })).toBeEnabled()
})
it('offers the header refresh on the task tab too', () => {
const onRefresh = vi.fn(async () => {})
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} onRefresh={onRefresh}
records={[makeRecord(1)]} tokenUsage={makeTokenUsage()} />)
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
expect(onRefresh).toHaveBeenCalledOnce()
})
it('hides the refresh button when no refresh handler is provided', () => {
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[]} tokenUsage={makeTokenUsage()} />)
fireEvent.click(screen.getByRole('tab', { name: '用量统计' }))
+27 -24
View File
@@ -45,8 +45,11 @@ export type ActivityPanelProps = {
tokenUsage: TokenUsageSummary
onClear: () => void
onOpenConversation: (conversationId: string) => void
/** Re-reads token usage from storage; the button is hidden when omitted. */
onRefreshTokenUsage?: () => Promise<void>
/**
* Reloads the activity snapshot and token usage. The page does not follow
* live updates; the header refresh button is hidden when omitted.
*/
onRefresh?: () => Promise<void>
}
type ConversationActivityGroup = {
@@ -251,7 +254,7 @@ export const ActivityPanel = memo(function ActivityPanel({
tokenUsage,
onClear,
onOpenConversation,
onRefreshTokenUsage
onRefresh
}: ActivityPanelProps): React.JSX.Element {
const { t, i18n } = useTranslation('activity')
const { t: tWorkspace } = useTranslation('workspace')
@@ -262,7 +265,7 @@ export const ActivityPanel = memo(function ActivityPanel({
useState<Exclude<TokenUsageGroup, 'source'>>('project')
const [systemTokenGroup, setSystemTokenGroup] =
useState<SystemTokenUsageGroup>('source')
const [refreshingTokenUsage, setRefreshingTokenUsage] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [expandedTokenRows, setExpandedTokenRows] = useState<Set<string>>(
() => new Set()
)
@@ -612,15 +615,15 @@ export const ActivityPanel = memo(function ActivityPanel({
: row.label
return { label }
}
const refreshTokenUsage = async (): Promise<void> => {
if (!onRefreshTokenUsage || refreshingTokenUsage) {
const refresh = async (): Promise<void> => {
if (!onRefresh || refreshing) {
return
}
setRefreshingTokenUsage(true)
setRefreshing(true)
try {
await onRefreshTokenUsage()
await onRefresh()
} finally {
setRefreshingTokenUsage(false)
setRefreshing(false)
}
}
const renderRecordCard = (
@@ -760,6 +763,21 @@ export const ActivityPanel = memo(function ActivityPanel({
className="activity-panel"
>
<PageHeader
actions={
onRefresh && (
<button
aria-busy={refreshing}
className="secondary-button activity-panel__refresh"
disabled={refreshing}
onClick={() => void refresh()}
title={t('refresh.hint')}
type="button"
>
<RefreshCw aria-hidden="true" size={13} />
{refreshing ? t('refresh.refreshing') : t('refresh.label')}
</button>
)
}
help={t('header.description')}
headingId="activity-panel-title"
icon={<Activity size={20} />}
@@ -1125,21 +1143,6 @@ export const ActivityPanel = memo(function ActivityPanel({
options={groups}
value={group}
/>
{onRefreshTokenUsage && (
<button
aria-busy={refreshingTokenUsage}
className="secondary-button token-usage__refresh"
disabled={refreshingTokenUsage}
onClick={() => void refreshTokenUsage()}
title={t('tokenUsage.refreshHint')}
type="button"
>
<RefreshCw aria-hidden="true" size={13} />
{refreshingTokenUsage
? t('tokenUsage.refreshing')
: t('tokenUsage.refresh')}
</button>
)}
</header>
{description && (
<p className="token-usage__description">{description}</p>
+193 -26
View File
@@ -79,6 +79,15 @@ vi.mock('./ConversationTaskStrip', async (importOriginal) => {
const original = await importOriginal<typeof import('./ConversationTaskStrip')>();
return { ...original, ConversationTaskStrip: historyRenderProbes.instrument(original.ConversationTaskStrip, historyRenderProbes.task) };
});
const sidebarRenderProbes = vi.hoisted(() => ({ row: vi.fn(), projectActivity: vi.fn() }));
vi.mock('./OverflowMarquee', async (importOriginal) => {
const original = await importOriginal<typeof import('./OverflowMarquee')>();
return { ...original, OverflowMarquee: historyRenderProbes.instrument(original.OverflowMarquee, sidebarRenderProbes.row) };
});
vi.mock('./ProjectActivity', async (importOriginal) => {
const original = await importOriginal<typeof import('./ProjectActivity')>();
return { ...original, ProjectActivity: historyRenderProbes.instrument(original.ProjectActivity, sidebarRenderProbes.projectActivity) };
});
vi.mock('./RuntimeChecklistStrip', async (importOriginal) => {
const original = await importOriginal<typeof import('./RuntimeChecklistStrip')>();
return { ...original, RuntimeChecklistStrip: historyRenderProbes.instrument(original.RuntimeChecklistStrip, historyRenderProbes.checklist) };
@@ -1221,7 +1230,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: enabled,
@@ -3098,8 +3107,11 @@ describe("App", () => {
await screen.findByText(new RegExp(delta.trim(), "u"));
}
expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0);
activityRenderProbe.mockClear();
act(() => agentListener?.({ requestId, type: "done" }));
await waitFor(() => expect(activityRenderProbe.mock.calls.length).toBeGreaterThan(0));
await waitFor(() => expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument());
// The hidden page keeps its snapshot; it refreshes when shown again.
expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0);
expect(route).toHaveAttribute("hidden");
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
expect(screen.getByRole("heading", { name: "运行记录" })).toBe(heading);
@@ -3302,7 +3314,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -3407,7 +3419,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -3424,7 +3436,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -3527,7 +3539,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -3544,7 +3556,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -4564,6 +4576,60 @@ describe("App", () => {
expect(run).not.toHaveBeenCalled();
});
it("re-renders only the streaming conversation row and keeps sidebar actions live", async () => {
const otherConversations = Array.from({ length: 30 }, (_, c) => ({
id: `00000000-0000-4000-9${String(c).padStart(3, "0")}-000000000000`,
projectId, title: `Sidebar row ${c}`, updatedAt: 1_000 + c,
messages: Array.from({ length: 40 }, (_, index) => ({
id: `00000000-0000-4000-9${String(c).padStart(3, "0")}-${String(index).padStart(12, "0")}`,
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
content: `history ${index}`, createdAt: 1_000 + index, state: "complete" as const,
})),
}));
vi.mocked(api.conversations.list).mockResolvedValueOnce(otherConversations);
render(<App />);
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
await screen.findByText("Sidebar row 29");
fireEvent.change(composer, { target: { value: "Row isolation" } });
fireEvent.click(await screen.findByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const requestId = run.mock.calls[0]![0].requestId;
act(() => agentListener?.({ requestId, type: "text", delta: "Streaming" }));
await screen.findByText("Streaming");
const rows = () => [...document.querySelectorAll<HTMLElement>(".conversation-entry")];
expect(rows().length).toBeGreaterThanOrEqual(30);
const rowNodes = rows().map((row) => row.querySelector(".conversation-item__title")!);
sidebarRenderProbes.row.mockClear();
sidebarRenderProbes.projectActivity.mockClear();
for (const delta of [" one", " two", " three"]) {
act(() => agentListener?.({ requestId, type: "text", delta }));
}
await screen.findByText("Streaming one two three");
// Only the streaming conversation's own row may re-render per delta.
expect(sidebarRenderProbes.row.mock.calls.length).toBeLessThanOrEqual(3);
expect(sidebarRenderProbes.projectActivity).toHaveBeenCalledTimes(0);
expect(rows().map((row) => row.querySelector(".conversation-item__title"))).toEqual(rowNodes);
sidebarRenderProbes.row.mockClear();
for (const value of ["a", "ab", "abc"]) {
fireEvent.change(composer, { target: { value } });
}
expect(sidebarRenderProbes.row).toHaveBeenCalledTimes(0);
// Row actions still run the latest App state after the rows were skipped.
const target = screen.getByText("Sidebar row 3").closest(".conversation-entry")!;
fireEvent.click(within(target as HTMLElement).getByRole("button", { name: "更多会话操作 Sidebar row 3" }));
fireEvent.click(await screen.findByRole("menuitem", { name: /^重命名/u }));
const renameInput = within(target as HTMLElement).getByRole("textbox");
fireEvent.change(renameInput, { target: { value: "Renamed while streaming" } });
fireEvent.submit(renameInput.closest("form")!);
const renamedTitle = () => within(target as HTMLElement).getByText("Renamed while streaming");
await waitFor(() => expect(renamedTitle()).toBeInTheDocument());
fireEvent.click(renamedTitle());
await waitFor(() => expect(renamedTitle().closest(".conversation-item")).toHaveClass("conversation-item--active"));
expect(document.querySelector(".conversation-title__text")).toHaveTextContent("Renamed while streaming");
expect(composer).toHaveValue("");
});
it("isolates history and task strips while typing during a running request", async () => {
const conversationId = crypto.randomUUID();
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
@@ -5938,7 +6004,7 @@ describe("App", () => {
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -7919,7 +7985,7 @@ describe("App", () => {
})
})
it("loads token usage in activity and refreshes it when a run finishes", async () => {
it("loads token usage in activity and refreshes it on demand after a run", async () => {
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
totals: {
callCount: 1,
@@ -8009,11 +8075,68 @@ describe("App", () => {
type: "done",
});
});
await waitFor(() =>
expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument(),
);
expect(api.usage.getTokenSummary).toHaveBeenCalledOnce();
expect(within(stats).getByText("120")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "刷新" }));
await waitFor(() =>
expect(api.usage.getTokenSummary).toHaveBeenCalledTimes(2),
);
expect(within(stats).getByText("345")).toBeInTheDocument();
expect(await within(stats).findByText("345")).toBeInTheDocument();
});
it("refreshes visible activity records every 15 seconds without following live updates", async () => {
render(<App />);
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "定时刷新运行记录" },
});
fireEvent.click(await screen.findByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const requestId = run.mock.calls[0]![0].requestId;
const realSetInterval = window.setInterval.bind(window);
const refreshTicks: Array<() => void> = [];
const intervalSpy = vi
.spyOn(window, "setInterval")
.mockImplementation(((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
if (timeout === 15_000 && typeof handler === "function") {
refreshTicks.push(handler as () => void);
}
return realSetInterval(handler, timeout, ...args);
}) as typeof window.setInterval);
try {
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
await screen.findByRole("heading", { level: 1, name: "运行记录" });
await waitFor(() =>
expect(api.usage.getTokenSummary).toHaveBeenCalledOnce(),
);
const items = () =>
document.querySelectorAll(".activity-list__item").length;
const baseline = items();
expect(baseline).toBeGreaterThan(0);
act(() =>
agentListener?.({
requestId,
type: "tool",
callId: "interval-tool",
name: "read",
state: "running",
summary: "定时刷新工具",
}),
);
// The page keeps its snapshot until the next periodic refresh.
expect(items()).toBe(baseline);
expect(refreshTicks).toHaveLength(2);
act(() => refreshTicks.forEach((tick) => tick()));
expect(items()).toBeGreaterThan(baseline);
await waitFor(() =>
expect(api.usage.getTokenSummary).toHaveBeenCalledTimes(2),
);
} finally {
intervalSpy.mockRestore();
}
});
it("offers only Ask and Execute in visible work mode controls", async () => {
@@ -13936,7 +14059,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -13953,7 +14076,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -14007,7 +14130,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -14068,7 +14191,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -14106,7 +14229,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -14123,7 +14246,7 @@ describe("App", () => {
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -14186,7 +14309,7 @@ describe("App", () => {
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -14246,7 +14369,7 @@ describe("App", () => {
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -14328,14 +14451,14 @@ describe("App", () => {
await waitFor(() => expect(move).toBeEnabled())
fireEvent.click(move)
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
'监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享',
'监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享(技术预览)',
]))
await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled())
const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article')
fireEvent.dragStart(cards[3]!)
fireEvent.drop(cards[1]!)
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
'监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享',
'监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)',
]))
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing'])
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
@@ -14343,23 +14466,67 @@ describe("App", () => {
'对话', '监督者', '知识库', '魔法笔记', '运行记录',
])
fireEvent.click(screen.getByRole('button', { name: '应用' }))
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享', '管理应用'])
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '管理应用'])
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享'])
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)'])
})
it('opens device sharing from Application Center while keeping the Settings entry available', async () => {
it('leaves device sharing on an external settings event and keeps it closed when re-enabled', async () => {
const updates = api.updates!
await updates.updateSettings({ deviceSharingEnabled: true })
let changed!: Parameters<typeof updates.onSettingsChanged>[0]
vi.mocked(updates.onSettingsChanged).mockImplementation(listener => { changed = listener; return vi.fn() })
render(<App />)
await screen.findByRole('button', { name: '监督者' })
fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '设备共享(技术预览)' }))
await screen.findByRole('button', { name: '注册本机' })
await act(async () => changed(await updates.updateSettings({ deviceSharingEnabled: false })))
expect(screen.queryByRole('heading', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '对话' })).toHaveAttribute('aria-current', 'page')
await act(async () => changed(await updates.updateSettings({ deviceSharingEnabled: true })))
expect(screen.queryByRole('heading', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '应用' }))
expect(screen.getByRole('menuitem', { name: '设备共享(技术预览)' })).toBeInTheDocument()
})
it('enables device sharing from Application Center and leaves its page when disabled', async () => {
render(<App />)
await screen.findByRole('button', { name: '监督者' })
fireEvent.click(screen.getByRole('button', { name: '应用' }))
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
const row = screen.getByText('设备共享', { exact: true }).closest('article')!
fireEvent.click(within(row).getByRole('button', { name: '打开' }))
const row = screen.getByText('设备共享(技术预览)', { exact: true }).closest('article')!
expect(within(row).getByRole('button', { name: '打开' })).toBeDisabled()
fireEvent.click(within(row).getByRole('button', { name: '设备共享(技术预览) 应用设置' }))
const enable = screen.getByRole('switch', { name: '启用应用' })
await waitFor(() => expect(enable).toBeEnabled())
expect(enable).not.toBeChecked()
expect(screen.queryByRole('switch', { name: '常驻左侧菜单' })).not.toBeInTheDocument()
fireEvent.click(enable)
await waitFor(() => expect(enable).toBeChecked())
expect(api.updates!.updateSettings).toHaveBeenCalledWith({ deviceSharingEnabled: true })
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '设备共享(技术预览)' }))
await waitFor(() => {
expect(screen.getByRole('heading', { name: '设备共享' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: '设备共享(技术预览)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled()
})
expect(screen.queryByRole('dialog', { name: '应用中心' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
fireEvent.click(screen.getByRole('button', { name: '设备共享(技术预览) 应用设置' }))
const disable = screen.getByRole('switch', { name: '启用应用' })
await waitFor(() => expect(disable).toBeEnabled())
fireEvent.click(disable)
await waitFor(() => expect(disable).not.toBeChecked())
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
expect(screen.queryByRole('heading', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '注册本机' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '应用' }))
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: '设置' }))
expect(await screen.findByRole('dialog', { name: '设置中心' })).toBeInTheDocument()
})
+690 -502
View File
File diff suppressed because it is too large Load Diff
+31 -6
View File
@@ -48,6 +48,31 @@ afterEach(async () => {
})
describe('Application Center', () => {
it('offers device sharing enablement without pinning and waits for confirmed settings', () => {
const handlers = props()
const { rerender } = render(<ApplicationCenter {...handlers} />)
expect(isApplicationEnabled(undefined, 'device-sharing')).toBe(false)
const card = screen.getByText('设备共享(技术预览)', { exact: true }).closest('article')!
expect(within(card).getByText('可选 · 已关闭')).toBeVisible()
expect(within(card).getByRole('button', { name: '打开' })).toBeDisabled()
fireEvent.click(within(card).getByRole('button', { name: '设备共享(技术预览) 应用设置' }))
const enable = screen.getByRole('switch', { name: '启用应用' })
expect(enable).not.toBeChecked()
expect(screen.queryByRole('switch', { name: '常驻左侧菜单' })).not.toBeInTheDocument()
fireEvent.click(enable)
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ deviceSharingEnabled: true })
expect(enable).not.toBeChecked()
rerender(<ApplicationCenter {...handlers} pending />)
expect(enable).toBeDisabled()
rerender(<ApplicationCenter {...handlers} settings={{ ...settings, deviceSharingEnabled: true }} />)
expect(enable).toBeChecked()
fireEvent.click(enable)
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ deviceSharingEnabled: false })
fireEvent.click(screen.getByRole('button', { name: '返回应用中心' }))
fireEvent.click(within(screen.getByText('设备共享(技术预览)', { exact: true }).closest('article')!).getByRole('button', { name: '打开' }))
expect(handlers.onOpen).toHaveBeenCalledWith('device-sharing')
})
it.each(['zh-CN', 'en-US'] as const)('keeps disable consequences visible and pin help separate in %s', async (locale) => {
await changeUiLocale(locale)
const onUpdate = vi.fn(async () => true)
@@ -238,9 +263,9 @@ describe('Application Center', () => {
const handlers = props()
const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
const displayedOrder = () => screen.getAllByRole('article').map((row) => row.querySelector('strong')?.textContent)
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享'])
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)'])
expect(screen.getByRole('button', { name: '上移 知识库' })).toBeDisabled()
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' })
expect(moveUp.closest('article')).not.toBeNull()
expect(moveUp).toHaveAttribute('title', '上移 本机推理监控')
@@ -251,15 +276,15 @@ describe('Application Center', () => {
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
},
})
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
expect(await screen.findByRole('status')).not.toBeEmptyDOMElement()
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享'])
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)'])
rerender(<ApplicationCenter {...handlers} settings={{
...pinnedSettings,
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] },
}} />)
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享'])
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享(技术预览)'])
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' }))
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation })
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
+8 -7
View File
@@ -32,7 +32,7 @@ import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import './application-center.css'
export const applicationDefinitions = {
'device-sharing': { icon: Network, title: 'sharing.title' },
'device-sharing': { icon: Network, title: 'sharing.title', enabled: 'deviceSharingEnabled' },
'magic-notes': {
icon: Sparkles,
title: 'navigation.magicNotes',
@@ -58,7 +58,8 @@ export function isApplicationEnabled(
settings: ApplicationSettings | undefined,
id: BuiltInApplicationId,
): boolean {
if (id === 'knowledge' || id === 'device-sharing') return true
if (id === 'knowledge') return true
if (id === 'device-sharing') return settings?.deviceSharingEnabled === true
if (id === 'heartbeat') return settings?.heartbeatEnabled === true
return (
settings !== undefined &&
@@ -121,7 +122,7 @@ export function ApplicationSettingsView({
/>
<span>{t('applications.enable')}</span>
</label>
<div className="inline-help-label">
{id !== 'device-sharing' && <div className="inline-help-label">
<label className="toggle-row">
<input
role="switch"
@@ -141,8 +142,8 @@ export function ApplicationSettingsView({
<span>{t('applications.pin')}</span>
</label>
<InlineHelp label={t('applications.pin')} id="application-pin-help">{t('applications.pinHelp')}</InlineHelp>
</div>
<p className="settings-notice">{t('applications.disableHelp')}</p>
</div>}
<p className="settings-notice">{t(id === 'device-sharing' ? 'applications.deviceSharingHelp' : 'applications.disableHelp')}</p>
{id === 'magic-notes' && (
<label className="toggle-row">
<input
@@ -451,7 +452,7 @@ export function ApplicationCenter({
<div className="application-center__title">
<strong>{t(definition.title)}</strong>
<small>
{id === 'device-sharing' ? t('applications.builtin') : alwaysShown
{alwaysShown
? t('applications.alwaysShown')
: `${t('applications.optional')} · ${t(enabled ? 'applications.enabled' : 'applications.disabled')}`}
</small>
@@ -468,7 +469,7 @@ export function ApplicationCenter({
>
{t('applications.open')}
</button>
{id !== 'knowledge' && id !== 'device-sharing' && (
{id !== 'knowledge' && (
<button
className="secondary-button"
type="button"
+13
View File
@@ -22,6 +22,19 @@ function Harness({ value = settings, pending = false, error, onOpen = vi.fn(), o
}
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
it('only shows device sharing when explicitly enabled and removes it on disable', () => {
const onOpen = vi.fn()
const { rerender } = render(<Harness onOpen={onOpen} />)
fireEvent.click(screen.getByText('Launcher'))
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
rerender(<Harness value={{ ...settings, deviceSharingEnabled: true }} onOpen={onOpen} />)
fireEvent.click(screen.getByRole('menuitem', { name: '设备共享(技术预览)' }))
expect(onOpen).toHaveBeenCalledWith('device-sharing')
fireEvent.click(screen.getByText('Launcher'))
rerender(<Harness value={{ ...settings, deviceSharingEnabled: false }} onOpen={onOpen} />)
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
})
it('filters the shared order by enablement only, including reordered always-shown apps', () => {
const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true, heartbeatEnabled: true,
applicationNavigation: { order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] as ApplicationSettings['applicationNavigation']['order'],
@@ -268,7 +268,7 @@ describe('EmbeddingSettingsSection', () => {
).not.toHaveAttribute('value')
})
it('keeps an unavailable source explicit and opens General settings', () => {
it('keeps an unavailable source explicit and opens source settings', () => {
const onOpenModelDownloadSourceSettings = vi.fn()
renderSection({
models: {
@@ -298,7 +298,7 @@ describe('EmbeddingSettingsSection', () => {
})
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '前往通用设置' })
screen.getByRole('button', { name: '模型下载源' })
)
expect(onOpenModelDownloadSourceSettings).toHaveBeenCalledOnce()
})
@@ -77,7 +77,7 @@ const settings: ApplicationSettings = {
transparentFrostedEffectEnabled: false,
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, applicationNavigation: defaultApplicationNavigation,
localInferenceEnabled: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
localInferenceEnabled: true, deviceSharingEnabled: false, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', magicNoteCanvasPageCount: 1
}
+97 -1
View File
@@ -18,9 +18,12 @@ import { changeUiLocale } from './i18n'
import {
InlineMarkdown,
MarkdownRenderer,
normalizeLatexDelimiters
normalizeLatexDelimiters,
splitMarkdownSegments,
UnsegmentedMarkdownRenderer
} from './MarkdownRenderer'
const mermaidMock = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn()
@@ -721,3 +724,96 @@ ${'A'.repeat(20_001)}
await changeUiLocale('zh-CN')
})
})
describe('segmented Markdown rendering', () => {
afterEach(() => cleanup())
const prose = ('A growing paragraph with **emphasis**, `inline code`, and [a link](https://example.com).\n\n' +
'- First item\n- Second item\n\n> A quoted explanation.\n\n').repeat(8)
const segmentationCorpus = [
'# Heading\n\n**bold** and [link](https://example.com)\n- [x] ready\n',
'Inline \\(a+b\\), $x$ and \\\\(escaped\\).\n$x^2$\n\\(y\\)\n\\[z\\]\n\\[\na+b\n\\]\n',
'`\\(code\\)` ``a ` \\(b\\)`` and \\(x\\)\n```tex\n\\(x\\)\n```\n~~~\n$y$\n~~~\n',
'````tex\n```\n\\[x\\]\n````\n ~~~txt\n\\(y\\)\n ~~~~\n\\(z\\)\n',
'a\r\nb\r\n$x$\r\n```txt\r\n\\(code\\)\r\n```\r\nend\r',
'partial \\(x + \\) then `code \\( and `` and \\[\n\\',
'\n\nplain\rtext\r\n\n $indented$\n \\(indented\\)\n',
'# Streaming answer\n\n' + prose,
prose.slice(0, 600) + '\n\\(x^2 + y^2\\)\n\n```ts\nconst value = "\\(literal\\)"\n```\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n',
'# 标题\n\n第一段 **粗体**。\n\n第二段 `code`。\n\n## 小节\n\n最后一段。',
'- a\n- b\n\n- c\n\nafter list\n\n1. one\n2. two\n\n3) three\n\npara',
'- item\n\n continued paragraph\n\n indented code in item\n\nend',
'> quote\n\n> second quote\n\nplain\n\n>lazy\ncontinuation',
'para\n\n indented code\n\n more code\n\nafter',
'```ts\nconst a = 1\n\nconst b = 2\n```\n\ntext\n\n~~~\n\n~~~\n\nend',
'````\n```\n\ninner\n```\n````\n\nafter fence\n\n```\nunterminated\n\nstill code',
'$$\na^2\n\n+ b^2\n$$\n\ntext with $x$ math\n\n\\[\ny\n\\]\n\nend',
'| a | b |\n| - | - |\n| 1 | 2 |\n\n| c |\n| - |\n| 3 |\n\nafter',
'Setext\n===\n\nAnother\n---\n\n***\n\n- - -\n\nend',
'see [ref] and [^note]\n\n[ref]: https://example.com\n\n[^note]: footnote body',
'- [ ] task\n- [x] done\n\n~~strike~~ and https://example.com autolink',
'<div>\n\nraw html block\n\n</div>\n\nafter html',
'\n\n\nleading blanks\n\n\n\nmany blanks\n\n',
' indented by three\n\n\tafter tab\n\n - nested\n\ntrailing',
'```a`b\n\ntext\n\n```\n\ncode?\n\n```\n\nend',
'- item\n\n ```\n x\n\n y\n ```\n\nafter\n\n> ```\n\nq',
'1. one\n\n $$\n a\n\n $$\n\nb\n\n10. ten\n\nc'
]
function canonicalHtml(element: HTMLElement): string {
const ids = new Map<string, string>()
const clone = element.cloneNode(true) as HTMLElement
for (const node of clone.querySelectorAll('*')) {
for (const name of ['id', 'aria-controls', 'aria-labelledby', 'aria-describedby', 'href']) {
const value = node.getAttribute(name)
if (value && /_r_[a-z0-9]+_|user-content-fn/u.test(value)) {
if (!ids.has(value)) ids.set(value, `id-${ids.size}`)
node.setAttribute(name, ids.get(value)!)
}
}
}
return clone.innerHTML
}
it('renders every streaming prefix identically to whole-document parsing', () => {
const segmented = render(<div />)
const reference = render(<div />)
let prefixes = 0
let multiSegment = 0
for (const content of segmentationCorpus) {
const step = Math.max(1, Math.floor(content.length / 120))
for (let end = 0; end <= content.length; end += step) {
const prefix = content.slice(0, end)
segmented.rerender(<div><MarkdownRenderer>{prefix}</MarkdownRenderer></div>)
reference.rerender(<div><UnsegmentedMarkdownRenderer>{prefix}</UnsegmentedMarkdownRenderer></div>)
expect(canonicalHtml(segmented.container), JSON.stringify(prefix))
.toBe(canonicalHtml(reference.container))
prefixes++
if (splitMarkdownSegments(normalizeLatexDelimiters(prefix)).length > 1) multiSegment++
}
}
expect(prefixes).toBeGreaterThan(1500)
expect(multiSegment).toBeGreaterThan(500)
})
it('keeps completed segments intact and falls back for document-scoped syntax', () => {
// List markers never start a segment: the list may continue or turn loose.
expect(splitMarkdownSegments('a\n\nb\n\n- c\n\n- d\n\ne')).toEqual(['a\n\n', 'b\n\n- c\n\n- d\n\n', 'e'])
expect(splitMarkdownSegments('```\n\nx\n\n```\n\ny')).toEqual(['```\n\nx\n\n```\n\n', 'y'])
expect(splitMarkdownSegments('a\n\n[x]: https://example.com')).toHaveLength(1)
expect(splitMarkdownSegments('a\n\n<div>\n\nb\n\n</div>')).toHaveLength(1)
expect(splitMarkdownSegments('a\r\n\r\nb')).toHaveLength(1)
})
it('re-parses only the growing tail when completed segments are unchanged', () => {
const base = Array.from({ length: 30 }, (_, i) => `段落 ${i} **粗体** $x_${i}$`).join('\n\n')
const { rerender, container } = render(<MarkdownRenderer>{base}</MarkdownRenderer>)
const firstParagraph = container.querySelector('p')!
rerender(<MarkdownRenderer>{`${base} 追加`}</MarkdownRenderer>)
rerender(<MarkdownRenderer>{`${base} 追加\n\n新段落`}</MarkdownRenderer>)
// Untouched blocks keep their DOM nodes: they were not re-rendered.
expect(container.querySelector('p')).toBe(firstParagraph)
expect(container.querySelectorAll('p')).toHaveLength(31)
expect(container.querySelectorAll('.katex')).toHaveLength(30)
})
})
+163 -24
View File
@@ -1,5 +1,6 @@
import {
Children,
Fragment,
isValidElement,
lazy,
memo,
@@ -254,10 +255,154 @@ function standaloneHtmlSource(content: string): string | undefined {
return undefined
}
export const MarkdownRenderer = memo(function MarkdownRenderer({
// Link reference and footnote definitions resolve across the whole document,
// even from inside lists or block quotes, so their presence disables splitting.
const documentScopedMarkdown = /\]:|\[\^/u
// Constructs that may legally span blank lines in ways a line scanner cannot
// track: raw HTML blocks and `$$` lines carrying other text.
const unsafeSegmentLine = /^ {0,3}(?:<|\$\$.*\S)/u
// CommonMark: a backtick fence's info string cannot contain backticks.
const segmentFenceOpening = /^ {0,3}(?:(`{3,})(?!.*`)|(~{3,}))/u
const segmentMathFence = /^ {0,3}\$\$\s*$/u
// A block after a blank line belongs to the previous top-level block when it
// is indented (list item or indented code continuation), a list marker (the
// list may continue and its looseness may change), or a block quote marker.
const continuationLine = /^(?:\s|[-*+](?:\s|$)|\d{1,9}[.)](?:\s|$)|>)/u
/**
* Splits Markdown at top-level boundaries where parsing each segment
* independently produces exactly the same blocks as parsing the whole text:
* a blank line, outside fenced code and math, followed by a line that starts
* a new top-level block. Returns a single segment whenever any construct
* could make that unsafe.
*/
export function splitMarkdownSegments(content: string): string[] {
if (documentScopedMarkdown.test(content) || content.includes('\r')) {
return [content]
}
const segments: string[] = []
let fence: { character: '`' | '~'; length: number } | undefined
let inMath = false
let segmentStart = 0
let segmentHasBlock = false
let previousBlank = false
let lineStart = 0
while (lineStart <= content.length) {
const newline = content.indexOf('\n', lineStart)
const lineEnd = newline === -1 ? content.length : newline
const line = content.slice(lineStart, lineEnd)
if (fence) {
const closing = new RegExp(
`^ {0,3}${fence.character}{${fence.length},}\\s*$`,
'u'
)
if (closing.test(line)) {
fence = undefined
}
previousBlank = false
} else if (inMath) {
if (segmentMathFence.test(line)) {
inMath = false
}
previousBlank = false
} else if (line.trim() === '') {
previousBlank = true
} else {
if (unsafeSegmentLine.test(line)) {
return [content]
}
// Leading blank lines render nothing; a segment of them would add an
// extra separator, so split only after the segment has a block.
if (
previousBlank &&
segmentHasBlock &&
!continuationLine.test(line)
) {
segments.push(content.slice(segmentStart, lineStart))
segmentStart = lineStart
}
segmentHasBlock = true
const opening = segmentFenceOpening.exec(line)
if (opening) {
const marker = (opening[1] ?? opening[2])!
fence = { character: marker[0] as '`' | '~', length: marker.length }
} else if (/^ {0,3}`{3,}/u.test(line)) {
// Backtick run with a backtick in its info string: inline code that
// the line scanner cannot model, so keep the document whole.
return [content]
} else if (segmentMathFence.test(line)) {
inMath = true
}
previousBlank = false
}
if (newline === -1) {
break
}
lineStart = newline + 1
}
segments.push(content.slice(segmentStart))
return segments
}
const markdownRehypePlugins: NonNullable<
React.ComponentProps<typeof ReactMarkdown>['rehypePlugins']
> = [
[
rehypeKatex,
{
output: 'htmlAndMathml',
strict: 'warn',
trust: false
}
]
]
const markdownRemarkPlugins: NonNullable<
React.ComponentProps<typeof ReactMarkdown>['remarkPlugins']
> = [remarkGfm, [remarkMath, { singleDollarTextMath: true }]]
const MarkdownSegment = memo(function MarkdownSegment({
components,
content
}: {
components: Components
content: string
}): React.JSX.Element {
return (
<ReactMarkdown
components={components}
rehypePlugins={markdownRehypePlugins}
remarkPlugins={markdownRemarkPlugins}
skipHtml
>
{content}
</ReactMarkdown>
)
})
export const MarkdownRenderer = memo(function MarkdownRenderer(
props: MarkdownRendererProps
): React.JSX.Element {
return <MarkdownDocument {...props} segmented />
})
/** Whole-document reference rendering; used to verify segmentation. */
export const UnsegmentedMarkdownRenderer = memo(
function UnsegmentedMarkdownRenderer(
props: MarkdownRendererProps
): React.JSX.Element {
return <MarkdownDocument {...props} segmented={false} />
}
)
function MarkdownDocument({
children,
renderHtml = false
}: MarkdownRendererProps): React.JSX.Element {
renderHtml = false,
segmented
}: MarkdownRendererProps & { segmented: boolean }): React.JSX.Element {
const { t } = useTranslation('app')
const normalizedContent = normalizeLatexDelimiters(
unwrapMarkdownFence(children)
@@ -283,26 +428,20 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
)
}
// Completed segments keep identical strings, so streaming updates re-parse
// only the growing tail. The "\n" matches the text node ReactMarkdown
// places between top-level blocks, keeping the DOM identical.
const segments = segmented
? splitMarkdownSegments(normalizedContent)
: [normalizedContent]
return (
<ReactMarkdown
components={components}
rehypePlugins={[
[
rehypeKatex,
{
output: 'htmlAndMathml',
strict: 'warn',
trust: false
}
]
]}
remarkPlugins={[
remarkGfm,
[remarkMath, { singleDollarTextMath: true }]
]}
skipHtml
>
{normalizedContent}
</ReactMarkdown>
<>
{segments.map((segment, index) => (
<Fragment key={index}>
{index > 0 && '\n'}
<MarkdownSegment components={components} content={segment} />
</Fragment>
))}
</>
)
})
}
@@ -0,0 +1,243 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { ModelDownloadSource } from '../../shared/application-settings-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import type { AppNotificationInput } from './notifications'
export function ModelDownloadSourceSettings({
open, onOpen, onClose, onNotify, onSourceChanged
}: {
open: boolean
onOpen: () => void
onClose: () => void
onNotify?: (notification: AppNotificationInput) => void
onSourceChanged: (source: ModelDownloadSource) => void
}): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const [source, setSource] = useState<ModelDownloadSource>()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string>()
const [attempt, setAttempt] = useState(0)
const trigger = useRef<HTMLButtonElement>(null)
const returnFocus = useCallback(() => trigger.current, [])
const receiveSource = useCallback((value: ModelDownloadSource) => {
setSource(value)
onSourceChanged(value)
}, [onSourceChanged])
useEffect(() => {
let active = true
const updates = window.goodbuddy.updates
// A settings event is newer than the initial read still in flight.
let receivedChange = false
const unsubscribe = updates?.onSettingsChanged((settings) => {
receivedChange = true
receiveSource(settings.modelDownloadSource)
setError(undefined)
setLoading(false)
})
void (async () => {
try {
if (!updates) throw new Error('unavailable')
const settings = await updates.getSettings()
if (active && !receivedChange) {
receiveSource(settings.modelDownloadSource)
}
} catch {
if (active && !receivedChange) {
setError(t('platformFeatures.modelDownloadSource.readFailed'))
}
} finally {
if (active) setLoading(false)
}
})()
return () => {
active = false
unsubscribe?.()
}
}, [attempt, t, receiveSource])
const retry = (): void => {
setLoading(true)
setError(undefined)
setAttempt((value) => value + 1)
}
return (
<div className="model-download-source-control">
<small role={loading ? 'status' : undefined}>
{source
? t('platformFeatures.modelDownloadSource.current', {
source: t(`modelDownloadSources.${source}`)
})
: loading
? t('platformFeatures.loading')
: t('platformFeatures.modelDownloadSource.readFailed')}
</small>
<button className="secondary-button" onClick={onOpen} ref={trigger} type="button">
{t('platformFeatures.modelDownloadSource.title')}
</button>
{open && (
<ModelDownloadSourceDialog
source={source}
loading={loading}
loadError={error}
onRetry={retry}
onClose={onClose}
onSaved={receiveSource}
onNotify={onNotify}
returnFocus={returnFocus}
/>
)}
</div>
)
}
function ModelDownloadSourceDialog({
source, loading, loadError, onRetry, onClose, onSaved, onNotify, returnFocus
}: {
source?: ModelDownloadSource
loading: boolean
loadError?: string
onRetry: () => void
onClose: () => void
onSaved: (source: ModelDownloadSource) => void
onNotify?: (notification: AppNotificationInput) => void
returnFocus: () => HTMLElement | null
}): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const { t: settingsT } = useTranslation('settings')
const [draft, setDraft] = useState(source)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string>()
const closeButton = useRef<HTMLButtonElement>(null)
useEffect(() => activateModalFocus(() => closeButton.current, returnFocus), [returnFocus])
const selection = draft ?? source
const close = (): void => {
if (!saving) onClose()
}
const confirm = async (): Promise<void> => {
if (saving || loading || !selection || loadError) return
if (selection === source) {
onClose()
return
}
setSaving(true)
setError(undefined)
try {
const updates = window.goodbuddy.updates
if (!updates) throw new Error('unavailable')
const settings = await updates.updateSettings({ modelDownloadSource: selection })
onSaved(settings.modelDownloadSource)
onNotify?.({
tone: 'success',
dedupeKey: 'model-download-source',
message: t('platformFeatures.modelDownloadSource.notification', {
source: t(`modelDownloadSources.${settings.modelDownloadSource}`)
})
})
onClose()
} catch {
setError(t('platformFeatures.errors.saveModelDownloadSourceFailed'))
} finally {
setSaving(false)
}
}
return createPortal(
<div className="custom-task-dialog" onMouseDown={(event) => {
if (event.target === event.currentTarget) {
event.preventDefault()
close()
}
}}>
<section
className="custom-task-dialog__surface"
role="dialog"
aria-modal="true"
tabIndex={-1}
aria-labelledby="model-download-source-title"
aria-describedby="model-download-source-description"
aria-busy={saving || loading}
onKeyDown={(event) => {
event.stopPropagation()
trapTabFocus(event, event.currentTarget)
if (event.key === 'Escape') {
event.preventDefault()
close()
}
}}
>
<header className="custom-task-dialog__header">
<h2 id="model-download-source-title">{t('platformFeatures.modelDownloadSource.title')}</h2>
<button
className="icon-button"
type="button"
ref={closeButton}
disabled={saving}
onClick={close}
aria-label={t('platformFeatures.modelDownloadSource.close')}
title={t('platformFeatures.modelDownloadSource.close')}
>
<X size={18} aria-hidden="true" />
</button>
</header>
<div className="custom-task-dialog__content">
<p id="model-download-source-description">{t('platformFeatures.modelDownloadSource.description')}</p>
{loading ? (
<p role="status">{t('platformFeatures.loading')}</p>
) : loadError ? (
<div>
<p role="alert">{loadError}</p>
<button className="secondary-button" type="button" onClick={onRetry}>
{t('platformFeatures.modelDownloadSource.retry')}
</button>
</div>
) : (
<fieldset
className="model-download-source"
disabled={saving}
aria-describedby={error ? 'model-download-source-error' : undefined}
>
<legend className="sr-only">{t('platformFeatures.modelDownloadSource.title')}</legend>
{(['modelscope', 'hugging-face'] as const).map((value) => (
<label
key={value}
className={`model-download-source__option${selection === value ? ' model-download-source__option--selected' : ''}`}
>
<input
type="radio"
name="model-download-source"
value={value}
checked={selection === value}
onChange={() => {
setDraft(value)
setError(undefined)
}}
/>
<span>
<strong>{t(`modelDownloadSources.${value}`)}</strong>
<small>{t(`platformFeatures.modelDownloadSource.options.${value}`)}</small>
</span>
</label>
))}
</fieldset>
)}
{error && <p className="settings-warning" id="model-download-source-error" role="alert">{error}</p>}
</div>
<footer className="custom-task-dialog__actions">
<button className="secondary-button" type="button" disabled={saving} onClick={close}>
{settingsT('actions.cancel')}
</button>
<button
className="primary-button"
type="button"
disabled={saving || loading || !selection || Boolean(loadError)}
onClick={() => void confirm()}
>
{saving ? settingsT('actions.saving') : t('platformFeatures.modelDownloadSource.confirm')}
</button>
</footer>
</section>
</div>,
document.body
)
}
@@ -16,8 +16,7 @@ import {
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import type {
ApplicationSettings,
ModelDownloadSource
ApplicationSettings
} from '../../shared/application-settings-contracts'
import type {
AgentPackageDownloadProgress,
@@ -94,7 +93,6 @@ export function PlatformFeaturesSettingsSection({
: t('platformFeatures.shortcut.errors.serviceUnavailable')
)
const [saving, setSaving] = useState(false)
const [sourceError, setSourceError] = useState<string>()
const [agentInventory, setAgentInventory] =
useState<AgentPackageInventory>()
const [agentInventoryLoading, setAgentInventoryLoading] =
@@ -426,45 +424,6 @@ export function PlatformFeaturesSettingsSection({
}
}
const changeModelDownloadSource = async (
modelDownloadSource: ModelDownloadSource
): Promise<void> => {
const updates = window.goodbuddy.updates
if (
!updates ||
!settings ||
settings.modelDownloadSource === modelDownloadSource
) {
return
}
setSaving(true)
setSourceError(undefined)
try {
const nextSettings = await updates.updateSettings({
modelDownloadSource
})
setSettings(nextSettings)
onNotify?.({
tone: 'success',
message: t(
'platformFeatures.modelDownloadSource.notification',
{
source: t(
`modelDownloadSources.${nextSettings.modelDownloadSource}`
)
}
),
dedupeKey: 'model-download-source'
})
} catch {
setSourceError(
t('platformFeatures.errors.saveModelDownloadSourceFailed')
)
} finally {
setSaving(false)
}
}
const changeConversationHtmlRendering = async (
enabled: boolean
): Promise<void> => {
@@ -780,69 +739,6 @@ export function PlatformFeaturesSettingsSection({
</button>
</div>
</label>
{settings ? (
<>
<div className="capability-card__header">
<span className="inline-help-label">
<strong>
{t('platformFeatures.modelDownloadSource.title')}
</strong>
<InlineHelp label={t('platformFeatures.modelDownloadSource.title')}>
{t(
'platformFeatures.modelDownloadSource.description'
)}
</InlineHelp>
</span>
</div>
<fieldset className="model-download-source">
<legend className="sr-only">
{t('platformFeatures.modelDownloadSource.title')}
</legend>
{(
['modelscope', 'hugging-face'] as const
).map((source) => (
<label
className={
source === settings.modelDownloadSource
? 'model-download-source__option model-download-source__option--selected'
: 'model-download-source__option'
}
key={source}
>
<input
checked={source === settings.modelDownloadSource}
disabled={saving}
name="model-download-source"
onChange={() =>
void changeModelDownloadSource(source)
}
type="radio"
value={source}
/>
<span>
<strong>{t(`modelDownloadSources.${source}`)}</strong>
<small>
{t(
`platformFeatures.modelDownloadSource.options.${source}`
)}
</small>
</span>
</label>
))}
</fieldset>
{sourceError && (
<p className="settings-warning" role="alert">
{sourceError}
</p>
)}
</>
) : (
!error && (
<p className="settings-notice" role="status">
{t('platformFeatures.loading')}
</p>
)
)}
</article>
</section>
)}
+5 -3
View File
@@ -1,5 +1,5 @@
import { Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle } from 'lucide-react'
import { useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { memo, useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal, flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type { ConversationActivity } from './conversation-activity'
@@ -241,7 +241,9 @@ function ActivityMenu({
)
}
export function ProjectActivity({
// Memoized: App re-renders on every streaming delta and keystroke, while the
// activity summary it passes keeps its identity unless an activity changes.
export const ProjectActivity = memo(function ProjectActivity({
activities,
projects,
visible = true,
@@ -292,4 +294,4 @@ export function ProjectActivity({
)}
</>
)
}
})
+186 -17
View File
@@ -40,6 +40,7 @@ import type {
EmbeddingSettingsSnapshot
} from '../../shared/embedding-contracts'
import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts'
import type { DocumentParsingSnapshot } from '../../shared/document-parsing-contracts'
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
import {
@@ -524,7 +525,7 @@ let applicationSettings: ApplicationSettings = {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -819,7 +820,7 @@ describe('SettingsPanel runtime files', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -1403,7 +1404,7 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(
screen.getByRole('tab', { name: /Platform Features/i })
)
expect(screen.getByText('Workspace & downloads')).toBeInTheDocument()
expect(screen.getByText('Workspace')).toBeInTheDocument()
expect(
screen.getByLabelText('Default workspace folder')
).toBeInTheDocument()
@@ -1507,7 +1508,7 @@ describe('SettingsPanel runtime files', () => {
expect(applicationSettings).toEqual(storedSettings)
})
it('switches the global model download source from General settings', async () => {
it('confirms the model download source beside the model types, below save actions', async () => {
const onNotify = vi.fn()
render(
<SettingsPanel
@@ -1520,8 +1521,15 @@ describe('SettingsPanel runtime files', () => {
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const modelScope = await screen.findByRole('radio', {
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
const trigger = screen.getByRole('button', { name: '模型下载源' })
expect(trigger.closest('.model-type-navigation')).toContainElement(screen.getByRole('group', { name: '模型类型' }))
expect(trigger.closest('.settings-category-header__navigation')?.previousElementSibling).toHaveClass('settings-category-header__actions')
expect(await screen.findByText('当前来源:ModelScope')).toBeInTheDocument()
trigger.focus()
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
const modelScope = within(dialog).getByRole('radio', {
name: /ModelScope/u
})
const huggingFace = screen.getByRole('radio', {
@@ -1534,21 +1542,28 @@ describe('SettingsPanel runtime files', () => {
).not.toBeInTheDocument()
fireEvent.click(huggingFace)
expect(updateApplicationSettings).not.toHaveBeenCalled()
expect(screen.getByText('当前来源:ModelScope')).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
modelDownloadSource: 'hugging-face'
})
)
expect(huggingFace).toBeChecked()
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
expect(screen.getByText('当前来源:Hugging Face')).toBeInTheDocument()
expect(trigger).toHaveFocus()
})
expect(onNotify).toHaveBeenCalledWith({
tone: 'success',
message: '模型下载源已切换为 Hugging Face。',
dedupeKey: 'model-download-source'
})
expect(
screen.getByRole('tab', { name: '远程项目' })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(screen.queryByRole('radio', { name: /ModelScope/u })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '模型下载源' })).not.toBeInTheDocument()
})
it.each([
@@ -1630,9 +1645,9 @@ describe('SettingsPanel runtime files', () => {
const conversation = await screen.findByRole('article', { name: '会话与通知' })
expect(within(conversation).getByRole('switch', { name: '桌面通知' })).not.toBeChecked()
expect(within(conversation).getByRole('switch', { name: '在会话中渲染 HTML' })).toBeChecked()
const workspace = screen.getByRole('article', { name: '工作目录与下载' })
const workspace = screen.getByRole('article', { name: '工作目录' })
expect(within(workspace).getByRole('textbox')).toHaveValue(runtimeSettings.workspacePath)
expect(within(workspace).getByRole('radio', { name: /ModelScope/u })).toBeChecked()
expect(within(workspace).queryByRole('radio')).not.toBeInTheDocument()
expect(updateApplicationSettings).not.toHaveBeenCalled()
})
@@ -1708,7 +1723,7 @@ describe('SettingsPanel runtime files', () => {
expect(toggle).not.toBeChecked()
})
it('does not guess a model download source when settings fail to load', async () => {
it('does not guess platform settings when they fail to load', async () => {
getApplicationSettings.mockRejectedValueOnce(
new Error('read failed')
)
@@ -1744,7 +1759,7 @@ describe('SettingsPanel runtime files', () => {
)
})
it('keeps the confirmed model download source when saving fails', async () => {
it('keeps the source draft after a failed confirmation and allows retry', async () => {
updateApplicationSettings.mockRejectedValueOnce(
new Error('save failed')
)
@@ -1758,7 +1773,9 @@ describe('SettingsPanel runtime files', () => {
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByText('当前来源:ModelScope')
fireEvent.click(screen.getByRole('button', { name: '模型下载源' }))
const modelScope = await screen.findByRole('radio', {
name: /ModelScope/u
})
@@ -1766,12 +1783,164 @@ describe('SettingsPanel runtime files', () => {
name: /Hugging Face/u
})
fireEvent.click(huggingFace)
fireEvent.click(screen.getByRole('button', { name: '确认' }))
expect(
await screen.findByText('保存模型下载源失败,请重试')
).toBeInTheDocument()
expect(modelScope).toBeChecked()
expect(huggingFace).not.toBeChecked()
expect(modelScope).not.toBeChecked()
expect(huggingFace).toBeChecked()
expect(screen.getByText('当前来源:ModelScope')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '确认' }))
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
expect(screen.getByText('当前来源:Hugging Face')).toBeInTheDocument()
expect(updateApplicationSettings).toHaveBeenCalledTimes(2)
})
})
it.each(['cancel', 'escape', 'close', 'backdrop'])('discards source selection on %s without saving', async (method) => {
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByText('当前来源:ModelScope')
const trigger = screen.getByRole('button', { name: '模型下载源' })
trigger.focus()
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
fireEvent.click(within(dialog).getByRole('radio', { name: /Hugging Face/u }))
if (method === 'escape') fireEvent.keyDown(dialog, { key: 'Escape' })
else if (method === 'backdrop') fireEvent.mouseDown(dialog.parentElement!)
else fireEvent.click(within(dialog).getByRole('button', { name: method === 'cancel' ? '取消' : '关闭模型下载源' }))
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
expect(updateApplicationSettings).not.toHaveBeenCalled()
fireEvent.click(trigger)
expect(screen.getByRole('radio', { name: /ModelScope/u })).toBeChecked()
})
it('locks the source dialog during confirmation and keeps the confirmed label until saved', async () => {
let resolveSave!: (value: ApplicationSettings) => void
updateApplicationSettings.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
const onClose = vi.fn()
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={onClose} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByText('当前来源:ModelScope')
fireEvent.click(screen.getByRole('button', { name: '模型下载源' }))
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
fireEvent.click(within(dialog).getByRole('radio', { name: /Hugging Face/u }))
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
expect(within(dialog).getByRole('button', { name: '保存中…' })).toBeDisabled()
expect(within(dialog).getByRole('button', { name: '取消' })).toBeDisabled()
expect(within(dialog).getByRole('button', { name: '关闭模型下载源' })).toBeDisabled()
expect(within(dialog).getByRole('radio', { name: /ModelScope/u })).toBeDisabled()
fireEvent.keyDown(dialog, { key: 'Escape' })
fireEvent.mouseDown(dialog.parentElement!)
expect(dialog).toBeInTheDocument()
expect(onClose).not.toHaveBeenCalled()
expect(updateApplicationSettings).toHaveBeenCalledTimes(1)
expect(screen.getByText('当前来源:ModelScope')).toBeInTheDocument()
await act(async () => resolveSave({ ...applicationSettings, modelDownloadSource: 'hugging-face' }))
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
expect(screen.getByText('当前来源:Hugging Face')).toBeInTheDocument()
})
it('retries loading the source without guessing a selected radio', async () => {
getApplicationSettings.mockRejectedValueOnce(new Error('read failed'))
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByText('读取模型下载源失败,请重试')
fireEvent.click(screen.getByRole('button', { name: '模型下载源' }))
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
expect(within(dialog).queryByRole('radio')).not.toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: '确认' })).toBeDisabled()
fireEvent.click(within(dialog).getByRole('button', { name: '重试' }))
await waitFor(() => {
expect(within(dialog).getByRole('radio', { name: /ModelScope/u })).toBeChecked()
expect(within(dialog).getByRole('button', { name: '确认' })).toBeEnabled()
})
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
expect(updateApplicationSettings).not.toHaveBeenCalled()
})
it.each(['embedding', 'speech'])('opens the shared source dialog from %s and updates download availability after confirm', async (kind) => {
const downloadAvailability = [
{ source: 'modelscope' as const, available: false, unavailableReason: '当前来源缺少完整文件' },
{ source: 'hugging-face' as const, available: true, totalBytes: 1 }
]
getEmbeddingSnapshot.mockResolvedValueOnce({
...embeddingSnapshot,
models: { ...embeddingSnapshot.models, catalog: [{ ...embeddingCatalogEntry, downloadAvailability }] }
})
speechModelSnapshot = {
...createSpeechModelSnapshot(null),
installed: [],
catalog: [{ ...speechCatalog[0]!, downloadAvailability }]
}
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: kind === 'embedding' ? '向量模型' : '语音输入' }))
if (kind === 'embedding') {
fireEvent.click(await screen.findByRole('button', { name: '编辑向量模型连接 GoodBuddy 内置向量模型' }))
}
await screen.findByText('当前来源不可下载')
const entry = screen.getAllByRole('button', { name: '模型下载源' }).find((button) => !button.closest('.model-type-navigation'))!
entry.focus()
fireEvent.click(entry)
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
expect(document.getElementById('settings-tab-model')).toHaveAttribute('aria-selected', 'true')
fireEvent.click(within(dialog).getByRole('radio', { name: /Hugging Face/u }))
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
const modelName = kind === 'embedding' ? embeddingCatalogEntry.displayName : speechCatalog[0]!.displayName
const download = await screen.findByRole('button', { name: `下载 ${modelName}` })
expect(screen.queryByText('当前来源不可下载')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '模型下载源' })).toHaveFocus()
fireEvent.click(download)
await waitFor(() => {
if (kind === 'embedding') expect(installEmbeddingModel).toHaveBeenCalledWith(embeddingCatalogEntry.id, 'hugging-face')
else expect(window.goodbuddy.speechModels!.install).toHaveBeenCalledWith(speechCatalog[0]!.id, 'hugging-face')
})
})
it.each([false, true])('opens the source dialog on the model page from OCR, respecting dirty draft = %s', async (dirty) => {
const snapshot: DocumentParsingSnapshot = {
settings: { chatWorkflow: 'auto', knowledgeWorkflow: 'complete-index', localOcrModelId: 'pp-ocrv6-tiny', maximumPages: 100, pageTimeoutSeconds: 60 },
status: { nativeParsingAvailable: true, conversionAvailable: false, localOcr: {
id: 'pp-ocrv6-tiny', displayName: 'PP-OCRv6 Tiny', available: false, verified: false, runtime: 'onnxruntime-web-wasm', detail: '模型尚未安装'
} },
ocrModels: {
rootDirectory: 'C:\\models\\ocr', selectedDownloadSource: 'modelscope', installed: [], operations: [],
catalog: [{
id: 'pp-ocrv6-tiny', displayName: 'PP-OCRv6 Tiny', description: 'OCR', languages: ['中文'],
runtime: 'onnxruntime-web-wasm', quality: 'basic', speed: 'fast', recommended: true,
license: { name: 'Apache-2.0', notice: 'Apache-2.0', url: 'https://example.com/license' }, files: [],
downloadAvailability: [{ source: 'modelscope', available: false, unavailableReason: '当前来源缺少完整文件' }]
}]
}
}
Object.defineProperty(window.goodbuddy, 'documentParsing', {
configurable: true,
value: { getSnapshot: vi.fn(async () => snapshot), getOcrModelProgress: vi.fn(async () => ({ operations: [] })) }
})
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '文档解析' }))
const entry = await screen.findByRole('button', { name: '模型下载源' })
if (dirty) fireEvent.change(screen.getByRole('combobox', { name: '聊天与成果文件' }), { target: { value: 'fast-text' } })
entry.focus()
fireEvent.click(entry)
if (dirty) {
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
expect(screen.getByRole('combobox', { name: '聊天与成果文件' })).toHaveValue('fast-text')
fireEvent.click(entry)
fireEvent.click(screen.getByRole('button', { name: '放弃更改并切换' }))
}
const dialog = await screen.findByRole('dialog', { name: '模型下载源' })
expect(document.getElementById('settings-tab-model')).toHaveAttribute('aria-selected', 'true')
expect(await within(dialog).findByRole('radio', { name: /ModelScope/u })).toBeChecked()
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }))
expect(screen.getByRole('button', { name: '模型下载源' })).toHaveFocus()
expect(updateApplicationSettings).not.toHaveBeenCalled()
})
it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
+32 -14
View File
@@ -54,6 +54,8 @@ import { CapabilitiesAndToolsSettingsSection } from './CapabilitiesAndToolsSetti
import { ChannelSettingsSection } from './ChannelSettingsSection'
import { UpdateSettingsSection } from './UpdateSettingsSection'
import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSection'
import { ModelDownloadSourceSettings } from './ModelDownloadSourceSettings'
import type { ModelDownloadSource } from '../../shared/application-settings-contracts'
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { DshMarketplaceSection } from './DshMarketplaceSection'
@@ -779,7 +781,7 @@ export function SettingsPanel({
const [testing, setTesting] = useState(false)
const [embeddingSnapshot, setEmbeddingSnapshot] =
useState<EmbeddingSettingsSnapshot>()
const [embeddingModels, setEmbeddingModels] =
const [embeddingModelSnapshot, setEmbeddingModels] =
useState<EmbeddingModelSnapshot>()
const [embeddingDiagnostic, setEmbeddingDiagnostic] =
useState<EmbeddingDiagnosticResult>()
@@ -808,6 +810,11 @@ export function SettingsPanel({
? 'platform-features'
: selectedTab
const [modelType, setModelType] = useState<ModelType>('llm')
const [modelDownloadSourceOpen, setModelDownloadSourceOpen] = useState(false)
const [modelDownloadSource, setModelDownloadSource] = useState<ModelDownloadSource>()
const embeddingModels = embeddingModelSnapshot && modelDownloadSource
? { ...embeddingModelSnapshot, selectedDownloadSource: modelDownloadSource }
: embeddingModelSnapshot
const [speechModelDraftId, setSpeechModelDraftId] = useState<
string | null | undefined
>()
@@ -828,7 +835,7 @@ export function SettingsPanel({
const [sshHostsDirty, setSshHostsDirty] = useState(false)
const [pendingLeave, setPendingLeave] = useState<
| { kind: 'close' }
| { kind: 'navigate'; category: SettingsCategoryId }
| { kind: 'navigate'; category: SettingsCategoryId; openModelDownloadSource?: boolean }
| { kind: 'external'; proceed: () => void }
>()
const runtimeCustomizationRef =
@@ -1053,7 +1060,8 @@ export function SettingsPanel({
const requestTabChange = (
category: SettingsCategoryId,
trigger?: HTMLElement
trigger?: HTMLElement,
openModelDownloadSource = false
): boolean => {
if (category === activeTab) {
return true
@@ -1064,7 +1072,7 @@ export function SettingsPanel({
(document.activeElement instanceof HTMLElement
? document.activeElement
: undefined)
setPendingLeave({ kind: 'navigate', category })
setPendingLeave({ kind: 'navigate', category, openModelDownloadSource })
return false
}
setPendingLeave(undefined)
@@ -1073,6 +1081,10 @@ export function SettingsPanel({
return true
}
const openModelDownloadSource = (): void => {
if (requestTabChange('model', undefined, true)) setModelDownloadSourceOpen(true)
}
const visibleSettingsCategories = getSettingsCategoryList(
remoteProjectsEnabled
)
@@ -1328,6 +1340,10 @@ export function SettingsPanel({
setError(undefined)
if (leave?.kind === 'navigate') {
setActiveTab(leave.category)
if (leave.openModelDownloadSource) {
setModelDownloadSourceOpen(true)
return
}
requestAnimationFrame(() =>
document
.getElementById(`settings-tab-${leave.category}`)
@@ -2285,7 +2301,7 @@ export function SettingsPanel({
/>
</div>
) : activeTab === 'model' ? (
<div className="model-type-navigation">
<div className="model-type-navigation model-connections-navigation">
<SegmentedControl
ariaLabel={t('model.typeAriaLabel')}
onChange={setModelType}
@@ -2297,6 +2313,13 @@ export function SettingsPanel({
]}
value={modelType}
/>
<ModelDownloadSourceSettings
open={modelDownloadSourceOpen}
onSourceChanged={setModelDownloadSource}
onOpen={openModelDownloadSource}
onClose={() => setModelDownloadSourceOpen(false)}
onNotify={onNotify}
/>
</div>
) : undefined
}
@@ -3769,9 +3792,7 @@ export function SettingsPanel({
})
)
}}
onOpenModelDownloadSourceSettings={() =>
requestTabChange('platform-features')
}
onOpenModelDownloadSourceSettings={openModelDownloadSource}
onSelectConnection={setEditingEmbeddingConnectionId}
onSetCurrent={setActiveEmbeddingConnectionId}
onTestConnection={(connectionId) => {
@@ -3917,10 +3938,9 @@ export function SettingsPanel({
)}
{modelType === 'speech' && (
<SpeechModelSettingsSection
downloadSource={modelDownloadSource}
onNotify={onNotify}
onOpenModelDownloadSourceSettings={() =>
requestTabChange('platform-features')
}
onOpenModelDownloadSourceSettings={openModelDownloadSource}
onSelectedModelIdChange={(modelId, changed) => {
setSpeechModelDraftId(modelId)
setSpeechModelSelectionDirty(changed)
@@ -4127,9 +4147,7 @@ export function SettingsPanel({
onBusyChange={setTesting}
onDirtyChange={setDocumentParsingDirty}
onNotify={onNotify}
onOpenModelDownloadSourceSettings={() =>
requestTabChange('platform-features')
}
onOpenModelDownloadSourceSettings={openModelDownloadSource}
/>
)}
@@ -275,7 +275,7 @@ describe('SpeechModelSettingsSection', () => {
)
})
it('keeps an unavailable source explicit and offers General settings', async () => {
it('keeps an unavailable source explicit and offers source settings', async () => {
await changeUiLocale('zh-CN')
const onOpenModelDownloadSourceSettings = vi.fn()
const unavailableSnapshot: SpeechModelSnapshot = {
@@ -338,7 +338,7 @@ describe('SpeechModelSettingsSection', () => {
})
).toBeDisabled()
fireEvent.click(
screen.getByRole('button', { name: '前往通用设置' })
screen.getByRole('button', { name: '模型下载源' })
)
expect(onOpenModelDownloadSourceSettings).toHaveBeenCalledOnce()
})
@@ -17,12 +17,14 @@ import type {
SpeechModelSnapshot
} from '../../shared/speech-model-contracts'
import type { AppNotificationInput } from './notifications'
import type { ModelDownloadSource } from '../../shared/application-settings-contracts'
import {
formatModelPackageBytes,
modelOperationPercent
} from './model-download-presentation'
type SpeechModelSettingsSectionProps = {
downloadSource?: ModelDownloadSource
onNotify?: (notification: AppNotificationInput) => void
persistedSelectedModelId?: string | null
selectedModelId?: string | null
@@ -64,6 +66,7 @@ function operationLabel(
}
export function SpeechModelSettingsSection({
downloadSource,
onNotify,
persistedSelectedModelId,
selectedModelId,
@@ -258,6 +261,7 @@ export function SpeechModelSettingsSection({
)
}
const selectedDownloadSource = downloadSource ?? snapshot.selectedDownloadSource
const installedById = new Map(
snapshot.installed.map((model) => [model.id, model])
)
@@ -299,7 +303,7 @@ export function SpeechModelSettingsSection({
: undefined
const downloadAvailability = model?.downloadAvailability.find(
(availability) =>
availability.source === snapshot.selectedDownloadSource
availability.source === selectedDownloadSource
)
const size = installed
? installed.files.reduce(
@@ -386,7 +390,7 @@ export function SpeechModelSettingsSection({
: entry.downloadAvailability.some(
(availability) =>
availability.source ===
snapshot.selectedDownloadSource &&
selectedDownloadSource &&
availability.available
)
? t('speech.status.availableToDownload')
@@ -421,7 +425,7 @@ export function SpeechModelSettingsSection({
{
name: displayName,
source: t(
`modelDownloadSources.${snapshot.selectedDownloadSource}`
`modelDownloadSources.${selectedDownloadSource}`
)
}
)}
@@ -437,7 +441,7 @@ export function SpeechModelSettingsSection({
{
name: displayName,
source: t(
`modelDownloadSources.${snapshot.selectedDownloadSource}`
`modelDownloadSources.${selectedDownloadSource}`
)
}
)}
@@ -450,7 +454,7 @@ export function SpeechModelSettingsSection({
<div className="document-ocr-model__tags">
<span className="speech-model-tag">
{t(
`modelDownloadSources.${snapshot.selectedDownloadSource}`
`modelDownloadSources.${selectedDownloadSource}`
)}
</span>
<span className="speech-model-tag">
@@ -577,7 +581,7 @@ export function SpeechModelSettingsSection({
() =>
window.goodbuddy.speechModels!.install(
model.id,
snapshot.selectedDownloadSource
selectedDownloadSource
),
t('speech.notifications.installed', {
name: displayName
@@ -637,7 +641,7 @@ export function SpeechModelSettingsSection({
<p className="settings-warning">
{t('speech.sourceUnavailableDescription', {
source: t(
`modelDownloadSources.${snapshot.selectedDownloadSource}`
`modelDownloadSources.${selectedDownloadSource}`
)
})}
</p>
@@ -28,7 +28,7 @@ describe('UpdateSettingsSection', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
@@ -125,6 +125,12 @@ describe('WorkspacePrimitives', () => {
expect(segmentedStyles).toBeDefined()
expect(segmentedStyles).toMatch(/flex:\s*0 0 auto;/u)
})
it('keeps model download source on the model navigation row and aligned right', () => {
expect(stylesheet).toMatch(/\.model-type-navigation\s*\{[^}]*flex:\s*0 0 auto;/u)
expect(stylesheet).toMatch(/\.model-type-navigation\.model-connections-navigation\s*\{[^}]*flex-direction:\s*row;/u)
expect(stylesheet).toMatch(/\.model-connections-navigation > \.segmented-control\s*\{[^}]*flex-shrink:\s*1;[^}]*min-width:\s*0;/u)
expect(stylesheet).toMatch(/\.model-download-source-control\s*\{[^}]*flex:\s*0 0 auto;[^}]*margin-left:\s*auto;/u)
})
it('bounds knowledge library cards before truncating long names', () => {
expect(stylesheet).toMatch(
/\.knowledge-workspace__library-list\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/u
+24 -7
View File
@@ -5,16 +5,33 @@ export type Conversation = Omit<ConversationListSnapshot, "messages"> & {
messages: Message[];
};
// Message arrays are replaced, never mutated, so a conversation whose messages
// did not change (every conversation except the streaming one) reuses its scan.
const latestMessageTimes = new WeakMap<readonly Message[], number | undefined>();
function latestMessageTime(messages: readonly Message[]): number | undefined {
if (latestMessageTimes.has(messages)) {
return latestMessageTimes.get(messages);
}
const latest = messages.reduce<number | undefined>(
(current, message) => Math.max(current ?? message.createdAt, message.createdAt),
undefined,
);
latestMessageTimes.set(messages, latest);
return latest;
}
export function sortConversationsForDisplay(conversations: readonly Conversation[]): Conversation[] {
// updatedAt tracks snapshot freshness, including every streaming delta.
// Compute stable message times once per conversation, not per comparison.
return conversations.map(conversation => ({
conversation,
time: conversation.messages.reduce<number | undefined>(
(latest, message) => Math.max(latest ?? message.createdAt, message.createdAt),
conversation.messageSummary?.latestMessageAt,
) ?? conversation.updatedAt,
})).sort((left, right) =>
return conversations.map(conversation => {
const summaryTime = conversation.messageSummary?.latestMessageAt;
const messageTime = latestMessageTime(conversation.messages);
const latest = summaryTime === undefined
? messageTime
: messageTime === undefined ? summaryTime : Math.max(summaryTime, messageTime);
return { conversation, time: latest ?? conversation.updatedAt };
}).sort((left, right) =>
Number(Boolean(right.conversation.pinned)) - Number(Boolean(left.conversation.pinned)) ||
right.time - left.time || left.conversation.id.localeCompare(right.conversation.id),
).map(item => item.conversation);
@@ -78,3 +78,22 @@ describe('deriveConversationActivity', () => {
expect(summary.activities.map(row => row.status)).toEqual(['completed', 'running', 'completed'])
})
})
describe('conversation activity reuse', () => {
it('reflects replaced message arrays and compares summaries by content', async () => {
const { sameActivitySummary } = await import('./conversation-activity')
const messages = [{ state: 'complete' }]
const conversation = { id: 'a', title: 'A', projectId: 'local', messages }
const first = deriveConversationActivity([conversation], [], new Set(), projects, 'Unknown')
expect(first.activities).toEqual([])
// A new message array (the immutable update path) is rescanned.
const streaming = { ...conversation, messages: [...messages, { state: 'streaming' }] }
const second = deriveConversationActivity([streaming], [], new Set(), projects, 'Unknown')
expect(second.activities.map(({ status }) => status)).toEqual(['running'])
const third = deriveConversationActivity([{ ...streaming, messages: [...streaming.messages] }], [], new Set(), projects, 'Unknown')
expect(sameActivitySummary(second, third)).toBe(true)
expect(sameActivitySummary(first, second)).toBe(false)
const renamed = deriveConversationActivity([{ ...streaming, title: 'B' }], [], new Set(), projects, 'Unknown')
expect(sameActivitySummary(second, renamed)).toBe(false)
})
})
+49 -12
View File
@@ -14,6 +14,51 @@ export type ConversationActivitySummary = {
completed: number
}
type ActivityMessage = { state?: string; approval?: unknown; pendingQuestions?: readonly unknown[] }
type MessageStatus = 'running' | 'approval' | 'question' | undefined
const statusPriority = { completed: -1, running: 0, attention: 1, approval: 2, question: 3 }
// Message arrays are replaced rather than mutated, so unchanged conversations
// reuse their scan while one conversation streams.
const messageStatuses = new WeakMap<readonly ActivityMessage[], MessageStatus>()
function highestMessageStatus(messages: readonly ActivityMessage[]): MessageStatus {
if (messageStatuses.has(messages)) return messageStatuses.get(messages)
let status: MessageStatus
for (const message of messages) {
const candidate = message.pendingQuestions?.length
? 'question'
: message.approval
? 'approval'
: message.state === 'streaming'
? 'running'
: undefined
if (candidate && (!status || statusPriority[candidate] > statusPriority[status])) {
status = candidate
}
}
messageStatuses.set(messages, status)
return status
}
export function sameActivitySummary(left: ConversationActivitySummary, right: ConversationActivitySummary): boolean {
if (left.running !== right.running || left.attention !== right.attention ||
left.completed !== right.completed || left.activities.length !== right.activities.length) return false
for (let index = 0; index < left.activities.length; index++) {
const a = left.activities[index]!
const b = right.activities[index]!
if (a.conversationId !== b.conversationId || a.projectId !== b.projectId || a.title !== b.title ||
a.projectName !== b.projectName || a.status !== b.status) return false
}
const leftProjects = Object.keys(left.byProjectId)
if (leftProjects.length !== Object.keys(right.byProjectId).length) return false
return leftProjects.every((projectId) => {
const a = left.byProjectId[projectId]!
const b = right.byProjectId[projectId]
return b !== undefined && a.running === b.running && a.attention === b.attention && a.completed === b.completed
})
}
export function deriveConversationActivity(
conversations: readonly {
id: string
@@ -35,23 +80,15 @@ export function deriveConversationActivity(
const projectNames = new Map(projects.map((project) => [project.id, project.name]))
const metadata = new Map(conversations.map((conversation) => [conversation.id, conversation]))
const rows = new Map<string, ConversationActivity>()
const priority = { completed: -1, running: 0, attention: 1, approval: 2, question: 3 }
const priority = statusPriority
for (const conversation of conversations) {
let status: ConversationActivity['status'] | undefined =
activeConversationIds.has(conversation.id) ? 'running'
: completedConversationIds.has(conversation.id) ? 'completed' : undefined
for (const message of conversation.messages) {
const candidate = message.pendingQuestions?.length
? 'question'
: message.approval
? 'approval'
: message.state === 'streaming'
? 'running'
: undefined
if (candidate && (!status || priority[candidate] > priority[status])) {
status = candidate
}
const candidate = highestMessageStatus(conversation.messages)
if (candidate && (!status || priority[candidate] > priority[status])) {
status = candidate
}
if (status) {
rows.set(conversation.id, {
@@ -7,6 +7,11 @@ export const activity = {
description:
'Review execution details by project, task, and conversation, or browse the activity timeline and model usage.'
},
refresh: {
label: 'Refresh',
refreshing: 'Refreshing',
hint: 'Run history updates every 15 seconds; click to refresh now'
},
tabs: {
ariaLabel: 'Run history views',
tasks: 'Tasks and conversations',
@@ -72,9 +77,6 @@ export const activity = {
},
detailAriaLabel: 'Token usage details by {{group}}',
empty: 'No token usage',
refresh: 'Refresh',
refreshing: 'Refreshing',
refreshHint: 'Reload saved token usage',
fallbacks: {
unassignedProject: 'Unassigned project',
deletedConversation: 'Deleted conversation',
+3 -2
View File
@@ -3,7 +3,7 @@ import type { app as chineseApp } from '../zh-CN/app'
export const app = {
sharing: {
title: 'Device Sharing', boundary: 'Local unauthenticated metadata service. Device IDs identify records, not authenticated owners; registration does not indicate online status. Remote execution and knowledge content access are not provided here.',
title: 'Device Sharing (Technical Preview)', boundary: 'Local unauthenticated metadata service. Device IDs identify records, not authenticated owners; registration does not indicate online status. Remote execution and knowledge content access are not provided here.',
refresh: 'Refresh lists', working: 'Working…', loadFailed: 'Could not load the lists. Previous results may be stale. Check the service URL and refresh.',
thisDevice: 'This device', deviceId: 'Device ID', serverUrl: 'Service URL', deviceName: 'Device name', save: 'Save settings', saved: 'Sharing settings saved',
register: 'Register this device', registered: 'Device registration saved to the service', saveFirst: 'Save settings before registering or publishing.',
@@ -29,13 +29,14 @@ export const app = {
localInference: 'Local Inference Monitor', settings: 'Application settings', enable: 'Enable application', pin: 'Pin to sidebar',
pinHelp: 'Pinning only controls the sidebar shortcut.',
disableHelp: 'Disabling preserves data and pins without stopping existing requests or background services.',
deviceSharingHelp: 'Off by default during testing. Enable to open the page and register devices or publish metadata to the configured ShareServer. Disabling hides the entry and leaves the page without clearing the catalog, revoking publications, or stopping existing requests.',
disabledPage: 'Application is disabled', close: 'Close application center', retry: 'Reload settings', back: 'Back to application center',
search: 'Search built-in applications', noResults: 'No matching applications', clear: 'Clear search', builtin: 'Built-in',
enabled: 'Enabled', disabled: 'Disabled', open: 'Open', moveUp: 'Move {{name}} up', moveDown: 'Move {{name}} down',
orderUpdated: 'Application order updated', loadFailed: 'Could not load application settings', saveFailed: 'Could not save application settings',
confirmFailed: 'Could not confirm the save result. Reload settings before editing again.',
descriptions: {
'device-sharing': 'Register devices, publish and revoke capability or knowledge metadata.',
'device-sharing': 'Test feature, off by default. Register devices, publish and revoke capability or knowledge metadata.',
'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.',
heartbeat: 'Review work evolution, explore the story graph, and configure Smart heartbeat.', 'local-inference': 'Inspect local inference tasks and execution services.'
}
@@ -636,7 +636,7 @@ export const settings = {
confirmDelete: 'Confirm delete',
cancel: 'Cancel',
openRepository: 'Open {{source}}',
openDownloadSourceSettings: 'Open General settings',
openDownloadSourceSettings: 'Model download source',
catalogUnavailable:
'No OCR model catalog is available in this version.',
selectedModelUnavailable:
@@ -644,7 +644,7 @@ export const settings = {
installBeforeSelecting:
'Download or import this model first. It will become the current model after installation.',
sourceUnavailableDescription:
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or explicitly change the source in General settings.',
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or click Model download source to change sources.',
privacyNotice:
'OCR is enabled only when required by the scenario modes above. It always runs locally through ONNX Runtime WebAssembly and never uploads documents.',
operations: {
@@ -300,7 +300,7 @@ export const settingsSections = {
delete: 'Delete',
confirmDelete: 'Confirm delete',
download: 'Download',
openDownloadSourceSettings: 'Open General settings',
openDownloadSourceSettings: 'Model download source',
importZip: 'Import ZIP',
exportZip: 'Export ZIP'
},
@@ -320,7 +320,7 @@ export const settingsSections = {
removed: 'Speech model deleted'
},
sourceUnavailableDescription:
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or explicitly change the source in General settings.',
'{{source}} does not currently provide the complete verified files for this model. You can still import a ZIP archive or click Model download source to change sources.',
languages: {
中文: 'Chinese',
粤语: 'Cantonese',
@@ -393,7 +393,7 @@ export const settingsSections = {
download: 'Download',
cancel: 'Cancel',
importZip: 'Import ZIP',
openDownloadSourceSettings: 'Go to General settings',
openDownloadSourceSettings: 'Model download source',
remove: 'Remove model',
clearCredential: 'Clear credential',
clearAfterSave: 'Credential will be cleared on save'
@@ -564,7 +564,7 @@ export const settingsSections = {
},
groups: {
conversation: 'Conversations & notifications',
workspace: 'Workspace & downloads'
workspace: 'Workspace'
},
desktopNotifications: {
enabled: 'Desktop notifications',
@@ -678,6 +678,11 @@ export const settingsSections = {
},
modelDownloadSource: {
title: 'Model download source',
current: 'Current source: {{source}}',
readFailed: 'Could not load the model download source. Try again.',
retry: 'Retry',
close: 'Close model download source',
confirm: 'Confirm',
description:
'Choose the platform for future GoodBuddy-managed local model downloads. Installed models, ZIP imports, Ollama models, and app updates are not affected.',
options: {
@@ -4,6 +4,11 @@ export const activity = {
description:
'按项目、任务与会话查看执行详情,或浏览活动时间线和模型用量。'
},
refresh: {
label: '刷新',
refreshing: '正在刷新',
hint: '运行记录每 15 秒自动更新,点击立即刷新'
},
tabs: {
ariaLabel: '运行记录视图',
tasks: '任务与会话',
@@ -69,9 +74,6 @@ export const activity = {
},
detailAriaLabel: 'Token 用量{{group}}明细',
empty: '暂无 Token 用量',
refresh: '刷新',
refreshing: '正在刷新',
refreshHint: '重新读取已保存的 Token 用量',
fallbacks: {
unassignedProject: '未归属项目',
deletedConversation: '已删除会话',
+3 -2
View File
@@ -1,6 +1,6 @@
export const app = {
sharing: {
title: '设备共享', boundary: '本地未认证的元数据服务。设备 ID 仅用于识别,不代表身份认证;注册记录不代表设备在线。此页不提供远程执行或知识内容访问。',
title: '设备共享(技术预览)', boundary: '本地未认证的元数据服务。设备 ID 仅用于识别,不代表身份认证;注册记录不代表设备在线。此页不提供远程执行或知识内容访问。',
refresh: '刷新列表', working: '正在处理…', loadFailed: '列表读取失败,已有列表可能过期。请检查服务地址并刷新。',
thisDevice: '本机', deviceId: '设备 ID', serverUrl: '服务地址', deviceName: '设备名称', save: '保存设置', saved: '共享设置已保存',
register: '注册本机', registered: '本机注册信息已保存到服务', saveFirst: '请先保存设置,再注册或发布。',
@@ -26,13 +26,14 @@ export const app = {
localInference: '本机推理监控', settings: '应用设置', enable: '启用应用', pin: '常驻左侧菜单',
pinHelp: '常驻只控制左侧捷径。',
disableHelp: '关闭应用保留数据和常驻偏好,不停止已有请求或后台服务。',
deviceSharingHelp: '测试阶段默认关闭。启用后可打开页面,向配置的 ShareServer 注册设备和发布元数据。关闭只隐藏入口并退出页面,不清除目录或撤销已发布记录,也不停止已有请求。',
disabledPage: '应用已关闭', close: '关闭应用中心', retry: '重新读取', back: '返回应用中心',
search: '搜索内置应用', noResults: '未找到相关应用', clear: '清除搜索', builtin: '内置',
enabled: '已启用', disabled: '已关闭', open: '打开', moveUp: '上移 {{name}}', moveDown: '下移 {{name}}',
orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败',
confirmFailed: '无法确认保存结果,请重新读取后再修改。',
descriptions: {
'device-sharing': '注册设备,发布和撤销能力或知识的元数据。',
'device-sharing': '测试功能,默认关闭。注册设备,发布和撤销能力或知识的元数据。',
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
heartbeat: '回顾工作演变、查看故事线图谱,并配置智能心跳。', 'local-inference': '查看本机推理任务与执行服务。'
}
@@ -587,14 +587,14 @@ export const settings = {
confirmDelete: '确认删除',
cancel: '取消',
openRepository: '打开 {{source}}',
openDownloadSourceSettings: '前往通用设置',
openDownloadSourceSettings: '模型下载源',
catalogUnavailable: '当前版本没有可用的 OCR 模型目录。',
selectedModelUnavailable:
'已保存的 OCR 模型在当前版本不可用,请从上方选择并安装其他模型。',
installBeforeSelecting:
'请先下载或导入该模型;安装完成后会自动设为当前模型。',
sourceUnavailableDescription:
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或前往通用设置明确更换下载源。',
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或点击“模型下载源”更换来源。',
privacyNotice:
'OCR 只在需要时由上方场景模式启用,并始终在本机通过 ONNX Runtime WebAssembly 运行,不会上传文档。',
operations: {
@@ -284,7 +284,7 @@ export const settingsSections = {
delete: '删除',
confirmDelete: '确认删除',
download: '下载',
openDownloadSourceSettings: '前往通用设置',
openDownloadSourceSettings: '模型下载源',
importZip: '导入 ZIP',
exportZip: '导出 ZIP'
},
@@ -304,7 +304,7 @@ export const settingsSections = {
removed: '语音模型已删除'
},
sourceUnavailableDescription:
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或前往通用设置明确更换下载源。',
'{{source}} 暂不提供此模型的完整已验证文件。你仍可从 ZIP 导入,或点击“模型下载源”更换来源。',
languages: {
中文: '中文',
粤语: '粤语',
@@ -377,7 +377,7 @@ export const settingsSections = {
download: '下载',
cancel: '取消',
importZip: '导入 ZIP',
openDownloadSourceSettings: '前往通用设置',
openDownloadSourceSettings: '模型下载源',
remove: '移除模型',
clearCredential: '清除凭据',
clearAfterSave: '保存后清除凭据'
@@ -534,7 +534,7 @@ export const settingsSections = {
},
groups: {
conversation: '会话与通知',
workspace: '工作目录与下载'
workspace: '工作目录'
},
desktopNotifications: {
enabled: '桌面通知',
@@ -634,6 +634,11 @@ export const settingsSections = {
},
modelDownloadSource: {
title: '模型下载源',
current: '当前来源:{{source}}',
readFailed: '读取模型下载源失败,请重试',
retry: '重试',
close: '关闭模型下载源',
confirm: '确认',
description:
'选择 GoodBuddy 托管本地模型后续下载使用的平台。已安装模型、ZIP 导入、Ollama 模型和应用更新不受影响。',
options: {
@@ -0,0 +1,75 @@
import { act, render } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { sortConversationsForDisplay, type Conversation } from './chat-conversation'
import {
sameArrayItems,
sameMapEntries,
useStableDerivedValue,
useStableHandlers
} from './stable-derived-value'
describe('useStableDerivedValue', () => {
it('keeps the previous value only while it is equal', () => {
const seen: Map<string, string>[] = []
let setEntries!: (entries: [string, string][]) => void
function Probe(): null {
const [entries, set] = useState<[string, string][]>([['a', 'A']])
setEntries = set
seen.push(useStableDerivedValue(new Map(entries), sameMapEntries))
return null
}
render(<Probe />)
act(() => setEntries([['a', 'A']]))
expect(seen[1]).toBe(seen[0])
act(() => setEntries([['a', 'B']]))
expect(seen[2]).not.toBe(seen[1])
expect(seen[2]!.get('a')).toBe('B')
})
it('compares arrays item by item', () => {
expect(sameArrayItems([1, 2], [1, 2])).toBe(true)
expect(sameArrayItems([1, 2], [1])).toBe(false)
expect(sameArrayItems([{ id: 1 }], [{ id: 1 }], (a, b) => a.id === b.id)).toBe(true)
})
})
describe('useStableHandlers', () => {
it('keeps one identity and always calls the latest implementation', () => {
const results: string[] = []
let handlersSeen: { run: () => void }[] = []
let setLabel!: (label: string) => void
function Probe(): null {
const [label, set] = useState('first')
setLabel = set
handlersSeen.push(useStableHandlers({ run: () => results.push(label) }))
return null
}
render(<Probe />)
handlersSeen[0]!.run()
act(() => setLabel('second'))
handlersSeen[1]!.run()
expect(handlersSeen[1]).toBe(handlersSeen[0])
expect(results).toEqual(['first', 'second'])
handlersSeen = []
})
})
describe('sortConversationsForDisplay', () => {
const message = (id: string, createdAt: number): Conversation['messages'][number] =>
({ id, role: 'user', content: id, createdAt, state: 'complete' })
it('reflects new message arrays and summaries while reusing unchanged scans', () => {
const a: Conversation = { id: 'a', title: 'A', updatedAt: 1, messages: [message('a1', 10)] }
const b: Conversation = { id: 'b', title: 'B', updatedAt: 1, messages: [message('b1', 20)] }
expect(sortConversationsForDisplay([a, b]).map(({ id }) => id)).toEqual(['b', 'a'])
const newer = { ...a, messages: [...a.messages, message('a2', 30)] }
expect(sortConversationsForDisplay([newer, b]).map(({ id }) => id)).toEqual(['a', 'b'])
const summarized = { ...b, messageSummary: { count: 2, latestMessageAt: 40 } } as Conversation
expect(sortConversationsForDisplay([newer, summarized]).map(({ id }) => id)).toEqual(['b', 'a'])
const pinned = { ...a, pinned: true }
expect(sortConversationsForDisplay([summarized, pinned]).map(({ id }) => id)).toEqual(['a', 'b'])
const empty: Conversation = { id: 'c', title: 'C', updatedAt: 50, messages: [] }
expect(sortConversationsForDisplay([newer, empty]).map(({ id }) => id)).toEqual(['c', 'a'])
})
})
+82
View File
@@ -0,0 +1,82 @@
import { useLayoutEffect, useState } from 'react'
type AnyHandler = (...args: never[]) => unknown
/**
* Returns an object of handlers whose identity never changes while each call
* still runs the latest implementation (published after every commit).
* Memoized children receiving it skip re-renders caused by new closures.
* Calls are only valid after mount, as for any event handler.
*/
export function useStableHandlers<T extends Record<string, AnyHandler>>(
latest: T
): T {
const [{ slot, handlers }] = useState(() => createForwardingHandlers(latest))
useLayoutEffect(() => {
latestHandlers.set(slot, latest)
})
return handlers
}
const latestHandlers = new WeakMap<object, Record<string, AnyHandler>>()
function createForwardingHandlers<T extends Record<string, AnyHandler>>(
initial: T
): { slot: object; handlers: T } {
const slot = {}
latestHandlers.set(slot, initial)
const handlers = {} as Record<string, AnyHandler>
for (const key of Object.keys(initial)) {
// Looked up when a handler is invoked (an event), never during render.
handlers[key] = (...args: never[]) => latestHandlers.get(slot)![key]!(...args)
}
return { slot, handlers: handlers as T }
}
/**
* Keeps the previous derived value when a recomputation produces an equal one.
*
* Streaming updates replace the conversation array on every delta, so values
* derived from it (titles, pending approvals, activity summaries) would get a
* new identity each time and re-render memoized consumers even though nothing
* they display changed. Returning the previous, equal value is always safe.
*/
const previousValues = new WeakMap<object, unknown>()
export function useStableDerivedValue<T>(
next: T,
equal: (previous: T, next: T) => boolean
): T {
const [slot] = useState(() => ({}))
if (previousValues.has(slot)) {
const previous = previousValues.get(slot) as T
if (previous === next || equal(previous, next)) {
return previous
}
}
previousValues.set(slot, next)
return next
}
export function sameMapEntries<K, V>(
left: ReadonlyMap<K, V>,
right: ReadonlyMap<K, V>
): boolean {
if (left.size !== right.size) return false
for (const [key, value] of left) {
if (!right.has(key) || !Object.is(right.get(key), value)) return false
}
return true
}
export function sameArrayItems<T>(
left: readonly T[],
right: readonly T[],
sameItem: (left: T, right: T) => boolean = Object.is
): boolean {
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index++) {
if (!sameItem(left[index]!, right[index]!)) return false
}
return true
}
+37 -5
View File
@@ -8794,7 +8794,7 @@ button > svg {
min-height: 26px;
margin: var(--space-1) 0 0;
align-items: center;
justify-content: flex-end;
justify-content: flex-start;
font-size: var(--font-caption);
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
@@ -8806,7 +8806,6 @@ button > svg {
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-right: auto;
min-width: 0;
}
@@ -8824,7 +8823,7 @@ button > svg {
}
.composer-meta__error {
margin-right: auto;
margin-left: auto;
}
.composer-meta__error {
@@ -8866,7 +8865,6 @@ button > svg {
display: flex;
min-width: 0;
align-items: center;
margin-right: auto;
color: var(--text-muted);
gap: var(--space-2);
}
@@ -10207,6 +10205,29 @@ button > svg {
flex-shrink: 0;
}
.model-type-navigation.model-connections-navigation {
flex-direction: row;
align-items: center;
}
.model-connections-navigation > .segmented-control {
flex-shrink: 1;
min-width: 0;
}
.model-download-source-control {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
margin-left: auto;
}
.model-download-source-control > small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.model-type-navigation > small {
flex: 1 1 220px;
min-width: 0;
@@ -15880,7 +15901,18 @@ textarea.field-control {
font-size: var(--font-section-title);
}
.token-usage__refresh {
/* Keep the full title and its help visible; wrap the refresh action instead. */
.activity-panel > .page-header {
flex-wrap: wrap;
row-gap: var(--space-3);
}
.activity-panel > .page-header > .page-header__content {
flex: 1 0 auto;
max-width: 100%;
}
.activity-panel__refresh {
display: inline-flex;
align-items: center;
gap: 6px;