From 242a32f09dec0982cb1502ab65ae3ba2f273aafd Mon Sep 17 00:00:00 2001 From: Yurken <2380850316@qq.com> Date: Tue, 21 Jul 2026 15:03:33 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(tauri):=20=E7=BB=9F=E4=B8=80=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E6=A8=A1=E5=9E=8B=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E8=AE=BA=E6=96=87=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将视觉模型测试错误解释逻辑抽取为可复用的 explain_vision_error - 聊天流在视觉请求失败时使用该提示,引导用户检查视界·视觉模型配置 - 支持 paper 类型的会话上下文,与 interest 一并保留 - 补充 explain_vision_error 与 scoped_context_type 单元测试 --- apps/desktop/src-tauri/src/commands/chat.rs | 54 ++++++++++--- apps/desktop/src-tauri/src/llm.rs | 1 + apps/desktop/src-tauri/src/llm/shared.rs | 79 +++++++++++++++++++ .../src/services/settings_service.rs | 59 +------------- 4 files changed, 125 insertions(+), 68 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands/chat.rs b/apps/desktop/src-tauri/src/commands/chat.rs index 4710da04..61995f2e 100644 --- a/apps/desktop/src-tauri/src/commands/chat.rs +++ b/apps/desktop/src-tauri/src/commands/chat.rs @@ -2,7 +2,8 @@ use crate::assistant_prompts::main_chat_system; use crate::commands::chat_tools::{build_chat_tools, dispatch_tool}; use crate::commands::memory::is_long_term_memory_enabled; use crate::llm::{ - resolve_model, resolve_temperature, LlmClient, LlmImage, LlmMessage, StreamOutcome, + explain_vision_error, resolve_model, resolve_temperature, LlmClient, LlmImage, LlmMessage, + StreamOutcome, }; use crate::services::agent_runtime_service::{ run_agent_runtime, AgentRuntimeKind, AgentRuntimeRequest, @@ -22,6 +23,31 @@ use std::collections::HashMap; use tauri::{Emitter, State}; use uuid::Uuid; +fn scoped_context_type(context_type: Option<&str>, has_context_id: bool) -> String { + match (context_type, has_context_id) { + (Some("interest"), true) => "interest".to_string(), + (Some("paper"), true) => "paper".to_string(), + _ => "general".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::scoped_context_type; + + #[test] + fn preserves_supported_scoped_contexts() { + assert_eq!(scoped_context_type(Some("interest"), true), "interest"); + assert_eq!(scoped_context_type(Some("paper"), true), "paper"); + } + + #[test] + fn falls_back_to_general_without_a_scope_id() { + assert_eq!(scoped_context_type(Some("paper"), false), "general"); + assert_eq!(scoped_context_type(Some("unknown"), true), "general"); + } +} + /// 前端 chat_stream 传入的图片块:data 为 base64(不含 data: 前缀),mediaType 为 MIME 类型。 #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -136,11 +162,8 @@ pub async fn chat_update_session_context( Some(trimmed) } }); - let normalized_context_type = if context_type == "interest" && normalized_context_id.is_some() { - "interest".to_string() - } else { - "general".to_string() - }; + let normalized_context_type = + scoped_context_type(Some(&context_type), normalized_context_id.is_some()); sqlx::query( "UPDATE chat_sessions SET context_type = ?, context_id = ?, updated_at = ? WHERE id = ?", @@ -287,12 +310,7 @@ pub async fn chat_stream( Some(trimmed) } }); - let ctx_type = if context_type.as_deref() == Some("interest") && normalized_context_id.is_some() - { - "interest".to_string() - } else { - "general".to_string() - }; + let ctx_type = scoped_context_type(context_type.as_deref(), normalized_context_id.is_some()); let sid = if let Some(id) = session_id { let _ = sqlx::query( @@ -369,6 +387,11 @@ pub async fn chat_stream( let ctx_type_clone = ctx_type.clone(); let context_id_clone = normalized_context_id.clone(); let chat_handles = state.chat_handles.clone(); + let uses_vision = !images.is_empty() || history.iter().any(|item| !item.images.is_empty()); + let vision_model = settings + .get("vision_model") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); let handle = tokio::spawn(async move { let result = run_chat( @@ -388,6 +411,11 @@ pub async fn chat_stream( if let Err(e) = result { let error_message = e.to_string(); + let visible_error = if uses_vision { + explain_vision_error(&error_message, vision_model.as_deref()) + } else { + error_message.clone() + }; if long_term_memory_enabled { let _ = crate::commands::memory::record_chat_failure_event( &db, @@ -413,7 +441,7 @@ pub async fn chat_stream( } let _ = app.emit( "chat:error", - json!({ "request_id": rid, "error": error_message }), + json!({ "request_id": rid, "error": visible_error }), ); } let _ = app.emit("chat:done", json!({ "request_id": rid })); diff --git a/apps/desktop/src-tauri/src/llm.rs b/apps/desktop/src-tauri/src/llm.rs index c60884df..c8a8ca3c 100644 --- a/apps/desktop/src-tauri/src/llm.rs +++ b/apps/desktop/src-tauri/src/llm.rs @@ -7,6 +7,7 @@ use std::sync::OnceLock; mod shared; mod transport; +pub(crate) use self::shared::explain_vision_error; use self::shared::{ build_anthropic_tools, build_anthropic_user_messages, build_message_array as build_message_array_impl, diff --git a/apps/desktop/src-tauri/src/llm/shared.rs b/apps/desktop/src-tauri/src/llm/shared.rs index debe321e..e38ee52a 100644 --- a/apps/desktop/src-tauri/src/llm/shared.rs +++ b/apps/desktop/src-tauri/src/llm/shared.rs @@ -12,6 +12,62 @@ pub(super) fn compact_preview(text: &str, max_chars: usize) -> String { .join(" ") } +/// 将视觉模型的网关错误转换为可操作的中文提示;未知错误保留原文供排查。 +pub(crate) fn explain_vision_error(error: &str, model: Option<&str>) -> String { + let lower = error.to_ascii_lowercase(); + let model = model.map(str::trim).filter(|value| !value.is_empty()); + + if lower.contains("no endpoints found that support image input") + || lower.contains("does not support image input") + || lower.contains("image input is not supported") + { + let target = model + .map(|value| format!("视觉模型「{}」", value)) + .unwrap_or_else(|| "当前使用的模型".to_string()); + return format!( + "{}不支持图片输入。请前往「设置 → 模型角色 → 视界·视觉」更换多模态模型,并通过连接测试后重试。", + target + ); + } + + if lower.contains("html") || lower.contains(" serde_json::Value { json!(messages .iter() @@ -186,3 +242,26 @@ pub(super) fn extract_anthropic_response_text( preview )) } + +#[cfg(test)] +mod tests { + use super::explain_vision_error; + + #[test] + fn explains_unsupported_image_input_with_next_step() { + let error = r#"LLM streaming API error: HTTP 404 {"error":{"message":"No endpoints found that support image input"}}"#; + let message = explain_vision_error(error, Some("text-only-model")); + + assert!(message.contains("不支持图片输入")); + assert!(message.contains("视界·视觉")); + assert!(message.contains("text-only-model")); + } + + #[test] + fn keeps_unknown_vision_errors_for_diagnostics() { + assert_eq!( + explain_vision_error("unexpected upstream failure", None), + "unexpected upstream failure" + ); + } +} diff --git a/apps/desktop/src-tauri/src/services/settings_service.rs b/apps/desktop/src-tauri/src/services/settings_service.rs index db27a326..b122b55c 100644 --- a/apps/desktop/src-tauri/src/services/settings_service.rs +++ b/apps/desktop/src-tauri/src/services/settings_service.rs @@ -1,4 +1,6 @@ -use crate::llm::{anthropic_auth_header, is_anthropic_compatible_base_url, LlmClient}; +use crate::llm::{ + anthropic_auth_header, explain_vision_error, is_anthropic_compatible_base_url, LlmClient, +}; use crate::repositories::settings_repository::{ delete_settings_history, get_settings_history, insert_settings_history, list_settings_history, load_all_settings, rename_settings_history, update_settings_history, upsert_settings, @@ -35,59 +37,6 @@ const ERR_NO_VALID_CONFIG_ITEMS: &str = "文件中未找到有效配置项。"; const ERR_SETTINGS_HISTORY_NOT_FOUND: &str = "未找到对应的配置历史。"; const ERR_SETTINGS_HISTORY_EMPTY: &str = "这份配置历史中没有可应用的设置项。"; -/// 把视觉模型测试的原始错误/HTTP 返回转换为用户可读的中文提示。 -fn explain_vision_test_error(error: &str, model: &str) -> String { - let lower = error.to_ascii_lowercase(); - - if lower.contains("no endpoints found that support image input") - || lower.contains("does not support image input") - || lower.contains("image input is not supported") - { - return format!( - "当前接口或模型「{}」不支持图片输入,请确认选择的是多模态模型。", - model - ); - } - - if lower.contains("html") - || lower.contains(" Date: Tue, 21 Jul 2026 15:03:42 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(desktop):=20=E8=AE=BA=E6=96=87?= =?UTF-8?q?=E5=BA=93=E5=88=97=E8=A1=A8=E7=BC=93=E5=AD=98=E4=B8=8E=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 paperListCache,合并并发加载并缓存首页全量论文列表 - 新增 usePaperLibrarySnapshot 统一论文列表状态与加载 - papersApi 在增删改、排序、分析/复现/重解析后自动失效缓存 - usePapersList 复用快照,排序后恢复本地乐观更新 - 补充 paperListCache 单元测试 --- .../src/__tests__/lib/paperListCache.test.ts | 58 ++++++++++++++++ .../papers/usePaperLibrarySnapshot.ts | 44 +++++++++++++ .../src/features/papers/usePapersList.ts | 20 ++---- apps/desktop/src/lib/client.ts | 66 +++++++++++++------ apps/desktop/src/lib/paperListCache.ts | 50 ++++++++++++++ 5 files changed, 204 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/__tests__/lib/paperListCache.test.ts create mode 100644 apps/desktop/src/features/papers/usePaperLibrarySnapshot.ts create mode 100644 apps/desktop/src/lib/paperListCache.ts diff --git a/apps/desktop/src/__tests__/lib/paperListCache.test.ts b/apps/desktop/src/__tests__/lib/paperListCache.test.ts new file mode 100644 index 00000000..b24251ee --- /dev/null +++ b/apps/desktop/src/__tests__/lib/paperListCache.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Paper } from "@research-copilot/types"; +import { + invalidatePaperListCache, + loadPaperListOnce, + readCachedPaperList, + replaceCachedPaperList, + resetPaperListCacheForTests, +} from "../../lib/paperListCache"; + +function paper(id: string): Paper { + return { + id, + title: `Paper ${id}`, + status: "parsed", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; +} + +describe("paperListCache", () => { + beforeEach(() => resetPaperListCacheForTests()); + + it("复用首次加载的列表并合并并发请求", async () => { + const loader = vi.fn(async () => [paper("1")]); + + const [first, second] = await Promise.all([ + loadPaperListOnce(loader), + loadPaperListOnce(loader), + ]); + const third = await loadPaperListOnce(loader); + + expect(loader).toHaveBeenCalledTimes(1); + expect(second).toBe(first); + expect(third).toBe(first); + }); + + it("只常驻一个最多 500 条的快照", () => { + const cached = replaceCachedPaperList(Array.from({ length: 520 }, (_, index) => paper(String(index)))); + + expect(cached).toHaveLength(500); + expect(readCachedPaperList()).toBe(cached); + }); + + it("数据变更后失效且不会让旧请求覆盖新缓存", async () => { + let resolveOld!: (papers: Paper[]) => void; + const oldLoad = loadPaperListOnce(() => new Promise((resolve) => { resolveOld = resolve; })); + + invalidatePaperListCache(); + const fresh = await loadPaperListOnce(async () => [paper("fresh")]); + resolveOld([paper("old")]); + const oldCallerResult = await oldLoad; + + expect(oldCallerResult).toBe(fresh); + expect(readCachedPaperList()).toBe(fresh); + expect(readCachedPaperList()?.[0].id).toBe("fresh"); + }); +}); diff --git a/apps/desktop/src/features/papers/usePaperLibrarySnapshot.ts b/apps/desktop/src/features/papers/usePaperLibrarySnapshot.ts new file mode 100644 index 00000000..60a4ecae --- /dev/null +++ b/apps/desktop/src/features/papers/usePaperLibrarySnapshot.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; +import type { Paper } from "@research-copilot/types"; +import { apiClient, formatErrorMessage } from "../../lib/client"; +import { readCachedPaperList, replaceCachedPaperList } from "../../lib/paperListCache"; + +/** 论文库与阅读页共享的、进程内常驻的论文列表快照。 */ +export function usePaperLibrarySnapshot() { + const initialSnapshot = useRef(readCachedPaperList()); + const [papers, setPapersState] = useState(() => initialSnapshot.current ?? []); + const [loading, setLoading] = useState(initialSnapshot.current === null); + const [loadError, setLoadError] = useState(""); + + const setPapers = useCallback>>((nextValue) => { + setPapersState((previous) => { + const next = typeof nextValue === "function" ? nextValue(previous) : nextValue; + return replaceCachedPaperList(next); + }); + }, []); + + useEffect(() => { + const cached = readCachedPaperList(); + if (cached) { + setPapersState(cached); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + setLoadError(""); + apiClient.papers.list(0, 500) + .then((data) => { if (!cancelled) setPapers(data); }) + .catch((error) => { + if (!cancelled) { + setLoadError(formatErrorMessage(error)); + setPapersState([]); + } + }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [setPapers]); + + return { papers, setPapers, loading, loadError, setLoadError }; +} diff --git a/apps/desktop/src/features/papers/usePapersList.ts b/apps/desktop/src/features/papers/usePapersList.ts index 319cfef6..e1965242 100644 --- a/apps/desktop/src/features/papers/usePapersList.ts +++ b/apps/desktop/src/features/papers/usePapersList.ts @@ -5,6 +5,7 @@ import type { KnowledgeNote, Paper, ResearchInterest } from "@research-copilot/t import { buildInterestForest, collectInterestSubtreeIds } from "./interestTree"; import { type PaperSortDirection, type PaperSortKey } from "./shared"; import type { NoteDraft } from "../knowledge/NoteEditorModal"; +import { usePaperLibrarySnapshot } from "./usePaperLibrarySnapshot"; type SortKey = PaperSortKey; type SortDirection = PaperSortDirection; @@ -12,12 +13,10 @@ type SortDirection = PaperSortDirection; const COLOR_PRIORITY = ["#FF3B30", "#FF9500", "#FFCC00", "#34C759", "#007AFF", "#AF52DE"]; export function usePapersList() { - const [papers, setPapers] = useState([]); + const { papers, setPapers, loading, loadError, setLoadError } = usePaperLibrarySnapshot(); const [interests, setInterests] = useState([]); - const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null); - const [loadError, setLoadError] = useState(""); const [deletingPaperId, setDeletingPaperId] = useState(null); const [savingEdit, setSavingEdit] = useState(false); const [selectedInterestId, setSelectedInterestId] = usePersistentState("rc:papers:selected-interest-id", ""); @@ -27,17 +26,6 @@ export function usePapersList() { const [sortDirections, setSortDirections] = usePersistentState>("rc:papers:sort-directions", {}); const [keywordFilter, setKeywordFilter] = usePersistentState("rc:papers:keyword-filter", ""); - useEffect(() => { - let cancelled = false; - setLoading(true); - setLoadError(""); - apiClient.papers.list(0, 500) - .then((data) => { if (!cancelled) setPapers(data); }) - .catch((error) => { if (!cancelled) { setLoadError(formatErrorMessage(error)); setPapers([]); } }) - .finally(() => { if (!cancelled) setLoading(false); }); - return () => { cancelled = true; }; - }, []); - useEffect(() => { let cancelled = false; apiClient.knowledge.listNotesBySource("paper_note", "*") @@ -85,7 +73,7 @@ export function usePapersList() { setUploading(false); setBatchProgress(null); } - }, []); + }, [setLoadError, setPapers]); const handleUpload = async (interestId = selectedInterestId) => { try { @@ -342,6 +330,8 @@ export function usePapersList() { try { setLoadError(""); await apiClient.papers.reorder(orderedIds); + // API 层会先使旧快照失效;排序成功后用已乐观更新的列表恢复缓存。 + setPapers((prev) => prev); } catch (error) { setLoadError(formatErrorMessage(error)); const fresh = await apiClient.papers.list(0, 500).catch(() => null); diff --git a/apps/desktop/src/lib/client.ts b/apps/desktop/src/lib/client.ts index 3468d5c3..b44914e0 100644 --- a/apps/desktop/src/lib/client.ts +++ b/apps/desktop/src/lib/client.ts @@ -37,6 +37,7 @@ import type { TavilyKeyTest, WebSearchOutcome, } from "@research-copilot/types"; +import { invalidatePaperListCache, loadPaperListOnce } from "./paperListCache"; import { streamChat } from "./chatStream"; import { streamTranslation } from "./translationStream"; export { streamChat } from "./chatStream"; @@ -229,16 +230,27 @@ export const updatesApi = { // ── Papers ─────────────────────────────────────────────────────── export const papersApi = { - list: (offset = 0, limit = 20, research_interest_id?: string): Promise => - invoke("papers_list", { offset, limit, researchInterestId: research_interest_id ?? null }), + list: (offset = 0, limit = 20, research_interest_id?: string): Promise => { + const load = () => invoke("papers_list", { + offset, + limit, + researchInterestId: research_interest_id ?? null, + }); + return offset === 0 && limit === 500 && !research_interest_id + ? loadPaperListOnce(load) + : load(); + }, get: (id: string): Promise => invoke("papers_get", { id }), listParseRuns: (paper_id: string): Promise<{ runs: unknown[] }> => invoke("papers_list_parse_runs", { paperId: paper_id }), - upload: (filePath: string, research_interest_id?: string, suggested_title?: string): Promise<{ paper_id: string; title: string }> => - invoke("papers_upload", { filePath, researchInterestId: research_interest_id ?? null, suggestedTitle: suggested_title ?? null }), - update: (id: string, data: { title?: string; authors?: string; venue?: string; year?: number; doi?: string; research_interest_id?: string; importance_color?: string; notes?: string; tags?: string[] }): Promise => - invoke("papers_update", { + upload: async (filePath: string, research_interest_id?: string, suggested_title?: string): Promise<{ paper_id: string; title: string }> => { + const result = await invoke<{ paper_id: string; title: string }>("papers_upload", { filePath, researchInterestId: research_interest_id ?? null, suggestedTitle: suggested_title ?? null }); + invalidatePaperListCache(); + return result; + }, + update: async (id: string, data: { title?: string; authors?: string; venue?: string; year?: number; doi?: string; research_interest_id?: string; importance_color?: string; notes?: string; tags?: string[] }): Promise => { + const result = await invoke("papers_update", { id, title: data.title ?? null, authors: data.authors ?? null, @@ -249,23 +261,39 @@ export const papersApi = { importanceColor: data.importance_color ?? null, notes: data.notes ?? null, tags: data.tags ?? null, - }), - reorder: (orderedIds: string[]): Promise => - invoke("papers_reorder", { orderedIds }), - delete: (id: string): Promise => - invoke("papers_delete", { id }), - merge: (keepId: string, deleteIds: string[]): Promise => - invoke("papers_merge", { keepId, deleteIds }), + }); + invalidatePaperListCache(); + return result; + }, + reorder: async (orderedIds: string[]): Promise => { + await invoke("papers_reorder", { orderedIds }); + invalidatePaperListCache(); + }, + delete: async (id: string): Promise => { + await invoke("papers_delete", { id }); + invalidatePaperListCache(); + }, + merge: async (keepId: string, deleteIds: string[]): Promise => { + const result = await invoke("papers_merge", { keepId, deleteIds }); + invalidatePaperListCache(); + return result; + }, openFile: (id: string): Promise => invoke("papers_open_pdf", { id }), revealInFolder: (id: string): Promise => invoke("papers_reveal_in_folder", { id }), - analyze: (id: string): Promise => - invoke("papers_analyze", { id }), - reparse: (id: string): Promise => - invoke("papers_reparse", { id }), - reproduce: (id: string): Promise => - invoke("papers_reproduce", { id }), + analyze: async (id: string): Promise => { + await invoke("papers_analyze", { id }); + invalidatePaperListCache(); + }, + reparse: async (id: string): Promise => { + await invoke("papers_reparse", { id }); + invalidatePaperListCache(); + }, + reproduce: async (id: string): Promise => { + await invoke("papers_reproduce", { id }); + invalidatePaperListCache(); + }, generateNote: (id: string): Promise => invoke("papers_generate_note", { id }), listFigures: (paper_id: string): Promise> => diff --git a/apps/desktop/src/lib/paperListCache.ts b/apps/desktop/src/lib/paperListCache.ts new file mode 100644 index 00000000..4051bb3a --- /dev/null +++ b/apps/desktop/src/lib/paperListCache.ts @@ -0,0 +1,50 @@ +import type { Paper } from "@research-copilot/types"; + +// 论文库页面本身最多请求 500 条。这里只保留同一份数组快照,不按查询条件 +// 建立多份缓存,避免论文分析文本长期占用成倍内存。 +const MAX_CACHED_PAPERS = 500; + +let cachedPapers: Paper[] | null = null; +let pendingLoad: Promise | null = null; +let cacheRevision = 0; + +export function readCachedPaperList(): Paper[] | null { + return cachedPapers; +} + +export function replaceCachedPaperList(papers: Paper[]): Paper[] { + cachedPapers = papers.length > MAX_CACHED_PAPERS + ? papers.slice(0, MAX_CACHED_PAPERS) + : papers; + return cachedPapers; +} + +export function invalidatePaperListCache() { + cachedPapers = null; + pendingLoad = null; + cacheRevision += 1; +} + +export function loadPaperListOnce(loader: () => Promise): Promise { + if (cachedPapers) return Promise.resolve(cachedPapers); + if (pendingLoad) return pendingLoad; + + const loadRevision = cacheRevision; + const request = loader() + .then((papers) => { + if (loadRevision === cacheRevision) return replaceCachedPaperList(papers); + // 旧请求失效后,旧调用方也必须等待并获得当前修订的数据,不能把过期数组再写回快照。 + return cachedPapers ?? loadPaperListOnce(loader); + }) + .finally(() => { + if (pendingLoad === request) pendingLoad = null; + }); + pendingLoad = request; + return pendingLoad; +} + +export function resetPaperListCacheForTests() { + cachedPapers = null; + pendingLoad = null; + cacheRevision = 0; +} From 9ddab76326bf5bf17d155ad9491d88d2f598ce5b Mon Sep 17 00:00:00 2001 From: Yurken <2380850316@qq.com> Date: Tue, 21 Jul 2026 15:03:50 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(desktop):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E8=AE=BA=E6=96=87=E9=98=85=E8=AF=BB=E5=99=A8=E5=B9=B6=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=9B=AE=E5=BD=95=E5=AF=BC=E8=88=AA=E3=80=81=E9=98=85?= =?UTF-8?q?=E8=AF=BB=E8=BF=9B=E5=BA=A6=E4=B8=8E=E9=97=AE=E7=AD=94=E4=BE=A7?= =?UTF-8?q?=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分 PaperReader 页面状态到独立 hooks:useReaderPdf、useReaderPdfDocument、useReaderProgress、useReaderQuestionAnswer - 新增 ReaderSidebar、ReaderRightRail、ReaderZoomControl、ReaderQaPanel 等组合组件 - 新增 PdfTextAnnotationLayer 与 TextAnnotationInput,支持文本批注 - 新增 readerNavigation 与 useReaderDocumentNavigation,提供大纲、搜索、跳转 - 阅读进度按论文持久化到 localStorage,恢复上次阅读位置 - 批注模式拆分为 text-annotation 与 shape-annotation,工具栏按模式切换工具 - 补充 PdfReaderViewer、ReaderAnnotations、readerNavigation、useReaderProgress 测试 --- .../features/reader/PdfReaderViewer.test.tsx | 47 +++ .../reader/ReaderAnnotations.test.tsx | 257 +++++++++++++++ .../features/reader/readerNavigation.test.ts | 142 ++++++++ .../features/reader/useReaderProgress.test.ts | 31 ++ .../src/features/reader/PdfReaderViewer.tsx | 166 +++++----- .../reader/PdfTextAnnotationLayer.tsx | 167 ++++++++++ .../src/features/reader/ReaderPaperList.tsx | 49 +-- .../src/features/reader/ReaderQaPanel.tsx | 132 ++++++++ .../src/features/reader/ReaderRightRail.tsx | 26 ++ .../src/features/reader/ReaderSidebar.tsx | 312 ++++++++++++++++++ .../src/features/reader/ReaderToolbar.tsx | 136 ++++---- .../reader/ReaderTranslationPanel.tsx | 2 +- .../src/features/reader/ReaderZoomControl.tsx | 113 +++++++ .../features/reader/TextAnnotationInput.tsx | 118 +++++++ .../src/features/reader/readerNavigation.ts | 168 ++++++++++ .../features/reader/readerTextAnnotations.ts | 36 ++ .../src/features/reader/readerTypes.ts | 15 +- .../features/reader/usePdfAreaSelection.ts | 6 +- .../features/reader/usePdfTextSelection.ts | 6 + .../features/reader/usePdfViewportProgress.ts | 75 +++++ .../reader/useReaderDocumentNavigation.ts | 148 +++++++++ .../src/features/reader/useReaderPdf.ts | 50 +++ .../features/reader/useReaderPdfDocument.ts | 53 +++ .../src/features/reader/useReaderProgress.ts | 53 +++ .../reader/useReaderQuestionAnswer.ts | 115 +++++++ .../features/reader/useSmoothReaderZoom.ts | 19 +- apps/desktop/src/pages/PaperReader.tsx | 209 ++++++------ 27 files changed, 2351 insertions(+), 300 deletions(-) create mode 100644 apps/desktop/src/__tests__/features/reader/PdfReaderViewer.test.tsx create mode 100644 apps/desktop/src/__tests__/features/reader/ReaderAnnotations.test.tsx create mode 100644 apps/desktop/src/__tests__/features/reader/readerNavigation.test.ts create mode 100644 apps/desktop/src/__tests__/features/reader/useReaderProgress.test.ts create mode 100644 apps/desktop/src/features/reader/PdfTextAnnotationLayer.tsx create mode 100644 apps/desktop/src/features/reader/ReaderQaPanel.tsx create mode 100644 apps/desktop/src/features/reader/ReaderRightRail.tsx create mode 100644 apps/desktop/src/features/reader/ReaderSidebar.tsx create mode 100644 apps/desktop/src/features/reader/ReaderZoomControl.tsx create mode 100644 apps/desktop/src/features/reader/TextAnnotationInput.tsx create mode 100644 apps/desktop/src/features/reader/readerNavigation.ts create mode 100644 apps/desktop/src/features/reader/readerTextAnnotations.ts create mode 100644 apps/desktop/src/features/reader/usePdfViewportProgress.ts create mode 100644 apps/desktop/src/features/reader/useReaderDocumentNavigation.ts create mode 100644 apps/desktop/src/features/reader/useReaderPdf.ts create mode 100644 apps/desktop/src/features/reader/useReaderPdfDocument.ts create mode 100644 apps/desktop/src/features/reader/useReaderProgress.ts create mode 100644 apps/desktop/src/features/reader/useReaderQuestionAnswer.ts diff --git a/apps/desktop/src/__tests__/features/reader/PdfReaderViewer.test.tsx b/apps/desktop/src/__tests__/features/reader/PdfReaderViewer.test.tsx new file mode 100644 index 00000000..48223617 --- /dev/null +++ b/apps/desktop/src/__tests__/features/reader/PdfReaderViewer.test.tsx @@ -0,0 +1,47 @@ +import type { PDFDocumentProxy } from "pdfjs-dist"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import PdfReaderViewer from "../../../features/reader/PdfReaderViewer"; +import { render } from "../../helpers/render"; + +vi.mock("pdfjs-dist", () => ({ + setLayerDimensions: vi.fn(), + TextLayer: class { + render = vi.fn(async () => undefined); + cancel = vi.fn(); + }, +})); + +describe("PdfReaderViewer", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("把第 5 页及后续页面交给懒渲染观察器", () => { + const observedPages = new Set(); + class CapturingIntersectionObserver { + observe(element: Element) { + const page = Number((element as HTMLElement).dataset.pageNum); + if (page > 0) observedPages.add(page); + } + unobserve() { return undefined; } + disconnect() { return undefined; } + } + vi.stubGlobal("IntersectionObserver", CapturingIntersectionObserver); + vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1); + + render( + , + ); + + expect([...observedPages]).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); +}); diff --git a/apps/desktop/src/__tests__/features/reader/ReaderAnnotations.test.tsx b/apps/desktop/src/__tests__/features/reader/ReaderAnnotations.test.tsx new file mode 100644 index 00000000..8b35d2e2 --- /dev/null +++ b/apps/desktop/src/__tests__/features/reader/ReaderAnnotations.test.tsx @@ -0,0 +1,257 @@ +import { useState } from "react"; +import { userEvent } from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import ReaderToolbar from "../../../features/reader/ReaderToolbar"; +import ReaderZoomControl from "../../../features/reader/ReaderZoomControl"; +import ReaderQaPanel from "../../../features/reader/ReaderQaPanel"; +import { createReaderQaMessageId } from "../../../features/reader/useReaderQuestionAnswer"; +import { createTextAnnotationRect } from "../../../features/reader/readerTextAnnotations"; +import ReaderPaperList from "../../../features/reader/ReaderPaperList"; +import ReaderSidebar from "../../../features/reader/ReaderSidebar"; +import TextAnnotationInput from "../../../features/reader/TextAnnotationInput"; +import type { AnnotationStyle, ReaderMode } from "../../../features/reader/readerTypes"; +import { render, screen } from "../../helpers/render"; + +vi.mock("../../../features/papers/usePaperLibrarySnapshot", () => ({ + usePaperLibrarySnapshot: () => ({ + papers: [ + { id: "paper-1", title: "论文一", authors: "作者甲", year: 2025, file_path: "/paper-1.pdf" }, + { id: "paper-2", title: "论文二", authors: "作者乙", year: 2026, file_path: "/paper-2.pdf" }, + ], + loading: false, + }), +})); + +function ToolbarHarness() { + const [mode, setMode] = useState("view"); + const [tool, setTool] = useState("highlight"); + return ( + + ); +} + +describe("reader annotations", () => { + it("同一毫秒内生成的问答消息 ID 仍保持唯一", () => { + const now = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + + expect(createReaderQaMessageId("user")).not.toBe(createReaderQaMessageId("assistant")); + now.mockRestore(); + }); + + it("按文字输入框实际尺寸约束新批注位置", () => { + const rect = createTextAnnotationRect(995, 1395, { left: 0, top: 0, width: 1000, height: 1400 }); + + expect(rect.x + rect.w).toBeLessThanOrEqual(1); + expect(rect.y + rect.h).toBeLessThanOrEqual(1); + expect(rect.w).toBeCloseTo(0.192); + }); + + it("将文本和形状作为两个直接模式展示", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByRole("button", { name: "批注" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "文本" })); + expect(screen.getByTitle("添加文字")).toBeInTheDocument(); + expect(screen.queryByTitle("矩形框选")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "形状" })); + expect(screen.getByTitle("矩形框选")).toBeInTheDocument(); + expect(screen.queryByTitle("添加文字")).not.toBeInTheDocument(); + }); + + it("缩放控件从顶部工具栏拆出后仍可独立操作", async () => { + const onZoomIn = vi.fn(); + const onZoomOut = vi.fn(); + const onScalePercentChange = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + const input = screen.getByRole("textbox", { name: "缩放比例" }); + expect(input).toHaveValue("140"); + await user.click(input); + await user.clear(input); + await user.type(input, "175{Enter}"); + expect(onScalePercentChange).toHaveBeenCalledWith(175); + await user.click(screen.getByTitle("放大")); + await user.click(screen.getByTitle("缩小")); + expect(onZoomIn).toHaveBeenCalledOnce(); + expect(onZoomOut).toHaveBeenCalledOnce(); + }); + + it("直接在原位文本框中编辑文字", async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + const input = screen.getByRole("textbox"); + await user.clear(input); + await user.type(input, "直接编辑后的文字"); + await user.click(screen.getByTitle("保存文字")); + + expect(onSubmit).toHaveBeenCalledWith("直接编辑后的文字"); + }); + + it("以 Markdown 渲染论文问答回复", () => { + render( + , + ); + + expect(screen.getByRole("heading", { name: "核心结论" })).toBeInTheDocument(); + expect(screen.getByText("贡献一").tagName).toBe("STRONG"); + expect(screen.getByRole("list")).toBeInTheDocument(); + }); + + it("缩略图可以在单栏与双栏间切换", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "页面" })); + const grid = screen.getByAltText("第 1 页缩略图").closest("button")?.parentElement; + expect(grid).toHaveClass("grid-cols-2"); + + await user.click(screen.getByRole("button", { name: "单栏" })); + expect(grid).toHaveClass("grid-cols-1"); + }); + + it("目录为空时默认展示页面,并让删除批注始终可达", async () => { + const onNoteDelete = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + expect(await screen.findByAltText("第 1 页缩略图")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "批注" })); + await user.click(screen.getByRole("button", { name: "删除第 1 页高亮批注" })); + expect(onNoteDelete).toHaveBeenCalledWith("note-1"); + }); + + it("论文列表不显示冗余标题,并将总数放在搜索框右侧", () => { + render(); + + expect(screen.queryByText("论文库")).not.toBeInTheDocument(); + const search = screen.getByPlaceholderText("搜索标题/作者"); + expect(search.parentElement).toHaveTextContent("2"); + }); + + it("搜索结果中高亮所有匹配词", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "搜索" })); + const marks = screen.getAllByText(/visual|regions/i).filter((el) => el.tagName === "MARK"); + expect(marks).toHaveLength(2); + }); +}); diff --git a/apps/desktop/src/__tests__/features/reader/readerNavigation.test.ts b/apps/desktop/src/__tests__/features/reader/readerNavigation.test.ts new file mode 100644 index 00000000..a4872dc8 --- /dev/null +++ b/apps/desktop/src/__tests__/features/reader/readerNavigation.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { + buildReaderQuestionContext, + deriveOutlineFromPages, + searchReaderPages, + supplementSparseOutline, + type ReaderPageContent, +} from "../../../features/reader/readerNavigation"; + +const pages: ReaderPageContent[] = [ + { + page: 1, + lines: ["A Study of Reading", "Abstract", "This paper presents an annotation system."], + text: "A Study of Reading\nAbstract\nThis paper presents an annotation system.", + }, + { + page: 2, + lines: ["1 Introduction", "Readers need reliable full-text search."], + text: "1 Introduction\nReaders need reliable full-text search.", + }, + { + page: 3, + lines: ["2.1 Method", "The method combines text selection and visual regions."], + text: "2.1 Method\nThe method combines text selection and visual regions.", + }, +]; + +describe("reader navigation", () => { + it("从论文文本推断目录并保留章节层级", () => { + const outline = deriveOutlineFromPages(pages); + + expect(outline.map((entry) => [entry.title, entry.page, entry.depth])).toEqual([ + ["Abstract", 1, 0], + ["1 Introduction", 2, 0], + ["2.1 Method", 3, 1], + ]); + }); + + it("不会把代码、公式或正文编号误识别成目录", () => { + const outline = deriveOutlineFromPages([{ + page: 4, + text: "5 annotate ( train : Examples , fn : Transformation )", + lines: [ + "5 annotate ( train : Examples , fn : Transformation )", + "1 sample ( train : Examples , k: int )", + "1 from dsp import generate", + "15 return x", + "1 My task is to write a simple query that gathers", + "3 def pipeline (x):", + ], + lineDetails: [ + "5 annotate ( train : Examples , fn : Transformation )", + "1 sample ( train : Examples , k: int )", + "1 from dsp import generate", + "15 return x", + "1 My task is to write a simple query that gathers", + "3 def pipeline (x):", + ].map((text) => ({ text, fontSize: 10 })), + }, { + page: 5, + text: "2 System Design", + lines: ["2 System Design", "The system is evaluated on three datasets."], + lineDetails: [ + { text: "2 System Design", fontSize: 15 }, + { text: "The system is evaluated on three datasets.", fontSize: 10 }, + ], + }]); + + expect(outline.map((entry) => entry.title)).toEqual(["2 System Design"]); + }); + + it("保留包含领域术语的学术章节标题", () => { + const outline = deriveOutlineFromPages([{ + page: 3, + text: "3 Query Strategy\n4 Training Pipeline", + lines: ["3 Query Strategy", "4 Training Pipeline"], + lineDetails: [ + { text: "3 Query Strategy", fontSize: 15 }, + { text: "4 Training Pipeline", fontSize: 15 }, + { text: "This section explains the strategy in detail.", fontSize: 10 }, + { text: "The query selects informative samples for training.", fontSize: 10 }, + { text: "We compare the resulting pipeline with prior work.", fontSize: 10 }, + ], + }]); + + expect(outline.map((entry) => entry.title)).toEqual(["3 Query Strategy", "4 Training Pipeline"]); + }); + + it("识别编号末尾带句点的论文目录", () => { + const outline = deriveOutlineFromPages([{ + page: 2, + text: "2. DEMONSTRATE–SEARCH–PREDICT\n2.1. Pretrained Modules: LM and RM\n2.2. Datatypes and Control Flow", + lines: [ + "2. DEMONSTRATE–SEARCH–PREDICT", + "2.1. Pretrained Modules: LM and RM", + "2.2. Datatypes and Control Flow", + ], + lineDetails: [ + { text: "2. DEMONSTRATE–SEARCH–PREDICT", fontSize: 16 }, + { text: "2.1. Pretrained Modules: LM and RM", fontSize: 14 }, + { text: "A DSP program defines the communication between models.", fontSize: 10 }, + { text: "2.2. Datatypes and Control Flow", fontSize: 14 }, + { text: "The present section introduces the core data types.", fontSize: 10 }, + { text: "The framework provides composable functions for programs.", fontSize: 10 }, + { text: "These modules communicate through typed interfaces.", fontSize: 10 }, + ], + }]); + + expect(outline.map((entry) => [entry.title, entry.depth])).toEqual([ + ["2. DEMONSTRATE–SEARCH–PREDICT", 0], + ["2.1. Pretrained Modules: LM and RM", 1], + ["2.2. Datatypes and Control Flow", 1], + ]); + }); + + it("PDF 自带目录仅有摘要时用推断章节补齐", () => { + const nativeOutline = [{ id: "native-abstract", title: "Abstract", page: 1, depth: 0 }]; + const derivedOutline = [ + { id: "derived-abstract", title: "Abstract", page: 1, depth: 0 }, + { id: "derived-section", title: "2. DEMONSTRATE–SEARCH–PREDICT", page: 2, depth: 0 }, + ]; + + expect(supplementSparseOutline(nativeOutline, derivedOutline).map((entry) => entry.title)).toEqual([ + "Abstract", + "2. DEMONSTRATE–SEARCH–PREDICT", + ]); + }); + + it("全文搜索按相关度返回页码与上下文摘要", () => { + const results = searchReaderPages(pages, "full-text search"); + + expect(results[0]?.page).toBe(2); + expect(results[0]?.snippet).toContain("full-text search"); + }); + + it("问答上下文包含页码来源并优先相关页面", () => { + const context = buildReaderQuestionContext(pages, "visual regions", 1); + + expect(context).toMatch(/^\[第 3 页\]/); + expect(context).toContain("[第 1 页]"); + }); +}); diff --git a/apps/desktop/src/__tests__/features/reader/useReaderProgress.test.ts b/apps/desktop/src/__tests__/features/reader/useReaderProgress.test.ts new file mode 100644 index 00000000..10b4866e --- /dev/null +++ b/apps/desktop/src/__tests__/features/reader/useReaderProgress.test.ts @@ -0,0 +1,31 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useReaderProgress } from "../../../features/reader/useReaderProgress"; + +describe("useReaderProgress", () => { + beforeEach(() => localStorage.clear()); + + it("按论文分别保存并恢复阅读断点", async () => { + localStorage.setItem("xiaoyan:reader-progress:paper-a", JSON.stringify({ + page: 7, + totalPages: 20, + percent: 35, + updatedAt: "2026-07-21T00:00:00.000Z", + })); + const { result, rerender } = renderHook( + ({ paperId }) => useReaderProgress(paperId), + { initialProps: { paperId: "paper-a" } }, + ); + + expect(result.current.initialPage).toBe(7); + rerender({ paperId: "paper-b" }); + await waitFor(() => expect(result.current.initialPage).toBe(1)); + + act(() => result.current.recordProgress({ page: 3, totalPages: 12, percent: 25 })); + expect(JSON.parse(localStorage.getItem("xiaoyan:reader-progress:paper-b") ?? "{}")).toMatchObject({ + page: 3, + totalPages: 12, + percent: 25, + }); + }); +}); diff --git a/apps/desktop/src/features/reader/PdfReaderViewer.tsx b/apps/desktop/src/features/reader/PdfReaderViewer.tsx index 9a1f0118..047ec961 100644 --- a/apps/desktop/src/features/reader/PdfReaderViewer.tsx +++ b/apps/desktop/src/features/reader/PdfReaderViewer.tsx @@ -2,11 +2,11 @@ import "./text-layer.css"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import * as pdfjsLib from "pdfjs-dist"; import type { PDFDocumentProxy, RenderTask, TextLayer } from "pdfjs-dist"; -import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import { boundingRect, HIGHLIGHT_COLORS, isShapeStyle, + isTextStyle, mergeNormalizedRects, SHAPE_BORDER_RADIUS, type HighlightColor, @@ -16,26 +16,28 @@ import { type ReaderSelection, type ShapeStyle, } from "./readerTypes"; +import PdfTextAnnotationLayer from "./PdfTextAnnotationLayer"; import { useDevicePixelRatio } from "./useDevicePixelRatio"; import { usePdfTextSelection } from "./usePdfTextSelection"; import { usePdfAreaSelection } from "./usePdfAreaSelection"; +import { usePdfViewportProgress } from "./usePdfViewportProgress"; import { registerTextLayer } from "./textLayerSelection"; import { useLineHighlightInteraction } from "./useLineHighlightInteraction"; import { openLink } from "../../lib/links"; -pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl; - const MAX_CANVAS_SIDE = 4096; const MAX_CANVAS_PIXELS = 12_000_000; const MIN_CANVAS_OUTPUT_SCALE = 2; const MAX_CANVAS_OUTPUT_SCALE = 2; interface PdfReaderViewerProps { - data: Uint8Array; + pdfDoc: PDFDocumentProxy; notes: PaperNote[]; scale: number; renderScale?: number; - areaSelectEnabled?: boolean; + readingAreaSelectionEnabled?: boolean; + initialPage?: number; + onProgressChange?: (progress: { page: number; totalPages: number; percent: number }) => void; onTextSelected: (selection: ReaderSelection) => void; onSelectionCleared: () => void; onNoteClick: (note: PaperNote, point: { x: number; y: number }) => void; @@ -45,11 +47,17 @@ interface PdfReaderViewerProps { onAreaSelectError?: (message: string) => void; /** 形状绘制模式:非空时在页面上拖拽画框,而非划词选择。 */ drawShape?: ShapeStyle | null; + /** 自由文字模式:点击页面任意位置后就地输入。 */ + drawText?: boolean; drawColor?: HighlightColor; drawFill?: HighlightColor | null; onShapeDrawn?: (page: number, rect: NormalizedRect) => void; - /** 拖动已有形状批注到新位置。 */ - onShapeMove?: (note: PaperNote, rect: NormalizedRect) => void; + onTextDrawn?: (page: number, rect: NormalizedRect, content: string) => void; + onTextUpdate?: (note: PaperNote, content: string) => void; + onTextDelete?: (note: PaperNote) => void; + onTextColorChange?: (note: PaperNote, color: HighlightColor) => void; + /** 拖动已有形状或文字批注到新位置。 */ + onAnnotationMove?: (note: PaperNote, rect: NormalizedRect) => void; } export interface PdfReaderViewerHandle { @@ -58,17 +66,15 @@ export interface PdfReaderViewerHandle { const PdfReaderViewer = forwardRef( function PdfReaderViewer( - { data, notes, scale, renderScale = scale, areaSelectEnabled = false, onTextSelected, onSelectionCleared, onNoteClick, onZoom, onAreaSelected, onAreaSelectError, drawShape, drawColor, drawFill, onShapeDrawn, onShapeMove }, + { pdfDoc, notes, scale, renderScale = scale, readingAreaSelectionEnabled = false, initialPage, onProgressChange, onTextSelected, onSelectionCleared, onNoteClick, onZoom, onAreaSelected, onAreaSelectError, drawShape, drawText = false, drawColor, drawFill, onShapeDrawn, onTextDrawn, onTextUpdate, onTextDelete, onTextColorChange, onAnnotationMove }, ref, ) { const containerRef = useRef(null); const pageRefs = useRef<(HTMLDivElement | null)[]>([]); - const [numPages, setNumPages] = useState(0); - const [pdfDoc, setPdfDoc] = useState(null); - const [error, setError] = useState(null); const [renderedPages, setRenderedPages] = useState>(new Set()); const [linkMode, setLinkMode] = useState(false); const devicePixelRatio = useDevicePixelRatio(); + const numPages = pdfDoc.numPages; // 渲染期同步计算:批注一变化即可立刻显示,避免靠 ref+effect 延迟一拍。 const notesByPage = useMemo(() => { @@ -81,25 +87,10 @@ const PdfReaderViewer = forwardRef( return map; }, [notes]); - // 加载 PDF 文档 + // 文档实例由页面级 hook 统一加载;切换文档时仅重置视图状态。 useEffect(() => { - let cancelled = false; - setError(null); - setPdfDoc(null); - (async () => { - try { - const doc = await pdfjsLib.getDocument({ data: data.slice(0) }).promise; - if (cancelled) return; - setPdfDoc(doc); - setNumPages(doc.numPages); - } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : "PDF 加载失败"); - } - })(); - return () => { - cancelled = true; - }; - }, [data]); + setRenderedPages(new Set()); + }, [pdfDoc]); const scrollToPage = useCallback((page: number) => { const el = pageRefs.current[page - 1]; @@ -110,11 +101,13 @@ const PdfReaderViewer = forwardRef( usePdfTextSelection({ containerRef, - enabled: Boolean(pdfDoc) && !drawShape && !areaSelectEnabled, // 形状/图像框选时关闭划词选择 + enabled: Boolean(pdfDoc) && !drawShape && !drawText, onTextSelected, onSelectionCleared, }); + usePdfViewportProgress({ containerRef, pageRefs, numPages, initialPage, onProgressChange }); + // Cmd(mac)/ Ctrl(win)+ 滚轮缩放。必须用非 passive 原生监听才能 preventDefault。 useEffect(() => { const container = containerRef.current; @@ -165,29 +158,7 @@ const PdfReaderViewer = forwardRef( ); pageRefs.current.forEach((el) => el && io.observe(el)); return () => io.disconnect(); - }, [numPages]); - - if (error) { - return ( -
-
-

PDF 加载失败

-

{error}

-
-
- ); - } - - if (!pdfDoc) { - return ( -
-
-
-

正在加载 PDF...

-
-
- ); - } + }, [numPages, pdfDoc]); return (
( pdfDoc={pdfDoc} scale={scale} renderScale={renderScale} - areaSelectEnabled={areaSelectEnabled} + readingAreaSelectionEnabled={readingAreaSelectionEnabled} devicePixelRatio={devicePixelRatio} shouldRender={renderedPages.has(pageNum) || (renderedPages.size === 0 && pageNum <= 4)} pageNotes={notesByPage.get(pageNum) ?? []} @@ -210,10 +181,15 @@ const PdfReaderViewer = forwardRef( onAreaSelected={onAreaSelected} onAreaSelectError={onAreaSelectError} drawShape={drawShape} + drawText={drawText} drawColor={drawColor} drawFill={drawFill} onShapeDrawn={onShapeDrawn} - onShapeMove={onShapeMove} + onTextDrawn={onTextDrawn} + onTextUpdate={onTextUpdate} + onTextDelete={onTextDelete} + onTextColorChange={onTextColorChange} + onAnnotationMove={onAnnotationMove} ref={(el) => { pageRefs.current[pageNum - 1] = el; }} @@ -255,7 +231,7 @@ interface PdfPageProps { pdfDoc: PDFDocumentProxy; scale: number; renderScale: number; - areaSelectEnabled: boolean; + readingAreaSelectionEnabled: boolean; devicePixelRatio: number; shouldRender: boolean; pageNotes: PaperNote[]; @@ -264,14 +240,19 @@ interface PdfPageProps { onAreaSelected?: (selection: ReaderImageSelection) => void; onAreaSelectError?: (message: string) => void; drawShape?: ShapeStyle | null; + drawText: boolean; drawColor?: HighlightColor; drawFill?: HighlightColor | null; onShapeDrawn?: (page: number, rect: NormalizedRect) => void; - onShapeMove?: (note: PaperNote, rect: NormalizedRect) => void; + onTextDrawn?: (page: number, rect: NormalizedRect, content: string) => void; + onTextUpdate?: (note: PaperNote, content: string) => void; + onTextDelete?: (note: PaperNote) => void; + onTextColorChange?: (note: PaperNote, color: HighlightColor) => void; + onAnnotationMove?: (note: PaperNote, rect: NormalizedRect) => void; } const PdfPage = forwardRef(function PdfPage( - { pageNum, pdfDoc, scale, renderScale, areaSelectEnabled, devicePixelRatio, shouldRender, pageNotes, onNoteClick, onScrollToPage, onAreaSelected, onAreaSelectError, drawShape, drawColor, drawFill, onShapeDrawn, onShapeMove }, + { pageNum, pdfDoc, scale, renderScale, readingAreaSelectionEnabled, devicePixelRatio, shouldRender, pageNotes, onNoteClick, onScrollToPage, onAreaSelected, onAreaSelectError, drawShape, drawText, drawColor, drawFill, onShapeDrawn, onTextDrawn, onTextUpdate, onTextDelete, onTextColorChange, onAnnotationMove }, ref, ) { const imageRef = useRef(null); @@ -513,7 +494,7 @@ const PdfPage = forwardRef(function PdfPage( [drawShape, onShapeDrawn, pageNum], ); - // 形状批注:按住拖动改位置;没怎么动(视为点击)则打开编辑弹窗。 + // 形状批注:按住拖动改位置;没怎么动(视为点击)则打开编辑浮层。 const startShapeMove = useCallback( (event: React.MouseEvent, note: PaperNote, box: NormalizedRect) => { event.stopPropagation(); @@ -539,13 +520,13 @@ const PdfPage = forwardRef(function PdfPage( window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); setMovingShape(null); - if (moved) onShapeMove?.(note, rectAt(e.clientX, e.clientY)); + if (moved) onAnnotationMove?.(note, rectAt(e.clientX, e.clientY)); else onNoteClick(note, { x: elRect.left + elRect.width / 2, y: elRect.top }); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }, - [pageSize, onShapeMove, onNoteClick], + [pageSize, onAnnotationMove, onNoteClick], ); const { @@ -557,12 +538,13 @@ const PdfPage = forwardRef(function PdfPage( handlePageMouseLeave, } = useLineHighlightInteraction({ pageNotes, pageSize, onNoteClick }); const { draftRect: areaDraftRect, startAreaSelection } = usePdfAreaSelection({ - enabled: areaSelectEnabled, + enabled: readingAreaSelectionEnabled, page: pageNum, pageSize, imageRef, onAreaSelected, onError: onAreaSelectError, + requireAltKey: true, }); const visualScaleRatio = renderedScale > 0 ? scale / renderedScale : 1; @@ -592,9 +574,10 @@ const PdfPage = forwardRef(function PdfPage( data-page-num={pageNum} className="rc-selectable relative mx-auto select-text" style={{ ...containerStyle, background: "white", boxShadow: "0 2px 12px rgba(0,0,0,0.08)", borderRadius: 4 }} - onClick={areaSelectEnabled ? undefined : handlePageClick} - onMouseMove={areaSelectEnabled ? undefined : handlePageMouseMove} + onClick={handlePageClick} + onMouseMove={handlePageMouseMove} onMouseLeave={handlePageMouseLeave} + onMouseDownCapture={readingAreaSelectionEnabled ? startAreaSelection : undefined} >
{imgSrc ? ( @@ -617,7 +600,7 @@ const PdfPage = forwardRef(function PdfPage( ? pageNotes.map((note) => { const color = HIGHLIGHT_COLORS[note.highlight_color]; - // 形状:整段套一个外框(矩形/圆角/椭圆),只用颜色描边、内部不填充。 + // 形状:整段套一个外框(矩形/圆角/椭圆)。 if (isShapeStyle(note.style)) { const stored = boundingRect(note.highlight_positions ?? []); const box = movingShape?.id === note.id ? movingShape.rect : stored; @@ -649,6 +632,10 @@ const PdfPage = forwardRef(function PdfPage( ); } + if (isTextStyle(note.style)) { + return null; + } + const positions = mergeNormalizedRects(note.highlight_positions ?? []); if (positions.length === 0) return null; return positions.map((pos, i) => { @@ -708,6 +695,21 @@ const PdfPage = forwardRef(function PdfPage(
) : null} + {pageSize ? ( + + ) : null} + {pageSize && drawShape ? (
(function PdfPage(
) : null} - {pageSize && areaSelectEnabled ? ( + {pageSize && areaDraftRect ? (
- {areaDraftRect ? ( -
- ) : null} +
) : null}
diff --git a/apps/desktop/src/features/reader/PdfTextAnnotationLayer.tsx b/apps/desktop/src/features/reader/PdfTextAnnotationLayer.tsx new file mode 100644 index 00000000..7d11809d --- /dev/null +++ b/apps/desktop/src/features/reader/PdfTextAnnotationLayer.tsx @@ -0,0 +1,167 @@ +import { useCallback, useEffect, useState } from "react"; +import TextAnnotationInput from "./TextAnnotationInput"; +import { + boundingRect, + HIGHLIGHT_COLORS, + isTextStyle, + type HighlightColor, + type NormalizedRect, + type PaperNote, +} from "./readerTypes"; +import { clampTextAnnotationInputPosition, createTextAnnotationRect } from "./readerTextAnnotations"; + +interface PdfTextAnnotationLayerProps { + page: number; + pageSize: { w: number; h: number }; + notes: PaperNote[]; + enabled: boolean; + color?: HighlightColor; + onCreate?: (page: number, rect: NormalizedRect, content: string) => void; + onUpdate?: (note: PaperNote, content: string) => void; + onDelete?: (note: PaperNote) => void; + onColorChange?: (note: PaperNote, color: HighlightColor) => void; + onMove?: (note: PaperNote, rect: NormalizedRect) => void; +} + +/** 自由文字批注层:负责创建、原位编辑与拖动,不把交互状态留在 PDF 页面组件中。 */ +export default function PdfTextAnnotationLayer({ + page, + pageSize, + notes, + enabled, + color, + onCreate, + onUpdate, + onDelete, + onColorChange, + onMove, +}: PdfTextAnnotationLayerProps) { + const [draft, setDraft] = useState(null); + const [editingId, setEditingId] = useState(null); + const [moving, setMoving] = useState<{ id: string; rect: NormalizedRect } | null>(null); + + useEffect(() => { + if (!enabled) setDraft(null); + }, [enabled]); + + const startCreate = useCallback( + (event: React.MouseEvent) => { + if (!enabled || !onCreate) return; + event.preventDefault(); + event.stopPropagation(); + const bounds = event.currentTarget.getBoundingClientRect(); + if (bounds.width === 0 || bounds.height === 0) return; + setDraft(createTextAnnotationRect(event.clientX, event.clientY, bounds)); + setEditingId(null); + }, + [enabled, onCreate], + ); + + const startMove = useCallback( + (event: React.MouseEvent, note: PaperNote, box: NormalizedRect) => { + event.stopPropagation(); + event.preventDefault(); + const startX = event.clientX; + const startY = event.clientY; + const pageRect = (event.currentTarget.closest("[data-page-num]") as HTMLElement | null)?.getBoundingClientRect(); + if (!pageRect || pageRect.width === 0 || pageRect.height === 0) return; + let moved = false; + const rectAt = (clientX: number, clientY: number): NormalizedRect => ({ + x: Math.min(1 - box.w, Math.max(0, box.x + (clientX - startX) / pageRect.width)), + y: Math.min(1 - box.h, Math.max(0, box.y + (clientY - startY) / pageRect.height)), + w: box.w, + h: box.h, + }); + const onPointerMove = (moveEvent: MouseEvent) => { + if (Math.abs(moveEvent.clientX - startX) > 3 || Math.abs(moveEvent.clientY - startY) > 3) moved = true; + setMoving({ id: note.id, rect: rectAt(moveEvent.clientX, moveEvent.clientY) }); + }; + const onPointerUp = (upEvent: MouseEvent) => { + window.removeEventListener("mousemove", onPointerMove); + window.removeEventListener("mouseup", onPointerUp); + setMoving(null); + if (moved) onMove?.(note, rectAt(upEvent.clientX, upEvent.clientY)); + else setEditingId(note.id); + }; + window.addEventListener("mousemove", onPointerMove); + window.addEventListener("mouseup", onPointerUp); + }, + [onMove], + ); + + const textNotes = notes.filter((note) => isTextStyle(note.style)); + + return ( + <> + {textNotes.map((note) => { + const stored = boundingRect(note.highlight_positions ?? []); + const box = moving?.id === note.id ? moving.rect : stored; + if (!box || !note.content.trim()) return null; + if (editingId === note.id) { + const inputPosition = clampTextAnnotationInputPosition(box, pageSize); + return ( + onColorChange?.(note, nextColor)} + onDelete={() => { + setEditingId(null); + onDelete?.(note); + }} + onCancel={() => setEditingId(null)} + onSubmit={(content) => { + setEditingId(null); + onUpdate?.(note, content); + }} + /> + ); + } + const noteColor = HIGHLIGHT_COLORS[note.highlight_color]; + return ( +
startMove(event, note, box)} + > + {note.content} +
+ ); + })} + + {draft && color ? ( + setDraft(null)} + onSubmit={(content) => { + onCreate?.(page, draft, content); + setDraft(null); + }} + /> + ) : null} + + {enabled ? ( +
+ ) : null} + + ); +} diff --git a/apps/desktop/src/features/reader/ReaderPaperList.tsx b/apps/desktop/src/features/reader/ReaderPaperList.tsx index 6d4b46e3..bfad52b3 100644 --- a/apps/desktop/src/features/reader/ReaderPaperList.tsx +++ b/apps/desktop/src/features/reader/ReaderPaperList.tsx @@ -1,40 +1,20 @@ -import { useEffect, useMemo, useState } from "react"; -import { FileText, Search } from "lucide-react"; -import type { Paper } from "@research-copilot/types"; -import { papersApi } from "../../lib/client"; +import { useMemo, useState } from "react"; +import { Search } from "lucide-react"; +import { usePaperLibrarySnapshot } from "../papers/usePaperLibrarySnapshot"; interface ReaderPaperListProps { currentId?: string; onSelect: (id: string) => void; width?: number; onDragStart?: (event: React.MouseEvent) => void; + embedded?: boolean; } /** 阅读页左侧的库内论文列表,点击切换到另一篇论文阅读。 */ -export default function ReaderPaperList({ currentId, onSelect, width, onDragStart }: ReaderPaperListProps) { - const [papers, setPapers] = useState([]); - const [loading, setLoading] = useState(true); +export default function ReaderPaperList({ currentId, onSelect, width, onDragStart, embedded = false }: ReaderPaperListProps) { + const { papers, loading } = usePaperLibrarySnapshot(); const [keyword, setKeyword] = useState(""); - useEffect(() => { - let cancelled = false; - setLoading(true); - papersApi - .list(0, 200) - .then((rows) => { - if (!cancelled) setPapers(Array.isArray(rows) ? rows : []); - }) - .catch(() => { - if (!cancelled) setPapers([]); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, []); - const filtered = useMemo(() => { const kw = keyword.trim().toLowerCase(); if (!kw) return papers; @@ -45,16 +25,10 @@ export default function ReaderPaperList({ currentId, onSelect, width, onDragStar return (