Skip to content

Commit d0bf4a2

Browse files
committed
feat: 实现引用功能并优化相关组件
1 parent 71543ef commit d0bf4a2

11 files changed

Lines changed: 158 additions & 19 deletions

File tree

packages/app/src/components/chat/ChatPage.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useStreamingChat } from "@/hooks/use-streaming-chat";
55
import { convertToMessageV2, mergeMessagesWithStreaming } from "@/lib/chat-utils";
66
import { useChatReaderStore } from "@/stores/chat-reader-store";
77
import { useChatStore } from "@/stores/chat-store";
8+
import type { CitationPart } from "@/types";
89
import {
910
BookOpen,
1011
Brain,
@@ -207,6 +208,12 @@ export function ChatPage() {
207208
setGeneralActiveThread(null);
208209
}, [setGeneralActiveThread]);
209210

211+
const handleCitationClick = useCallback((citation: CitationPart) => {
212+
// TODO: Navigate to reader page with this citation
213+
// For now, log to console. Future enhancement: use router to navigate to /reader/${citation.bookId}?cfi=${citation.cfi}
214+
console.log('Citation clicked:', citation);
215+
}, []);
216+
210217
const displayMessages = convertToMessageV2(activeThread?.messages || []);
211218
const allMessages = mergeMessagesWithStreaming(displayMessages, currentMessage, isStreaming);
212219

