diff --git a/apps/api/deps.py b/apps/api/deps.py index bbe2604..a8531ab 100644 --- a/apps/api/deps.py +++ b/apps/api/deps.py @@ -106,6 +106,7 @@ def paper_list_response(papers: list, repo: PaperRepository) -> dict: """论文列表统一序列化""" paper_ids = [str(p.id) for p in papers] topic_map = repo.get_topic_names_for_papers(paper_ids) + tag_map = repo.get_tags_for_papers(paper_ids) return { "items": [ { @@ -123,6 +124,7 @@ def paper_list_response(papers: list, repo: PaperRepository) -> dict: "title_zh": (p.metadata_json or {}).get("title_zh", ""), "abstract_zh": (p.metadata_json or {}).get("abstract_zh", ""), "topics": topic_map.get(str(p.id), []), + "tags": tag_map.get(str(p.id), []), } for p in papers ] diff --git a/apps/api/main.py b/apps/api/main.py index ea8abc5..40eba18 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -158,6 +158,7 @@ async def app_error_handler(_request: Request, exc: AppError): papers, pipelines, system, + tags, topics, writing, ) @@ -168,6 +169,7 @@ async def app_error_handler(_request: Request, exc: AppError): app.include_router(system.router) app.include_router(papers.router) app.include_router(topics.router) +app.include_router(tags.router) app.include_router(cs_feeds.router) app.include_router(graph.router) app.include_router(agent.router) diff --git a/apps/api/routers/papers.py b/apps/api/routers/papers.py index 0f21555..b06f777 100644 --- a/apps/api/routers/papers.py +++ b/apps/api/routers/papers.py @@ -56,6 +56,7 @@ def latest( sort_by: str = Query(default="created_at"), sort_order: str = Query(default="desc"), category: str | None = Query(default=None), + tag_ids: list[str] | None = Query(default=None), ) -> dict: with session_scope() as session: repo = PaperRepository(session) @@ -72,6 +73,7 @@ def latest( else "created_at", sort_order=sort_order if sort_order in ("asc", "desc") else "desc", category=category, + tag_ids=tag_ids, ) resp = paper_list_response(papers, repo) resp["total"] = total @@ -218,6 +220,7 @@ def paper_detail(paper_id: UUID) -> dict: except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc topic_map = repo.get_topic_names_for_papers([str(p.id)]) + tag_map = repo.get_tags_for_papers([str(p.id)]) # 查询已有分析报告 from sqlalchemy import select as _sel @@ -253,6 +256,7 @@ def paper_detail(paper_id: UUID) -> dict: "title_zh": (p.metadata_json or {}).get("title_zh", ""), "abstract_zh": (p.metadata_json or {}).get("abstract_zh", ""), "topics": topic_map.get(str(p.id), []), + "tags": tag_map.get(str(p.id), []), "metadata": p.metadata_json, "has_embedding": p.embedding is not None, "skim_report": skim_data, diff --git a/apps/api/routers/tags.py b/apps/api/routers/tags.py new file mode 100644 index 0000000..02204b8 --- /dev/null +++ b/apps/api/routers/tags.py @@ -0,0 +1,188 @@ +"""标签管理路由 +@author Color2333 +""" + +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Query + +from packages.storage.db import session_scope +from packages.storage.repositories import PaperRepository, TagRepository + +router = APIRouter() + + +@router.get("/tags") +def list_tags() -> dict: + """获取所有标签列表""" + with session_scope() as session: + repo = TagRepository(session) + tags = repo.list_all() + return { + "items": [ + { + "id": tag.id, + "name": tag.name, + "color": tag.color, + "paper_count": getattr(tag, "paper_count", 0), + "created_at": tag.created_at.isoformat() if tag.created_at else None, + "updated_at": tag.updated_at.isoformat() if tag.updated_at else None, + } + for tag in tags + ] + } + + +@router.post("/tags") +def create_tag(name: str, color: str = Query(default="#3b82f6")) -> dict: + """创建新标签""" + if not name or not name.strip(): + raise HTTPException(status_code=400, detail="标签名称不能为空") + with session_scope() as session: + repo = TagRepository(session) + try: + tag = repo.create(name.strip(), color) + return { + "id": tag.id, + "name": tag.name, + "color": tag.color, + "paper_count": 0, + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + +@router.patch("/tags/{tag_id}") +def update_tag( + tag_id: UUID, + name: str | None = Query(default=None), + color: str | None = Query(default=None), +) -> dict: + """更新标签""" + with session_scope() as session: + repo = TagRepository(session) + try: + tag = repo.update(str(tag_id), name=name, color=color) + paper_count = repo.get_paper_count(str(tag_id)) + return { + "id": tag.id, + "name": tag.name, + "color": tag.color, + "paper_count": paper_count, + } + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + + +@router.delete("/tags/{tag_id}") +def delete_tag(tag_id: UUID) -> dict: + """删除标签""" + with session_scope() as session: + repo = TagRepository(session) + tag = repo.get_by_id(str(tag_id)) + if tag is None: + raise HTTPException(status_code=404, detail="标签不存在") + repo.delete(str(tag_id)) + return {"deleted": str(tag_id), "name": tag.name} + + +@router.get("/papers/{paper_id}/tags") +def get_paper_tags(paper_id: UUID) -> dict: + """获取论文的标签""" + with session_scope() as session: + paper_repo = PaperRepository(session) + try: + paper_repo.get_by_id(paper_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + + tags_map = paper_repo.get_tags_for_papers([str(paper_id)]) + return {"items": tags_map.get(str(paper_id), [])} + + +@router.post("/papers/{paper_id}/tags") +def add_paper_tag(paper_id: UUID, tag_id: UUID) -> dict: + """为论文添加标签""" + with session_scope() as session: + paper_repo = PaperRepository(session) + tag_repo = TagRepository(session) + + try: + paper_repo.get_by_id(paper_id) + except ValueError as e: + raise HTTPException(status_code=404, detail="论文不存在") from e + + tag = tag_repo.get_by_id(str(tag_id)) + if tag is None: + raise HTTPException(status_code=404, detail="标签不存在") + + paper_repo.link_to_tag(str(paper_id), str(tag_id)) + session.commit() + + return { + "paper_id": str(paper_id), + "tag": { + "id": tag.id, + "name": tag.name, + "color": tag.color, + }, + } + + +@router.delete("/papers/{paper_id}/tags/{tag_id}") +def remove_paper_tag(paper_id: UUID, tag_id: UUID) -> dict: + """移除论文的标签""" + with session_scope() as session: + paper_repo = PaperRepository(session) + tag_repo = TagRepository(session) + + try: + paper_repo.get_by_id(paper_id) + except ValueError as e: + raise HTTPException(status_code=404, detail="论文不存在") from e + + tag = tag_repo.get_by_id(str(tag_id)) + if tag is None: + raise HTTPException(status_code=404, detail="标签不存在") + + paper_repo.unlink_from_tag(str(paper_id), str(tag_id)) + session.commit() + + return { + "paper_id": str(paper_id), + "tag_id": str(tag_id), + "removed": True, + } + + +@router.post("/papers/{paper_id}/tags/batch") +def batch_update_paper_tags(paper_id: UUID, tag_ids: list[UUID]) -> dict: + """批量更新论文的标签(替换所有标签)""" + with session_scope() as session: + paper_repo = PaperRepository(session) + tag_repo = TagRepository(session) + + try: + paper_repo.get_by_id(paper_id) + except ValueError as e: + raise HTTPException(status_code=404, detail="论文不存在") from e + + current_tags = paper_repo.get_tags_for_papers([str(paper_id)]).get(str(paper_id), []) + current_tag_ids = {t["id"] for t in current_tags} + new_tag_ids = {str(tid) for tid in tag_ids} + + for tid in current_tag_ids - new_tag_ids: + paper_repo.unlink_from_tag(str(paper_id), tid) + + for tid in new_tag_ids - current_tag_ids: + tag = tag_repo.get_by_id(tid) + if tag: + paper_repo.link_to_tag(str(paper_id), tid) + + session.commit() + + updated_tags = paper_repo.get_tags_for_papers([str(paper_id)]).get(str(paper_id), []) + return { + "paper_id": str(paper_id), + "items": updated_tags, + } diff --git a/frontend/src/pages/PaperDetail.tsx b/frontend/src/pages/PaperDetail.tsx index ad564c0..009d024 100644 --- a/frontend/src/pages/PaperDetail.tsx +++ b/frontend/src/pages/PaperDetail.tsx @@ -12,13 +12,14 @@ import { PaperDetailSkeleton } from "@/components/Skeleton"; const Markdown = lazy(() => import("@/components/Markdown")); const PdfReader = lazy(() => import("@/components/PdfReader")); import { useToast } from "@/contexts/ToastContext"; -import { paperApi, pipelineApi } from "@/services/api"; +import { paperApi, pipelineApi, tagApi } from "@/services/api"; import type { Paper, SkimReport, DeepDiveReport, ReasoningChainResult, FigureAnalysisItem, + Tag as TagType, } from "@/types"; import { ArrowLeft, @@ -55,6 +56,7 @@ import { Loader2, Check, Download, + Plus, } from "lucide-react"; /* ================================================================ @@ -225,22 +227,41 @@ export default function PaperDetail() { const [readerOpen, setReaderOpen] = useState(false); const [reportTab, setReportTab] = useState("skim"); + /* 标签相关 */ + const [allTags, setAllTags] = useState([]); + const [tagModalOpen, setTagModalOpen] = useState(false); + const [newTagName, setNewTagName] = useState(""); + const [newTagColor, setNewTagColor] = useState("#3b82f6"); + const [tagsLoading, setTagsLoading] = useState(false); + const skimAbort = useRef(null); const deepAbort = useRef(null); + /* 加载标签列表 */ + const loadTags = useCallback(async () => { + try { + const res = await tagApi.list(); + setAllTags(res.items); + } catch { + // 静默失败 + } + }, []); + useEffect(() => { if (!id) return; setLoading(true); Promise.all([ paperApi.detail(id), paperApi.getFigures(id).catch(() => ({ items: [] as FigureAnalysisItem[] })), + tagApi.list().catch(() => ({ items: [] as TagType[] })), ]) - .then(([p, figRes]) => { + .then(([p, figRes, tagRes]) => { setPaper(p); setEmbedDone(p.has_embedding ?? false); if (p.skim_report) setSavedSkim(p.skim_report); if (p.deep_report) setSavedDeep(p.deep_report); setFigures(figRes.items); + setAllTags(tagRes.items); const rc = p.metadata?.reasoning_chain as ReasoningChainResult | undefined; if (rc) setReasoning(rc); if (p.deep_report) setReportTab("deep"); @@ -434,6 +455,70 @@ export default function PaperDetail() { } }, [id, paper, toast]); + /* 标签管理 */ + const handleToggleTag = useCallback( + async (tagId: string, isSelected: boolean) => { + if (!id) return; + try { + if (isSelected) { + await tagApi.removePaperTag(id, tagId); + setPaper((prev) => + prev + ? { + ...prev, + tags: (prev.tags || []).filter((t) => t.id !== tagId), + } + : prev + ); + } else { + const res = await tagApi.addPaperTag(id, tagId); + setPaper((prev) => + prev + ? { + ...prev, + tags: [...(prev.tags || []), res.tag], + } + : prev + ); + } + } catch { + toast("error", "标签操作失败"); + } + }, + [id, toast] + ); + + const handleCreateTag = useCallback(async () => { + if (!newTagName.trim()) { + toast("error", "标签名称不能为空"); + return; + } + setTagsLoading(true); + try { + const newTag = await tagApi.create(newTagName.trim(), newTagColor); + setAllTags((prev) => [...prev, newTag]); + if (id) { + const res = await tagApi.addPaperTag(id, newTag.id); + setPaper((prev) => + prev + ? { + ...prev, + tags: [...(prev.tags || []), res.tag], + } + : prev + ); + } + toast("success", "标签创建成功"); + setTagModalOpen(false); + setNewTagName(""); + setNewTagColor("#3b82f6"); + } catch { + toast("error", "创建标签失败"); + } finally { + setTagsLoading(false); + } + }, [newTagName, newTagColor, id, toast]); + if (loading) return ; if (!paper) { return ( @@ -565,6 +650,22 @@ export default function PaperDetail() { {t} ))} + {/* 用户自定义标签 */} + {paper.tags && + paper.tags.length > 0 && + paper.tags.map((tag) => ( + + + {tag.name} + + ))} {paper.keywords && paper.keywords.map((kw) => ( ))} + + {/* 标签管理区域 */} +
+
+

