From b50a1b6fe9cb580c4fc245f1589411cea703cfca Mon Sep 17 00:00:00 2001 From: jerry609 <1772030600@qq.com> Date: Thu, 12 Mar 2026 10:50:00 +0800 Subject: [PATCH 1/2] feat(web): add Obsidian handoff to Research workspace --- .../components/research/ResearchPageNew.tsx | 59 +++++++- web/src/components/research/SavedTab.tsx | 143 ++++++++++++++++-- web/src/components/research/SearchBox.tsx | 20 ++- web/src/lib/obsidian.test.ts | 36 +++++ web/src/lib/obsidian.ts | 44 ++++++ 5 files changed, 284 insertions(+), 18 deletions(-) create mode 100644 web/src/lib/obsidian.test.ts create mode 100644 web/src/lib/obsidian.ts diff --git a/web/src/components/research/ResearchPageNew.tsx b/web/src/components/research/ResearchPageNew.tsx index 99479db8..281d0a69 100644 --- a/web/src/components/research/ResearchPageNew.tsx +++ b/web/src/components/research/ResearchPageNew.tsx @@ -26,15 +26,18 @@ import { import { Sheet, SheetContent, + SheetDescription, SheetHeader, SheetTitle, } from "@/components/ui/sheet" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Button } from "@/components/ui/button" import { SearchBox } from "./SearchBox" import { TrackPills } from "./TrackPills" import { SearchResults } from "./SearchResults" import { MemoryTab } from "./MemoryTab" +import { SavedTab } from "./SavedTab" import { CreateTrackModal } from "./CreateTrackModal" import { EditTrackModal } from "./EditTrackModal" import { ManageTracksModal } from "./ManageTracksModal" @@ -83,6 +86,7 @@ export default function ResearchPageNew() { // Memory drawer state const [memoryOpen, setMemoryOpen] = useState(false) + const [workspaceTab, setWorkspaceTab] = useState<"saved" | "memory">("saved") // Anchor mode state (used for search filtering) const [anchorPersonalized, setAnchorPersonalized] = useState(true) @@ -549,7 +553,14 @@ export default function ResearchPageNew() { disabled={loading} anchorMode={anchorPersonalized ? "personalized" : "global"} onAnchorModeChange={(mode) => setAnchorPersonalized(mode === "personalized")} - onOpenMemory={() => setMemoryOpen(true)} + onOpenLibrary={() => { + setWorkspaceTab("saved") + setMemoryOpen(true) + }} + onOpenMemory={() => { + setWorkspaceTab("memory") + setMemoryOpen(true) + }} yearFrom={yearFrom} yearTo={yearTo} onYearFromChange={setYearFrom} @@ -630,12 +641,46 @@ export default function ResearchPageNew() { {/* Memory Sheet Drawer */} - - - Track Memory - -
- + +
+ +
+ Workspace Library + + {activeTrack?.name ? `Track: ${activeTrack.name}` : "Global scope"} + +
+ + Review saved papers, trigger export handoffs, and inspect track memory without leaving the + research workspace. + +
+
+ setWorkspaceTab(value as "saved" | "memory")} + className="flex min-h-0 flex-1 flex-col" + > + + + Saved + + + Memory + + + + + + + + + +
diff --git a/web/src/components/research/SavedTab.tsx b/web/src/components/research/SavedTab.tsx index 3941390f..6372c129 100644 --- a/web/src/components/research/SavedTab.tsx +++ b/web/src/components/research/SavedTab.tsx @@ -1,11 +1,12 @@ "use client" import { useEffect, useMemo, useState } from "react" -import { Copy, Download, FileText, Loader2, RefreshCw } from "lucide-react" +import { Copy, Download, ExternalLink, FileText, FolderSync, Loader2, RefreshCw } from "lucide-react" import { Button } from "@/components/ui/button" import { getErrorMessage } from "@/lib/fetch" import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" import { Dialog, DialogContent, @@ -21,6 +22,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" +import { buildObsidianExportCommand, describeObsidianScope } from "@/lib/obsidian" import { PaperCard, type Paper } from "./PaperCard" @@ -47,6 +49,7 @@ type SavedResponse = { interface SavedTabProps { userId: string trackId: number | null + trackName?: string | null } function toPaper(item: SavedItem): Paper { @@ -64,7 +67,7 @@ function toPaper(item: SavedItem): Paper { } } -export function SavedTab({ userId, trackId }: SavedTabProps) { +export function SavedTab({ userId, trackId, trackName = null }: SavedTabProps) { const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -73,8 +76,26 @@ export function SavedTab({ userId, trackId }: SavedTabProps) { const [rwLoading, setRwLoading] = useState(false) const [rwMarkdown, setRwMarkdown] = useState(null) const [rwCopied, setRwCopied] = useState(false) + const [obsidianOpen, setObsidianOpen] = useState(false) + const [obsidianVaultPath, setObsidianVaultPath] = useState("~/Obsidian/MyVault") + const [obsidianRootDir, setObsidianRootDir] = useState("PaperBot") + const [obsidianCopied, setObsidianCopied] = useState(false) const papers = useMemo(() => items.map(toPaper), [items]) + const obsidianCommand = useMemo( + () => + buildObsidianExportCommand({ + vaultPath: obsidianVaultPath, + rootDir: obsidianRootDir, + userId, + trackId, + }), + [obsidianRootDir, obsidianVaultPath, trackId, userId] + ) + const obsidianScope = useMemo( + () => describeObsidianScope(trackId, trackName), + [trackId, trackName] + ) const handleExport = async (format: "bibtex" | "ris" | "markdown" | "csl_json") => { const qs = new URLSearchParams({ format, user_id: userId }) @@ -127,6 +148,12 @@ export function SavedTab({ userId, trackId }: SavedTabProps) { setTimeout(() => setRwCopied(false), 2000) } + const handleCopyObsidianCommand = async () => { + await navigator.clipboard.writeText(obsidianCommand) + setObsidianCopied(true) + setTimeout(() => setObsidianCopied(false), 2000) + } + const load = async () => { setLoading(true) setError(null) @@ -159,15 +186,38 @@ export function SavedTab({ userId, trackId }: SavedTabProps) { }, [userId, trackId]) return ( -
-
-

Saved papers scoped to current track.

-
+
+
+
+
+
+ {obsidianScope} + {papers.length} saved +
+

+ {trackId + ? "Saved papers scoped to the current track." + : "Saved papers across your library."} +

+
+
+ @@ -190,6 +240,7 @@ export function SavedTab({ userId, trackId }: SavedTabProps) {
+
{error && ( @@ -200,7 +251,9 @@ export function SavedTab({ userId, trackId }: SavedTabProps) { {loading && !papers.length ? (
Loading saved papers...
) : !papers.length ? ( -
No saved papers in this track.
+
+ {trackId ? "No saved papers in this track yet." : "No saved papers in your library yet."} +
) : (
{papers.map((paper, idx) => ( @@ -247,6 +300,78 @@ export function SavedTab({ userId, trackId }: SavedTabProps) { )} + + + +
+ + Export to Obsidian + + This dashboard does not write vault files directly. Copy the local CLI command below and run + it on the machine that owns your Obsidian vault. + + + +
+ + +
+ {obsidianScope} + {papers.length} saved paper{papers.length === 1 ? "" : "s"} +
+

+ Markdown fallback stays available from the normal export menu if you only need a flat bundle. +

+
+
+ +
+ + +
+ +
+
{obsidianCommand}
+
+ +
+ + + Direct dashboard-triggered vault writes stay out of scope until we have a trusted local bridge or + allowlisted directory model. See issue #346. + +
+
+ + + + + +
+
+
) -} \ No newline at end of file +} diff --git a/web/src/components/research/SearchBox.tsx b/web/src/components/research/SearchBox.tsx index 0b973072..8d942ed1 100644 --- a/web/src/components/research/SearchBox.tsx +++ b/web/src/components/research/SearchBox.tsx @@ -1,7 +1,7 @@ "use client" import { KeyboardEvent, useRef, useState } from "react" -import { Brain, CalendarRange, Check, Loader2, Search } from "lucide-react" +import { BookCopy, Brain, CalendarRange, Check, Loader2, Search } from "lucide-react" import { cn } from "@/lib/utils" import { showTrackMemoryButton } from "@/config/features" @@ -116,6 +116,7 @@ interface SearchBoxProps { anchorMode?: "personalized" | "global" onAnchorModeChange?: (mode: "personalized" | "global") => void onOpenMemory?: () => void + onOpenLibrary?: () => void yearFrom?: string yearTo?: string onYearFromChange?: (value: string) => void @@ -138,6 +139,7 @@ export function SearchBox({ anchorMode = "personalized", onAnchorModeChange, onOpenMemory, + onOpenLibrary, yearFrom = "", yearTo = "", onYearFromChange, @@ -248,14 +250,28 @@ export function SearchBox({ {/* Right side - Memory, Track selector and Search button */}
+ {onOpenLibrary && ( + + )} {onOpenMemory && showTrackMemoryButton() && ( diff --git a/web/src/lib/obsidian.test.ts b/web/src/lib/obsidian.test.ts new file mode 100644 index 00000000..c58abd7e --- /dev/null +++ b/web/src/lib/obsidian.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest" + +import { buildObsidianExportCommand, describeObsidianScope } from "./obsidian" + +describe("buildObsidianExportCommand", () => { + it("builds a track-scoped command with quoted values", () => { + expect( + buildObsidianExportCommand({ + vaultPath: "~/vaults/research", + rootDir: "Research Notes", + userId: "default", + trackId: 7, + }) + ).toBe( + "paperbot export obsidian --user-id 'default' --track-id 7 --root-dir 'Research Notes' --vault '~/vaults/research'" + ) + }) + + it("falls back to a placeholder path when the vault is empty", () => { + expect( + buildObsidianExportCommand({ + vaultPath: " ", + }) + ).toBe("paperbot export obsidian --user-id 'default' --vault '/path/to/your/vault'") + }) +}) + +describe("describeObsidianScope", () => { + it("describes track scope when a track is selected", () => { + expect(describeObsidianScope(12, "Agent Memory")).toBe("Track: Agent Memory") + }) + + it("falls back to global scope text without a track", () => { + expect(describeObsidianScope(null, null)).toBe("Global saved library") + }) +}) diff --git a/web/src/lib/obsidian.ts b/web/src/lib/obsidian.ts new file mode 100644 index 00000000..4e83b3b8 --- /dev/null +++ b/web/src/lib/obsidian.ts @@ -0,0 +1,44 @@ +export interface ObsidianCommandOptions { + vaultPath: string + rootDir?: string + userId?: string + trackId?: number | null +} + +function shellQuote(value: string): string { + return `'${String(value).replace(/'/g, `'\"'\"'`)}'` +} + +export function buildObsidianExportCommand({ + vaultPath, + rootDir = "PaperBot", + userId = "default", + trackId = null, +}: ObsidianCommandOptions): string { + const trimmedVault = vaultPath.trim() + const trimmedRootDir = rootDir.trim() || "PaperBot" + const args = ["paperbot", "export", "obsidian", "--user-id", shellQuote(userId)] + + if (trackId) { + args.push("--track-id", String(trackId)) + } + + if (trimmedRootDir && trimmedRootDir !== "PaperBot") { + args.push("--root-dir", shellQuote(trimmedRootDir)) + } + + if (trimmedVault) { + args.push("--vault", shellQuote(trimmedVault)) + } else { + args.push("--vault", shellQuote("/path/to/your/vault")) + } + + return args.join(" ") +} + +export function describeObsidianScope(trackId: number | null, trackName?: string | null): string { + if (trackId) { + return trackName?.trim() ? `Track: ${trackName.trim()}` : `Track #${trackId}` + } + return "Global saved library" +} From cdcefa0662e0fbea1ad3825038158b29707e9ee8 Mon Sep 17 00:00:00 2001 From: jerry609 <1772030600@qq.com> Date: Thu, 12 Mar 2026 13:11:53 +0800 Subject: [PATCH 2/2] fix(web): address obsidian workspace review feedback --- .../components/research/ResearchPageNew.tsx | 6 +- web/src/components/research/SavedTab.tsx | 85 +++++++++++-------- web/src/lib/obsidian.test.ts | 13 +++ web/src/lib/obsidian.ts | 4 +- 4 files changed, 68 insertions(+), 40 deletions(-) diff --git a/web/src/components/research/ResearchPageNew.tsx b/web/src/components/research/ResearchPageNew.tsx index 281d0a69..07794d16 100644 --- a/web/src/components/research/ResearchPageNew.tsx +++ b/web/src/components/research/ResearchPageNew.tsx @@ -658,7 +658,11 @@ export default function ResearchPageNew() {
setWorkspaceTab(value as "saved" | "memory")} + onValueChange={(value) => { + if (value === "saved" || value === "memory") { + setWorkspaceTab(value) + } + }} className="flex min-h-0 flex-1 flex-col" > diff --git a/web/src/components/research/SavedTab.tsx b/web/src/components/research/SavedTab.tsx index 6372c129..0624fec9 100644 --- a/web/src/components/research/SavedTab.tsx +++ b/web/src/components/research/SavedTab.tsx @@ -80,6 +80,7 @@ export function SavedTab({ userId, trackId, trackName = null }: SavedTabProps) { const [obsidianVaultPath, setObsidianVaultPath] = useState("~/Obsidian/MyVault") const [obsidianRootDir, setObsidianRootDir] = useState("PaperBot") const [obsidianCopied, setObsidianCopied] = useState(false) + const [obsidianCopyError, setObsidianCopyError] = useState(null) const papers = useMemo(() => items.map(toPaper), [items]) const obsidianCommand = useMemo( @@ -99,7 +100,7 @@ export function SavedTab({ userId, trackId, trackName = null }: SavedTabProps) { const handleExport = async (format: "bibtex" | "ris" | "markdown" | "csl_json") => { const qs = new URLSearchParams({ format, user_id: userId }) - if (trackId) qs.set("track_id", String(trackId)) + if (trackId != null) qs.set("track_id", String(trackId)) try { const res = await fetch(`/api/papers/export?${qs.toString()}`) if (!res.ok) throw new Error(`${res.status}`) @@ -149,9 +150,16 @@ export function SavedTab({ userId, trackId, trackName = null }: SavedTabProps) { } const handleCopyObsidianCommand = async () => { - await navigator.clipboard.writeText(obsidianCommand) - setObsidianCopied(true) - setTimeout(() => setObsidianCopied(false), 2000) + try { + await navigator.clipboard.writeText(obsidianCommand) + setObsidianCopyError(null) + setObsidianCopied(true) + setTimeout(() => setObsidianCopied(false), 2000) + } catch { + setObsidianCopied(false) + setObsidianCopyError("Clipboard access failed. Copy the command manually.") + setTimeout(() => setObsidianCopyError(null), 3000) + } } const load = async () => { @@ -163,7 +171,7 @@ export function SavedTab({ userId, trackId, trackName = null }: SavedTabProps) { sort_by: "saved_at", limit: "100", }) - if (trackId) { + if (trackId != null) { qs.set("track_id", String(trackId)) } const res = await fetch(`/api/research/papers/saved?${qs.toString()}`) @@ -195,7 +203,7 @@ export function SavedTab({ userId, trackId, trackName = null }: SavedTabProps) { {papers.length} saved

- {trackId + {trackId != null ? "Saved papers scoped to the current track." : "Saved papers across your library."}

@@ -209,38 +217,38 @@ export function SavedTab({ userId, trackId, trackName = null }: SavedTabProps) { > Obsidian - - - - - - - handleExport("bibtex")}>BibTeX - handleExport("ris")}>RIS - handleExport("markdown")}>Markdown - handleExport("csl_json")}>Zotero (CSL-JSON) - - - + + + + + + + handleExport("bibtex")}>BibTeX + handleExport("ris")}>RIS + handleExport("markdown")}>Markdown + handleExport("csl_json")}>Zotero (CSL-JSON) + + + +
-
{error && ( @@ -252,7 +260,7 @@ export function SavedTab({ userId, trackId, trackName = null }: SavedTabProps) {
Loading saved papers...
) : !papers.length ? (
- {trackId ? "No saved papers in this track yet." : "No saved papers in your library yet."} + {trackId != null ? "No saved papers in this track yet." : "No saved papers in your library yet."}
) : (
@@ -349,6 +357,9 @@ export function SavedTab({ userId, trackId, trackName = null }: SavedTabProps) {
{obsidianCommand}
+ {obsidianCopyError ? ( +

{obsidianCopyError}

+ ) : null}
diff --git a/web/src/lib/obsidian.test.ts b/web/src/lib/obsidian.test.ts index c58abd7e..326d3bd1 100644 --- a/web/src/lib/obsidian.test.ts +++ b/web/src/lib/obsidian.test.ts @@ -23,6 +23,15 @@ describe("buildObsidianExportCommand", () => { }) ).toBe("paperbot export obsidian --user-id 'default' --vault '/path/to/your/vault'") }) + + it("keeps zero track ids instead of dropping them as falsy", () => { + expect( + buildObsidianExportCommand({ + vaultPath: "~/vaults/research", + trackId: 0, + }) + ).toBe("paperbot export obsidian --user-id 'default' --track-id 0 --vault '~/vaults/research'") + }) }) describe("describeObsidianScope", () => { @@ -33,4 +42,8 @@ describe("describeObsidianScope", () => { it("falls back to global scope text without a track", () => { expect(describeObsidianScope(null, null)).toBe("Global saved library") }) + + it("renders zero track ids as track scope", () => { + expect(describeObsidianScope(0, null)).toBe("Track #0") + }) }) diff --git a/web/src/lib/obsidian.ts b/web/src/lib/obsidian.ts index 4e83b3b8..8a7cb648 100644 --- a/web/src/lib/obsidian.ts +++ b/web/src/lib/obsidian.ts @@ -19,7 +19,7 @@ export function buildObsidianExportCommand({ const trimmedRootDir = rootDir.trim() || "PaperBot" const args = ["paperbot", "export", "obsidian", "--user-id", shellQuote(userId)] - if (trackId) { + if (trackId != null) { args.push("--track-id", String(trackId)) } @@ -37,7 +37,7 @@ export function buildObsidianExportCommand({ } export function describeObsidianScope(trackId: number | null, trackName?: string | null): string { - if (trackId) { + if (trackId != null) { return trackName?.trim() ? `Track: ${trackName.trim()}` : `Track #${trackId}` } return "Global saved library"