@@ -249,11 +256,12 @@ export function ChatPage() {
249256
{/* Message list or empty state - consistent container structure */}
250257
<div className="flex-1 overflow-hidden">
251258
{allMessages.length > 0 ? (
252-
<MessageList
253-
messages={allMessages}
259+
<MessageList
260+
messages={allMessages}
254261
isStreaming={isStreaming}
255262
currentStep={currentStep}
256263
onStop={stopStream}
264+
onCitationClick={handleCitationClick}
257265
/>
258266
) : (
259267
<EmptyState onSuggestionClick={handleSend} />

packages/app/src/components/chat/ChatPanel.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { useStreamingChat } from "@/hooks/use-streaming-chat";
55
import { convertToMessageV2, mergeMessagesWithStreaming } from "@/lib/chat-utils";
66
import { useChatStore } from "@/stores/chat-store";
7-
import type { Book } from "@/types";
7+
import type { Book, CitationPart } from "@/types";
88
import { Brain, History, MessageCirclePlus, Trash2 } from "lucide-react";
99
import { useCallback, useEffect, useRef, useState } from "react";
1010
import { useTranslation } from "react-i18next";
@@ -14,6 +14,7 @@ import { ModelSelector } from "./ModelSelector";
1414

1515
interface ChatPanelProps {
1616
book?: Book | null;
17+
onNavigateToCitation?: (citation: CitationPart) => void;
1718
}
1819

1920
function formatRelativeTime(ts: number, t: (key: string) => string): string {
@@ -29,7 +30,7 @@ function formatRelativeTime(ts: number, t: (key: string) => string): string {
2930
return `${months}mo`;
3031
}
3132

32-
export function ChatPanel({ book }: ChatPanelProps) {
33+
export function ChatPanel({ book, onNavigateToCitation }: ChatPanelProps) {
3334
const { t } = useTranslation();
3435
const bookId = book?.id;
3536

@@ -266,11 +267,12 @@ export function ChatPanel({ book }: ChatPanelProps) {
266267
{/* Messages or empty state */}
267268
<div className="flex-1 overflow-hidden">
268269
{allMessages.length > 0 ? (
269-
<MessageList
270-
messages={allMessages}
270+
<MessageList
271+
messages={allMessages}
271272
isStreaming={isStreaming}
272273
currentStep={currentStep}
273274
onStop={stopStream}
275+
onCitationClick={onNavigateToCitation}
274276
/>
275277
) : (
276278
<div className="flex h-full flex-col items-start justify-end gap-3 overflow-y-auto p-4 pb-6">

packages/app/src/components/chat/MarkdownRenderer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* - Mermaid diagrams via beautiful-mermaid (synchronous SVG rendering)
66
*/
77
import { renderMermaidSVG } from "beautiful-mermaid";
8-
import { Check, Copy } from "lucide-react";
8+
import { Check, Copy, ArrowUpRight } from "lucide-react";
99
import React, { useMemo, useState } from "react";
1010
import Markdown from "react-markdown";
1111
import rehypeHighlight from "rehype-highlight";
@@ -108,6 +108,7 @@ function processCitationText(
108108
title={`${citation.chapterTitle}: ${citation.text.slice(0, 50)}${citation.text.length > 50 ? "..." : ""}`}
109109
>
110110
[{num}]
111+
<ArrowUpRight className="inline h-2.5 w-2.5 ml-0.5" />
111112
</button>
112113
);
113114
}

packages/app/src/components/chat/PartRenderer.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ export function PartRenderer({ part, citations, onCitationClick }: PartProps) {
7676
case "tool_call":
7777
return <ToolCallPartView part={part} />;
7878
case "citation":
79-
return <CitationPartView part={part} onCitationClick={onCitationClick} />;
79+
// Don't render citation parts as standalone cards
80+
// Citations are rendered inline in text via MarkdownRenderer
81+
return null;
8082
case "mindmap":
8183
return <MindmapPartView part={part} />;
8284
default:

packages/app/src/components/reader/ReaderView.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { useLibraryStore } from "@/stores/library-store";
2727
import { useReaderStore } from "@/stores/reader-store";
2828
import { useSettingsStore } from "@/stores/settings-store";
2929
import { useNotebookStore } from "@/stores/notebook-store";
30-
import type { HighlightColor } from "@/types";
30+
import type { HighlightColor, CitationPart } from "@/types";
3131
import { X } from "lucide-react";
3232
import { useCallback, useEffect, useRef, useState } from "react";
3333
import { useTranslation } from "react-i18next";
@@ -939,6 +939,25 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) {
939939
});
940940
}, []);
941941

942+
const handleNavigateToCitation = useCallback((citation: CitationPart) => {
943+
// Validate CFI before attempting navigation
944+
if (!citation.cfi || citation.cfi.trim() === "") {
945+
console.warn("Citation has no valid CFI, cannot navigate:", {
946+
chapterTitle: citation.chapterTitle,
947+
chapterIndex: citation.chapterIndex,
948+
text: citation.text.slice(0, 50),
949+
});
950+
// TODO: Consider fallback navigation using chapter index
951+
return;
952+
}
953+
954+
try {
955+
foliateRef.current?.goToCFI(citation.cfi);
956+
} catch (error) {
957+
console.error("Failed to navigate to citation:", error, citation);
958+
}
959+
}, []);
960+
942961
if (!readerTab) {
943962
return <div className="flex h-full items-center justify-center">{t("common.loading")}</div>;
944963
}
@@ -1180,7 +1199,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) {
11801199
</button>
11811200
</div>
11821201
<div className="flex-1 overflow-hidden">
1183-
<ChatPanel book={book} />
1202+
<ChatPanel book={book} onNavigateToCitation={handleNavigateToCitation} />
11841203
</div>
11851204
</div>
11861205
)}

packages/app/src/hooks/use-streaming-chat.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import { useChatStore } from "@/stores/chat-store";
33
import { useSettingsStore } from "@/stores/settings-store";
44
import { getSkills as getDbSkills } from "@/lib/db/database";
55
import { getBuiltinSkills } from "@/lib/ai/skills/builtin-skills";
6-
import type { Book, SemanticContext, Skill, Thread, Part, TextPart, ToolCallPart, MessageV2, ReasoningPart } from "@/types";
6+
import type { Book, SemanticContext, Skill, Thread, Part, TextPart, ToolCallPart, MessageV2, ReasoningPart, CitationPart } from "@/types";
77
import { useCallback, useRef, useState } from "react";
88
import {
99
createTextPart,
1010
createReasoningPart,
1111
createToolCallPart,
1212
createQuotePart,
1313
createMindmapPart,
14+
createCitationPart,
1415
} from "@/types/message";
1516
import type { AttachedQuote } from "@/components/chat/ChatInput";
1617

@@ -241,6 +242,17 @@ export function useStreamingChat(options?: StreamingChatOptions) {
241242
markdown: (p as import("@/types/message").MindmapPart).markdown,
242243
};
243244
}
245+
if (p.type === "citation") {
246+
// Store citation data so it can be reconstructed from database
247+
return {
248+
...base,
249+
bookId: (p as CitationPart).bookId,
250+
chapterTitle: (p as CitationPart).chapterTitle,
251+
chapterIndex: (p as CitationPart).chapterIndex,
252+
cfi: (p as CitationPart).cfi,
253+
text: (p as CitationPart).text,
254+
};
255+
}
244256
return base;
245257
});
246258