标签管理

+ +
+
+ {allTags.length === 0 ? ( +

暂无标签,点击上方按钮创建

+ ) : ( + allTags.map((tag) => { + const isSelected = paper.tags?.some((t) => t.id === tag.id) ?? false; + return ( + + ); + }) + )} +
+
{/* ========== 操作区:一键分析 + 主操作 + 辅助操作 ========== */} @@ -1088,6 +1234,108 @@ export default function PaperDetail() { /> )} + + {/* 新建标签弹窗 */} + {tagModalOpen && ( +
+
+
+

新建标签

+ +
+
+
+ + setNewTagName(e.target.value)} + placeholder="输入标签名称" + className="border-border bg-surface text-ink focus:border-primary h-10 w-full rounded-lg border px-3 text-sm focus:outline-none" + autoFocus + /> +
+
+ +
+ {[ + "#3b82f6", + "#10b981", + "#f59e0b", + "#ef4444", + "#8b5cf6", + "#ec4899", + "#06b6d4", + "#84cc16", + ].map((color) => ( +
+
+
+ 预览: + + {newTagName || "标签名称"} + +
+
+
+ + +
+
+
+ )} ); } diff --git a/frontend/src/pages/Papers.tsx b/frontend/src/pages/Papers.tsx index dbabbc7..1603981 100644 --- a/frontend/src/pages/Papers.tsx +++ b/frontend/src/pages/Papers.tsx @@ -7,9 +7,9 @@ import { useNavigate } from "react-router-dom"; import { Button, Badge, Empty, Spinner, Modal, Input } from "@/components/ui"; import { PaperListSkeleton } from "@/components/Skeleton"; import { useToast } from "@/contexts/ToastContext"; -import { paperApi, ingestApi, topicApi, pipelineApi, actionApi, tasksApi } from "@/services/api"; +import { paperApi, ingestApi, topicApi, pipelineApi, actionApi, tasksApi, tagApi } from "@/services/api"; import { formatDate, truncate } from "@/lib/utils"; -import type { Paper, Topic, FolderStats, CollectionAction } from "@/types"; +import type { Paper, Topic, FolderStats, CollectionAction, Tag as TagType } from "@/types"; import { FileText, Download, @@ -40,6 +40,10 @@ import { CalendarClock, ArrowUp, ArrowDown, + Plus, + X, + Edit2, + Trash2, } from "lucide-react"; /* ========== 类型 ========== */ @@ -118,6 +122,16 @@ export default function Papers() { const [activeCategory, setActiveCategory] = useState(); const [csFeeds, setCsFeeds] = useState<{ category_code: string; category_name: string }[]>([]); + /* 标签相关 */ + const [tags, setTags] = useState([]); + const [activeTagIds, setActiveTagIds] = useState([]); + const [tagSectionOpen, setTagSectionOpen] = useState(false); + const [tagModalOpen, setTagModalOpen] = useState(false); + const [editingTag, setEditingTag] = useState(null); + const [newTagName, setNewTagName] = useState(""); + const [newTagColor, setNewTagColor] = useState("#3b82f6"); + const [tagsLoading, setTagsLoading] = useState(false); + useEffect(() => { clearTimeout(searchTimerRef.current); searchTimerRef.current = setTimeout(() => { @@ -185,6 +199,7 @@ export default function Papers() { sortBy, sortOrder, category: activeCategory || undefined, + tagIds: activeTagIds.length > 0 ? activeTagIds : undefined, }); setPapers(res.items); setTotal(res.total); @@ -206,12 +221,84 @@ export default function Papers() { sortBy, sortOrder, activeCategory, + activeTagIds, toast, ]); + /* 加载标签列表 */ + const loadTags = useCallback(async () => { + setTagsLoading(true); + try { + const res = await tagApi.list(); + setTags(res.items); + } catch { + toast("error", "加载标签列表失败"); + } finally { + setTagsLoading(false); + } + }, [toast]); + + /* 创建/更新标签 */ + const handleSaveTag = useCallback(async () => { + if (!newTagName.trim()) { + toast("error", "标签名称不能为空"); + return; + } + try { + if (editingTag) { + await tagApi.update(editingTag.id, { + name: newTagName.trim(), + color: newTagColor, + }); + toast("success", "标签更新成功"); + } else { + await tagApi.create(newTagName.trim(), newTagColor); + toast("success", "标签创建成功"); + } + setTagModalOpen(false); + setEditingTag(null); + setNewTagName(""); + setNewTagColor("#3b82f6"); + await loadTags(); + } catch (e: unknown) { + toast("error", editingTag ? "更新标签失败" : "创建标签失败"); + } + }, [newTagName, newTagColor, editingTag, loadTags, toast]); + + /* 删除标签 */ + const handleDeleteTag = useCallback( + async (tag: TagType) => { + if (!window.confirm(`确定要删除标签 "${tag.name}" 吗?`)) { + return; + } + try { + await tagApi.delete(tag.id); + toast("success", `标签 "${tag.name}" 已删除`); + setActiveTagIds((prev) => prev.filter((id) => id !== tag.id)); + await loadTags(); + } catch { + toast("error", "删除标签失败"); + } + }, + [loadTags, toast] + ); + + /* 切换标签筛选 */ + const toggleTagFilter = useCallback( + (tagId: string) => { + setActiveTagIds((prev) => { + const newIds = prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]; + return newIds; + }); + setPage(1); + }, + [] + ); + useEffect(() => { loadFolderStats(); - }, [loadFolderStats]); + loadTags(); + }, [loadFolderStats, loadTags]); useEffect(() => { loadPapers(); }, [loadPapers]); @@ -642,6 +729,116 @@ export default function Papers() { )} )} + + {/* ========== 标签筛选 ========== */} +
+
+ + +
+ {tagSectionOpen && ( +
+ {tagsLoading ? ( +
+ +
+ ) : tags.length === 0 ? ( +

+ 暂无标签,点击 + 创建 +

+ ) : ( +
+ {tags.map((tag) => { + const isActive = activeTagIds.includes(tag.id); + return ( +
+ +
+ + +
+
+ ); + })} +
+ )} + {activeTagIds.length > 0 && ( + + )} +
+ )}
)} @@ -1004,6 +1201,22 @@ export default function Papers() { loadFolderStats(); }} /> + + { + setTagModalOpen(false); + setEditingTag(null); + setNewTagName(""); + setNewTagColor("#3b82f6"); + }} + editingTag={editingTag} + tagName={newTagName} + tagColor={newTagColor} + onNameChange={setNewTagName} + onColorChange={setNewTagColor} + onSave={handleSaveTag} + /> ); } @@ -1088,6 +1301,19 @@ const PaperListItem = memo(function PaperListItem({ {t}
))} + {paper.tags?.map((tag) => ( + + + {tag.name} + + ))} {paper.keywords?.slice(0, 3).map((kw) => ( void; + editingTag: TagType | null; + tagName: string; + tagColor: string; + onNameChange: (name: string) => void; + onColorChange: (color: string) => void; + onSave: () => void; +}) { + const presetColors = [ + "#3b82f6", + "#10b981", + "#f59e0b", + "#ef4444", + "#8b5cf6", + "#ec4899", + "#06b6d4", + "#84cc16", + ]; + + return ( + +
+ onNameChange(e.target.value)} + /> +
+ +
+ {presetColors.map((color) => ( +
+
+
+ 预览: + + {tagName || "标签名称"} + +
+
+ + +
+
+
+ ); +} + function ActionBadge({ type }: { type: string }) { const cls = "h-3 w-3 shrink-0"; switch (type) { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0c0f863..8dd92fc 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -53,6 +53,9 @@ import type { ActiveTaskInfo, LoginResponse, AuthStatusResponse, + Tag, + TagCreate, + TagUpdate, } from "@/types"; export type { @@ -214,6 +217,30 @@ export const topicApi = { csFeedDelete: (categoryCode: string) => del<{ deleted: boolean }>(`/cs/feeds/${categoryCode}`), }; +/* ========== 标签 ========== */ +export const tagApi = { + list: () => get<{ items: Tag[] }>("/tags"), + create: (name: string, color?: string) => { + const params = new URLSearchParams({ name }); + if (color) params.append("color", color); + return post(`/tags?${params}`); + }, + update: (id: string, data: { name?: string; color?: string }) => { + const params = new URLSearchParams(); + if (data.name) params.append("name", data.name); + if (data.color) params.append("color", data.color); + return patch(`/tags/${id}?${params}`); + }, + delete: (id: string) => del<{ deleted: string; name: string }>(`/tags/${id}`), + getPaperTags: (paperId: string) => get<{ items: Tag[] }>(`/papers/${paperId}/tags`), + addPaperTag: (paperId: string, tagId: string) => + post<{ paper_id: string; tag: Tag }>(`/papers/${paperId}/tags?tag_id=${tagId}`), + removePaperTag: (paperId: string, tagId: string) => + del<{ paper_id: string; tag_id: string; removed: boolean }>(`/papers/${paperId}/tags/${tagId}`), + batchUpdatePaperTags: (paperId: string, tagIds: string[]) => + post<{ paper_id: string; items: Tag[] }>(`/papers/${paperId}/tags/batch`, tagIds), +}; + /* ========== 论文 ========== */ export const paperApi = { latest: ( @@ -228,6 +255,7 @@ export const paperApi = { sortBy?: string; sortOrder?: string; category?: string; + tagIds?: string[]; } = {} ) => { const params = new URLSearchParams(); @@ -241,6 +269,9 @@ export const paperApi = { if (opts.sortBy) params.append("sort_by", opts.sortBy); if (opts.sortOrder) params.append("sort_order", opts.sortOrder); if (opts.category) params.append("category", opts.category); + if (opts.tagIds && opts.tagIds.length > 0) { + opts.tagIds.forEach((tid) => params.append("tag_ids", tid)); + } return get(`/papers/latest?${params}`); }, folderStats: () => get("/papers/folder-stats"), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6eea8be..15aac1b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -113,6 +113,26 @@ export interface TopicFetchResult { topic?: Topic; } +/* ========== 标签 ========== */ +export interface Tag { + id: string; + name: string; + color: string; + paper_count?: number; + created_at?: string; + updated_at?: string; +} + +export interface TagCreate { + name: string; + color?: string; +} + +export interface TagUpdate { + name?: string; + color?: string; +} + /* ========== 论文 ========== */ export type ReadStatus = "unread" | "skimmed" | "deep_read"; @@ -133,6 +153,7 @@ export interface Paper { title_zh?: string; abstract_zh?: string; topics?: string[]; + tags?: Tag[]; skim_report?: { summary_md: string; skim_score: number | null; diff --git a/infra/migrations/versions/20260303_0009_ieee_mvp.py b/infra/migrations/versions/20260303_0009_ieee_mvp.py index f065f21..2136128 100644 --- a/infra/migrations/versions/20260303_0009_ieee_mvp.py +++ b/infra/migrations/versions/20260303_0009_ieee_mvp.py @@ -19,7 +19,7 @@ # revision identifiers, used by Alembic. revision = "20260303_0009_ieee_mvp" -down_revision: Union[str, None] = "20260228_0008" +down_revision: Union[str, None] = "20260228_0008_agent_conversations" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/infra/migrations/versions/20260415_0001_add_tags_table.py b/infra/migrations/versions/20260415_0001_add_tags_table.py new file mode 100644 index 0000000..98bbd82 --- /dev/null +++ b/infra/migrations/versions/20260415_0001_add_tags_table.py @@ -0,0 +1,49 @@ +"""add tags table + +Revision ID: 20260415_0001 +Revises: b1d72ad8a6ed +Create Date: 2026-04-15 00:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "20260415_0001" +down_revision = "b1d72ad8a6ed" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "tags", + sa.Column("id", sa.String(length=36), primary_key=True, nullable=False), + sa.Column("name", sa.String(length=64), nullable=False, unique=True), + sa.Column("color", sa.String(length=32), nullable=False, server_default="#3b82f6"), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + ) + op.create_index("ix_tags_name", "tags", ["name"], unique=True) + + op.create_table( + "paper_tags", + sa.Column("id", sa.String(length=36), primary_key=True, nullable=False), + sa.Column("paper_id", sa.String(length=36), nullable=False), + sa.Column("tag_id", sa.String(length=36), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.ForeignKeyConstraint(["paper_id"], ["papers.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE"), + sa.UniqueConstraint("paper_id", "tag_id", name="uq_paper_tag"), + ) + op.create_index("ix_paper_tags_paper_id", "paper_tags", ["paper_id"], unique=False) + op.create_index("ix_paper_tags_tag_id", "paper_tags", ["tag_id"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_paper_tags_tag_id", table_name="paper_tags") + op.drop_index("ix_paper_tags_paper_id", table_name="paper_tags") + op.drop_table("paper_tags") + op.drop_index("ix_tags_name", table_name="tags") + op.drop_table("tags") diff --git a/packages/ai/pipelines.py b/packages/ai/pipelines.py index 36a4a8f..8615d1a 100644 --- a/packages/ai/pipelines.py +++ b/packages/ai/pipelines.py @@ -15,7 +15,7 @@ from packages.ai.pdf_parser import PdfTextExtractor from packages.ai.prompts import build_deep_prompt, build_skim_prompt from packages.ai.vision_reader import VisionPdfReader -from packages.config import get_settings +from packages.config import get_ieee_api_key, get_ieee_enabled, get_settings from packages.domain.enums import ActionType, ReadStatus from packages.domain.schemas import DeepDiveReport, PaperCreate, SkimReport from packages.domain.task_tracker import global_tracker @@ -57,8 +57,9 @@ def __init__(self) -> None: self.pdf_extractor = PdfTextExtractor() # IEEE 客户端(MVP 阶段新增) self.ieee: IeeeClient | None = None - if self.settings.ieee_api_key: - self.ieee = IeeeClient(api_key=self.settings.ieee_api_key) + ieee_api_key = get_ieee_api_key() + if ieee_api_key and get_ieee_enabled(): + self.ieee = IeeeClient(api_key=ieee_api_key) logger.info("IEEE 客户端已初始化") else: logger.warning("IEEE API Key 未配置,IEEE 摄取功能将不可用") diff --git a/packages/storage/db.py b/packages/storage/db.py index 0628b07..4fceeaa 100644 --- a/packages/storage/db.py +++ b/packages/storage/db.py @@ -247,6 +247,63 @@ def run_migrations() -> None: # 初始化:给没有 action 的已有论文创建 initial_import 记录 _init_existing_papers_action(conn) + # 初始化标签表 + _init_tags_table(conn) + + +def _init_tags_table(conn) -> None: + """初始化标签表""" + try: + # 检查 tags 表是否存在 + result = conn.execute(text(""" + SELECT name FROM sqlite_master + WHERE type='table' AND name='tags' + """)) + tags_exists = result.fetchone() is not None + + if not tags_exists: + logger.info("Creating tags table...") + conn.execute(text(""" + CREATE TABLE tags ( + id VARCHAR(36) PRIMARY KEY NOT NULL, + name VARCHAR(64) NOT NULL UNIQUE, + color VARCHAR(32) NOT NULL DEFAULT '#3b82f6', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text("CREATE INDEX ix_tags_name ON tags(name)")) + logger.info("tags table created") + + # 检查 paper_tags 表是否存在 + result = conn.execute(text(""" + SELECT name FROM sqlite_master + WHERE type='table' AND name='paper_tags' + """)) + paper_tags_exists = result.fetchone() is not None + + if not paper_tags_exists: + logger.info("Creating paper_tags table...") + conn.execute(text(""" + CREATE TABLE paper_tags ( + id VARCHAR(36) PRIMARY KEY NOT NULL, + paper_id VARCHAR(36) NOT NULL, + tag_id VARCHAR(36) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (paper_id) REFERENCES papers(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE, + UNIQUE(paper_id, tag_id) + ) + """)) + conn.execute(text("CREATE INDEX ix_paper_tags_paper_id ON paper_tags(paper_id)")) + conn.execute(text("CREATE INDEX ix_paper_tags_tag_id ON paper_tags(tag_id)")) + logger.info("paper_tags table created") + + conn.commit() + except Exception as e: + conn.rollback() + logger.error("Failed to initialize tags table: %s", e) + def _init_existing_papers_action(conn) -> None: """为没有行动记录的已有论文创建 initial_import 记录(只执行一次)""" diff --git a/packages/storage/models.py b/packages/storage/models.py index 6036200..4886ffe 100644 --- a/packages/storage/models.py +++ b/packages/storage/models.py @@ -498,6 +498,42 @@ class CSCategory(Base): cached_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow) +class Tag(Base): + """用户自定义标签""" + + __tablename__ = "tags" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + name: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) + color: Mapped[str] = mapped_column(String(32), nullable=False, default="#3b82f6") + created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=_utcnow, onupdate=_utcnow, nullable=False + ) + + +class PaperTag(Base): + """论文-标签关联表""" + + __tablename__ = "paper_tags" + __table_args__ = (UniqueConstraint("paper_id", "tag_id", name="uq_paper_tag"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + paper_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("papers.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + tag_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("tags.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False) + + class CSFeedSubscription(Base): """arXiv CS 分类订阅""" diff --git a/packages/storage/repositories.py b/packages/storage/repositories.py index 79c7ae9..f6b3d5d 100644 --- a/packages/storage/repositories.py +++ b/packages/storage/repositories.py @@ -30,10 +30,12 @@ IeeeApiQuota, LLMProviderConfig, Paper, + PaperTag, PaperTopic, PipelineRun, PromptTrace, SourceCheckpoint, + Tag, TopicSubscription, ) @@ -557,10 +559,12 @@ def list_paginated( sort_by: str = "created_at", sort_order: str = "desc", category: str | None = None, + tag_ids: list[str] | None = None, ) -> tuple[list[Paper], int]: """分页查询论文,返回 (papers, total_count)""" filters = [] need_join_topic = False + need_join_tag = False if search: like_pat = f"%{search}%" @@ -582,6 +586,10 @@ def list_paginated( need_join_topic = True filters.append(PaperTopic.topic_id == topic_id) + if tag_ids and len(tag_ids) > 0: + need_join_tag = True + filters.append(PaperTag.tag_id.in_(tag_ids)) + if status and status in ("unread", "skimmed", "deep_read"): filters.append(Paper.read_status == ReadStatus(status)) @@ -603,6 +611,9 @@ def list_paginated( if need_join_topic: base_q = base_q.join(PaperTopic, Paper.id == PaperTopic.paper_id) count_q = count_q.join(PaperTopic, Paper.id == PaperTopic.paper_id) + if need_join_tag: + base_q = base_q.join(PaperTag, Paper.id == PaperTag.paper_id) + count_q = count_q.join(PaperTag, Paper.id == PaperTag.paper_id) for f in filters: base_q = base_q.where(f) count_q = count_q.where(f) @@ -744,6 +755,110 @@ def get_topic_names_for_papers(self, paper_ids: list[str]) -> dict[str, list[str result.setdefault(pid, []).append(tname) return result + def get_tags_for_papers(self, paper_ids: list[str]) -> dict[str, list[dict]]: + """批量查 paper → tags 映射""" + if not paper_ids: + return {} + q = ( + select(PaperTag.paper_id, Tag.id, Tag.name, Tag.color) + .join(Tag, PaperTag.tag_id == Tag.id) + .where(PaperTag.paper_id.in_(paper_ids)) + ) + rows = self.session.execute(q).all() + result: dict[str, list[dict]] = {} + for pid, tid, tname, tcolor in rows: + result.setdefault(pid, []).append({"id": tid, "name": tname, "color": tcolor}) + return result + + def link_to_tag(self, paper_id: str, tag_id: str) -> None: + """为论文添加标签""" + q = select(PaperTag).where( + PaperTag.paper_id == paper_id, + PaperTag.tag_id == tag_id, + ) + found = self.session.execute(q).scalar_one_or_none() + if found: + return + self.session.add(PaperTag(paper_id=paper_id, tag_id=tag_id)) + + def unlink_from_tag(self, paper_id: str, tag_id: str) -> None: + """移除论文的标签""" + q = select(PaperTag).where( + PaperTag.paper_id == paper_id, + PaperTag.tag_id == tag_id, + ) + found = self.session.execute(q).scalar_one_or_none() + if found: + self.session.delete(found) + + +class TagRepository: + """标签数据仓储""" + + def __init__(self, session: Session): + self.session = session + + def list_all(self) -> list[Tag]: + """获取所有标签,按使用次数排序""" + q = ( + select(Tag, func.count(PaperTag.id).label("paper_count")) + .join(PaperTag, Tag.id == PaperTag.tag_id, isouter=True) + .group_by(Tag.id) + .order_by(func.count(PaperTag.id).desc()) + ) + rows = self.session.execute(q).all() + tags = [] + for row in rows: + tag = row[0] + tag.paper_count = row[1] or 0 + tags.append(tag) + return tags + + def get_by_id(self, tag_id: str) -> Tag | None: + """根据 ID 获取标签""" + return self.session.get(Tag, tag_id) + + def get_by_name(self, name: str) -> Tag | None: + """根据名称获取标签""" + q = select(Tag).where(Tag.name == name) + return self.session.execute(q).scalar_one_or_none() + + def create(self, name: str, color: str = "#3b82f6") -> Tag: + """创建新标签""" + existing = self.get_by_name(name) + if existing: + raise ValueError(f"标签 '{name}' 已存在") + tag = Tag(name=name, color=color) + self.session.add(tag) + self.session.flush() + return tag + + def update(self, tag_id: str, name: str | None = None, color: str | None = None) -> Tag: + """更新标签""" + tag = self.get_by_id(tag_id) + if tag is None: + raise ValueError(f"标签 {tag_id} 不存在") + if name is not None: + existing = self.get_by_name(name) + if existing and existing.id != tag_id: + raise ValueError(f"标签 '{name}' 已存在") + tag.name = name + if color is not None: + tag.color = color + self.session.flush() + return tag + + def delete(self, tag_id: str) -> None: + """删除标签""" + tag = self.get_by_id(tag_id) + if tag is not None: + self.session.delete(tag) + + def get_paper_count(self, tag_id: str) -> int: + """获取标签关联的论文数量""" + q = select(func.count()).select_from(PaperTag).where(PaperTag.tag_id == tag_id) + return self.session.execute(q).scalar() or 0 + class AnalysisRepository: def __init__(self, session: Session): diff --git a/scripts/init_tags_table.py b/scripts/init_tags_table.py new file mode 100644 index 0000000..2a0d4d3 --- /dev/null +++ b/scripts/init_tags_table.py @@ -0,0 +1,76 @@ +""" +初始化标签表 +@author Color2333 +""" + +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from sqlalchemy import create_engine, text +from packages.storage.db import get_database_url + + +def init_tags_table(): + """初始化标签表""" + engine = create_engine(get_database_url()) + + with engine.connect() as conn: + # 检查 tags 表是否存在 + result = conn.execute(text(""" + SELECT name FROM sqlite_master + WHERE type='table' AND name='tags' + """)) + tags_exists = result.fetchone() is not None + + if not tags_exists: + print("创建 tags 表...") + conn.execute(text(""" + CREATE TABLE tags ( + id VARCHAR(36) PRIMARY KEY NOT NULL, + name VARCHAR(64) NOT NULL UNIQUE, + color VARCHAR(32) NOT NULL DEFAULT '#3b82f6', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.execute(text("CREATE INDEX ix_tags_name ON tags(name)")) + print("tags 表创建成功") + else: + print("tags 表已存在") + + # 检查 paper_tags 表是否存在 + result = conn.execute(text(""" + SELECT name FROM sqlite_master + WHERE type='table' AND name='paper_tags' + """)) + paper_tags_exists = result.fetchone() is not None + + if not paper_tags_exists: + print("创建 paper_tags 表...") + conn.execute(text(""" + CREATE TABLE paper_tags ( + id VARCHAR(36) PRIMARY KEY NOT NULL, + paper_id VARCHAR(36) NOT NULL, + tag_id VARCHAR(36) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (paper_id) REFERENCES papers(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE, + UNIQUE(paper_id, tag_id) + ) + """)) + conn.execute(text("CREATE INDEX ix_paper_tags_paper_id ON paper_tags(paper_id)")) + conn.execute(text("CREATE INDEX ix_paper_tags_tag_id ON paper_tags(tag_id)")) + print("paper_tags 表创建成功") + else: + print("paper_tags 表已存在") + + conn.commit() + print("\n标签表初始化完成!") + + +if __name__ == "__main__": + init_tags_table() diff --git "a/\347\254\254\344\272\214\350\275\256\345\244\261\350\264\245.txt" "b/\347\254\254\344\272\214\350\275\256\345\244\261\350\264\245.txt" new file mode 100644 index 0000000..4574c6a --- /dev/null +++ "b/\347\254\254\344\272\214\350\275\256\345\244\261\350\264\245.txt" @@ -0,0 +1 @@ +模型未修改任何内容,检查了一遍后仅仅是重启了后端,但是重启之后加载文件夹统计失败、加载论文列表失败,之前数据库中的论文消失,并且点收集论文功能显示网络连接失败,请检查后端服务是否启动 \ No newline at end of file