feat: add persistent document handling and protect note drafts
Add HTTP PaddleOCR-VL settings and shared document previews for chat and knowledge imports. Preserve attachment originals and parsed assets across drafts, messages and queues; support explicit image OCR, selecting document images into a conversation draft, and restoring queued input for editing. Protect unsaved note bodies and titles during navigation and asynchronous updates. Match canvas analysis to source revisions, invalidate affected comments, preserve annotations during pagination, and provide a resizable record index with saved width preferences. Existing OCR settings retain local behavior. Historical attachments without saved originals are not reconstructed. Advanced OCR options depend on the configured service; image sending depends on the effective model and Runtime. Validation: full typecheck and lint passed; 477 focused tests passed across 12 files; staged whitespace checks passed. The full repository suite was not rerun for this commit. Release note: Keep note drafts safe while switching workspaces, resize the record index, and revisit saved document attachments with OCR previews and selected images in chat.
This commit is contained in:
@@ -530,6 +530,8 @@ const api: DesktopApi = {
|
||||
onChanged: vi.fn(() => () => undefined),
|
||||
},
|
||||
conversationQueue: {
|
||||
getAttachments: vi.fn(async () => []),
|
||||
restoreToDraft: vi.fn(async () => ({ conversationId: '', prompt: '', attachments: [] })),
|
||||
list: vi.fn(async () => []),
|
||||
enqueueUser: vi.fn(async (input) => {
|
||||
const item = {
|
||||
@@ -787,6 +789,20 @@ const api: DesktopApi = {
|
||||
})),
|
||||
},
|
||||
context: {
|
||||
imageCapability: vi.fn(async () => ({ supported: true })),
|
||||
copyToDraft: vi.fn(async () => []),
|
||||
pendingParsing: vi.fn(async () => []),
|
||||
retryParsing: vi.fn(async () => []),
|
||||
dismissParsing: vi.fn(async () => undefined),
|
||||
cancelImport: vi.fn(async () => undefined),
|
||||
reparseDraft: vi.fn(async () => []),
|
||||
cancelParsing: vi.fn(async () => undefined),
|
||||
sendOriginal: vi.fn(async () => []),
|
||||
openOriginal: vi.fn(async () => undefined),
|
||||
addResultImages: vi.fn(async () => []),
|
||||
onDraftChanged: vi.fn(() => () => undefined),
|
||||
getDraft: vi.fn(async () => []),
|
||||
saveDraft: vi.fn(async () => undefined),
|
||||
getFilePath: vi.fn(() => ""),
|
||||
importFiles: vi.fn(async () => []),
|
||||
selectFiles: vi.fn(async () => []),
|
||||
@@ -6511,7 +6527,7 @@ describe("App", () => {
|
||||
expect(screen.getByText("正在解析 扫描材料.pdf")).toBeInTheDocument();
|
||||
expect(screen.getByText("第 1 / 1 个文件")).toBeInTheDocument();
|
||||
fireEvent.click(addButton);
|
||||
expect(api.context.selectFiles).toHaveBeenCalledOnce();
|
||||
await waitFor(() => expect(api.context.selectFiles).toHaveBeenCalledOnce());
|
||||
|
||||
act(() => resolveSelection?.([attachment]));
|
||||
expect(await screen.findByText("扫描材料.pdf")).toBeInTheDocument();
|
||||
@@ -6612,7 +6628,7 @@ describe("App", () => {
|
||||
const input = await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
fireEvent.change(input, { target: { value: "Read these files" } });
|
||||
expect(fireEvent.paste(input, { clipboardData: { files } })).toBe(false);
|
||||
expect(api.context.importFiles).toHaveBeenCalledExactlyOnceWith(["C:\\notes.txt", "C:\\report.docx"]);
|
||||
await waitFor(() => expect(api.context.importFiles).toHaveBeenCalledExactlyOnceWith(["C:\\notes.txt", "C:\\report.docx"], expect.any(String)));
|
||||
expect(screen.getByLabelText("添加附件")).toBeDisabled();
|
||||
act(() => fileSelectionProgressListener?.({
|
||||
phase: "parsing", fileName: "report.docx", fileNumber: 2, fileCount: 2,
|
||||
@@ -13571,6 +13587,53 @@ describe("App", () => {
|
||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['title', 'text'])('guards real App navigation to multiple apps and discards only after confirmation (%s)', async (draft) => {
|
||||
await api.updates!.updateSettings({ magicNotesEnabled: true })
|
||||
const note = {
|
||||
id: '00000000-0000-4000-8000-000000000601', title: 'Navigation note', preview: '',
|
||||
entryCount: 0, pinned: false, revision: 1, createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z', entries: [],
|
||||
}
|
||||
vi.mocked(api.magicNotes.list).mockResolvedValue({ notes: [note] })
|
||||
vi.mocked(api.magicNotes.get).mockResolvedValue(note)
|
||||
render(<App />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '魔法笔记' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Navigation note.*条记录/ }))
|
||||
const title = await screen.findByLabelText('笔记标题')
|
||||
if (draft === 'title') fireEvent.change(title, { target: { value: 'Unsaved navigation title' } })
|
||||
else {
|
||||
const { default: Quill } = await import('quill')
|
||||
await waitFor(() => expect(document.querySelector('.magic-note-composer .ql-container')).not.toBeNull())
|
||||
act(() => (Quill.find(document.querySelector('.magic-note-composer .ql-container')!) as InstanceType<typeof Quill>).setText('Unsaved body', 'user'))
|
||||
}
|
||||
for (const name of ['知识库', '智能心跳', '运行记录', '对话']) {
|
||||
fireEvent.click(within(screen.getByRole('navigation', { name: '主导航' })).getByRole('button', { name }))
|
||||
expect(await screen.findByRole('alertdialog')).toBeVisible()
|
||||
expect(title).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
|
||||
if (draft === 'title') expect(title).toHaveValue('Unsaved navigation title')
|
||||
else expect(document.querySelector('.magic-note-composer .ql-editor')).toHaveTextContent('Unsaved body')
|
||||
}
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置' }))
|
||||
await screen.findByRole('button', { name: '关闭设置' })
|
||||
fireEvent.click(within(screen.getByRole('navigation', { name: '主导航' })).getByRole('button', { name: '知识库' }))
|
||||
expect(await screen.findByRole('alertdialog')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '关闭设置' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '知识库' }))
|
||||
expect(await screen.findByRole('alertdialog')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃草稿并切换' }))
|
||||
expect(await screen.findByLabelText('知识工作区')).toBeVisible()
|
||||
for (const name of ['智能心跳', '运行记录', '对话', '魔法笔记']) {
|
||||
fireEvent.click(within(screen.getByRole('navigation', { name: '主导航' })).getByRole('button', { name }))
|
||||
await act(async () => {})
|
||||
}
|
||||
expect(screen.queryByDisplayValue('Unsaved navigation title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Unsaved body')).not.toBeInTheDocument()
|
||||
expect(api.magicNotes.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps fixed apps available when application settings cannot be loaded', async () => {
|
||||
vi.mocked(api.updates!.getSettings).mockRejectedValue(new Error('unavailable'))
|
||||
render(<App />)
|
||||
|
||||
+121
-36
@@ -1,6 +1,12 @@
|
||||
import { ImageCapabilityNotice } from "./ImageCapabilityNotice";
|
||||
import LocalInferencePage from "./LocalInferencePage";
|
||||
import { ApplicationMenu } from './ApplicationMenu';
|
||||
import { AttachmentResultButton } from './AttachmentResultButton';
|
||||
import { AttachmentActions, AttachmentStatus } from './AttachmentActions';
|
||||
import { PendingDocumentImports } from './PendingDocumentImports';
|
||||
import { AttachmentCapabilityNotice } from './AttachmentCapabilityNotice';
|
||||
import { DocumentConversationContext } from './DocumentConversationContext';
|
||||
import { maximumAttachmentsPerMessage } from '../../shared/attachment-limits';
|
||||
import {
|
||||
ApplicationAvailability,
|
||||
ApplicationCenter,
|
||||
@@ -2437,6 +2443,10 @@ function App(): React.JSX.Element {
|
||||
const settingsLeaveRequesterRef = useRef<SettingsLeaveRequester | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const notesLeaveRequesterRef = useRef<SettingsLeaveRequester | undefined>(undefined);
|
||||
const registerNotesLeaveRequester = useCallback((requester: SettingsLeaveRequester | undefined): void => {
|
||||
notesLeaveRequesterRef.current = requester;
|
||||
}, []);
|
||||
const [cachedWorkspaceViews, setCachedWorkspaceViews] = useState<
|
||||
KeepAliveCacheEntry<WorkspaceView>[]
|
||||
>(() => [{ key: "chat", lastVisitedAt: Date.now() }]);
|
||||
@@ -2508,6 +2518,21 @@ function App(): React.JSX.Element {
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const requestWorkspaceLeave = useCallback((next: WorkspaceView, leave: () => void): void => {
|
||||
const leaveNotes = (): void => {
|
||||
if (viewRef.current === "magic-notes" && next !== "magic-notes" && notesLeaveRequesterRef.current) {
|
||||
if (settingsOpenRef.current) commitView(viewRef.current);
|
||||
notesLeaveRequesterRef.current(leave);
|
||||
} else {
|
||||
leave();
|
||||
}
|
||||
};
|
||||
if (settingsOpenRef.current && settingsLeaveRequesterRef.current) {
|
||||
settingsLeaveRequesterRef.current(leaveNotes);
|
||||
} else {
|
||||
leaveNotes();
|
||||
}
|
||||
}, [commitView]);
|
||||
const setView = useCallback(
|
||||
(update: SetStateAction<WorkspaceView>): void => {
|
||||
const next =
|
||||
@@ -2534,16 +2559,9 @@ function App(): React.JSX.Element {
|
||||
setSettingsOpen(true);
|
||||
return;
|
||||
}
|
||||
if (settingsOpenRef.current) {
|
||||
const requestLeave = settingsLeaveRequesterRef.current;
|
||||
if (requestLeave) {
|
||||
requestLeave(() => commitView(next));
|
||||
return;
|
||||
}
|
||||
}
|
||||
commitView(next);
|
||||
requestWorkspaceLeave(next, () => commitView(next));
|
||||
},
|
||||
[commitView],
|
||||
[commitView, requestWorkspaceLeave],
|
||||
);
|
||||
const registerSettingsLeaveRequester = useCallback(
|
||||
(requester: SettingsLeaveRequester | undefined): void => {
|
||||
@@ -2695,6 +2713,21 @@ function App(): React.JSX.Element {
|
||||
const [imageReferencesByConversation, setImageReferencesByConversation] = useState<Record<string, AssistantArtifact[]>>({});
|
||||
const imageReferences = imageReferencesByConversation[activeId] ?? [];
|
||||
const attachmentsRef = useRef(new Map<string, ContextAttachment[]>());
|
||||
const attachmentSaveQueue = useRef<Promise<void>>(Promise.resolve());
|
||||
useEffect(() => window.goodbuddy.context.onDraftChanged((conversationId, saved) => {
|
||||
attachmentsRef.current.set(conversationId, saved);
|
||||
setAttachmentsByConversation((current) => ({ ...current, [conversationId]: saved }));
|
||||
}), []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (attachmentsRef.current.has(activeId) || conversationsRef.current.find((conversation) => conversation.id === activeId)?.remote) return;
|
||||
void window.goodbuddy.context.getDraft(activeId).then((saved) => {
|
||||
if (!active || attachmentsRef.current.has(activeId) || !saved.length) return;
|
||||
attachmentsRef.current.set(activeId, saved);
|
||||
setAttachmentsByConversation((current) => ({ ...current, [activeId]: saved }));
|
||||
}).catch((error: unknown) => notify({ tone: 'error', message: error instanceof Error ? error.message : '附件草稿读取失败' }));
|
||||
return () => { active = false; };
|
||||
}, [activeId]);
|
||||
const updateAttachments = useCallback(
|
||||
(
|
||||
update:
|
||||
@@ -2703,6 +2736,16 @@ function App(): React.JSX.Element {
|
||||
): void => {
|
||||
const current = attachmentsRef.current.get(activeId) ?? [];
|
||||
const next = typeof update === "function" ? update(current) : update;
|
||||
attachmentSaveQueue.current = attachmentSaveQueue.current.catch(() => undefined).then(async () => {
|
||||
await conversationPersistenceQueueRef.current;
|
||||
const owner = conversationsRef.current.find((conversation) => conversation.id === activeId);
|
||||
if (!owner || owner.remote) throw new Error('附件目标会话不可编辑');
|
||||
if (next.length && !persistedLocalConversationsRef.current.has(activeId)) {
|
||||
await window.goodbuddy.conversations.saveLocal([{ header: toLocalConversationHeader(owner), messages: [] }]);
|
||||
}
|
||||
await window.goodbuddy.context.saveDraft(activeId, next.map((attachment) => attachment.id));
|
||||
});
|
||||
void attachmentSaveQueue.current.catch((error: unknown) => notify({ tone: 'error', message: error instanceof Error ? error.message : '附件草稿保存失败' }));
|
||||
if (next.length > 0) {
|
||||
attachmentsRef.current.set(activeId, next);
|
||||
} else {
|
||||
@@ -2724,6 +2767,12 @@ function App(): React.JSX.Element {
|
||||
useState<ContextFileSelectionProgress>();
|
||||
const [selectingContextFiles, setSelectingContextFiles] = useState(false);
|
||||
const selectingContextFilesRef = useRef(false);
|
||||
const [attachmentOperations, setAttachmentOperations] = useState(0);
|
||||
const attachmentOperationsRef = useRef(0);
|
||||
const updateAttachmentBusy = useCallback((busy: boolean): void => {
|
||||
attachmentOperationsRef.current = Math.max(0, attachmentOperationsRef.current + (busy ? 1 : -1));
|
||||
setAttachmentOperations(attachmentOperationsRef.current);
|
||||
}, []);
|
||||
const [imageViewerItem, setImageViewerItem] = useState<ImageViewerItem>();
|
||||
const [citationDialog, setCitationDialog] = useState<{
|
||||
reference: KnowledgeSearchReference;
|
||||
@@ -3926,10 +3975,10 @@ function App(): React.JSX.Element {
|
||||
}, [activeRuntimeSelectionKey, runtimeSettings, setView]);
|
||||
|
||||
const startNewConversation = useCallback(
|
||||
(projectId?: string): boolean => {
|
||||
(projectId?: string, preview?: { ready: (conversation: Conversation) => void }): boolean => {
|
||||
const project = projects.find((candidate) => candidate.id === projectId);
|
||||
if (project?.kind === "channel") {
|
||||
setView("chat");
|
||||
if (!preview) setView("chat");
|
||||
notify({
|
||||
tone: "info",
|
||||
message: tRef.current("notices.channelConversationAutomatic"),
|
||||
@@ -3946,8 +3995,8 @@ function App(): React.JSX.Element {
|
||||
currentConversation.projectId === projectId &&
|
||||
isUnusedConversation(currentConversation)
|
||||
) {
|
||||
setView("chat");
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
if (preview) preview.ready(currentConversation);
|
||||
else { setView("chat"); requestAnimationFrame(() => inputRef.current?.focus()); }
|
||||
return true;
|
||||
}
|
||||
const conversation = createConversation(
|
||||
@@ -3961,9 +4010,8 @@ function App(): React.JSX.Element {
|
||||
conversations: nextConversations,
|
||||
};
|
||||
setConversations(nextConversations);
|
||||
setActiveId(conversation.id);
|
||||
setView("chat");
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
if (preview) preview.ready(conversation);
|
||||
else { setActiveId(conversation.id); setView("chat"); requestAnimationFrame(() => inputRef.current?.focus()); }
|
||||
return true;
|
||||
},
|
||||
[notify, projects, setActiveId, setView],
|
||||
@@ -5414,6 +5462,7 @@ function App(): React.JSX.Element {
|
||||
flushConversationPersistenceAfterRenderRef.current = false;
|
||||
persistLocalConversationChanges();
|
||||
await conversationPersistenceQueueRef.current;
|
||||
await attachmentSaveQueue.current;
|
||||
}),
|
||||
[
|
||||
conversationStoreReady,
|
||||
@@ -6517,9 +6566,10 @@ function App(): React.JSX.Element {
|
||||
}),
|
||||
}));
|
||||
});
|
||||
const removeOpenSettingsListener = window.goodbuddy.app.onOpenSettings(() =>
|
||||
setView("settings"),
|
||||
);
|
||||
const removeOpenSettingsListener = window.goodbuddy.app.onOpenSettings((category) => {
|
||||
setSettingsInitialCategory(category ?? "runtime");
|
||||
setView("settings");
|
||||
});
|
||||
return () => {
|
||||
removeAgentListener();
|
||||
removeImageListener();
|
||||
@@ -6954,6 +7004,7 @@ function App(): React.JSX.Element {
|
||||
deletingLocalConversationIdsRef.current.add(conversationId);
|
||||
try {
|
||||
await conversationPersistenceQueueRef.current;
|
||||
await attachmentSaveQueue.current;
|
||||
await window.goodbuddy.conversations.deleteLocal(conversationId);
|
||||
persistedLocalConversationsRef.current.delete(conversationId);
|
||||
} catch {
|
||||
@@ -7437,7 +7488,7 @@ function App(): React.JSX.Element {
|
||||
await releaseQueuedItem();
|
||||
return;
|
||||
}
|
||||
if (!queuedInput && selectingContextFilesRef.current) {
|
||||
if (!queuedInput && (selectingContextFilesRef.current || attachmentOperationsRef.current > 0)) {
|
||||
notify({
|
||||
tone: "info",
|
||||
message: t("composer.attachmentProgress.waitBeforeSending"),
|
||||
@@ -7573,6 +7624,7 @@ function App(): React.JSX.Element {
|
||||
) {
|
||||
throw new Error(t("notices.conversationPersistenceFailed"));
|
||||
}
|
||||
await attachmentSaveQueue.current;
|
||||
await window.goodbuddy.conversationQueue.enqueueUser(queueInput);
|
||||
setComposerMenuOpen(undefined);
|
||||
setRuntimeMenuOpen(false);
|
||||
@@ -8048,13 +8100,19 @@ function App(): React.JSX.Element {
|
||||
const conversationId = activeId;
|
||||
setContextError(undefined);
|
||||
try {
|
||||
await conversationPersistenceQueueRef.current;
|
||||
if (!persistedLocalConversationsRef.current.has(conversationId)) {
|
||||
const owner = conversationsRef.current.find((conversation) => conversation.id === conversationId);
|
||||
if (!owner || owner.remote) throw new Error('附件目标会话不可编辑');
|
||||
await window.goodbuddy.conversations.saveLocal([{ header: toLocalConversationHeader(owner), messages: owner.messages.map(toConversationMessage) }]);
|
||||
}
|
||||
const result = await action();
|
||||
const selected = Array.isArray(result) ? result : [result];
|
||||
const current = attachmentsRef.current.get(conversationId) ?? [];
|
||||
const unique = selected.filter(
|
||||
(item) => !current.some((existing) => existing.id === item.id),
|
||||
);
|
||||
const accepted = unique.slice(0, Math.max(0, 8 - current.length));
|
||||
const accepted = unique.slice(0, Math.max(0, maximumAttachmentsPerMessage - current.length));
|
||||
for (const attachment of unique.slice(accepted.length)) {
|
||||
void window.goodbuddy.context.remove(attachment.id);
|
||||
}
|
||||
@@ -8104,8 +8162,8 @@ function App(): React.JSX.Element {
|
||||
setFileSelectionProgress(undefined);
|
||||
try {
|
||||
await addContext(() => paths
|
||||
? window.goodbuddy.context.importFiles(paths)
|
||||
: window.goodbuddy.context.selectFiles());
|
||||
? window.goodbuddy.context.importFiles(paths, activeId)
|
||||
: window.goodbuddy.context.selectFiles(activeId));
|
||||
} finally {
|
||||
selectingContextFilesRef.current = false;
|
||||
setSelectingContextFiles(false);
|
||||
@@ -8412,12 +8470,7 @@ function App(): React.JSX.Element {
|
||||
commitView("chat");
|
||||
if (narrowWindow) closeNarrowSidebar();
|
||||
};
|
||||
const requestLeave = settingsLeaveRequesterRef.current;
|
||||
if (settingsOpenRef.current && requestLeave) {
|
||||
requestLeave(() => { void open(); });
|
||||
} else {
|
||||
void open();
|
||||
}
|
||||
requestWorkspaceLeave("chat", () => { void open(); });
|
||||
};
|
||||
|
||||
const openAssistantTask = (task: AssistantTask): void => {
|
||||
@@ -8825,6 +8878,19 @@ function App(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<DocumentConversationContext value={{
|
||||
activeId: activeConversation?.remote ? undefined : activeId,
|
||||
create: () => new Promise((resolve, reject) => {
|
||||
const created = startNewConversation(activeProjectId || undefined, { ready: (conversation) => {
|
||||
void window.goodbuddy.conversations.saveLocal([{ header: toLocalConversationHeader(conversation), messages: conversation.messages.map(toConversationMessage) }])
|
||||
.then(() => resolve({ id: conversation.id, title: conversation.title }), reject);
|
||||
} });
|
||||
if (!created) reject(new Error('当前项目不能创建会话,请先选择普通项目'));
|
||||
}),
|
||||
navigate: (id) => { setActiveId(id); setView('chat'); },
|
||||
notify: (message) => notify({ tone: 'success', message }),
|
||||
openImage: (src, title, trigger) => { imageViewerTriggerRef.current = trigger; setImageViewerItem({ src, title }); }
|
||||
}}>
|
||||
<aside
|
||||
aria-label={
|
||||
narrowWindow && sidebarOpen ? t("sidebar.label") : undefined
|
||||
@@ -9877,6 +9943,11 @@ function App(): React.JSX.Element {
|
||||
onError={handleConversationQueueError}
|
||||
onInterruptAndRun={interruptConversationQueueItem}
|
||||
onRemove={removeConversationQueueItem}
|
||||
onRestore={async (itemId) => {
|
||||
await attachmentSaveQueue.current;
|
||||
const restored = await window.goodbuddy.conversationQueue.restoreToDraft(itemId, input);
|
||||
setInput(restored.prompt);
|
||||
}}
|
||||
running={conversationExecutionRunning}
|
||||
/>
|
||||
<div className="composer">
|
||||
@@ -9893,6 +9964,8 @@ function App(): React.JSX.Element {
|
||||
{composerOptionSummary}
|
||||
</div>
|
||||
)}
|
||||
<PendingDocumentImports key={activeId} conversationId={activeId} refreshing={selectingContextFiles} onBusyChange={updateAttachmentBusy} />
|
||||
{attachments.some((attachment) => attachment.kind === 'image') && <AttachmentCapabilityNotice key={`${activeId}:${activeRuntimeSelectionKey}`} conversationId={activeId} selection={activeRuntimeSelection} revision={`${attachments.map((item) => item.id).join(',')}:${runtimeSettings?.defaultModelProfileId}:${runtimeStatusKey}`} />}
|
||||
{(attachments.length > 0 || imageReferences.length > 0 ||
|
||||
selectingContextFiles) && (
|
||||
<div
|
||||
@@ -9916,7 +9989,7 @@ function App(): React.JSX.Element {
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
className="context-chip"
|
||||
key={attachment.id}
|
||||
key={attachment.attachmentId ?? attachment.id}
|
||||
title={attachment.preview}
|
||||
>
|
||||
{attachment.kind === "image" &&
|
||||
@@ -9930,12 +10003,22 @@ function App(): React.JSX.Element {
|
||||
<FileText size={14} />
|
||||
)}
|
||||
<span>
|
||||
<strong>{attachment.name}</strong>
|
||||
<strong title={attachment.name}>{attachment.name}</strong>
|
||||
<span className="attachment-metadata">
|
||||
<AttachmentStatus attachment={attachment} />
|
||||
<small>
|
||||
{formatAttachmentSize(attachment.size)}
|
||||
</small>
|
||||
</span>
|
||||
<span className="attachment-actions">
|
||||
{attachment.resultId && <AttachmentResultButton resultId={attachment.resultId} name={attachment.name} conversationId={activeId} />}
|
||||
<AttachmentActions attachment={attachment} conversationId={activeId} onBusyChange={updateAttachmentBusy} />
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
className="icon-button attachment-action"
|
||||
title={t("composer.removeAttachment", { name: attachment.name })}
|
||||
data-tooltip={t("composer.removeAttachment", { name: attachment.name })}
|
||||
aria-label={t("composer.removeAttachment", {
|
||||
name: attachment.name,
|
||||
})}
|
||||
@@ -9951,7 +10034,7 @@ function App(): React.JSX.Element {
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -9961,6 +10044,7 @@ function App(): React.JSX.Element {
|
||||
className="context-chip context-chip--processing"
|
||||
role="status"
|
||||
>
|
||||
<button type="button" onClick={() => void window.goodbuddy.context.cancelImport(fileSelectionProgress?.operationId)}>取消文件导入</button>
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="context-chip__spinner"
|
||||
@@ -10031,7 +10115,7 @@ function App(): React.JSX.Element {
|
||||
setContextError(t("composer.attachmentProgress.waitBeforeSending"));
|
||||
return;
|
||||
}
|
||||
if (files.length > 8) {
|
||||
if (files.length > maximumAttachmentsPerMessage) {
|
||||
setContextError(t("composer.errors.attachmentLimit"));
|
||||
return;
|
||||
}
|
||||
@@ -10110,7 +10194,7 @@ function App(): React.JSX.Element {
|
||||
: undefined
|
||||
}
|
||||
aria-invalid={contextError ? true : undefined}
|
||||
disabled={selectingContextFiles}
|
||||
disabled={selectingContextFiles || attachmentOperations > 0}
|
||||
ref={attachmentButtonRef}
|
||||
onClick={() => void selectContextFiles()}
|
||||
title={t("composer.addAttachment")}
|
||||
@@ -10767,7 +10851,7 @@ function App(): React.JSX.Element {
|
||||
command.id === selectedRuntimeCommand,
|
||||
)
|
||||
)) ||
|
||||
selectingContextFiles ||
|
||||
selectingContextFiles || attachmentOperations > 0 ||
|
||||
activeProjectRecoveryBlocked ||
|
||||
!runtime?.available ||
|
||||
runtimeSwitching ||
|
||||
@@ -10974,7 +11058,7 @@ function App(): React.JSX.Element {
|
||||
<RouteLoadingStatus label={t("route.loading")} />
|
||||
}
|
||||
>
|
||||
<MagicNotesWorkspace onNotify={notify} applicationSettings={applicationSettings} />
|
||||
<MagicNotesWorkspace onNotify={notify} applicationSettings={applicationSettings} onBeforeLeave={registerNotesLeaveRequester} />
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
</PageShell>
|
||||
@@ -11723,6 +11807,7 @@ function App(): React.JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentConversationContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { AttachmentActions, AttachmentStatus } from './AttachmentActions'
|
||||
import { AttachmentResultButton } from './AttachmentResultButton'
|
||||
import type { ContextAttachment } from '../../shared/contracts'
|
||||
|
||||
vi.mock('./DocumentResultPreview', () => ({ DocumentResultPreview: () => <p>Saved result</p> }))
|
||||
|
||||
const attachment: ContextAttachment = {
|
||||
id: 'asset-id', resourceId: 'asset-id', resultId: 'result-id',
|
||||
name: 'quarterly-report.pdf', originalName: 'quarterly-report.pdf',
|
||||
kind: 'text', size: 1024, preview: 'Report', completeness: 'complete'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: {
|
||||
documentParsing: { getSnapshot: vi.fn(async () => ({ settings: { ocrProvider: 'paddleocr-vl' } })) },
|
||||
context: { openOriginal: vi.fn(async () => undefined) }
|
||||
} })
|
||||
})
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
it('keeps attachment actions icon-only and named, and restores keyboard focus after preview and menu dismissal', async () => {
|
||||
render(<>
|
||||
<AttachmentStatus attachment={attachment} />
|
||||
<div className="attachment-actions">
|
||||
<AttachmentResultButton resultId={attachment.resultId!} name={attachment.name} />
|
||||
<AttachmentActions attachment={attachment} />
|
||||
</div>
|
||||
</>)
|
||||
const preview = screen.getByRole('button', { name: `查看解析结果:${attachment.name}` })
|
||||
const more = screen.getByRole('button', { name: `更多附件操作:${attachment.name}` })
|
||||
for (const button of [preview, more]) {
|
||||
expect(button).toHaveClass('icon-button', 'attachment-action')
|
||||
expect(button).toHaveTextContent('')
|
||||
expect(button.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(button.getAttribute('title')).toBe(button.getAttribute('data-tooltip'))
|
||||
}
|
||||
expect(screen.getByText('解析完成')).toBeVisible()
|
||||
preview.focus()
|
||||
fireEvent.click(preview)
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('Saved result')
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(preview).toHaveFocus()
|
||||
more.focus()
|
||||
fireEvent.click(more)
|
||||
expect(more).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(await screen.findByText('解析来源:HTTP PaddleOCR-VL(远程服务)')).toBeVisible()
|
||||
expect(screen.getByRole('menuitem', { name: `打开原文件:${attachment.name}` })).toHaveFocus()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'End' })
|
||||
expect(screen.getByRole('menuitem', { name: '前往文档解析设置' })).toHaveFocus()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(more).toHaveFocus()
|
||||
fireEvent.click(more)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: `打开原文件:${attachment.name}` }))
|
||||
await waitFor(() => expect(window.goodbuddy.context.openOriginal).toHaveBeenCalledWith('asset-id'))
|
||||
})
|
||||
|
||||
it('keeps queued attachments read-only and exposes no action for legacy attachments without assets', async () => {
|
||||
const { rerender } = render(<AttachmentActions attachment={attachment} readOnly />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
await screen.findByText('解析来源:HTTP PaddleOCR-VL(远程服务)')
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(2)
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
rerender(<AttachmentActions attachment={{ ...attachment, resourceId: undefined }} />)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useContext, useId, useRef, useState } from 'react'
|
||||
import { Ellipsis } from 'lucide-react'
|
||||
import type { ContextAttachment } from '../../shared/contracts'
|
||||
import { AnchoredMenu } from './AnchoredMenu'
|
||||
import { DocumentConversationContext } from './DocumentConversationContext'
|
||||
|
||||
export function AttachmentStatus({ attachment }: { attachment: ContextAttachment }): React.JSX.Element {
|
||||
return <>
|
||||
{attachment.completeness && <small>{({ complete: '解析完成', partial: '部分解析,请查看警告', 'images-only': '仅图片,无可发送正文' })[attachment.completeness]}</small>}
|
||||
{attachment.sendMode && <small>{attachment.sendMode === 'text' ? '仅发送提取文字,原图保留' : '图片输入'}</small>}
|
||||
</>
|
||||
}
|
||||
|
||||
export function AttachmentActions({ attachment, conversationId, onBusyChange, readOnly = false }: {
|
||||
attachment: ContextAttachment; conversationId?: string; onBusyChange?: (busy: boolean) => void; readOnly?: boolean
|
||||
}): React.JSX.Element {
|
||||
const target = useContext(DocumentConversationContext)
|
||||
const [menu, setMenu] = useState(false)
|
||||
const [operation, setOperation] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [provider, setProvider] = useState('')
|
||||
const anchor = useRef<HTMLButtonElement>(null)
|
||||
const id = useId()
|
||||
if (!attachment.resourceId) return <></>
|
||||
const run = (action: (operationId: string) => Promise<unknown>): void => {
|
||||
const operationId = crypto.randomUUID()
|
||||
setMenu(false); setError(''); setOperation(operationId); onBusyChange?.(true)
|
||||
void action(operationId).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '附件操作失败'))
|
||||
.finally(() => { setOperation(''); onBusyChange?.(false) })
|
||||
}
|
||||
return <>
|
||||
<button type="button" className="icon-button attachment-action" title="更多附件操作" data-tooltip="更多附件操作" ref={anchor} aria-haspopup="menu" aria-expanded={menu} aria-controls={menu ? id : undefined}
|
||||
aria-label={`更多附件操作:${attachment.name}`} disabled={Boolean(operation)} onClick={() => {
|
||||
if (!menu) {
|
||||
setProvider('正在读取已保存的解析来源')
|
||||
void window.goodbuddy.documentParsing!.getSnapshot().then((snapshot) => setProvider(snapshot.settings.ocrProvider === 'paddleocr-vl' ? '解析来源:HTTP PaddleOCR-VL(远程服务)' : '解析来源:本地 OCR'), () => setProvider('来源读取失败;操作仍使用已保存的解析设置'))
|
||||
}
|
||||
setMenu((value) => !value)
|
||||
}}><Ellipsis size={16} aria-hidden="true" /></button>
|
||||
{menu && <AnchoredMenu anchorRef={anchor} id={id} label={`附件操作:${attachment.name}`} onClose={() => setMenu(false)}>
|
||||
<small>{provider}</small>
|
||||
<button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.openOriginal(attachment.resourceId!))}>打开原文件:{attachment.originalName ?? attachment.name}</button>
|
||||
{conversationId && <button type="button" role="menuitem" onClick={() => run((operationId) => window.goodbuddy.context.reparseDraft(conversationId, attachment.id, operationId))}>
|
||||
{attachment.sendMode ? '提取图片文字' : '使用当前设置重新解析'}:{attachment.originalName ?? attachment.name}
|
||||
</button>}
|
||||
{conversationId && attachment.sendMode === 'text' && <button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.sendOriginal(conversationId, attachment.id))}>改为发送原图:{attachment.originalName}</button>}
|
||||
{!readOnly && !conversationId && target?.activeId && <button type="button" role="menuitem" onClick={() => run(async (operationId) => {
|
||||
await window.goodbuddy.context.copyToDraft(target.activeId!, attachment.resourceId!, operationId)
|
||||
target.notify('已按当前设置解析并添加到目标会话草稿')
|
||||
})}>{attachment.sendMode ? '添加到当前草稿并提取文字' : '添加到当前草稿重新解析'}:{attachment.originalName ?? attachment.name}</button>}
|
||||
<button type="button" role="menuitem" onClick={() => { setMenu(false); void window.goodbuddy.localInference.openSettings('document-parsing') }}>前往文档解析设置</button>
|
||||
</AnchoredMenu>}
|
||||
{operation && <span role="status">正在处理附件<button type="button" className="secondary-button" onClick={() => void window.goodbuddy.context.cancelParsing(operation)}>取消解析</button></span>}
|
||||
{error && <span role="alert">{error}</span>}
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
|
||||
|
||||
export function AttachmentCapabilityNotice({ conversationId, selection, revision }: {
|
||||
conversationId: string; selection?: AgentRuntimeSelection; revision: string
|
||||
}): React.JSX.Element {
|
||||
const [reason, setReason] = useState<string>()
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
const key = JSON.stringify(selection)
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.goodbuddy.context.imageCapability(conversationId, JSON.parse(key ?? 'null') ?? undefined).then((result) => {
|
||||
if (active) { setConfirmed(true); setReason(result.supported ? undefined : result.reason) }
|
||||
}, () => { if (active) { setConfirmed(false); setReason('尚未确认图片输入能力;发送前将再次核对') } })
|
||||
return () => { active = false }
|
||||
}, [conversationId, key, revision])
|
||||
return !confirmed || reason ? <p className="settings-warning" role="status">{reason ?? '正在核对当前模型与 Runtime 的图片输入能力'}</p> : <></>
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { FileSearch } from 'lucide-react'
|
||||
import { DocumentResultPreview } from './DocumentResultPreview'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
|
||||
export function AttachmentResultButton({ resultId, name, conversationId, readOnly = false }: {
|
||||
resultId: string; name: string; conversationId?: string; readOnly?: boolean
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => open ? activateModalFocus(() => close.current) : undefined, [open])
|
||||
return <>
|
||||
<button type="button" className="icon-button attachment-action" title="查看解析结果" data-tooltip="查看解析结果" aria-label={`查看解析结果:${name}`} aria-haspopup="dialog" onClick={() => setOpen(true)}><FileSearch size={16} aria-hidden="true" /></button>
|
||||
{open && createPortal(<div className="document-parsing-diagnostic-backdrop">
|
||||
<section className="document-parsing-diagnostic" role="dialog" aria-modal="true" aria-label={`解析结果:${name}`}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target instanceof Element && event.target.closest('[aria-modal="true"]') !== event.currentTarget) return
|
||||
trapTabFocus(event, event.currentTarget)
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setOpen(false) }
|
||||
}}>
|
||||
<header><strong>{name}</strong><button type="button" className="secondary-button" ref={close} onClick={() => setOpen(false)}>关闭解析预览</button></header>
|
||||
<DocumentResultPreview resultId={resultId} allowAddImages={!readOnly} conversationId={conversationId} />
|
||||
</section>
|
||||
</div>, document.body)}
|
||||
</>
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AttachmentResultButton } from './AttachmentResultButton'
|
||||
import { AttachmentActions, AttachmentStatus } from './AttachmentActions'
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
@@ -744,8 +746,15 @@ function ChatMessageRowView({
|
||||
</span>
|
||||
)}
|
||||
<span className="message-attachment__details">
|
||||
<strong>{attachment.name}</strong>
|
||||
<strong title={attachment.name}>{attachment.name}</strong>
|
||||
<span className="attachment-metadata">
|
||||
<AttachmentStatus attachment={attachment} />
|
||||
<small>{formatAttachmentSize(attachment.size)}</small>
|
||||
</span>
|
||||
<span className="attachment-actions">
|
||||
{attachment.resultId && <AttachmentResultButton resultId={attachment.resultId} name={attachment.name} />}
|
||||
<AttachmentActions attachment={attachment} />
|
||||
</span>
|
||||
{imageItem && (
|
||||
<span className="message-image-actions">
|
||||
<button
|
||||
|
||||
@@ -2,12 +2,15 @@ import {
|
||||
ClockFading,
|
||||
CornerDownRight,
|
||||
ListRestart,
|
||||
Paperclip,
|
||||
Undo2,
|
||||
LoaderCircle,
|
||||
Trash2
|
||||
} from 'lucide-react'
|
||||
import { memo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ConversationQueueItem } from '../../shared/assistant-contracts'
|
||||
import { QueuedAttachmentsDialog } from './QueuedAttachmentsDialog'
|
||||
|
||||
type ConversationInputQueueProps = {
|
||||
items: ConversationQueueItem[]
|
||||
@@ -15,6 +18,7 @@ type ConversationInputQueueProps = {
|
||||
onError: (message: string) => void
|
||||
onInterruptAndRun: (itemId: string) => Promise<void>
|
||||
onRemove: (itemId: string) => Promise<void>
|
||||
onRestore?: (itemId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const ConversationInputQueue = memo(
|
||||
@@ -23,12 +27,14 @@ export const ConversationInputQueue = memo(
|
||||
running,
|
||||
onError,
|
||||
onInterruptAndRun,
|
||||
onRemove
|
||||
onRemove,
|
||||
onRestore
|
||||
}: ConversationInputQueueProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation('app')
|
||||
const [viewing, setViewing] = useState<string>()
|
||||
const [pendingAction, setPendingAction] = useState<{
|
||||
itemId: string
|
||||
action: 'run' | 'remove'
|
||||
action: 'run' | 'remove' | 'restore'
|
||||
}>()
|
||||
|
||||
if (items.length === 0) {
|
||||
@@ -37,7 +43,7 @@ export const ConversationInputQueue = memo(
|
||||
|
||||
const runAction = async (
|
||||
itemId: string,
|
||||
actionName: 'run' | 'remove',
|
||||
actionName: 'run' | 'remove' | 'restore',
|
||||
action: () => Promise<void>
|
||||
): Promise<void> => {
|
||||
setPendingAction({ itemId, action: actionName })
|
||||
@@ -67,7 +73,7 @@ export const ConversationInputQueue = memo(
|
||||
? t('composer.queue.scheduledTask')
|
||||
: t('composer.queue.message')
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<li key={item.id} className={item.error ? 'conversation-input-queue__item--error' : undefined}>
|
||||
<span
|
||||
aria-label={sourceLabel}
|
||||
className="conversation-input-queue__source"
|
||||
@@ -84,8 +90,11 @@ export const ConversationInputQueue = memo(
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
{item.error && <small className="knowledge-inline-error" role="alert">{item.error}</small>}
|
||||
</span>
|
||||
<span className="conversation-input-queue__actions">
|
||||
{item.source === 'user' && <button type="button" aria-label={`查看入队附件:${item.label}`} title="查看附件" onClick={() => setViewing(item.id)}><Paperclip aria-hidden="true" size={13} /><span>查看附件</span></button>}
|
||||
{onRestore && item.source === 'user' && <button type="button" disabled={pendingAction !== undefined} aria-label={`恢复到草稿:${item.label}`} title="恢复到草稿" onClick={() => void runAction(item.id, 'restore', () => onRestore(item.id))}><Undo2 aria-hidden="true" size={13} /><span>恢复到草稿</span></button>}
|
||||
<button
|
||||
aria-label={t(
|
||||
running
|
||||
@@ -150,6 +159,7 @@ export const ConversationInputQueue = memo(
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
{viewing && items.some((item) => item.id === viewing) && <QueuedAttachmentsDialog itemId={viewing} onClose={() => setViewing(undefined)} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
export const DocumentConversationContext = createContext<{
|
||||
activeId?: string
|
||||
create: () => Promise<{ id: string; title: string }>
|
||||
navigate: (id: string) => void
|
||||
notify: (message: string) => void
|
||||
openImage: (src: string, title: string, trigger: HTMLElement) => void
|
||||
} | undefined>(undefined)
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
DocumentParsingSettings,
|
||||
DocumentParsingSnapshot
|
||||
} from '../../shared/document-parsing-contracts'
|
||||
import { defaultHttpOcrSettings } from '../../shared/document-parsing-contracts'
|
||||
import { changeUiLocale } from './i18n'
|
||||
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
|
||||
|
||||
@@ -285,7 +286,7 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
})
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '测试聊天与成果模式'
|
||||
name: '测试解析'
|
||||
})
|
||||
).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
@@ -517,10 +518,11 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
await screen.findByText('PP-OCRv6 Tiny')
|
||||
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: '测试聊天与成果模式'
|
||||
name: '测试解析'
|
||||
})
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '测试聊天与成果模式' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '解析测试结果'
|
||||
@@ -543,7 +545,28 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
)
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
expect(container.inert).toBe(false)
|
||||
expect(test).toHaveBeenCalledWith('chat-attachment')
|
||||
expect(test).toHaveBeenCalledWith('chat-attachment', expect.any(String))
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps custom and unknown excluded labels across provider and filter switches', async () => {
|
||||
getSnapshot.mockResolvedValueOnce({ ...snapshot, settings: { ...settings, ocrProvider: 'paddleocr-vl', httpOcr: {
|
||||
...defaultHttpOcrSettings, baseUrl: 'http://ocr.test', filterMode: 'custom', ignoredLabels: ['header', 'text']
|
||||
} } })
|
||||
render(<DocumentParsingSettingsSection />)
|
||||
await screen.findByText('已保存来源:HTTP PaddleOCR-VL')
|
||||
fireEvent.click(screen.getByText('HTTP 高级设置'))
|
||||
expect(screen.getByRole('switch', { name: '保留页眉文字' })).not.toBeChecked()
|
||||
expect(screen.getByRole('switch', { name: '保留text' })).not.toBeChecked()
|
||||
fireEvent.click(screen.getByRole('button', { name: '服务默认' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '全部保留' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '自定义' }))
|
||||
expect(screen.getByRole('switch', { name: '保留text' })).not.toBeChecked()
|
||||
fireEvent.click(screen.getByRole('switch', { name: '保留页眉文字' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本地模型' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '远程服务' }))
|
||||
expect(screen.getByRole('button', { name: '测试解析' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(expect.objectContaining({ httpOcr: expect.objectContaining({ filterMode: 'custom', ignoredLabels: ['text'] }) })))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,11 +36,17 @@ import {
|
||||
SettingsWarningList
|
||||
} from './SettingsPrimitives'
|
||||
import { modelOperationPercent } from './model-download-presentation'
|
||||
import { defaultHttpOcrSettings } from '../../shared/document-parsing-contracts'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { HttpOcrSettings } from './HttpOcrSettings'
|
||||
import { DocumentResultPreview } from './DocumentResultPreview'
|
||||
import { AnchoredMenu } from './AnchoredMenu'
|
||||
|
||||
type DocumentParsingSettingsSectionProps = {
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
onOpenModelDownloadSourceSettings?: () => void
|
||||
onDirtyChange?: (dirty: boolean) => void
|
||||
onBusyChange?: (busy: boolean) => void
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -59,11 +65,13 @@ function catalogSize(entry: DocumentOcrModelCatalogViewEntry): number {
|
||||
function StatusRow({
|
||||
available,
|
||||
detail,
|
||||
label
|
||||
label,
|
||||
statusText
|
||||
}: {
|
||||
available: boolean
|
||||
detail: string
|
||||
label: string
|
||||
statusText?: string
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('settings')
|
||||
return (
|
||||
@@ -84,9 +92,9 @@ function StatusRow({
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{available
|
||||
{statusText ?? (available
|
||||
? t('documentParsing.status.available')
|
||||
: t('documentParsing.status.unavailable')}
|
||||
: t('documentParsing.status.unavailable'))}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@@ -94,16 +102,19 @@ function StatusRow({
|
||||
|
||||
function DiagnosticDialog({
|
||||
diagnostic,
|
||||
onClose
|
||||
onClose,
|
||||
restoreFocus
|
||||
}: {
|
||||
diagnostic: DocumentParsingDiagnostic
|
||||
onClose: () => void
|
||||
restoreFocus: () => HTMLElement | null
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('settings')
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
const restoreRef = useRef(restoreFocus)
|
||||
useEffect(() => {
|
||||
return activateModalFocus(() => closeRef.current)
|
||||
return activateModalFocus(() => closeRef.current, () => restoreRef.current())
|
||||
}, [])
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -121,6 +132,7 @@ function DiagnosticDialog({
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
@@ -154,7 +166,7 @@ function DiagnosticDialog({
|
||||
<div>
|
||||
<dt>{t('documentParsing.diagnostic.method')}</dt>
|
||||
<dd>
|
||||
{t(
|
||||
{diagnostic.provider === 'paddleocr-vl' ? 'HTTP PaddleOCR-VL' : t(
|
||||
`documentParsing.diagnostic.methods.${diagnostic.method}`
|
||||
)}
|
||||
</dd>
|
||||
@@ -176,11 +188,11 @@ function DiagnosticDialog({
|
||||
<dd>{(diagnostic.durationMs / 1_000).toFixed(1)}s</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="document-parsing-diagnostic__preview">
|
||||
{diagnostic.resultId ? <DocumentResultPreview key={diagnostic.resultId} resultId={diagnostic.resultId} /> : <div className="document-parsing-diagnostic__preview">
|
||||
<strong>{t('documentParsing.diagnostic.preview')}</strong>
|
||||
<pre>{diagnostic.preview}</pre>
|
||||
</div>
|
||||
{diagnostic.warnings.length > 0 && (
|
||||
</div>}
|
||||
{!diagnostic.resultId && diagnostic.warnings.length > 0 && (
|
||||
<div className="settings-warning">
|
||||
<strong>{t('documentParsing.diagnostic.warnings')}</strong>
|
||||
<ul>
|
||||
@@ -199,13 +211,18 @@ function DiagnosticDialog({
|
||||
export function DocumentParsingSettingsSection({
|
||||
onNotify,
|
||||
onOpenModelDownloadSourceSettings,
|
||||
onDirtyChange
|
||||
onDirtyChange,
|
||||
onBusyChange
|
||||
}: DocumentParsingSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settings')
|
||||
const [snapshot, setSnapshot] = useState<DocumentParsingSnapshot>()
|
||||
const [draft, setDraft] = useState<DocumentParsingSettings>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [clearApiKey, setClearApiKey] = useState(false)
|
||||
const [testMenuOpen, setTestMenuOpen] = useState(false)
|
||||
const testButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const [testingPurpose, setTestingPurpose] =
|
||||
useState<DocumentParsingTestPurpose>()
|
||||
const [busyModelId, setBusyModelId] = useState<string>()
|
||||
@@ -213,14 +230,19 @@ export function DocumentParsingSettingsSection({
|
||||
const [diagnostic, setDiagnostic] =
|
||||
useState<DocumentParsingDiagnostic>()
|
||||
const mountedRef = useRef(false)
|
||||
const testOperation = useRef<string | undefined>(undefined)
|
||||
const settingsDirty =
|
||||
snapshot !== undefined &&
|
||||
draft !== undefined &&
|
||||
JSON.stringify(draft) !== JSON.stringify(snapshot.settings)
|
||||
(JSON.stringify(draft) !== JSON.stringify(snapshot.settings) || Boolean(apiKey) || clearApiKey)
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(settingsDirty)
|
||||
}, [onDirtyChange, settingsDirty])
|
||||
useEffect(() => {
|
||||
onBusyChange?.(saving || testingPurpose !== undefined)
|
||||
return () => onBusyChange?.(false)
|
||||
}, [onBusyChange, saving, testingPurpose])
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
const api = window.goodbuddy.documentParsing
|
||||
@@ -335,9 +357,11 @@ export function DocumentParsingSettingsSection({
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const next = await api.update(draft)
|
||||
const next = await api.update({ ...draft, ...(apiKey ? { apiKey } : {}), ...(clearApiKey ? { clearApiKey } : {}) })
|
||||
setSnapshot(next)
|
||||
setDraft(next.settings)
|
||||
setApiKey('')
|
||||
setClearApiKey(false)
|
||||
onNotify?.({
|
||||
tone: 'success',
|
||||
message: t('notifications.documentParsingSaved'),
|
||||
@@ -415,21 +439,20 @@ export function DocumentParsingSettingsSection({
|
||||
}
|
||||
setTestingPurpose(purpose)
|
||||
setError(undefined)
|
||||
const operationId = crypto.randomUUID()
|
||||
testOperation.current = operationId
|
||||
try {
|
||||
const result = await api.test(purpose)
|
||||
const result = await api.test(purpose, operationId)
|
||||
if (result) {
|
||||
if (diagnostic?.resultId) await api.releaseResult(diagnostic.resultId)
|
||||
setDiagnostic(result)
|
||||
onNotify?.({
|
||||
tone: 'success',
|
||||
message: t('notifications.documentParsingTestSucceeded'),
|
||||
dedupeKey: 'document-parsing-test'
|
||||
})
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(
|
||||
displayErrorMessage(reason, t('errors.testDocumentParsing'))
|
||||
)
|
||||
} finally {
|
||||
testOperation.current = undefined
|
||||
setTestingPurpose(undefined)
|
||||
}
|
||||
}
|
||||
@@ -497,13 +520,23 @@ export function DocumentParsingSettingsSection({
|
||||
draft.localOcrModelId !== snapshot.settings.localOcrModelId
|
||||
const selectedModelReady = installedModel !== undefined
|
||||
const invalidPendingModel =
|
||||
pendingModelSelection && !selectedModelReady
|
||||
draft.ocrProvider !== 'paddleocr-vl' && pendingModelSelection && !selectedModelReady
|
||||
const testing = testingPurpose !== undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsCategoryHeader
|
||||
actions={
|
||||
<>
|
||||
<button type="button" className="secondary-button" ref={testButtonRef} disabled={saving || testing || settingsDirty}
|
||||
aria-describedby={settingsDirty ? 'document-parsing-unsaved-notice' : undefined}
|
||||
aria-haspopup="menu" aria-expanded={testMenuOpen} onClick={() => setTestMenuOpen((open) => !open)}>
|
||||
<FileSearch aria-hidden="true" size={14} />{testing ? '正在测试解析' : '测试解析'}
|
||||
</button>
|
||||
{testMenuOpen && <AnchoredMenu anchorRef={testButtonRef} id="document-test-menu" label="选择测试场景" onClose={() => setTestMenuOpen(false)}>
|
||||
<button type="button" role="menuitem" onClick={() => { setTestMenuOpen(false); void testParsing('chat-attachment') }}>{t('documentParsing.workflows.testChat')}</button>
|
||||
<button type="button" role="menuitem" onClick={() => { setTestMenuOpen(false); void testParsing('knowledge-index') }}>{t('documentParsing.workflows.testKnowledge')}</button>
|
||||
</AnchoredMenu>}
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={
|
||||
@@ -516,11 +549,15 @@ export function DocumentParsingSettingsSection({
|
||||
? t('actions.saving')
|
||||
: t('actions.saveSettings')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
category="document-parsing"
|
||||
error={error}
|
||||
/>
|
||||
<SettingsWarningList warnings={snapshot.warnings} />
|
||||
{testing && <div role="status" aria-live="polite"><span>正在解析测试文件</span><button type="button" className="secondary-button" onClick={() => {
|
||||
if (testOperation.current) void window.goodbuddy.documentParsing!.cancelTest(testOperation.current)
|
||||
}}>取消解析</button></div>}
|
||||
{settingsDirty && (
|
||||
<p
|
||||
className="settings-notice"
|
||||
@@ -549,7 +586,12 @@ export function DocumentParsingSettingsSection({
|
||||
detail={t('documentParsing.status.nativeDetail')}
|
||||
label={t('documentParsing.status.native')}
|
||||
/>
|
||||
<StatusRow
|
||||
{snapshot.settings.ocrProvider === 'paddleocr-vl' ? <StatusRow
|
||||
available={Boolean(snapshot.settings.httpOcr?.baseUrl)}
|
||||
statusText={snapshot.settings.httpOcr?.baseUrl ? '已配置' : '未配置'}
|
||||
label="HTTP PaddleOCR-VL"
|
||||
detail="远程 OCR;解析能力请使用真实文件测试,与本地模型安装状态无关。"
|
||||
/> : <StatusRow
|
||||
available={snapshot.status.localOcr.available}
|
||||
detail={t(
|
||||
snapshot.status.localOcr.available
|
||||
@@ -561,7 +603,7 @@ export function DocumentParsingSettingsSection({
|
||||
label={t('documentParsing.status.localOcrModel', {
|
||||
name: snapshot.status.localOcr.displayName
|
||||
})}
|
||||
/>
|
||||
/>}
|
||||
<StatusRow
|
||||
available={snapshot.status.conversionAvailable}
|
||||
detail={t(
|
||||
@@ -616,27 +658,9 @@ export function DocumentParsingSettingsSection({
|
||||
</select>
|
||||
<small>
|
||||
{t(
|
||||
`documentParsing.workflows.chatDescriptions.${draft.chatWorkflow}`
|
||||
`documentParsing.workflows.chatDescriptions.${draft.chatWorkflow === 'fast-text' ? 'fastText' : draft.chatWorkflow === 'high-fidelity' ? 'highFidelity' : 'auto'}`
|
||||
)}
|
||||
</small>
|
||||
<button
|
||||
aria-describedby={
|
||||
settingsDirty
|
||||
? 'document-parsing-unsaved-notice'
|
||||
: undefined
|
||||
}
|
||||
className="secondary-button document-parsing-workflow-test"
|
||||
disabled={saving || testing || settingsDirty}
|
||||
onClick={() =>
|
||||
void testParsing('chat-attachment')
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<FileSearch aria-hidden="true" size={14} />
|
||||
{testingPurpose === 'chat-attachment'
|
||||
? t('actions.testingParsing')
|
||||
: t('documentParsing.workflows.testChat')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="document-parsing-knowledge-workflow">
|
||||
@@ -675,29 +699,22 @@ export function DocumentParsingSettingsSection({
|
||||
`documentParsing.workflows.knowledgeDescriptions.${draft.knowledgeWorkflow}`
|
||||
)}
|
||||
</small>
|
||||
<button
|
||||
aria-describedby={
|
||||
settingsDirty
|
||||
? 'document-parsing-unsaved-notice'
|
||||
: undefined
|
||||
}
|
||||
className="secondary-button document-parsing-workflow-test"
|
||||
disabled={saving || testing || settingsDirty}
|
||||
onClick={() =>
|
||||
void testParsing('knowledge-index')
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<FileSearch aria-hidden="true" size={14} />
|
||||
{testingPurpose === 'knowledge-index'
|
||||
? t('actions.testingParsing')
|
||||
: t('documentParsing.workflows.testKnowledge')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
<section className="settings-section">
|
||||
<strong>OCR 来源</strong>
|
||||
<SegmentedControl ariaLabel="OCR 来源" value={draft.ocrProvider ?? 'local'}
|
||||
options={[{ value: 'local', label: '本地模型' }, { value: 'paddleocr-vl', label: '远程服务' }]}
|
||||
onChange={(value) => updateDraft('ocrProvider', value)} />
|
||||
<small>已保存来源:{snapshot.settings.ocrProvider === 'paddleocr-vl' ? 'HTTP PaddleOCR-VL' : '本地模型'}</small>
|
||||
</section>
|
||||
{draft.ocrProvider === 'paddleocr-vl' ? <HttpOcrSettings
|
||||
value={draft.httpOcr ?? defaultHttpOcrSettings} onChange={(value) => updateDraft('httpOcr', value)}
|
||||
dirty={settingsDirty || saving || testing} credentialConfigured={snapshot.httpCredentialConfigured ?? false}
|
||||
apiKey={apiKey} onApiKey={setApiKey} clearApiKey={clearApiKey} onClearApiKey={setClearApiKey}
|
||||
/> : <section
|
||||
aria-labelledby="document-parsing-ocr-title"
|
||||
className="settings-section document-ocr-settings"
|
||||
>
|
||||
@@ -1081,7 +1098,7 @@ export function DocumentParsingSettingsSection({
|
||||
<p className="settings-notice">
|
||||
{t('documentParsing.ocr.privacyNotice')}
|
||||
</p>
|
||||
</section>
|
||||
</section>}
|
||||
|
||||
<details className="settings-section">
|
||||
<summary>{t('documentParsing.advanced.title')}</summary>
|
||||
@@ -1123,7 +1140,11 @@ export function DocumentParsingSettingsSection({
|
||||
{diagnostic && (
|
||||
<DiagnosticDialog
|
||||
diagnostic={diagnostic}
|
||||
onClose={() => setDiagnostic(undefined)}
|
||||
restoreFocus={() => testButtonRef.current}
|
||||
onClose={() => {
|
||||
if (diagnostic.resultId) void window.goodbuddy.documentParsing!.releaseResult(diagnostic.resultId)
|
||||
setDiagnostic(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { DocumentResultPreview } from './DocumentResultPreview'
|
||||
import type { DocumentResult } from '../../shared/document-result-contracts'
|
||||
|
||||
const result: DocumentResult = {
|
||||
id: '00000000-0000-4000-8000-000000000001', fileName: 'report.pdf', sourceFormat: '.pdf',
|
||||
content: '<div>Invoice</div><table><tr><td rowspan="2">Amount</td><td>42</td></tr></table><script>bad()</script>',
|
||||
sections: [{ locator: 'Page 1', pageNumber: 1, content: '<div>Invoice</div>\n\n<table><tr><td rowspan="2">Amount</td><td>42</td></tr></table><script>bad()</script>' }],
|
||||
images: [{ id: '00000000-0000-4000-8000-000000000002', pageNumber: 1, key: 'imgs/a.png', mimeType: 'image/png', width: 10, height: 10, size: 20 }],
|
||||
missingImages: [], warnings: [], completeness: 'complete', parsedAt: '2026-09-19T00:00:00Z', durationMs: 100,
|
||||
settings: { chatWorkflow: 'auto', knowledgeWorkflow: 'complete-index', maximumPages: 100, pageTimeoutSeconds: 60, localOcrModelId: 'pp-ocrv6-tiny' }
|
||||
}
|
||||
const add = vi.fn(async () => [])
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
beforeEach(() => {
|
||||
add.mockClear()
|
||||
vi.stubGlobal('IntersectionObserver', class { observe() {} disconnect() {} })
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: {
|
||||
documentParsing: { getResult: vi.fn(async () => result), openResultOriginal: vi.fn(async () => { throw new Error('原文件不可用') }) },
|
||||
conversations: { listSummaries: vi.fn(async () => [{ id: 'conversation', title: 'Target', updatedAt: 0, messages: [] }]) },
|
||||
projects: { list: vi.fn(async () => []) }, context: { getDraft: vi.fn(async () => []), addResultImages: add, onDraftChanged: vi.fn(() => () => undefined) }
|
||||
} })
|
||||
})
|
||||
|
||||
it('renders OCR HTML tables and text without scripts, and keeps the result when its original is missing', async () => {
|
||||
const { container } = render(<DocumentResultPreview resultId={result.id} />)
|
||||
expect(await screen.findByText('Invoice')).toBeVisible()
|
||||
expect(screen.getByRole('table')).toHaveTextContent('Amount42')
|
||||
expect(container.querySelector('td')?.getAttribute('rowspan')).toBe('2')
|
||||
expect(container.querySelector('script')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开原文件' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('原文件不可用')
|
||||
expect(screen.getByRole('table')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '详情' }))
|
||||
expect(screen.getByText('未提供')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '添加所选图片' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts with zero selected images and retains selections across tabs until explicit batch submission', async () => {
|
||||
render(<DocumentResultPreview resultId={result.id} allowAddImages conversationId="conversation" />)
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '图片(1)' }))
|
||||
const checkbox = screen.getByRole('checkbox')
|
||||
expect(checkbox).not.toBeChecked()
|
||||
expect(screen.getByRole('button', { name: '添加所选图片' })).toBeDisabled()
|
||||
fireEvent.click(checkbox)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '详情' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '图片(1)' }))
|
||||
expect(screen.getByRole('checkbox')).toBeChecked()
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加所选图片' }))
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith('conversation', result.id, [result.images[0]!.id]))
|
||||
expect(screen.getByRole('checkbox')).not.toBeChecked()
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import DOMPurify from 'dompurify'
|
||||
import type { DocumentResult } from '../../shared/document-result-contracts'
|
||||
import type { ContextAttachment } from '../../shared/contracts'
|
||||
import type { ConversationListSnapshot } from '../../shared/assistant-contracts'
|
||||
import { PageTabs } from './WorkspacePrimitives'
|
||||
import { DocumentConversationContext } from './DocumentConversationContext'
|
||||
|
||||
function ResultImage({ resultId, image, onLocate, label }: {
|
||||
resultId: string; image: DocumentResult['images'][number]; onLocate?: () => void; label?: string
|
||||
}): React.JSX.Element {
|
||||
const conversationContext = useContext(DocumentConversationContext)
|
||||
const [url, setUrl] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [retry, setRetry] = useState(0)
|
||||
const element = useRef<HTMLDivElement>(null)
|
||||
const location = label ?? image.locator ?? `第 ${image.pageNumber} 页图片`
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return
|
||||
observer.disconnect()
|
||||
void window.goodbuddy.documentParsing!.readResultImage(resultId, image.id, true).then(
|
||||
(value) => { if (active) { setUrl(value); setError('') } },
|
||||
(reason: unknown) => { if (active) setError(reason instanceof Error ? reason.message : '图片读取失败') }
|
||||
)
|
||||
})
|
||||
if (element.current) observer.observe(element.current)
|
||||
return () => { active = false; observer.disconnect() }
|
||||
}, [resultId, image.id, retry])
|
||||
return <div ref={element} className="document-result-image">
|
||||
{url ? <button type="button" className="message-image-button" aria-label={`查看大图:${location}`} onClick={(event) => {
|
||||
const trigger = event.currentTarget
|
||||
void window.goodbuddy.documentParsing!.readResultImage(resultId, image.id).then((original) => conversationContext?.openImage(original, location, trigger),
|
||||
(reason: unknown) => setError(reason instanceof Error ? reason.message : '原图读取失败'))
|
||||
}}>
|
||||
<img src={url} alt={location} loading="lazy" />
|
||||
</button> : <p role="status">{error || '正在读取图片'}</p>}
|
||||
{error && <button type="button" className="secondary-button" onClick={() => setRetry((value) => value + 1)}>重试读取图片</button>}
|
||||
<small>{image.width} × {image.height} · {image.mimeType} · {(image.size / 1024).toFixed(1)} KiB</small>
|
||||
{onLocate && <button type="button" className="secondary-button" onClick={onLocate}>{location} · 查看正文位置</button>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function SectionContent({ content, result, onImage }: {
|
||||
content: string; result: DocumentResult; onImage: (id: string) => void
|
||||
}): React.JSX.Element {
|
||||
const pieces = useMemo(() => content.split(/(<table\b[\s\S]*?<\/table>)/giu), [content])
|
||||
const images = useMemo(() => new Map(result.images.map((image) => [image.id, image])), [result.images])
|
||||
return <div className="markdown-body">{pieces.map((piece, index) => /^<table\b/iu.test(piece)
|
||||
? <div key={index} className="document-result-table" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(piece, {
|
||||
ALLOWED_TAGS: ['table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'p', 'br', 'strong', 'em', 'sup', 'sub'],
|
||||
ALLOWED_ATTR: ['rowspan', 'colspan']
|
||||
}) }} />
|
||||
: <ReactMarkdown key={index} remarkPlugins={[remarkGfm]} skipHtml
|
||||
urlTransform={(url) => url.startsWith('asset:') && images.has(url.slice(6)) ? url : ''}
|
||||
components={{ p: ({ children }) => <div>{children}</div>, img: ({ src }) => {
|
||||
const id = typeof src === 'string' ? src.slice(6) : ''
|
||||
const image = images.get(id)
|
||||
return image ? <><ResultImage resultId={result.id} image={image} /><button type="button" className="secondary-button" onClick={() => onImage(id)}>第 {image.pageNumber} 页图片 · 在图片中查看</button></> : <span>图片资源不可用</span>
|
||||
} }}>{piece.replace(/<\/?(?:div|p|span|h[1-6])\b[^>]*>/giu, '\n').replace(/<br\s*\/?\s*>/giu, '\n\n')}</ReactMarkdown>)}</div>
|
||||
}
|
||||
|
||||
export function DocumentResultPreview({ resultId, allowAddImages = false, conversationId }: { resultId: string; allowAddImages?: boolean; conversationId?: string }): React.JSX.Element {
|
||||
const conversationContext = useContext(DocumentConversationContext)
|
||||
const [result, setResult] = useState<DocumentResult>()
|
||||
const [error, setError] = useState('')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [projectNames, setProjectNames] = useState<Record<string, string>>({})
|
||||
const [retry, setRetry] = useState(0)
|
||||
const [tab, setTab] = useState<'text' | 'images' | 'details'>('text')
|
||||
const prefix = useId()
|
||||
const [conversations, setConversations] = useState<ConversationListSnapshot[]>([])
|
||||
const [target, setTarget] = useState(conversationId ?? conversationContext?.activeId ?? '')
|
||||
const [draft, setDraft] = useState<ContextAttachment[]>([])
|
||||
const [selection, setSelection] = useState<string[]>([])
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addError, setAddError] = useState('')
|
||||
const attached = useMemo(() => new Set(draft.filter((item) => item.provenance?.resultId === resultId).map((item) => item.provenance!.imageId)), [draft, resultId])
|
||||
const imageSections = useMemo(() => {
|
||||
const byPage = new Map<number, number>(), byLocator = new Map<string, number>()
|
||||
result?.sections.forEach((section, index) => {
|
||||
byLocator.set(section.locator, index)
|
||||
for (const page of section.sourcePages ?? (section.pageNumber ? [section.pageNumber] : [])) if (!byPage.has(page)) byPage.set(page, index)
|
||||
})
|
||||
return new Map(result?.images.map((image) => [image.id, image.locator ? byLocator.get(image.locator) ?? byPage.get(image.pageNumber) ?? 0 : byPage.get(image.pageNumber) ?? 0]))
|
||||
}, [result])
|
||||
useEffect(() => {
|
||||
if (!allowAddImages) return
|
||||
let active = true
|
||||
void Promise.all([window.goodbuddy.conversations.listSummaries(), window.goodbuddy.projects.list()]).then(([value, projects]) => {
|
||||
if (active) {
|
||||
setConversations(value.filter((item) => !item.remote))
|
||||
setProjectNames(Object.fromEntries(projects.map((project) => [project.id, project.name])))
|
||||
}
|
||||
}).catch((reason: unknown) => { if (active) setAddError(reason instanceof Error ? reason.message : '会话读取失败') })
|
||||
return () => { active = false }
|
||||
}, [allowAddImages])
|
||||
useEffect(() => {
|
||||
if (!allowAddImages || !target) return
|
||||
let active = true
|
||||
void window.goodbuddy.context.getDraft(target).then((value) => { if (active) setDraft(value) })
|
||||
.catch((reason: unknown) => { if (active) setAddError(reason instanceof Error ? reason.message : '草稿读取失败') })
|
||||
const unsubscribe = window.goodbuddy.context.onDraftChanged((conversationId, items) => {
|
||||
if (conversationId === target) setDraft(items)
|
||||
})
|
||||
return () => { active = false; unsubscribe() }
|
||||
}, [allowAddImages, target])
|
||||
const focusTarget = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.goodbuddy.documentParsing!.getResult(resultId).then(
|
||||
(value) => { if (active) { setResult(value); setError('') } },
|
||||
(reason: unknown) => { if (active) setError(reason instanceof Error ? reason.message : '解析结果读取失败') }
|
||||
)
|
||||
return () => { active = false }
|
||||
}, [resultId, retry])
|
||||
useEffect(() => {
|
||||
if (!focusTarget.current) return
|
||||
const target = document.getElementById(focusTarget.current)
|
||||
target?.scrollIntoView({ block: 'nearest' })
|
||||
target?.focus()
|
||||
focusTarget.current = undefined
|
||||
}, [tab])
|
||||
if (error) return <div role="alert"><p>{error}</p><button type="button" className="secondary-button" onClick={() => setRetry((value) => value + 1)}>重新读取</button></div>
|
||||
if (!result) return <p role="status">正在读取解析结果</p>
|
||||
return <div className="document-result-preview">
|
||||
<div className="document-result-actions"><strong>{({ complete: '解析完成', partial: '部分解析', 'images-only': '仅图片,无可发送正文' })[result.completeness]}</strong>
|
||||
<button type="button" className="secondary-button" onClick={() => {
|
||||
setActionError('')
|
||||
void window.goodbuddy.documentParsing!.openResultOriginal(result.id).catch((reason: unknown) => setActionError(reason instanceof Error ? reason.message : '原文件不可用'))
|
||||
}}>打开原文件</button></div>
|
||||
{actionError && <p role="alert">{actionError}</p>}
|
||||
<PageTabs ariaLabel="解析结果" idPrefix={prefix} variant="segmented" value={tab} onChange={setTab}
|
||||
tabs={[{ id: 'text', label: '正文' }, { id: 'images', label: `图片(${result.images.length})` }, { id: 'details', label: '详情' }]} />
|
||||
<section className="document-result-panel" role="tabpanel" id={`${prefix}-panel-${tab}`} aria-labelledby={`${prefix}-tab-${tab}`}>
|
||||
{tab === 'text' && result.sections.map((section, index) => <article key={index}>
|
||||
<h3 tabIndex={-1} id={`${prefix}-section-${index}`}>{section.locator}</h3>
|
||||
<SectionContent content={section.content} result={result} onImage={(id) => {
|
||||
focusTarget.current = `${prefix}-image-${id}`; setTab('images')
|
||||
}} />
|
||||
</article>)}
|
||||
{tab === 'images' && <><p>已保存 {result.images.length} 张 · 未保存 {result.missingImages.length} 张</p>
|
||||
{!result.images.length && <p>此结果没有提取图片;原生解析器可能只提取文字。</p>}
|
||||
<div className="document-result-images">{result.images.map((image, index) => <article key={image.id}>
|
||||
<h3 tabIndex={-1} id={`${prefix}-image-${image.id}`}>{image.locator ?? `${result.sourceFormat === '.pptx' ? '幻灯片' : '页'} ${image.pageNumber} · 图片 ${index + 1}`}</h3>
|
||||
<ResultImage resultId={result.id} image={image} label={image.locator ?? `${result.sourceFormat === '.pptx' ? '幻灯片' : '页'} ${image.pageNumber} · 图片 ${index + 1}`} onLocate={() => { focusTarget.current = `${prefix}-section-${imageSections.get(image.id) ?? 0}`; setTab('text') }} />
|
||||
{allowAddImages && (attached.has(image.id) ? <span>已在此会话草稿 <button type="button" className="secondary-button" disabled={adding} onClick={() => {
|
||||
const existing = draft.find((item) => item.provenance?.resultId === resultId && item.provenance.imageId === image.id)!
|
||||
const next = draft.filter((item) => item.id !== existing.id)
|
||||
setAdding(true)
|
||||
void window.goodbuddy.context.saveDraft(target, next.map((item) => item.id)).then(async () => {
|
||||
await window.goodbuddy.context.remove(existing.id); setDraft(next)
|
||||
}).catch((reason: unknown) => setAddError(reason instanceof Error ? reason.message : '移除失败')).finally(() => setAdding(false))
|
||||
}}>从草稿移除</button></span> : <label>
|
||||
<input type="checkbox" checked={selection.includes(image.id)} disabled={adding}
|
||||
onChange={(event) => setSelection((current) => event.target.checked ? [...current, image.id] : current.filter((id) => id !== image.id))} />
|
||||
选择 {result.fileName} 第 {image.pageNumber} 页图片
|
||||
</label>)}
|
||||
</article>)}{result.missingImages.map((image, index) => <article className="settings-warning" key={`missing-${index}`}>
|
||||
<h3>第 {image.pageNumber} 页图片未保存</h3><p>{image.reason}</p>
|
||||
</article>)}</div></>}
|
||||
{tab === 'details' && <>
|
||||
<dl><dt>解析时间</dt><dd>{new Date(result.parsedAt).toLocaleString()}</dd>
|
||||
<dt>耗时</dt><dd>{(result.durationMs / 1000).toFixed(1)} 秒</dd>
|
||||
<dt>置信度</dt><dd>{result.sections.some((section) => section.confidence !== undefined) ? result.sections.map((section) => `${section.locator}: ${section.confidence ?? '未提供'}`).join(';') : '未提供'}</dd>
|
||||
<dt>跨页整理</dt><dd>{result.restructure ? result.restructure.changed ? '返回变化;请核对来源映射警告' : '未观察到变化' : '未请求'}</dd></dl>
|
||||
<p>以下为解析时保存的配置。省略的高级项使用服务默认,实际值未提供。</p>
|
||||
<pre>{JSON.stringify(result.settings, null, 2)}</pre>
|
||||
</>}
|
||||
{result.warnings.length > 0 && <div className="settings-warning"><strong>解析警告</strong><ul>{result.warnings.map((warning, index) => <li key={index}>{warning}</li>)}</ul></div>}
|
||||
</section>
|
||||
{allowAddImages && <div className="document-result-actions">
|
||||
<label className="field"><span>添加到会话草稿</span><select value={target} disabled={adding} onChange={(event) => { setTarget(event.target.value); setDraft([]) }}>
|
||||
<option value="">选择会话后添加</option>{conversations.map((conversation) => <option key={conversation.id} value={conversation.id}>{conversation.projectId ? projectNames[conversation.projectId] ?? '项目不可用' : '未绑定项目'} · {conversation.title}</option>)}
|
||||
</select></label>
|
||||
<span>待添加 {selection.filter((id) => !attached.has(id)).length} 张 · 已在草稿 {attached.size} 张</span>
|
||||
<button type="button" className="primary-button" disabled={adding || !target || !selection.some((id) => !attached.has(id))}
|
||||
onClick={() => {
|
||||
setAdding(true); setAddError('')
|
||||
void window.goodbuddy.context.addResultImages(target, resultId, selection.filter((id) => !attached.has(id))).then((value) => {
|
||||
setDraft(value); setSelection([])
|
||||
conversationContext?.notify(`图片已添加到会话草稿:${conversations.find((item) => item.id === target)?.title ?? target}`)
|
||||
}, (reason: unknown) => setAddError(reason instanceof Error ? reason.message : '添加图片失败')).finally(() => setAdding(false))
|
||||
}}>{adding ? '正在添加' : '添加所选图片'}</button>
|
||||
{conversationContext && <button type="button" className="secondary-button" disabled={adding} onClick={() => {
|
||||
setAdding(true); setAddError('')
|
||||
void conversationContext.create().then((conversation) => {
|
||||
setConversations((current) => current.some((item) => item.id === conversation.id) ? current : [...current, { ...conversation, updatedAt: Date.now(), messages: [] }])
|
||||
setTarget(conversation.id); setDraft([])
|
||||
}).catch((reason: unknown) => setAddError(reason instanceof Error ? reason.message : '创建会话失败')).finally(() => setAdding(false))
|
||||
}}>新建目标会话</button>}
|
||||
{conversationContext && target && attached.size > 0 && <button type="button" className="secondary-button" onClick={() => conversationContext.navigate(target)}>前往会话</button>}
|
||||
{addError && <p role="alert">{addError}</p>}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react'
|
||||
import type { defaultHttpOcrSettings } from '../../shared/document-parsing-contracts'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
|
||||
type HttpSettings = typeof defaultHttpOcrSettings
|
||||
const booleanOptions = {
|
||||
useDocOrientationClassify: '方向分类', useDocUnwarping: '页面去扭曲',
|
||||
useLayoutDetection: '版面检测', useChartRecognition: '图表识别',
|
||||
useSealRecognition: '印章识别', useOcrForImageBlock: '图片块文字识别',
|
||||
prettifyMarkdown: '美化 Markdown', showFormulaNumber: '显示公式编号',
|
||||
formatBlockContent: '格式化块内容', mergeLayoutBlocks: '合并版面块', layoutNms: '版面非极大值抑制'
|
||||
} as const
|
||||
const labels: Record<string, string> = {
|
||||
header: '页眉文字', header_image: '页眉图片', footer: '页脚文字',
|
||||
footer_image: '页脚图片', number: '页码', footnote: '脚注', aside_text: '侧栏文字'
|
||||
}
|
||||
const numericOptions = {
|
||||
layoutThreshold: '版面检测阈值', layoutUnclipRatio: '版面扩展比例',
|
||||
repetitionPenalty: '重复惩罚', temperature: '采样温度', topP: 'Top P',
|
||||
minPixels: '最小像素数', maxPixels: '最大像素数', maxNewTokens: '最大输出 Token'
|
||||
} as const
|
||||
|
||||
export function HttpOcrSettings({ value, onChange, dirty, credentialConfigured, apiKey, onApiKey,
|
||||
clearApiKey, onClearApiKey }: {
|
||||
value: HttpSettings; onChange: (value: HttpSettings) => void; dirty: boolean
|
||||
credentialConfigured: boolean; apiKey: string; onApiKey: (value: string) => void
|
||||
clearApiKey: boolean; onClearApiKey: (value: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [check, setCheck] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
return <section className="settings-section">
|
||||
<strong>HTTP PaddleOCR-VL</strong>
|
||||
<label className="field"><span>服务地址</span><input type="url" value={value.baseUrl}
|
||||
onChange={(event) => onChange({ ...value, baseUrl: event.target.value })} /></label>
|
||||
<p className="settings-notice">需要 OCR 的页面或图片将发送到此服务;快速文本模式不自动上传,主动提取图片文字仍使用此服务。</p>
|
||||
<label className="field"><span>认证方式</span><select value={value.authentication}
|
||||
onChange={(event) => onChange({ ...value, authentication: event.target.value as HttpSettings['authentication'] })}>
|
||||
<option value="none">无需认证</option><option value="bearer">Bearer API Key</option>
|
||||
</select></label>
|
||||
{value.authentication === 'bearer' && <label className="field"><span>API Key</span>
|
||||
<input type="password" autoComplete="off" value={apiKey} onChange={(event) => onApiKey(event.target.value)} />
|
||||
<small>{credentialConfigured ? '已保存凭据;留空保持原值' : '未配置凭据'}</small>
|
||||
</label>}
|
||||
{credentialConfigured && <label><input type="checkbox" checked={clearApiKey}
|
||||
onChange={(event) => onClearApiKey(event.target.checked)} />保存时清除凭据</label>}
|
||||
<button type="button" className="secondary-button" disabled={dirty || checking}
|
||||
aria-describedby={dirty ? 'document-parsing-unsaved-notice' : undefined}
|
||||
onClick={() => {
|
||||
setChecking(true); setError(''); setCheck('')
|
||||
void window.goodbuddy.documentParsing!.checkHttp().then((result) => {
|
||||
setCheck(`服务可连接,解析能力需用文件测试。检查时间:${new Date(result.checkedAt).toLocaleString()}`)
|
||||
}, (reason: unknown) => { setError(reason instanceof Error ? reason.message : '连接检查失败') })
|
||||
.finally(() => setChecking(false))
|
||||
}}>{checking ? '正在检查连接' : '检查连接'}</button>
|
||||
{check && <p role="status">{check}</p>}{error && <p role="alert">{error}</p>}
|
||||
<details><summary>HTTP 高级设置</summary>
|
||||
<p>高级能力取决于服务部署;接口声明不代表模型已安装或效果已经实测。</p>
|
||||
<SegmentedControl ariaLabel="内容过滤" value={value.filterMode}
|
||||
options={[{ value: 'default', label: '服务默认' }, { value: 'all', label: '全部保留' }, { value: 'custom', label: '自定义' }]}
|
||||
onChange={(filterMode) => onChange({ ...value, filterMode })} />
|
||||
{value.filterMode === 'custom' && <>
|
||||
<small>未设置自定义排除项时,从全部保留开始。</small>
|
||||
{[...new Set([...Object.keys(labels), ...value.ignoredLabels])].map((key) => <label className="toggle-row" key={key}>
|
||||
<span>保留{labels[key] ?? key}</span><input type="checkbox" role="switch" checked={!value.ignoredLabels.includes(key)}
|
||||
onChange={(event) => onChange({ ...value, ignoredLabels: event.target.checked
|
||||
? value.ignoredLabels.filter((label) => label !== key) : [...value.ignoredLabels, key] })} />
|
||||
</label>)}
|
||||
</>}
|
||||
<div className="document-parsing-grid">
|
||||
{Object.entries(booleanOptions).map(([key, label]) => <label className="field" key={key}><span>{label}</span>
|
||||
<select value={String(value.options[key as keyof typeof booleanOptions] ?? 'default')}
|
||||
onChange={(event) => {
|
||||
const options = { ...value.options }
|
||||
if (event.target.value === 'default') delete options[key as keyof typeof booleanOptions]
|
||||
else options[key as keyof typeof booleanOptions] = event.target.value === 'true'
|
||||
onChange({ ...value, options })
|
||||
}}><option value="default">服务默认</option><option value="true">开启</option><option value="false">关闭</option></select>
|
||||
</label>)}
|
||||
<label className="field"><span>HTTP 请求超时(秒)</span><input type="number" min={10} max={300} value={value.timeoutSeconds}
|
||||
onChange={(event) => onChange({ ...value, timeoutSeconds: Number(event.target.value) })} /></label>
|
||||
</div>
|
||||
<details><summary>版面与生成参数</summary><p>留空使用服务默认;接口字段的实际边界与效果需用当前部署测试。</p>
|
||||
<div className="document-parsing-grid">
|
||||
{Object.entries(numericOptions).map(([key, label]) => <label className="field" key={key}><span>{label}</span>
|
||||
<input type="number" step="any" value={value.options[key as keyof typeof numericOptions] ?? ''} onChange={(event) => {
|
||||
const options = { ...value.options }
|
||||
if (event.target.value === '') delete options[key as keyof typeof numericOptions]
|
||||
else options[key as keyof typeof numericOptions] = Number(event.target.value)
|
||||
onChange({ ...value, options })
|
||||
}} />
|
||||
</label>)}
|
||||
{(['promptLabel', 'layoutMergeBboxesMode'] as const).map((key) => <label className="field" key={key}><span>{key === 'promptLabel' ? '提示标签' : '边框合并模式'}</span>
|
||||
<input value={value.options[key] ?? ''} onChange={(event) => {
|
||||
const options = { ...value.options }
|
||||
if (event.target.value) options[key] = event.target.value
|
||||
else delete options[key]
|
||||
onChange({ ...value, options })
|
||||
}} /></label>)}
|
||||
<label className="field"><span>版面形状</span><select value={value.options.layoutShapeMode ?? ''} onChange={(event) => {
|
||||
const options = { ...value.options }
|
||||
if (event.target.value) options.layoutShapeMode = event.target.value as NonNullable<typeof options.layoutShapeMode>
|
||||
else delete options.layoutShapeMode
|
||||
onChange({ ...value, options })
|
||||
}}><option value="">服务默认</option>{['rect', 'quad', 'poly', 'auto'].map((mode) => <option key={mode} value={mode}>{mode}</option>)}</select></label>
|
||||
</div>
|
||||
</details>
|
||||
{(['mergeTables', 'relevelTitles'] as const).map((key) => <label className="toggle-row" key={key}>
|
||||
<span>{key === 'mergeTables' ? '合并跨页表格' : '调整标题层级'}</span>
|
||||
<input type="checkbox" role="switch" checked={value[key]} onChange={(event) => onChange({ ...value, [key]: event.target.checked })} />
|
||||
</label>)}
|
||||
<small>跨页整理会增加请求;当前部署尚未验证合并效果。来源无法映射时报告未完成并保留逐页结果。</small>
|
||||
</details>
|
||||
</section>
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DocumentResultPreview } from './DocumentResultPreview'
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
@@ -78,6 +79,7 @@ import {
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
PageTabs,
|
||||
ScopeBadge,
|
||||
SegmentedControl,
|
||||
type PageTab
|
||||
} from './WorkspacePrimitives'
|
||||
@@ -1346,6 +1348,9 @@ function DocumentsView({
|
||||
const { i18n, t } = useTranslation('knowledge')
|
||||
const locale = resolvedLocale(i18n.resolvedLanguage ?? i18n.language)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [viewedDocumentId, setViewedDocumentId] = useState<string>()
|
||||
const previewBackRef = useRef<HTMLButtonElement>(null)
|
||||
const previewOriginRef = useRef<{ trigger: HTMLElement; scroll?: HTMLElement; top: number } | undefined>(undefined)
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -1396,9 +1401,27 @@ function DocumentsView({
|
||||
.includes(normalized)
|
||||
)
|
||||
}, [documents, locale, query])
|
||||
const viewedDocument = useMemo(() => documents.find((document) => document.id === viewedDocumentId), [documents, viewedDocumentId])
|
||||
|
||||
return (
|
||||
<div className="knowledge-documents">
|
||||
{viewedDocument?.resultId && <section aria-label={`解析结果:${viewedDocument.name}`}>
|
||||
<div className="document-result-actions">
|
||||
<button ref={previewBackRef} type="button" className="secondary-button" onClick={() => {
|
||||
setViewedDocumentId(undefined)
|
||||
requestAnimationFrame(() => {
|
||||
previewOriginRef.current?.trigger.focus()
|
||||
if (previewOriginRef.current?.scroll) previewOriginRef.current.scroll.scrollTop = previewOriginRef.current.top
|
||||
})
|
||||
}}>返回文档列表</button>
|
||||
<strong>{library.name} · {viewedDocument.name}</strong><ScopeBadge scope={{ kind: 'global' }} />
|
||||
<button type="button" className="secondary-button" disabled={Boolean(pending)} onClick={() => void run(`retry:${viewedDocument.id}`, () => onRebuildDocument(library.id, viewedDocument.id))}>使用当前设置重新解析</button>
|
||||
</div>
|
||||
{pending && <p role="status">正在重新解析,当前显示上次结果</p>}
|
||||
{error && <p role="alert">本次解析失败,上次结果仍可用:{error}</p>}
|
||||
<DocumentResultPreview key={viewedDocument.resultId} resultId={viewedDocument.resultId} allowAddImages />
|
||||
</section>}
|
||||
<div hidden={Boolean(viewedDocument?.resultId)}>
|
||||
<section aria-labelledby="sources-title">
|
||||
<div
|
||||
className="knowledge-documents__section-heading"
|
||||
@@ -1862,6 +1885,13 @@ function DocumentsView({
|
||||
</td>
|
||||
<td>
|
||||
<div className="knowledge-document-actions">
|
||||
{document.resultId && <button type="button" className="secondary-button" onClick={(event) => {
|
||||
const scroll = event.currentTarget.closest<HTMLElement>('.workspace-panel-scroll') ?? undefined
|
||||
previewOriginRef.current = { trigger: event.currentTarget, scroll, top: scroll?.scrollTop ?? 0 }
|
||||
setViewedDocumentId(document.id)
|
||||
requestAnimationFrame(() => { previewBackRef.current?.focus(); if (scroll) scroll.scrollTop = 0 })
|
||||
}}>查看解析结果</button>}
|
||||
{document.resultId && document.status !== 'failed' && <button type="button" className="secondary-button" disabled={Boolean(activeTask) || pending === `retry:${document.id}`} onClick={() => void run(`retry:${document.id}`, () => onRebuildDocument(library.id, document.id))}>使用当前设置重新解析</button>}
|
||||
{relatedTasks.length > 0 && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
@@ -1941,6 +1971,7 @@ function DocumentsView({
|
||||
storageMode={library.storageMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ export default function LocalInferencePage({ onClose, enabled = true, restoreFoc
|
||||
{service.error && <p className="local-inference-error">{service.error}</p>}
|
||||
</div>
|
||||
<div className="local-inference-actions">
|
||||
{service.id === 'ocr' && <button className="secondary-button" type="button" disabled={busy} onClick={() => void window.goodbuddy.localInference.openSettings('document-parsing').catch((reason: unknown) => setOperationError(String(reason)))}>文档解析设置</button>}
|
||||
{service.actions.map((action) => <button type="button" className="secondary-button" key={action} disabled={busy || refreshing || Boolean(error)} onClick={() => selectAction(service, action)}>{actionLabels[action]}</button>)}
|
||||
{service.id === 'ocr' && <button type="button" className="secondary-button" disabled={busy || refreshing || Boolean(error) || ocr.busy || service.state === 'unavailable'} onClick={() => selectAction(service, ocr.loaded ? 'release' : 'load')}>{ocr.loaded ? '释放模型' : '加载模型'}</button>}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('MagicCanvasThumbnail cleanup', () => {
|
||||
.mockReturnValueOnce(second as unknown as ReturnType<typeof mountCanvasNote>)
|
||||
const view = render(<MagicCanvasThumbnail content={content} />)
|
||||
await waitFor(() => expect(first.capturePages).toHaveBeenCalledOnce())
|
||||
expect(vi.mocked(mountCanvasNote).mock.calls[0]?.[0]).toHaveClass('magic-canvas-editor')
|
||||
if (action === 'unmount') view.unmount()
|
||||
else view.rerender(<MagicCanvasThumbnail content={structuredClone(content)} />)
|
||||
expect(first.destroy).toHaveBeenCalledOnce()
|
||||
|
||||
@@ -19,7 +19,7 @@ export function MagicCanvasThumbnail({ content }: { content: MagicNoteCanvasCont
|
||||
const cached = images.get(content)
|
||||
if (cached) { setResult({ content, url: cached }); return }
|
||||
const surface = document.createElement('div')
|
||||
surface.className = 'magic-note-thumbnail-renderer'
|
||||
surface.className = 'magic-note-thumbnail-renderer magic-canvas-editor'
|
||||
surface.setAttribute('aria-hidden', 'true')
|
||||
surface.inert = true
|
||||
document.body.append(surface)
|
||||
|
||||
@@ -5,6 +5,7 @@ import i18n from './i18n'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
import { defaultLocalToolEnvironmentSettings, defaultApplicationNavigation, type ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||
import type { MagicNoteContent as NoteContent, MagicNoteCanvasContent, MagicNoteDetail, MagicNoteRichContent, MagicNotesSnapshot, MagicTodoItem } from '../../shared/magic-notes-contracts'
|
||||
import { magicNoteAnalyzeSchema, magicNoteDraftAnalyzeSchema, magicTodoIdSchema } from '../../shared/magic-notes-contracts'
|
||||
import type { MagicCanvasEditorProps, MagicCanvasEditorHandle } from './MagicCanvasEditor'
|
||||
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
|
||||
|
||||
@@ -137,7 +138,10 @@ beforeEach(async () => {
|
||||
getSettings.mockResolvedValue(settings)
|
||||
getRuntime.mockResolvedValue({ supportsImageInput: true, defaultModelProfileId: 'default', modelProfiles: [{ id: 'default', supportsImageInput: true }] })
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: {
|
||||
magicNotes: { list, get, listTodos, create, remove, update, createEntry, updateEntry, removeEntry, analyze, updateTodo, analyzeTodo, analyzeDraft,
|
||||
magicNotes: { list, get, listTodos, create, remove, update, createEntry, updateEntry, removeEntry, updateTodo,
|
||||
analyze: (...[entryId, options]: Parameters<typeof analyze>) => { magicNoteAnalyzeSchema.parse({ entryId, ...options }); return analyze(entryId, options) },
|
||||
analyzeTodo: (...[todoId, options]: Parameters<typeof analyzeTodo>) => { magicTodoIdSchema.parse({ todoId, ...options }); return analyzeTodo(todoId, options) },
|
||||
analyzeDraft: (...[content, options]: Parameters<typeof analyzeDraft>) => { magicNoteDraftAnalyzeSchema.parse({ content, ...options }); return analyzeDraft(content, options) },
|
||||
onChanged: (listener: () => void) => { changeListener = listener; return unsubscribeChanges },
|
||||
onAnalysisEvent: (listener: NonNullable<typeof analysisListener>) => { analysisListener = listener; return vi.fn() }
|
||||
}, updates: { getSettings }, settings: { getRuntime }
|
||||
@@ -179,6 +183,61 @@ async function openTodo(): Promise<void> {
|
||||
}
|
||||
|
||||
describe('MagicNotesWorkspace overview navigation', () => {
|
||||
it('flushes pending canvas input before an App leave and retains it when continuing', async () => {
|
||||
let requestLeave: ((leave: () => void) => void) | undefined
|
||||
const leave = vi.fn()
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} onBeforeLeave={(requester) => { requestLeave = requester }} />)
|
||||
await openNote()
|
||||
fireEvent.click(screen.getByRole('button', { name: '画布' }))
|
||||
let finish!: (content: NoteContent) => void
|
||||
canvas.flush.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
|
||||
act(() => requestLeave!(leave))
|
||||
expect(leave).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
await act(async () => finish(canvasContent))
|
||||
expect(screen.getByRole('alertdialog')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
|
||||
expect(leave).not.toHaveBeenCalled()
|
||||
act(() => requestLeave!(leave))
|
||||
await screen.findByRole('alertdialog')
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃草稿并切换' }))
|
||||
expect(leave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves a title typed during AI analysis and saves it with the returned revision', async () => {
|
||||
getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: 'after-save-manual' })
|
||||
let finish!: (value: MagicNoteDetail) => void
|
||||
analyze.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新分析' }))
|
||||
await waitFor(() => expect(analyze).toHaveBeenCalled())
|
||||
const title = screen.getByLabelText('笔记标题')
|
||||
fireEvent.change(title, { target: { value: 'AI 期间的新标题' } })
|
||||
fireEvent.blur(title)
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
await act(async () => finish({ ...detail, revision: 2 }))
|
||||
expect(title).toHaveValue('AI 期间的新标题')
|
||||
fireEvent.blur(title)
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith({ noteId, title: 'AI 期间的新标题', expectedRevision: 2 }))
|
||||
})
|
||||
|
||||
it('acknowledges only the submitted title while retaining subsequent input', async () => {
|
||||
let finish!: (value: MagicNoteDetail) => void
|
||||
update.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
const title = screen.getByLabelText('笔记标题')
|
||||
fireEvent.change(title, { target: { value: ' First title ' } })
|
||||
fireEvent.blur(title)
|
||||
await waitFor(() => expect(update).toHaveBeenCalled())
|
||||
fireEvent.change(title, { target: { value: 'Later title' } })
|
||||
await act(async () => finish({ ...detail, title: 'First title', revision: 2 }))
|
||||
expect(title).toHaveValue('Later title')
|
||||
fireEvent.blur(title)
|
||||
await waitFor(() => expect(update).toHaveBeenLastCalledWith({ noteId, title: 'Later title', expectedRevision: 2 }))
|
||||
})
|
||||
|
||||
it('keeps the default top draft while navigating thumbnails and guards leaving', async () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
@@ -745,8 +804,8 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
showAi()
|
||||
expect(screen.queryByRole('group', { name: 'AI 评论形式' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox', { name: 'AI 评论方向' })).toBeVisible()
|
||||
fireEvent.keyDown(screen.getByRole('separator'), { key: 'End' })
|
||||
const width = screen.getByRole('separator').getAttribute('aria-valuenow')
|
||||
fireEvent.keyDown(screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' }), { key: 'End' })
|
||||
const width = screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' }).getAttribute('aria-valuenow')
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '隐藏 AI 评论' })[0]!)
|
||||
expect(screen.queryByRole('complementary', { name: 'AI 评论' })).not.toBeInTheDocument()
|
||||
back()
|
||||
@@ -756,8 +815,8 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
fireEvent.click(screen.getByRole('button', { name: '显示 AI 评论' }))
|
||||
expect(screen.getByRole('separator')).toHaveAttribute('aria-valuenow', width)
|
||||
expect(JSON.parse(localStorage.getItem('goodbuddy.magic-notes-layout.v1')!)).toEqual({ indexPaneOpen: true, aiPaneOpen: true, aiPaneWidth: Number(width) })
|
||||
expect(screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' })).toHaveAttribute('aria-valuenow', width)
|
||||
expect(JSON.parse(localStorage.getItem('goodbuddy.magic-notes-layout.v1')!)).toEqual({ indexPaneOpen: true, indexPaneWidth: 168, aiPaneOpen: true, aiPaneWidth: Number(width) })
|
||||
})
|
||||
|
||||
it('resizes AI with pointer and keyboard and disables resizing in narrow layouts', async () => {
|
||||
@@ -788,6 +847,57 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
expect(screen.getByRole('separator')).toHaveAttribute('tabindex', '-1')
|
||||
})
|
||||
|
||||
it('resizes the record column, clamps against AI, and preserves preferences across hidden and narrow layouts', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
const view = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
const separator = screen.getByRole('separator', { name: '调整记录索引宽度' })
|
||||
const layout = separator.closest('.magic-notes-layout') as HTMLElement
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '168')
|
||||
expect(separator).toHaveAttribute('aria-controls', 'magic-notes-index')
|
||||
Object.defineProperties(separator, { setPointerCapture: { value: vi.fn() }, hasPointerCapture: { value: () => true }, releasePointerCapture: { value: vi.fn() } })
|
||||
fireEvent.pointerDown(separator, { button: 0, clientX: 168, pointerId: 7 })
|
||||
fireEvent.pointerMove(separator, { clientX: 900, pointerId: 8 })
|
||||
expect(layout.style.getPropertyValue('--magic-notes-index-width')).toBe('168px')
|
||||
fireEvent.pointerMove(separator, { clientX: 900, pointerId: 7 })
|
||||
expect(layout.style.getPropertyValue('--magic-notes-index-width')).toBe('320px')
|
||||
fireEvent.pointerUp(separator, { pointerId: 7 })
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '320')
|
||||
fireEvent.pointerDown(separator, { button: 0, clientX: 320, pointerId: 9 })
|
||||
fireEvent.pointerMove(separator, { clientX: -100, pointerId: 9 })
|
||||
expect(layout.style.getPropertyValue('--magic-notes-index-width')).toBe('140px')
|
||||
fireEvent.pointerCancel(separator, { pointerId: 9 })
|
||||
expect(layout).not.toHaveClass('magic-notes-layout--resizing')
|
||||
fireEvent.keyDown(separator, { key: 'Home' })
|
||||
fireEvent.keyDown(separator, { key: 'ArrowLeft' })
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '140')
|
||||
fireEvent.keyDown(separator, { key: 'ArrowRight' })
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '156')
|
||||
fireEvent.keyDown(separator, { key: 'End' })
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '320')
|
||||
fireEvent.click(document.getElementById('magic-notes-index-toggle')!)
|
||||
expect(screen.queryByRole('separator', { name: '调整记录索引宽度' })).not.toBeInTheDocument()
|
||||
expect(layout).toHaveClass('magic-notes-layout--index-hidden')
|
||||
fireEvent.click(document.getElementById('magic-notes-index-toggle')!)
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(820)
|
||||
back()
|
||||
await openNote()
|
||||
const index = screen.getByRole('separator', { name: '调整记录索引宽度' })
|
||||
const ai = screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' })
|
||||
expect(Number(index.getAttribute('aria-valuenow')) + Number(ai.getAttribute('aria-valuenow')) + 18 + 300).toBeLessThanOrEqual(818)
|
||||
expect(JSON.parse(localStorage.getItem('goodbuddy.magic-notes-layout.v1')!).indexPaneWidth).toBe(320)
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(700)
|
||||
back()
|
||||
await openNote()
|
||||
fireEvent.click(document.getElementById('magic-notes-index-toggle')!)
|
||||
expect(screen.queryByRole('separator', { name: '调整记录索引宽度' })).not.toBeInTheDocument()
|
||||
view.unmount()
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
expect(screen.getByRole('separator', { name: '调整记录索引宽度' })).toHaveAttribute('aria-valuenow', '320')
|
||||
})
|
||||
|
||||
it('shows loading then the AI empty state for a note without entries', async () => {
|
||||
let resolveDetail!: (value: MagicNoteDetail) => void
|
||||
get.mockImplementationOnce(() => new Promise((resolve) => { resolveDetail = resolve }))
|
||||
@@ -1323,6 +1433,88 @@ describe('MagicNotesWorkspace canvas integration', () => {
|
||||
expect(canvas.flush.mock.invocationCallOrder[0]).toBeLessThan(createEntry.mock.invocationCallOrder[0]!)
|
||||
})
|
||||
|
||||
it.each(['create', 'edit', 'entry', 'todo', 'draft'] as const)('sends the captured source revision through the production schema for %s analysis', async (action) => {
|
||||
getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: 'after-save-auto' })
|
||||
const source = { ...detail.entries[0]!, content: canvasContent, revision: 7 }
|
||||
get.mockResolvedValue({ ...detail, revision: 20, entries: [source] })
|
||||
const changed = { ...canvasContent, flow: { ops: [{ insert: 'Changed body\n' }] } }
|
||||
canvas.flush.mockResolvedValue(changed)
|
||||
const savedId = action === 'create' ? createdEntryId : entryId
|
||||
const saved = { ...detail, revision: 30, entries: [
|
||||
{ ...source, id: savedId, content: changed, revision: 11 },
|
||||
{ ...source, id: secondNoteId, revision: 91 }
|
||||
] }
|
||||
createEntry.mockResolvedValue({ ...saved, createdEntryId: savedId })
|
||||
updateEntry.mockResolvedValue(saved)
|
||||
analyze.mockResolvedValue(saved)
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
if (action === 'todo') {
|
||||
await openTodo()
|
||||
await screen.findByTestId('canvas-content')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AI 分析' }))
|
||||
} else {
|
||||
await openNote()
|
||||
if (action === 'entry') fireEvent.click(screen.getByRole('button', { name: '重新分析' }))
|
||||
else if (action === 'edit') {
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开编辑画布' }))
|
||||
await screen.findByTestId('canvas-editor')
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
|
||||
} else {
|
||||
fireEvent.click(screen.getByRole('button', { name: '画布' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: action === 'draft' ? '分析画布草稿' : '保存记录' }))
|
||||
}
|
||||
}
|
||||
const call = action === 'todo' ? analyzeTodo : action === 'draft' ? analyzeDraft : analyze
|
||||
await waitFor(() => expect(call).toHaveBeenCalledOnce())
|
||||
expect(call.mock.calls[0]![1]).toEqual({
|
||||
requestId: expect.any(String), direction: 'general', format: 'combined', canvasImages,
|
||||
...(action === 'todo' ? { sourceEntryRevision: 7 } : action === 'draft' ? {} : { expectedRevision: action === 'entry' ? 7 : 11 })
|
||||
})
|
||||
if (action !== 'todo' && action !== 'draft') expect(analyze.mock.calls[0]![0]).toBe(savedId)
|
||||
})
|
||||
|
||||
it.each(['entry', 'todo'] as const)('keeps the original %s revision when an external refresh finishes during capture', async (action) => {
|
||||
getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: 'after-save-manual' })
|
||||
const source = { ...detail, entries: [{ ...detail.entries[0]!, content: canvasContent, revision: 7 }] }
|
||||
get.mockResolvedValue(source)
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
if (action === 'todo') await openTodo()
|
||||
else await openNote()
|
||||
await screen.findByTestId('canvas-content')
|
||||
let finishRefresh!: (detail: MagicNoteDetail) => void
|
||||
get.mockImplementationOnce(() => new Promise(resolve => { finishRefresh = resolve }))
|
||||
listTodos.mockResolvedValue({ todos: [{ ...todo }, { ...otherTodo }] })
|
||||
act(() => changeListener?.())
|
||||
await waitFor(() => expect(finishRefresh).toBeDefined())
|
||||
let finishCapture!: (images: typeof canvasImages) => void
|
||||
canvas.viewCapture.mockImplementationOnce(() => new Promise(resolve => { finishCapture = resolve }))
|
||||
fireEvent.click(screen.getByRole('button', { name: action === 'todo' ? 'AI 分析' : '重新分析' }))
|
||||
await waitFor(() => expect(finishCapture).toBeDefined())
|
||||
await act(async () => finishRefresh({ ...source, revision: 50, entries: [{ ...source.entries[0]!, revision: 19 }] }))
|
||||
await act(async () => finishCapture(canvasImages))
|
||||
const call = action === 'todo' ? analyzeTodo : analyze
|
||||
await waitFor(() => expect(call).toHaveBeenCalledOnce())
|
||||
expect(call.mock.calls[0]![1]).toEqual(expect.objectContaining({ canvasImages,
|
||||
...(action === 'todo' ? { sourceEntryRevision: 7 } : { expectedRevision: 7 })
|
||||
}))
|
||||
})
|
||||
|
||||
it.each(['text', 'text-fallback'] as const)('re-analyzes %s comments when a blank page changes text page numbers', async (inputMode) => {
|
||||
getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: 'after-save-auto' })
|
||||
get.mockResolvedValue({ ...detail, entries: [{ ...detail.entries[0]!, content: canvasContent,
|
||||
comments: [{ ...detail.entries[0]!.comments[0]!, inputMode }] }] })
|
||||
canvas.flush.mockResolvedValue({ ...canvasContent, pages: [
|
||||
{ ...canvasContent.pages[0]!, id: 'blank', objects: [] }, ...canvasContent.pages
|
||||
] })
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开编辑画布' }))
|
||||
await screen.findByTestId('canvas-editor')
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
|
||||
await waitFor(() => expect(analyze).toHaveBeenCalledOnce())
|
||||
expect(canvas.capture).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('captures before resetting the composer in after-save-auto mode', async () => {
|
||||
getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: 'after-save-auto' })
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
@@ -1335,7 +1527,7 @@ describe('MagicNotesWorkspace canvas integration', () => {
|
||||
return canvasImages
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
|
||||
await waitFor(() => expect(analyze).toHaveBeenCalledWith(createdEntryId, expect.objectContaining({ canvasImages })))
|
||||
await waitFor(() => expect(analyze).toHaveBeenCalledWith(createdEntryId, expect.objectContaining({ canvasImages, expectedRevision: 1 })))
|
||||
expect(canvas.flush.mock.invocationCallOrder[0]).toBeLessThan(canvas.capture.mock.invocationCallOrder[0]!)
|
||||
expect(canvas.capture.mock.invocationCallOrder[0]).toBeLessThan(createEntry.mock.invocationCallOrder[0]!)
|
||||
expect(screen.queryByTestId('canvas-editor')).not.toBe(editor)
|
||||
@@ -1391,7 +1583,7 @@ describe('MagicNotesWorkspace canvas integration', () => {
|
||||
await openNote()
|
||||
expect(screen.getByTestId('canvas-content')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新分析' }))
|
||||
await waitFor(() => expect(analyze).toHaveBeenCalledWith(entryId, expect.objectContaining({ canvasImages })))
|
||||
await waitFor(() => expect(analyze).toHaveBeenCalledWith(entryId, expect.objectContaining({ canvasImages, expectedRevision: 1 })))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '展开编辑画布' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开编辑画布' }))
|
||||
await screen.findByTestId('canvas-editor')
|
||||
@@ -1412,7 +1604,7 @@ describe('MagicNotesWorkspace canvas integration', () => {
|
||||
await openTodo()
|
||||
await screen.findByTestId('canvas-content')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AI 分析' }))
|
||||
await waitFor(() => expect(analyzeTodo).toHaveBeenCalledWith(todo.id, expect.objectContaining({ canvasImages })))
|
||||
await waitFor(() => expect(analyzeTodo).toHaveBeenCalledWith(todo.id, expect.objectContaining({ canvasImages, sourceEntryRevision: 1 })))
|
||||
})
|
||||
|
||||
it('protects an edited canvas on cancellation and retains it after update failure', async () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type {
|
||||
MagicNoteAnalysisOptions,
|
||||
MagicNoteEntryAnalysisOptions,
|
||||
MagicNoteCommentDirection,
|
||||
MagicNoteCommentFormat,
|
||||
MagicNoteDraftAnalysis,
|
||||
@@ -42,10 +43,11 @@ import type {
|
||||
MagicNoteEntry,
|
||||
MagicNoteContent as NoteContent,
|
||||
MagicNoteSummary,
|
||||
MagicTodoItem
|
||||
MagicTodoItem,
|
||||
MagicTodoAnalysisOptions
|
||||
} from '../../shared/magic-notes-contracts'
|
||||
import type { ApplicationSettings, MagicNoteCommentMode } from '../../shared/application-settings-contracts'
|
||||
import { magicNoteCanvasPlainText } from '../../shared/magic-note-canvas-text'
|
||||
import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text'
|
||||
import { MagicNoteContent } from './MagicNoteContent'
|
||||
import { MagicNoteEditor } from './MagicNoteEditor'
|
||||
import { MagicCanvasEditor, canvasHasContent, type MagicCanvasEditorHandle } from './MagicCanvasEditor'
|
||||
@@ -64,6 +66,7 @@ import {
|
||||
export type MagicNotesWorkspaceProps = {
|
||||
applicationSettings?: ApplicationSettings
|
||||
onNotify: (notification: AppNotificationInput) => void
|
||||
onBeforeLeave?: (requester: ((leave: () => void) => void) | undefined) => void
|
||||
}
|
||||
|
||||
type LibraryView = 'notes' | 'todos'
|
||||
@@ -75,6 +78,7 @@ type ValidationTarget =
|
||||
| 'new-entry'
|
||||
| 'edit-entry'
|
||||
type DraftSwitchTarget =
|
||||
| { kind: 'leave'; leave: () => void }
|
||||
| { kind: 'select-entry'; entry: MagicNoteEntry }
|
||||
| { kind: 'entry-type'; value: 'text' | 'canvas' }
|
||||
| { kind: 'cancel-edit' }
|
||||
@@ -87,6 +91,9 @@ type DraftSwitchTarget =
|
||||
const defaultAiPaneWidth = 280
|
||||
const minimumAiPaneWidth = 240
|
||||
const maximumAiPaneWidth = 520
|
||||
const defaultIndexPaneWidth = 168
|
||||
const minimumIndexPaneWidth = 140
|
||||
const maximumIndexPaneWidth = 320
|
||||
const minimumMagicNotesEditorWidth = 300
|
||||
const magicNotesResizeHandleWidth = 9
|
||||
const magicNotesPaneKeyboardResizeStep = 16
|
||||
@@ -95,6 +102,7 @@ const magicNotesLayoutStorageKey =
|
||||
|
||||
type MagicNotesLayoutPreferences = {
|
||||
indexPaneOpen: boolean
|
||||
indexPaneWidth: number
|
||||
aiPaneOpen: boolean
|
||||
aiPaneWidth: number
|
||||
}
|
||||
@@ -102,6 +110,7 @@ type MagicNotesLayoutPreferences = {
|
||||
function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences {
|
||||
const defaults = {
|
||||
indexPaneOpen: true,
|
||||
indexPaneWidth: defaultIndexPaneWidth,
|
||||
aiPaneOpen: true,
|
||||
aiPaneWidth: defaultAiPaneWidth
|
||||
}
|
||||
@@ -115,6 +124,9 @@ function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences {
|
||||
>
|
||||
return {
|
||||
indexPaneOpen: parsed.indexPaneOpen !== false,
|
||||
indexPaneWidth: typeof parsed.indexPaneWidth === 'number' && Number.isFinite(parsed.indexPaneWidth)
|
||||
? Math.min(maximumIndexPaneWidth, Math.max(minimumIndexPaneWidth, parsed.indexPaneWidth))
|
||||
: defaults.indexPaneWidth,
|
||||
aiPaneOpen: parsed.aiPaneOpen !== false,
|
||||
aiPaneWidth:
|
||||
typeof parsed.aiPaneWidth === 'number' &&
|
||||
@@ -148,7 +160,7 @@ type MagicNotesPaneWidthLimits = {
|
||||
maximum: number
|
||||
}
|
||||
|
||||
function getAiPaneWidthLimits(layoutWidth: number): MagicNotesPaneWidthLimits {
|
||||
function getAiPaneWidthLimits(layoutWidth: number, indexWidth: number): MagicNotesPaneWidthLimits {
|
||||
return {
|
||||
minimum: minimumAiPaneWidth,
|
||||
maximum: Math.max(
|
||||
@@ -156,7 +168,7 @@ function getAiPaneWidthLimits(layoutWidth: number): MagicNotesPaneWidthLimits {
|
||||
Math.min(
|
||||
maximumAiPaneWidth,
|
||||
layoutWidth -
|
||||
168 -
|
||||
indexWidth -
|
||||
minimumMagicNotesEditorWidth -
|
||||
magicNotesResizeHandleWidth
|
||||
)
|
||||
@@ -173,16 +185,6 @@ function clampMagicNotesPaneWidth(
|
||||
)
|
||||
}
|
||||
|
||||
function clampAiPaneWidth(
|
||||
width: number,
|
||||
layoutWidth: number
|
||||
): number {
|
||||
return clampMagicNotesPaneWidth(
|
||||
width,
|
||||
getAiPaneWidthLimits(layoutWidth)
|
||||
)
|
||||
}
|
||||
|
||||
function noteSummary(note: MagicNoteDetail): MagicNoteSummary {
|
||||
return {
|
||||
id: note.id,
|
||||
@@ -342,6 +344,7 @@ function TodoListItem({
|
||||
|
||||
export function MagicNotesWorkspace({
|
||||
onNotify,
|
||||
onBeforeLeave,
|
||||
applicationSettings
|
||||
}: MagicNotesWorkspaceProps): React.JSX.Element {
|
||||
const { i18n, t } = useTranslation('magicNotes')
|
||||
@@ -461,8 +464,9 @@ export function MagicNotesWorkspace({
|
||||
initialLayoutPreferences.aiPaneWidth
|
||||
)
|
||||
const [indexPaneOpen, setIndexPaneOpen] = useState(initialLayoutPreferences.indexPaneOpen)
|
||||
const [indexPaneWidth, setIndexPaneWidth] = useState(initialLayoutPreferences.indexPaneWidth)
|
||||
const [narrowIndexOpen, setNarrowIndexOpen] = useState(false)
|
||||
const [aiPaneResizing, setAiPaneResizing] = useState(false)
|
||||
const [resizingPane, setResizingPane] = useState<'ai' | 'index'>()
|
||||
const [magicNotesLayoutWidth, setMagicNotesLayoutWidth] = useState(
|
||||
window.innerWidth
|
||||
)
|
||||
@@ -516,8 +520,7 @@ export function MagicNotesWorkspace({
|
||||
const draftAnalysisContextRef = useRef(0)
|
||||
const lastDraftAnalysisStartedAtRef = useRef(0)
|
||||
const magicNotesLayoutRef = useRef<HTMLDivElement>(null)
|
||||
const liveAiPaneWidthRef = useRef(aiPaneWidth)
|
||||
const aiResizePointerIdRef = useRef<number | undefined>(undefined)
|
||||
const paneResizeRef = useRef<{ pane: 'ai' | 'index'; pointerId: number; width: number } | undefined>(undefined)
|
||||
const composerRef = useRef<HTMLDivElement>(null)
|
||||
const continueEditingRef = useRef<HTMLButtonElement>(null)
|
||||
const discardDraftRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -577,71 +580,6 @@ export function MagicNotesWorkspace({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resizeAiPaneFromClientX = useCallback(
|
||||
(clientX: number, commit: boolean): void => {
|
||||
const bounds = getLayoutBounds()
|
||||
const width = clampAiPaneWidth(
|
||||
bounds.right - clientX,
|
||||
bounds.width
|
||||
)
|
||||
liveAiPaneWidthRef.current = width
|
||||
if (commit) {
|
||||
setAiPaneWidth(width)
|
||||
return
|
||||
}
|
||||
magicNotesLayoutRef.current?.style.setProperty(
|
||||
'--magic-notes-ai-width',
|
||||
`${width}px`
|
||||
)
|
||||
},
|
||||
[getLayoutBounds]
|
||||
)
|
||||
|
||||
const finishAiPaneResize = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (aiResizePointerIdRef.current !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
aiResizePointerIdRef.current = undefined
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
setAiPaneWidth(liveAiPaneWidthRef.current)
|
||||
setAiPaneResizing(false)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const resizeAiPaneWithKeyboard = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
const bounds = getLayoutBounds()
|
||||
const limits = getAiPaneWidthLimits(
|
||||
bounds.width
|
||||
)
|
||||
const nextWidth =
|
||||
event.key === 'Home'
|
||||
? limits.minimum
|
||||
: event.key === 'End'
|
||||
? limits.maximum
|
||||
: event.key === 'ArrowLeft'
|
||||
? aiPaneWidth + magicNotesPaneKeyboardResizeStep
|
||||
: event.key === 'ArrowRight'
|
||||
? aiPaneWidth - magicNotesPaneKeyboardResizeStep
|
||||
: undefined
|
||||
if (nextWidth === undefined) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const width = clampAiPaneWidth(
|
||||
nextWidth,
|
||||
bounds.width
|
||||
)
|
||||
liveAiPaneWidthRef.current = width
|
||||
setAiPaneWidth(width)
|
||||
},
|
||||
[aiPaneWidth, getLayoutBounds]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
window.goodbuddy.magicNotes.onAnalysisEvent((event) => {
|
||||
@@ -660,26 +598,17 @@ export function MagicNotesWorkspace({
|
||||
useEffect(() => {
|
||||
persistMagicNotesLayoutPreferences({
|
||||
indexPaneOpen,
|
||||
indexPaneWidth,
|
||||
aiPaneOpen,
|
||||
aiPaneWidth
|
||||
})
|
||||
}, [indexPaneOpen, aiPaneOpen, aiPaneWidth])
|
||||
}, [indexPaneOpen, indexPaneWidth, aiPaneOpen, aiPaneWidth])
|
||||
|
||||
useEffect(() => {
|
||||
const layout = magicNotesLayoutRef.current
|
||||
const updateLayoutWidth = (): void => {
|
||||
const width = layout?.getBoundingClientRect().width || window.innerWidth
|
||||
setMagicNotesLayoutWidth(width)
|
||||
if (width > 800) {
|
||||
setAiPaneWidth((current) => {
|
||||
const next = clampAiPaneWidth(
|
||||
current,
|
||||
width
|
||||
)
|
||||
liveAiPaneWidthRef.current = next
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
updateLayoutWidth()
|
||||
const observer =
|
||||
@@ -870,9 +799,11 @@ export function MagicNotesWorkspace({
|
||||
}, [])
|
||||
|
||||
const applyDetail = useCallback(
|
||||
(next: MagicNoteDetail) => {
|
||||
(next: MagicNoteDetail, submittedTitle?: string) => {
|
||||
const previous = refreshContextRef.current.detail
|
||||
setDetail(next)
|
||||
setTitleDraft(next.title)
|
||||
setTitleDraft((current) => previous?.id !== next.id ||
|
||||
current === (submittedTitle ?? previous.title) ? next.title : current)
|
||||
applyNoteSummary(next)
|
||||
},
|
||||
[applyNoteSummary]
|
||||
@@ -1042,6 +973,13 @@ export function MagicNotesWorkspace({
|
||||
(target: DraftSwitchTarget): void => {
|
||||
setPendingDraftSwitch(undefined)
|
||||
setValidation(undefined)
|
||||
if (target.kind === 'leave') {
|
||||
discardComposerDraft()
|
||||
discardEditingDraft()
|
||||
setTitleDraft(detail?.title ?? '')
|
||||
target.leave()
|
||||
return
|
||||
}
|
||||
if (target.kind === 'select-entry') {
|
||||
setSelectedEntryId(target.entry.id)
|
||||
setNarrowIndexOpen(false)
|
||||
@@ -1169,10 +1107,11 @@ export function MagicNotesWorkspace({
|
||||
return
|
||||
}
|
||||
const wouldClearComposer = target.kind !== 'cancel-edit'
|
||||
const current = refreshContextRef.current
|
||||
if (
|
||||
(wouldClearComposer && hasContent(composerContentRef.current)) ||
|
||||
(target.kind !== 'entry-type' && hasDirtyEditingDraft()) ||
|
||||
(target.kind === 'overview' && detail && titleDraft !== detail.title)
|
||||
((target.kind === 'overview' || target.kind === 'leave') && current.detail && current.titleDraft !== current.detail.title)
|
||||
) {
|
||||
setPendingDraftSwitch(target)
|
||||
return
|
||||
@@ -1181,9 +1120,7 @@ export function MagicNotesWorkspace({
|
||||
},
|
||||
[
|
||||
editingEntry?.id,
|
||||
detail,
|
||||
detailView,
|
||||
titleDraft,
|
||||
notifyInfo,
|
||||
hasDirtyEditingDraft,
|
||||
libraryView,
|
||||
@@ -1193,6 +1130,11 @@ export function MagicNotesWorkspace({
|
||||
]
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
onBeforeLeave?.((leave) => { void requestDraftSwitch({ kind: 'leave', leave }) })
|
||||
return () => onBeforeLeave?.(undefined)
|
||||
}, [onBeforeLeave, requestDraftSwitch])
|
||||
|
||||
const continueEditing = useCallback((): void => {
|
||||
setPendingDraftSwitch(undefined)
|
||||
requestAnimationFrame(() => {
|
||||
@@ -1563,13 +1505,90 @@ export function MagicNotesWorkspace({
|
||||
}, [detail, editingEntry])
|
||||
const isNarrowLayout = magicNotesLayoutWidth <= 800
|
||||
const indexExpanded = isNarrowLayout ? narrowIndexOpen : indexPaneOpen
|
||||
// Clamp displayed widths without replacing the user's saved desktop preferences.
|
||||
const layoutInnerWidth = Math.max(0, magicNotesLayoutWidth - 2)
|
||||
const displayedIndexWidth = clampMagicNotesPaneWidth(indexPaneWidth, {
|
||||
minimum: minimumIndexPaneWidth,
|
||||
maximum: Math.max(minimumIndexPaneWidth, Math.min(maximumIndexPaneWidth,
|
||||
layoutInnerWidth - minimumMagicNotesEditorWidth - magicNotesResizeHandleWidth -
|
||||
(aiPaneOpen ? minimumAiPaneWidth + magicNotesResizeHandleWidth : 0)))
|
||||
})
|
||||
const aiPaneWidthLimits = getAiPaneWidthLimits(
|
||||
magicNotesLayoutWidth
|
||||
layoutInnerWidth, indexPaneOpen ? displayedIndexWidth + magicNotesResizeHandleWidth : 0
|
||||
)
|
||||
const canResizeAiPane =
|
||||
aiPaneOpen &&
|
||||
magicNotesLayoutWidth > 800 &&
|
||||
aiPaneWidthLimits.maximum > aiPaneWidthLimits.minimum
|
||||
const displayedAiWidth = clampMagicNotesPaneWidth(aiPaneWidth, aiPaneWidthLimits)
|
||||
const indexPaneWidthLimits = {
|
||||
minimum: minimumIndexPaneWidth,
|
||||
maximum: Math.max(minimumIndexPaneWidth, Math.min(maximumIndexPaneWidth,
|
||||
layoutInnerWidth - minimumMagicNotesEditorWidth - magicNotesResizeHandleWidth -
|
||||
(aiPaneOpen ? displayedAiWidth + magicNotesResizeHandleWidth : 0)))
|
||||
}
|
||||
const finishPaneResize = (event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
const resize = paneResizeRef.current
|
||||
if (!resize || resize.pointerId !== event.pointerId) return
|
||||
paneResizeRef.current = undefined
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
if (resize.pane === 'ai') setAiPaneWidth(resize.width)
|
||||
else setIndexPaneWidth(resize.width)
|
||||
setResizingPane(undefined)
|
||||
}
|
||||
const updatePanePointerWidth = (event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
const resize = paneResizeRef.current
|
||||
if (!resize || resize.pointerId !== event.pointerId) return
|
||||
const isAi = resize.pane === 'ai'
|
||||
const limits = isAi ? aiPaneWidthLimits : indexPaneWidthLimits
|
||||
if (isNarrowLayout) { finishPaneResize(event); return }
|
||||
const bounds = getLayoutBounds()
|
||||
resize.width = clampMagicNotesPaneWidth(isAi ? bounds.right - 1 - event.clientX : event.clientX - bounds.left - 1, limits)
|
||||
magicNotesLayoutRef.current?.style.setProperty(`--magic-notes-${resize.pane}-width`, `${resize.width}px`)
|
||||
event.currentTarget.setAttribute('aria-valuenow', String(resize.width))
|
||||
event.currentTarget.setAttribute('aria-valuetext', t(isAi ? 'accessibility.aiPaneWidth' : 'accessibility.indexPaneWidth', { width: resize.width }))
|
||||
}
|
||||
const startPaneResize = (pane: 'ai' | 'index', event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
const limits = pane === 'ai' ? aiPaneWidthLimits : indexPaneWidthLimits
|
||||
if (event.button !== 0 || isNarrowLayout || limits.maximum <= limits.minimum) return
|
||||
event.preventDefault()
|
||||
paneResizeRef.current = { pane, pointerId: event.pointerId, width: pane === 'ai' ? displayedAiWidth : displayedIndexWidth }
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
setResizingPane(pane)
|
||||
}
|
||||
const renderPaneSeparator = (pane: 'ai' | 'index'): React.JSX.Element => {
|
||||
const isAi = pane === 'ai'
|
||||
const limits = isAi ? aiPaneWidthLimits : indexPaneWidthLimits
|
||||
const width = isAi ? displayedAiWidth : displayedIndexWidth
|
||||
const enabled = !isNarrowLayout && limits.maximum > limits.minimum
|
||||
const setWidth = isAi ? setAiPaneWidth : setIndexPaneWidth
|
||||
return <div
|
||||
aria-controls={isAi ? 'magic-notes-ai-pane' : 'magic-notes-index'}
|
||||
aria-disabled={!enabled}
|
||||
aria-label={t(isAi ? 'accessibility.resizeAiPane' : 'accessibility.resizeIndexPane')}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={limits.maximum}
|
||||
aria-valuemin={limits.minimum}
|
||||
aria-valuenow={width}
|
||||
aria-valuetext={t(isAi ? 'accessibility.aiPaneWidth' : 'accessibility.indexPaneWidth', { width })}
|
||||
className={`magic-notes-pane-resize-handle magic-notes-${pane}-resize-handle`}
|
||||
onKeyDown={(event) => {
|
||||
if (!enabled) return
|
||||
const direction = isAi ? -1 : 1
|
||||
const next = event.key === 'Home' ? limits.minimum : event.key === 'End' ? limits.maximum
|
||||
: event.key === 'ArrowRight' ? width + direction * magicNotesPaneKeyboardResizeStep
|
||||
: event.key === 'ArrowLeft' ? width - direction * magicNotesPaneKeyboardResizeStep : undefined
|
||||
if (next === undefined) return
|
||||
event.preventDefault()
|
||||
setWidth(clampMagicNotesPaneWidth(next, limits))
|
||||
}}
|
||||
onLostPointerCapture={finishPaneResize}
|
||||
onPointerCancel={finishPaneResize}
|
||||
onPointerDown={(event) => startPaneResize(pane, event)}
|
||||
onPointerMove={updatePanePointerWidth}
|
||||
onPointerUp={finishPaneResize}
|
||||
role="separator"
|
||||
tabIndex={enabled ? 0 : -1}
|
||||
/>
|
||||
}
|
||||
|
||||
const submitCreateNote = async (): Promise<void> => {
|
||||
const title = newTitle.trim()
|
||||
@@ -1592,9 +1611,13 @@ export function MagicNotesWorkspace({
|
||||
if (!beginBusy(operation)) {
|
||||
return
|
||||
}
|
||||
let options: MagicNoteAnalysisOptions | undefined
|
||||
let options: MagicTodoAnalysisOptions | undefined
|
||||
try {
|
||||
options = await createAnalysisOptions(selectedTodoSourceEntry?.content.version === 2 ? todoCanvasRef.current : undefined)
|
||||
const source = selectedTodoSourceEntry
|
||||
options = {
|
||||
...await createAnalysisOptions(source?.content.version === 2 ? todoCanvasRef.current : undefined),
|
||||
sourceEntryRevision: source?.revision
|
||||
}
|
||||
setLiveAnalysis({ ...options, content: '' })
|
||||
applyTodo(
|
||||
await window.goodbuddy.magicNotes.analyzeTodo(todoId, options)
|
||||
@@ -1678,7 +1701,7 @@ export function MagicNotesWorkspace({
|
||||
title: titleDraft.trim(),
|
||||
expectedRevision: detail.revision
|
||||
})
|
||||
applyDetail(updated)
|
||||
applyDetail(updated, titleDraft)
|
||||
await reloadTodos()
|
||||
} catch (updateError) {
|
||||
notifyError(updateError)
|
||||
@@ -1745,7 +1768,9 @@ export function MagicNotesWorkspace({
|
||||
format: options.format
|
||||
})
|
||||
try {
|
||||
const analyzed = await window.goodbuddy.magicNotes.analyze(createdEntry.id, options)
|
||||
const analyzed = await window.goodbuddy.magicNotes.analyze(createdEntry.id, {
|
||||
...options, expectedRevision: createdEntry.revision
|
||||
})
|
||||
applyDetail(analyzed)
|
||||
notifySuccess(t('notifications.aiCommentAdded'))
|
||||
} catch (analysisError) {
|
||||
@@ -1786,7 +1811,7 @@ export function MagicNotesWorkspace({
|
||||
(currentEntry.comments.some((comment) => comment.inputMode === 'canvas-images')
|
||||
? !richContentEqual(editingContent, currentEntry.content)
|
||||
: currentEntry.comments.length > 0
|
||||
? magicNoteCanvasPlainText(editingContent) !== magicNoteCanvasPlainText(currentEntry.content)
|
||||
? magicNoteCanvasAnalysisText(editingContent) !== magicNoteCanvasAnalysisText(currentEntry.content)
|
||||
: analysisContentKey(editingContent) !== analysisContentKey(currentEntry.content)))
|
||||
let options: MagicNoteAnalysisOptions | undefined
|
||||
let analysisPreparationError: string | undefined
|
||||
@@ -1815,7 +1840,7 @@ export function MagicNotesWorkspace({
|
||||
} catch (refreshTodosError) {
|
||||
notifyError(refreshTodosError)
|
||||
}
|
||||
if (options) {
|
||||
if (options && savedEntry) {
|
||||
setLiveAnalysis({
|
||||
requestId: options.requestId,
|
||||
content: '',
|
||||
@@ -1823,7 +1848,9 @@ export function MagicNotesWorkspace({
|
||||
format: options.format
|
||||
})
|
||||
try {
|
||||
const analyzed = await window.goodbuddy.magicNotes.analyze(editingEntry.id, options)
|
||||
const analyzed = await window.goodbuddy.magicNotes.analyze(savedEntry.id, {
|
||||
...options, expectedRevision: savedEntry.revision
|
||||
})
|
||||
applyDetail(analyzed)
|
||||
const analyzedEntry = analyzed.entries.find((entry) => entry.id === editingEntry.id)
|
||||
setEditingEntry(analyzedEntry)
|
||||
@@ -1850,10 +1877,13 @@ export function MagicNotesWorkspace({
|
||||
if (!beginBusy(operation)) {
|
||||
return
|
||||
}
|
||||
let options: MagicNoteAnalysisOptions | undefined
|
||||
let options: MagicNoteEntryAnalysisOptions | undefined
|
||||
try {
|
||||
const isCanvas = detail?.entries.find((entry) => entry.id === entryId)?.content.version === 2
|
||||
options = await createAnalysisOptions(isCanvas ? canvasViewRefs.current.get(entryId) ?? null : undefined)
|
||||
const entry = detail?.entries.find((entry) => entry.id === entryId)
|
||||
options = {
|
||||
...await createAnalysisOptions(entry?.content.version === 2 ? canvasViewRefs.current.get(entryId) ?? null : undefined),
|
||||
expectedRevision: entry?.revision
|
||||
}
|
||||
setLiveAnalysis({ ...options, content: '' })
|
||||
applyDetail(
|
||||
await window.goodbuddy.magicNotes.analyze(entryId, options)
|
||||
@@ -2013,17 +2043,18 @@ export function MagicNotesWorkspace({
|
||||
}${
|
||||
indexExpanded ? '' : ' magic-notes-layout--index-hidden'
|
||||
}${
|
||||
(aiPaneResizing && canResizeAiPane)
|
||||
(resizingPane && !isNarrowLayout)
|
||||
? ' magic-notes-layout--resizing'
|
||||
: ''
|
||||
}${
|
||||
aiPaneResizing && canResizeAiPane
|
||||
? ' magic-notes-layout--ai-resizing'
|
||||
resizingPane && !isNarrowLayout
|
||||
? ` magic-notes-layout--${resizingPane}-resizing`
|
||||
: ''
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
'--magic-notes-ai-width': `${aiPaneWidth}px`
|
||||
'--magic-notes-ai-width': `${displayedAiWidth}px`,
|
||||
'--magic-notes-index-width': `${displayedIndexWidth}px`
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -2416,6 +2447,7 @@ export function MagicNotesWorkspace({
|
||||
</button>)}
|
||||
</nav>
|
||||
</aside>
|
||||
{indexExpanded && !isNarrowLayout && renderPaneSeparator('index')}
|
||||
<section
|
||||
aria-label={t('notes.streamLabel')}
|
||||
className="magic-notes-stream-pane"
|
||||
@@ -2842,54 +2874,7 @@ export function MagicNotesWorkspace({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{aiPaneOpen && (
|
||||
<div
|
||||
aria-controls="magic-notes-ai-pane"
|
||||
aria-disabled={!canResizeAiPane}
|
||||
aria-label={t('accessibility.resizeAiPane')}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={aiPaneWidthLimits.maximum}
|
||||
aria-valuemin={aiPaneWidthLimits.minimum}
|
||||
aria-valuenow={aiPaneWidth}
|
||||
aria-valuetext={t('accessibility.aiPaneWidth', {
|
||||
width: aiPaneWidth
|
||||
})}
|
||||
className="magic-notes-ai-resize-handle"
|
||||
onKeyDown={resizeAiPaneWithKeyboard}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (aiResizePointerIdRef.current === event.pointerId) {
|
||||
aiResizePointerIdRef.current = undefined
|
||||
setAiPaneWidth(liveAiPaneWidthRef.current)
|
||||
setAiPaneResizing(false)
|
||||
}
|
||||
}}
|
||||
onPointerCancel={finishAiPaneResize}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || !canResizeAiPane) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
aiResizePointerIdRef.current = event.pointerId
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
resizeAiPaneFromClientX(event.clientX, true)
|
||||
setAiPaneResizing(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (aiResizePointerIdRef.current !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
if (!canResizeAiPane) {
|
||||
finishAiPaneResize(event)
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
resizeAiPaneFromClientX(event.clientX, false)
|
||||
}}
|
||||
onPointerUp={finishAiPaneResize}
|
||||
role="separator"
|
||||
tabIndex={canResizeAiPane ? 0 : -1}
|
||||
/>
|
||||
)}
|
||||
{aiPaneOpen && renderPaneSeparator('ai')}
|
||||
|
||||
<aside
|
||||
aria-label={t('comments.paneLabel')}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ContextAttachment } from '../../shared/contracts'
|
||||
|
||||
export function PendingDocumentImports({ conversationId, refreshing, onBusyChange }: {
|
||||
conversationId: string; refreshing: boolean; onBusyChange: (busy: boolean) => void
|
||||
}): React.JSX.Element | null {
|
||||
const [items, setItems] = useState<ContextAttachment[]>([])
|
||||
const [revision, setRevision] = useState(0)
|
||||
const [operation, setOperation] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.goodbuddy.context.pendingParsing(conversationId).then((value) => { if (active) setItems(value) },
|
||||
(reason: unknown) => { if (active) setError(reason instanceof Error ? reason.message : '解析记录读取失败') })
|
||||
return () => { active = false }
|
||||
}, [conversationId, refreshing, revision])
|
||||
if (!items.length && !error) return null
|
||||
return <div className="settings-warning" aria-label="未完成的文件解析">
|
||||
{items.map((item) => <div key={item.id}>
|
||||
<strong>{item.name} · {item.parsingState === 'interrupted' ? '解析已中断' : '解析失败'}</strong>
|
||||
<p>{item.parsingError ?? '原文件已保存,可使用当前设置手动重试。'}</p>
|
||||
<button type="button" className="secondary-button" disabled={Boolean(operation)} onClick={() => {
|
||||
const id = crypto.randomUUID()
|
||||
setOperation(id); setError(''); onBusyChange(true)
|
||||
void window.goodbuddy.context.retryParsing(conversationId, item.id, id).then(() => setRevision((value) => value + 1))
|
||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '重试失败'))
|
||||
.finally(() => { setOperation(''); onBusyChange(false) })
|
||||
}}>使用当前设置重试</button>
|
||||
<button type="button" className="secondary-button" disabled={Boolean(operation)} onClick={() => {
|
||||
void window.goodbuddy.context.dismissParsing(conversationId, item.id).then(() => setRevision((value) => value + 1))
|
||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '移除记录失败'))
|
||||
}}>移除记录与暂存原件</button>
|
||||
</div>)}
|
||||
{operation && <button type="button" className="secondary-button" onClick={() => void window.goodbuddy.context.cancelParsing(operation)}>取消解析</button>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { ContextAttachment } from '../../shared/contracts'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { AttachmentResultButton } from './AttachmentResultButton'
|
||||
import { AttachmentActions, AttachmentStatus } from './AttachmentActions'
|
||||
|
||||
export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; onClose: () => void }): React.JSX.Element {
|
||||
const close = useRef<HTMLButtonElement>(null)
|
||||
const [items, setItems] = useState<ContextAttachment[]>()
|
||||
const [error, setError] = useState('')
|
||||
useEffect(() => activateModalFocus(() => close.current), [])
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.goodbuddy.conversationQueue.getAttachments(itemId).then((value) => { if (active) setItems(value) },
|
||||
(reason: unknown) => { if (active) setError(reason instanceof Error ? reason.message : '队列附件读取失败') })
|
||||
return () => { active = false }
|
||||
}, [itemId])
|
||||
return createPortal(<div className="document-parsing-diagnostic-backdrop">
|
||||
<section className="document-parsing-diagnostic" role="dialog" aria-modal="true" aria-label="入队时的附件" onKeyDown={(event) => {
|
||||
if (event.target instanceof Element && event.target.closest('[aria-modal="true"]') !== event.currentTarget) return
|
||||
trapTabFocus(event, event.currentTarget)
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose() }
|
||||
}}>
|
||||
<header><strong>入队时的附件</strong><button type="button" className="secondary-button" ref={close} onClick={onClose}>关闭附件预览</button></header>
|
||||
<p>这里显示入队时的内容。需要修改附件时,请先将整条输入恢复到草稿。</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{!items && !error && <p role="status">正在读取附件</p>}
|
||||
{items?.length === 0 && <p>此输入没有附件。</p>}
|
||||
<div className="document-result-panel">{items?.map((item) => <article key={item.id}>
|
||||
<h3>{item.name}</h3>
|
||||
<div className="attachment-metadata"><AttachmentStatus attachment={item} /></div>
|
||||
<div className="attachment-actions">
|
||||
{item.resultId && <AttachmentResultButton resultId={item.resultId} name={item.name} readOnly />}
|
||||
<AttachmentActions attachment={item} readOnly />
|
||||
</div>
|
||||
</article>)}</div>
|
||||
</section>
|
||||
</div>, document.body)
|
||||
}
|
||||
@@ -4013,6 +4013,7 @@ export function SettingsPanel({
|
||||
|
||||
{activeTab === 'document-parsing' && (
|
||||
<DocumentParsingSettingsSection
|
||||
onBusyChange={setTesting}
|
||||
onDirtyChange={setDocumentParsingDirty}
|
||||
onNotify={onNotify}
|
||||
onOpenModelDownloadSourceSettings={() =>
|
||||
|
||||
@@ -494,6 +494,7 @@ export const app = {
|
||||
selecting: 'Adding attachments…',
|
||||
reading: 'Reading {{name}}',
|
||||
parsing: 'Parsing {{name}}',
|
||||
saving: 'Saving {{name}}',
|
||||
waiting: 'Files will be read and parsed after selection',
|
||||
fileCount: 'File {{current}} of {{total}}',
|
||||
progressLabel: 'Attachment reading and parsing progress',
|
||||
|
||||
@@ -180,6 +180,8 @@ export const magicNotes = {
|
||||
},
|
||||
accessibility: {
|
||||
resizeAiPane: 'Resize the editor and AI comments panes',
|
||||
resizeIndexPane: 'Resize the record index',
|
||||
indexPaneWidth: 'Record index width {{width}} pixels',
|
||||
aiPaneWidth: 'AI comments pane, {{width}} pixels'
|
||||
},
|
||||
editor: {
|
||||
|
||||
@@ -475,6 +475,7 @@ export const app = {
|
||||
selecting: '正在添加附件…',
|
||||
reading: '正在读取 {{name}}',
|
||||
parsing: '正在解析 {{name}}',
|
||||
saving: '正在保存 {{name}}',
|
||||
waiting: '选择文件后将自动读取并解析',
|
||||
fileCount: '第 {{current}} / {{total}} 个文件',
|
||||
progressLabel: '附件读取与解析进度',
|
||||
|
||||
@@ -168,6 +168,8 @@ export const magicNotes = {
|
||||
},
|
||||
accessibility: {
|
||||
resizeAiPane: '调整编辑区与 AI 评论宽度',
|
||||
resizeIndexPane: '调整记录索引宽度',
|
||||
indexPaneWidth: '记录索引宽度 {{width}} 像素',
|
||||
aiPaneWidth: 'AI 评论栏 {{width}} 像素'
|
||||
},
|
||||
editor: {
|
||||
|
||||
@@ -395,6 +395,7 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
const normalized = normalizeContent(initialContent);
|
||||
let pages = normalized.pages.map(makeRuntimePage);
|
||||
let currentIndex = 0;
|
||||
let loadedPageId = null;
|
||||
let tool = 'select';
|
||||
let style = { color: '#ff4d4f', width: 3 };
|
||||
let restoring = false;
|
||||
@@ -646,7 +647,7 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
}
|
||||
|
||||
function captureCurrentObjects() {
|
||||
if (destroyed || restoring || !pages.length) return;
|
||||
if (destroyed || restoring || !pages.length || currentPage().id !== loadedPageId) return;
|
||||
const value = fabricCanvas.toObject(SERIAL_PROPS);
|
||||
currentPage().objects = normalizeObjects(value?.objects);
|
||||
if (currentPage().objects.length) currentPage().flowAuto = false;
|
||||
@@ -943,6 +944,7 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
if (destroyed) return;
|
||||
const page = currentPage();
|
||||
restoring = true;
|
||||
loadedPageId = null;
|
||||
if (textTimer) {
|
||||
clearTimeout(textTimer);
|
||||
textTimer = 0;
|
||||
@@ -959,6 +961,7 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
restoring = false;
|
||||
}
|
||||
if (destroyed || options.signal?.aborted) return;
|
||||
loadedPageId = page.id;
|
||||
applyMode();
|
||||
await paintVisibleBackground();
|
||||
updateControls();
|
||||
@@ -1129,6 +1132,8 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
|
||||
async function syncFlowPages(requiredCount) {
|
||||
if (destroyed) return;
|
||||
flushPendingText();
|
||||
captureCurrentObjects();
|
||||
const requested = Math.max(1, Math.round(Number(requiredCount) || 1));
|
||||
const target = Math.min(MAX_PAGES, requested);
|
||||
let changed = false;
|
||||
@@ -1156,6 +1161,9 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
}
|
||||
if (!changed) return;
|
||||
currentIndex = Math.min(currentIndex, pages.length - 1);
|
||||
// Pagination can remove the loaded page; finish restoration in the same queue as flush.
|
||||
if (currentPage().id !== loadedPageId) await loadCurrentPage();
|
||||
else setPageDimensions(currentPage());
|
||||
updateControls();
|
||||
emitChange();
|
||||
}
|
||||
|
||||
+135
-19
@@ -1273,7 +1273,7 @@
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
grid-template-columns:
|
||||
var(--magic-notes-index-width) minmax(300px, 1fr) 9px
|
||||
var(--magic-notes-index-width) 9px minmax(300px, 1fr) 9px
|
||||
var(--magic-notes-ai-width, 280px);
|
||||
}
|
||||
|
||||
@@ -1282,7 +1282,7 @@
|
||||
}
|
||||
|
||||
.magic-notes-layout--ai-hidden {
|
||||
grid-template-columns: var(--magic-notes-index-width) minmax(0, 1fr);
|
||||
grid-template-columns: var(--magic-notes-index-width) 9px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.magic-notes-layout--index-hidden.magic-notes-layout--ai-hidden {
|
||||
@@ -1298,7 +1298,7 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.magic-notes-ai-resize-handle {
|
||||
.magic-notes-pane-resize-handle {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 9px;
|
||||
@@ -1308,7 +1308,7 @@
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.magic-notes-ai-resize-handle {
|
||||
.magic-notes-pane-resize-handle {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--surface-raised) 0 4px,
|
||||
@@ -1316,7 +1316,7 @@
|
||||
);
|
||||
}
|
||||
|
||||
.magic-notes-ai-resize-handle::after {
|
||||
.magic-notes-pane-resize-handle::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
@@ -1329,15 +1329,16 @@
|
||||
background var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.magic-notes-ai-resize-handle:hover::after,
|
||||
.magic-notes-ai-resize-handle:focus-visible::after,
|
||||
.magic-notes-pane-resize-handle:hover::after,
|
||||
.magic-notes-pane-resize-handle:focus-visible::after,
|
||||
.magic-notes-layout--index-resizing > .magic-notes-index-resize-handle::after,
|
||||
.magic-notes-layout--ai-resizing
|
||||
> .magic-notes-ai-resize-handle::after {
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.magic-notes-ai-resize-handle:focus-visible {
|
||||
.magic-notes-pane-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@@ -1367,13 +1368,13 @@
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--surface-subtle);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
}
|
||||
.magic-notes-index-pane[hidden] { display: none; }
|
||||
.magic-note-records { display: grid; gap: var(--space-3); padding: var(--space-2); }
|
||||
.magic-note-records[hidden] { display: none; }
|
||||
.magic-note-record {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
@@ -1389,7 +1390,7 @@
|
||||
.magic-note-record:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.magic-note-record time { color: var(--text-secondary); font-size: var(--font-caption); }
|
||||
.magic-note-record__summary { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
|
||||
.magic-note-record__image { display: block; text-align: center; }
|
||||
.magic-note-record__image { display: block; min-width: 0; text-align: center; }
|
||||
.magic-note-record__image img { display: block; width: 100%; max-height: 180px; object-fit: contain; }
|
||||
.magic-note-thumbnail-renderer { position: fixed; left: -10000px; top: 0; width: 850px; height: 1200px; visibility: hidden; pointer-events: none; }
|
||||
|
||||
@@ -2389,7 +2390,7 @@
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.magic-notes-ai-resize-handle {
|
||||
.magic-notes-pane-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -5871,6 +5872,7 @@ button > svg {
|
||||
.message-attachment__details {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
align-self: stretch;
|
||||
flex-direction: column;
|
||||
@@ -6938,8 +6940,8 @@ button > svg {
|
||||
|
||||
.context-chip {
|
||||
display: flex;
|
||||
min-width: 150px;
|
||||
max-width: 220px;
|
||||
min-width: 0;
|
||||
max-width: min(100%, 320px);
|
||||
align-items: center;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-default);
|
||||
@@ -6996,20 +6998,76 @@ button > svg {
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.context-chip button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
.context-chip > button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-chip button:hover {
|
||||
.context-chip > button:hover {
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.attachment-metadata,
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.attachment-actions {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.attachment-actions:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.attachment-actions > :is([role="status"], [role="alert"]) {
|
||||
flex-basis: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.attachment-action.icon-button {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
flex: 0 0 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Native title covers hover; keyboard users also get a visible hint. */
|
||||
.attachment-action:focus-visible::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--space-1));
|
||||
left: 0;
|
||||
z-index: calc(var(--z-dialog) - 1);
|
||||
width: max-content;
|
||||
max-width: min(240px, 70vw);
|
||||
overflow-wrap: anywhere;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-caption);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.context-chip > .attachment-action:focus-visible::after {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.conversation-input-queue {
|
||||
display: grid;
|
||||
margin: 0 0 var(--space-2);
|
||||
@@ -10930,10 +10988,11 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.document-parsing-diagnostic {
|
||||
display: grid;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(620px, 100%);
|
||||
max-height: min(760px, calc(100vh - 32px));
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
@@ -10943,6 +11002,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.document-parsing-diagnostic > header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
@@ -10965,6 +11025,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.document-parsing-diagnostic dl {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
margin: 0;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -18024,3 +18085,58 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
.workspace-git__commit strong, .workspace-git__commit small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-git__commit span, .workspace-git__commit small { color: var(--text-secondary); font-size: var(--font-caption); }
|
||||
@media (hover: none), (pointer: coarse) { .workspace-files__more { opacity: 1; } }
|
||||
.document-result-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.document-result-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.document-result-panel {
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.document-result-images {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.document-result-image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.document-result-image img {
|
||||
max-width: 100%;
|
||||
max-height: 480px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.document-result-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.document-result-panel pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.conversation-input-queue__item--error .conversation-input-queue__content {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.conversation-input-queue__item--error small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user