@@ -301,11 +313,33 @@ export function useStreamingChat(options?: StreamingChatOptions) {
301313
.map((p) => (p as TextPart).text)
302314
.join("\n");
303315

304-
const partsOrder = currentParts.map((p) => ({
305-
type: p.type as "text" | "reasoning" | "tool_call" | "citation" | "mindmap",
306-
id: p.id,
307-
...(p.type === "text" ? { text: (p as TextPart).text } : {}),
308-
}));
316+
const partsOrder = currentParts.map((p) => {
317+
const base = {
318+
type: p.type as "text" | "reasoning" | "tool_call" | "citation" | "mindmap",
319+
id: p.id,
320+
};
321+
if (p.type === "text") {
322+
return { ...base, text: (p as TextPart).text };
323+
}
324+
if (p.type === "mindmap") {
325+
return {
326+
...base,
327+
title: (p as import("@/types/message").MindmapPart).title,
328+
markdown: (p as import("@/types/message").MindmapPart).markdown,
329+
};
330+
}
331+
if (p.type === "citation") {
332+
return {
333+
...base,
334+
bookId: (p as CitationPart).bookId,
335+
chapterTitle: (p as CitationPart).chapterTitle,
336+
chapterIndex: (p as CitationPart).chapterIndex,
337+
cfi: (p as CitationPart).cfi,
338+
text: (p as CitationPart).text,
339+
};
340+
}
341+
return base;
342+
});
309343

310344
const errorMessage = {
311345
id: messageId,
@@ -408,6 +442,23 @@ export function useStreamingChat(options?: StreamingChatOptions) {
408442
currentStep: "thinking",
409443
}));
410444
},
445+
onCitation: (citation) => {
446+
// Create a CitationPart for each citation event
447+
const citationPart = createCitationPart(
448+
citation.bookId,
449+
citation.chapterTitle,
450+
citation.chapterIndex,
451+
citation.cfi,
452+
citation.text
453+
);
454+
currentParts.push(citationPart);
455+
setState((prev) => ({
456+
...prev,
457+
currentMessage: prev.currentMessage
458+
? { ...prev.currentMessage, parts: [...currentParts] }
459+
: null,
460+
}));
461+
},
411462
});
412463
} catch (err) {
413464
setError(err instanceof Error ? err : new Error("Unknown error"));

packages/app/src/lib/ai/agents/reading-agent.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,17 @@ export type AgentStreamEvent =
2727
content: string;
2828
stepType: "thinking" | "planning" | "analyzing" | "deciding";
2929
}
30-
| { type: "citation"; citation: { id: string; chapterTitle: string; text: string; cfi: string } }
30+
| {
31+
type: "citation";
32+
citation: {
33+
id: string;
34+
bookId: string;
35+
chapterTitle: string;
36+
chapterIndex: number;
37+
cfi: string;
38+
text: string;
39+
};
40+
}
3141
| { type: "error"; error: string };
3242

3343
export interface ReadingAgentOptions {
@@ -319,6 +329,25 @@ export async function* streamReadingAgent(
319329
} catch {
320330
/* keep as string */
321331
}
332+
333+
// Emit citation event for addCitation tool results
334+
if (event.name === "addCitation" && result && typeof result === "object") {
335+
const citationData = result as Record<string, unknown>;
336+
if (citationData.type === "citation") {
337+
yield {
338+
type: "citation",
339+
citation: {
340+
id: `citation-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
341+
bookId: citationData.bookId as string,
342+
chapterTitle: citationData.chapterTitle as string,
343+
chapterIndex: citationData.chapterIndex as number,
344+
cfi: citationData.cfi as string,
345+
text: citationData.text as string,
346+
},
347+
};
348+
}
349+
}
350+
322351
yield { type: "tool_result", name: event.name, result };
323352
}
324353
}

packages/app/src/lib/ai/streaming.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ export interface StreamingOptions {
2424
onToolCall?: (toolName: string, args: Record<string, unknown>) => void;
2525
onToolResult?: (toolName: string, result: unknown) => void;
2626
onReasoning?: (content: string, type?: "thinking" | "planning" | "analyzing" | "deciding") => void;
27+
onCitation?: (citation: {
28+
id: string;
29+
bookId: string;
30+
chapterTitle: string;
31+
chapterIndex: number;
32+
cfi: string;
33+
text: string;
34+
}) => void;
2735
}
2836

2937
export class StreamingChat {
@@ -97,6 +105,7 @@ export class StreamingChat {
97105
break;
98106

99107
case "citation":
108+
options.onCitation?.(event.citation);
100109
break;
101110

102111
case "error":

packages/app/src/lib/ai/tools.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ function createRagContextTool(bookId: string): ToolDefinition {
105105
return {
106106
name: "ragContext",
107107
description:
108-
"Get surrounding text context for a specific chapter. Use this when the user asks about content near a specific location.",
108+
"Get surrounding text context for a specific chapter. Use this when the user asks about content near a specific location. Returns chunks with CFI information - use the CFI from the chunk containing your quoted text when calling addCitation.",
109109
parameters: {
110110
chapterIndex: { type: "number", description: "The chapter index", required: true },
111111
range: {
@@ -125,7 +125,12 @@ function createRagContextTool(bookId: string): ToolDefinition {
125125

126126
return {
127127
chapterTitle: chapterChunks[0]?.chapterTitle || "Unknown",
128+
chapterIndex: chapterIndex,
128129
context: contextChunks.map((c) => c.content).join("\n\n"),
130+
chunks: contextChunks.map((c) => ({
131+
content: c.content,
132+
cfi: c.startCfi || "",
133+
})),
129134
chunksIncluded: contextChunks.length,
130135
};
131136
},

packages/app/src/lib/chat-utils.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,19 @@ export function convertToMessageV2(messages: any[]): MessageV2[] {
9090
}
9191
break;
9292
}
93+
case "citation":
94+
parts.push({
95+
id: entry.id,
96+
type: "citation",
97+
bookId: entry.bookId,
98+
chapterTitle: entry.chapterTitle,
99+
chapterIndex: entry.chapterIndex,
100+
cfi: entry.cfi,
101+
text: entry.text,
102+
status: "completed",
103+
createdAt: m.createdAt,
104+
});
105+
break;
93106
case "mindmap":
94107
parts.push({
95108
id: entry.id,

0 commit comments

Comments
 (0)