From b8a36969d7eb6363740dd1be12f5d8a1201c9a6c Mon Sep 17 00:00:00 2001 From: Feplus2 <202785243+Feplus2@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:47:23 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E5=AF=B9=E8=AF=9D=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E4=B8=BA=20Markdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/lib/export-thread-markdown.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 packages/app/src/lib/export-thread-markdown.ts diff --git a/packages/app/src/lib/export-thread-markdown.ts b/packages/app/src/lib/export-thread-markdown.ts new file mode 100644 index 00000000..6619c87e --- /dev/null +++ b/packages/app/src/lib/export-thread-markdown.ts @@ -0,0 +1,110 @@ +import { getBookById } from "@/services/book-service"; +import type { Thread } from "@/types/thread"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeTextFile } from "@tauri-apps/plugin-fs"; +import type { UIMessage } from "ai"; +import dayjs from "dayjs"; +import { toast } from "sonner"; + +/** + * 将单条消息的 parts 渲染为 Markdown。 + * 只导出 text 和 quote part;reasoning / tool 等过程性 part 与聊天页的定位一致,不进入导出文档。 + */ +function renderMessageMarkdown(message: UIMessage): string { + const parts = Array.isArray(message.parts) ? message.parts : []; + const blocks: string[] = []; + let textBuffer = ""; + + const flushText = () => { + const text = textBuffer.trim(); + if (text) { + blocks.push(text); + } + textBuffer = ""; + }; + + for (const part of parts as any[]) { + if (part?.type === "text") { + textBuffer += part.text ?? ""; + continue; + } + + if (part?.type === "quote") { + flushText(); + const quote = String(part.text ?? "") + .split("\n") + .map((line: string) => `> ${line}`.trimEnd()) + .join("\n"); + if (quote.trim()) { + blocks.push(quote); + } + } + } + + flushText(); + return blocks.join("\n\n"); +} + +/** + * 将整个对话构建为 Markdown 文档(含元信息头) + */ +export function buildThreadMarkdown(thread: Thread, bookTitle?: string): string { + const lines: string[] = []; + + lines.push(`# ${thread.title || "未命名对话"}`); + lines.push(""); + if (bookTitle) { + lines.push(`- 书名:《${bookTitle}》`); + } + lines.push(`- 导出时间:${dayjs().format("YYYY-MM-DD HH:mm:ss")}`); + lines.push(`- 消息数:${thread.messages.length}`); + lines.push(""); + lines.push("---"); + lines.push(""); + + for (const message of thread.messages) { + const body = renderMessageMarkdown(message); + if (!body) continue; + lines.push(message.role === "user" ? "## 🧑 用户" : "## 🤖 AI"); + lines.push(""); + lines.push(body); + lines.push(""); + } + + return lines.join("\n"); +} + +/** + * 弹出保存对话框并将对话导出为 Markdown 文件 + */ +export async function exportThreadToMarkdown(thread: Thread): Promise { + try { + const book = thread.book_id ? await getBookById(thread.book_id).catch(() => null) : null; + const markdown = buildThreadMarkdown(thread, book?.title); + + const safeFileName = (thread.title || "未命名对话").replace(/[<>:"/\\|?*]/g, "").trim() || "未命名对话"; + + const path = await save({ + defaultPath: `${safeFileName}.md`, + filters: [ + { + name: "Markdown", + extensions: ["md"], + }, + ], + }); + + // 用户取消保存,不视为失败 + if (!path) { + return false; + } + + await writeTextFile(path, markdown); + toast.success("对话导出成功"); + return true; + } catch (error) { + console.error("导出对话失败:", error); + toast.error("导出对话失败"); + return false; + } +} From 94cf796b2aa7453bf27938634192d587944115a2 Mon Sep 17 00:00:00 2001 From: Feplus2 <202785243+Feplus2@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:47:23 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=E5=AF=B9=E8=AF=9D=20AI=20=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E4=B8=8E=E6=A0=87=E9=A2=98=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app/src/hooks/use-chat-state.ts | 30 +++++ .../app/src/services/thread-title-service.ts | 124 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 packages/app/src/services/thread-title-service.ts diff --git a/packages/app/src/hooks/use-chat-state.ts b/packages/app/src/hooks/use-chat-state.ts index 4a2f1b26..36023c4e 100644 --- a/packages/app/src/hooks/use-chat-state.ts +++ b/packages/app/src/hooks/use-chat-state.ts @@ -12,10 +12,12 @@ import { getThreadContext, updateThreadContext, } from "@/services/thread-service"; +import { generateThreadTitleWithAI } from "@/services/thread-title-service"; import { type SelectedModel, useProviderStore } from "@/store/provider-store"; import { useThreadStore } from "@/store/thread-store"; import type { ChatReference, MessageMetadata } from "@/types/message"; import type { Thread, ThreadSummary } from "@/types/thread"; +import { useQueryClient } from "@tanstack/react-query"; import type { UIMessage } from "ai"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -86,6 +88,7 @@ export function useChatState(options: UseChatStateOptions): UseChatStateReturn { const currentThread = options.currentThread !== undefined ? options.currentThread : globalThreadStore.currentThread; const setCurrentThread = options.setCurrentThread || globalThreadStore.setCurrentThread; const forceUpdate = useForceUpdate(); + const queryClient = useQueryClient(); const messagesRef = useRef([]); const reasoningTimesRef = useRef<{ [messageId: string]: ReasoningTimes }>({}); @@ -155,11 +158,38 @@ export function useChatState(options: UseChatStateOptions): UseChatStateReturn { return; } + // 首轮问答完成后,若标题仍是占位标题(首条消息截断或"新对话"),用 AI 异步生成简短标题 + const autoNameFirstRound = (thread: Thread) => { + const userMessages = normalizedMessages.filter((m) => m.role === "user"); + const assistantMessages = normalizedMessages.filter((m) => m.role === "assistant"); + if (userMessages.length !== 1 || assistantMessages.length !== 1) return; + + const firstUserParts = userMessages[0]?.parts ?? []; + const firstUserText = firstUserParts.map((p: any) => (p.type === "text" ? p.text : "")).join(""); + const firstQuoteText = (firstUserParts.find((p: any) => p.type === "quote") as any)?.text || ""; + const placeholderTitle = (firstUserText || firstQuoteText || "新对话").slice(0, 50); + const isPlaceholderTitle = !thread.title || thread.title === "新对话" || thread.title === placeholderTitle; + if (!isPlaceholderTitle) return; + + generateThreadTitleWithAI(normalizedMessages, selectedModel ?? undefined) + .then(async (title) => { + if (!title) return; + const renamedThread = await editThread(thread.id, { title }); + setCurrentThread(renamedThread); + queryClient.invalidateQueries({ queryKey: ["threads"] }); + }) + .catch((error) => { + // 自动命名失败静默处理,保留占位标题 + console.warn("AI 自动命名失败,保留占位标题:", error); + }); + }; + const persistMessages = (threadId: string) => editThread(threadId, { messages: normalizedMessages }) .then((updatedThread) => { console.log("Thread updated successfully:", updatedThread.id); setCurrentThread(updatedThread); + autoNameFirstRound(updatedThread); }) .catch((error) => { console.error("Failed to update thread:", error); diff --git a/packages/app/src/services/thread-title-service.ts b/packages/app/src/services/thread-title-service.ts new file mode 100644 index 00000000..4ac7bf42 --- /dev/null +++ b/packages/app/src/services/thread-title-service.ts @@ -0,0 +1,124 @@ +import { createModelInstance } from "@/ai/providers/factory"; +import { useProviderStore } from "@/store/provider-store"; +import { type UIMessage, generateText } from "ai"; + +// 防御性长度上限,prompt 中要求的是 10 字以内 +const MAX_TITLE_LENGTH = 20; + +function extractText(message: UIMessage | undefined, limit: number): string { + if (!message) return ""; + const text = (message.parts ?? []) + .map((part: any) => (part?.type === "text" ? part.text : "")) + .join("") + .trim(); + return text.slice(0, limit); +} + +/** + * 使用AI为对话生成简短标题,失败时返回 null(由调用方决定如何提示) + * 基于首问首答;对话超过一轮时附加最近一轮问答摘录,让标题反映整体内容 + */ +export async function generateThreadTitleWithAI( + messages: UIMessage[], + selectedModel?: { providerId: string; modelId: string }, +): Promise { + try { + const firstUser = messages.find((m) => m.role === "user"); + const firstAssistant = messages.find((m) => m.role === "assistant"); + const userText = extractText(firstUser, 200); + const assistantText = extractText(firstAssistant, 500); + if (!userText && !assistantText) return null; + + // 超过一轮对话时,附加最近一轮问答的摘录 + let latestUserText = ""; + let latestAssistantText = ""; + if (messages.length > 2) { + latestUserText = extractText( + messages.findLast((m) => m.role === "user"), + 200, + ); + latestAssistantText = extractText( + messages.findLast((m) => m.role === "assistant"), + 300, + ); + } + + let modelConfig = selectedModel; + if (!modelConfig) { + const { selectedModel: storeModel } = useProviderStore.getState(); + if (!storeModel) return null; + modelConfig = { + providerId: storeModel.providerId, + modelId: storeModel.modelId, + }; + } + + const modelInstance = createModelInstance(modelConfig.providerId, modelConfig.modelId); + + const { text } = await generateText({ + model: modelInstance, + prompt: buildTitlePrompt(userText, assistantText, latestUserText, latestAssistantText), + maxOutputTokens: 30, + temperature: 0.3, + }); + + return sanitizeTitle(text); + } catch (error) { + console.warn("AI生成对话标题失败:", error); + return null; + } +} + +/** + * 构建对话标题生成的提示词 + */ +function buildTitlePrompt( + userText: string, + assistantText: string, + latestUserText?: string, + latestAssistantText?: string, +): string { + const hasLatest = !!(latestUserText || latestAssistantText); + if (!hasLatest) { + return `请根据以下阅读对话的内容,为这段对话起一个简短的标题。 + +用户提问:${userText || "(无)"} + +AI回答:${assistantText || "(无)"} + +要求: +1. 标题不超过10个字 +2. 概括对话的核心主题 +3. 只输出标题本身,不要引号、不要标点结尾、不要任何解释`; + } + + return `请根据以下阅读对话的内容,为这段对话起一个简短的标题。 + +对话开头: +用户提问:${userText || "(无)"} +AI回答:${assistantText || "(无)"} + +最近内容: +用户提问:${latestUserText || "(无)"} +AI回答:${latestAssistantText || "(无)"} + +以上是该对话的开头和最近内容,请基于对话的整体内容起标题。 + +要求: +1. 标题不超过10个字 +2. 概括对话的核心主题 +3. 只输出标题本身,不要引号、不要标点结尾、不要任何解释`; +} + +/** + * 清洗AI输出:取首行、去引号、去结尾标点、限制长度 + */ +function sanitizeTitle(rawText: string): string | null { + const firstLine = rawText.trim().split("\n")[0]?.trim() ?? ""; + const cleaned = firstLine + .replace(/^[\s"'“”‘’「」『』《》<>#*-]+|[\s"'“”‘’「」『』《》<>]+$/g, "") + .replace(/[。!?!?.…;;,,、::]+$/g, "") + .trim(); + if (!cleaned) return null; + return cleaned.slice(0, MAX_TITLE_LENGTH); +} From 7b4db634dea9d6600646ea9db882b7a4cdc1dbda Mon Sep 17 00:00:00 2001 From: Feplus2 <202785243+Feplus2@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:47:24 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=E5=AF=B9=E8=AF=9D=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E7=AE=A1=E7=90=86=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/side-chat/chat-threads.tsx | 127 +++++++++++++++++- packages/app/src/hooks/use-threads.ts | 65 ++++++++- 2 files changed, 188 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/side-chat/chat-threads.tsx b/packages/app/src/components/side-chat/chat-threads.tsx index 04ccf2e4..13c26d73 100644 --- a/packages/app/src/components/side-chat/chat-threads.tsx +++ b/packages/app/src/components/side-chat/chat-threads.tsx @@ -1,12 +1,17 @@ import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; import { useThreads } from "@/hooks/use-threads"; +import { exportThreadToMarkdown } from "@/lib/export-thread-markdown"; +import { getThreadById } from "@/services/thread-service"; import type { ThreadSummary } from "@/types/thread"; import { Menu } from "@tauri-apps/api/menu"; import { LogicalPosition } from "@tauri-apps/api/window"; import { ask } from "@tauri-apps/plugin-dialog"; import dayjs from "dayjs"; import { ArrowLeft, MessageCircle } from "lucide-react"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; interface ChatThreadsProps { bookId: string | undefined; @@ -15,7 +20,18 @@ interface ChatThreadsProps { } export function ChatThreads({ bookId, onBack, onSelectThread }: ChatThreadsProps) { - const { threads, error, status, handleDeleteThread: deleteThreadFn } = useThreads({ bookId }); + const { + threads, + error, + status, + handleDeleteThread: deleteThreadFn, + handleRenameThread: renameThreadFn, + handleAiRenameThread: aiRenameThreadFn, + } = useThreads({ bookId }); + + const [renameTarget, setRenameTarget] = useState(null); + const [renameTitle, setRenameTitle] = useState(""); + const [isRenaming, setIsRenaming] = useState(false); const handleNativeDelete = useCallback( async (thread: ThreadSummary) => { @@ -35,6 +51,52 @@ export function ChatThreads({ bookId, onBack, onSelectThread }: ChatThreadsProps [deleteThreadFn], ); + const handleOpenRename = useCallback((thread: ThreadSummary) => { + setRenameTitle(thread.title || ""); + setRenameTarget(thread); + }, []); + + const handleConfirmRename = useCallback(async () => { + if (!renameTarget) return; + + const title = renameTitle.trim(); + if (!title) { + toast.error("标题不能为空"); + return; + } + + setIsRenaming(true); + try { + await renameThreadFn(renameTarget.id, title); + setRenameTarget(null); + } catch { + // 失败提示已在 useThreads 中处理 + } finally { + setIsRenaming(false); + } + }, [renameTarget, renameTitle, renameThreadFn]); + + const handleExportThread = useCallback(async (thread: ThreadSummary) => { + try { + const fullThread = await getThreadById(thread.id); + await exportThreadToMarkdown(fullThread); + } catch (error) { + console.error("导出对话失败:", error); + toast.error("导出对话失败"); + } + }, []); + + const handleAiRename = useCallback( + (thread: ThreadSummary) => { + if (!thread.message_count) { + toast.error("对话为空,无法生成标题"); + return; + } + aiRenameThreadFn(thread.id); + }, + [aiRenameThreadFn], + ); + const handleMenuClick = useCallback( (thread: ThreadSummary) => async (menuEvent: React.MouseEvent) => { menuEvent.preventDefault(); @@ -43,6 +105,27 @@ export function ChatThreads({ bookId, onBack, onSelectThread }: ChatThreadsProps try { const menu = await Menu.new({ items: [ + { + id: "rename", + text: "重命名", + action: () => { + handleOpenRename(thread); + }, + }, + { + id: "ai-rename", + text: "AI 重命名", + action: () => { + handleAiRename(thread); + }, + }, + { + id: "export-markdown", + text: "导出为 Markdown", + action: () => { + handleExportThread(thread); + }, + }, { id: "delete", text: "删除", @@ -58,7 +141,7 @@ export function ChatThreads({ bookId, onBack, onSelectThread }: ChatThreadsProps console.error("显示菜单失败:", error); } }, - [handleNativeDelete], + [handleNativeDelete, handleOpenRename, handleExportThread, handleAiRename], ); if (status === "pending") { @@ -170,6 +253,44 @@ export function ChatThreads({ bookId, onBack, onSelectThread }: ChatThreadsProps )} + + { + if (!open && !isRenaming) { + setRenameTarget(null); + } + }} + > + + + 重命名对话 + +
+ setRenameTitle(e.target.value)} + placeholder="输入新的对话标题" + maxLength={50} + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter" && !isRenaming) { + e.preventDefault(); + handleConfirmRename(); + } + }} + /> +
+ + +
+
+
+
); } diff --git a/packages/app/src/hooks/use-threads.ts b/packages/app/src/hooks/use-threads.ts index a2037169..b0b0ac93 100644 --- a/packages/app/src/hooks/use-threads.ts +++ b/packages/app/src/hooks/use-threads.ts @@ -1,4 +1,6 @@ -import { deleteThread, getAllThreads, getThreadsBybookId } from "@/services/thread-service"; +import { deleteThread, editThread, getAllThreads, getThreadById, getThreadsBybookId } from "@/services/thread-service"; +import { generateThreadTitleWithAI } from "@/services/thread-title-service"; +import { useThreadStore } from "@/store/thread-store"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import { toast } from "sonner"; @@ -41,6 +43,65 @@ export const useThreads = ({ bookId }: UseThreadsProps = {}) => { [queryClient, bookId], ); + // 重命名 thread + const handleRenameThread = useCallback( + async (threadId: string, title: string) => { + try { + const updatedThread = await editThread(threadId, { title }); + toast.success("对话重命名成功"); + + // 若重命名的是当前对话,同步更新 store + const { currentThread, setCurrentThread } = useThreadStore.getState(); + if (currentThread?.id === threadId) { + setCurrentThread(updatedThread); + } + + // 刷新所有 threads 列表 + queryClient.invalidateQueries({ queryKey: ["threads"] }); + } catch (error) { + console.error("重命名对话失败:", error); + toast.error("重命名对话失败"); + throw error; + } + }, + [queryClient], + ); + + // AI 重命名 thread(基于当前全部对话内容,手动触发) + const handleAiRenameThread = useCallback( + async (threadId: string) => { + const toastId = toast.loading("正在生成标题..."); + try { + const thread = await getThreadById(threadId); + if (!thread.messages?.length) { + toast.error("对话为空,无法生成标题", { id: toastId }); + return; + } + + const title = await generateThreadTitleWithAI(thread.messages); + if (!title) { + throw new Error("未能生成标题"); + } + + const updatedThread = await editThread(threadId, { title }); + + // 若重命名的是当前对话,同步更新 store + const { currentThread, setCurrentThread } = useThreadStore.getState(); + if (currentThread?.id === threadId) { + setCurrentThread(updatedThread); + } + + // 刷新所有 threads 列表 + queryClient.invalidateQueries({ queryKey: ["threads"] }); + toast.success(`已重命名为「${title}」`, { id: toastId }); + } catch (error) { + console.error("AI 重命名失败:", error); + toast.error("AI 重命名失败", { id: toastId }); + } + }, + [queryClient], + ); + return { // 查询相关 threads: threads ?? [], @@ -50,5 +111,7 @@ export const useThreads = ({ bookId }: UseThreadsProps = {}) => { // 操作相关 handleDeleteThread, + handleRenameThread, + handleAiRenameThread, }; }; From ec44f64aa3dbf25620eca26c5143fbe556ba6180 Mon Sep 17 00:00:00 2001 From: Feplus2 <202785243+Feplus2@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:47:24 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20=E5=AF=B9=E8=AF=9D=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E6=AD=A3=E7=A1=AE=20thread=20=E6=9D=A5?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app/src/hooks/use-chat-state.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/app/src/hooks/use-chat-state.ts b/packages/app/src/hooks/use-chat-state.ts index 36023c4e..45ef2b95 100644 --- a/packages/app/src/hooks/use-chat-state.ts +++ b/packages/app/src/hooks/use-chat-state.ts @@ -93,6 +93,12 @@ export function useChatState(options: UseChatStateOptions): UseChatStateReturn { const messagesRef = useRef([]); const reasoningTimesRef = useRef<{ [messageId: string]: ReasoningTimes }>({}); + // 异步回调(onFinish 等)里要读最新的 currentThread,且来源必须与调用方一致(options 优先) + const currentThreadRef = useRef(currentThread); + useEffect(() => { + currentThreadRef.current = currentThread; + }, [currentThread]); + const handleReasoningTimesUpdate = (messageId: string, reasoningTimes: ReasoningTimes) => { reasoningTimesRef.current[messageId] = reasoningTimes; }; @@ -109,7 +115,7 @@ export function useChatState(options: UseChatStateOptions): UseChatStateReturn { console.error("Error:", error); }, onFinish: ({ message, messages: finishedMessages, isError }) => { - const { currentThread } = useThreadStore.getState(); + const currentThread = currentThreadRef.current; const { selectedModel } = useProviderStore.getState(); const resolvedMessages = finishedMessages ?? messagesRef.current; @@ -358,8 +364,7 @@ export function useChatState(options: UseChatStateOptions): UseChatStateReturn { const generateSemanticContextAsync = useCallback( async (userQuestion: string) => { try { - const { currentThread } = useThreadStore.getState(); - const thread = currentThread; + const thread = currentThreadRef.current; if (!thread) { console.log("No current thread, skipping context generation"); return; From 4b43476e90bc9055be6410eca85e746060275610 Mon Sep 17 00:00:00 2001 From: Feplus2 <202785243+Feplus2@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:47:24 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=E8=BE=85=E5=8A=A9=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app/src/ai/providers/factory.ts | 12 ++++++++- .../app/src/components/settings/providers.tsx | 27 +++++++++++++++++-- .../components/side-chat/model-selector.tsx | 10 +++++-- .../app/src/services/ai-context-service.ts | 12 ++++----- packages/app/src/services/ai-tag-service.ts | 13 +++++---- .../app/src/services/thread-title-service.ts | 12 ++++----- packages/app/src/store/provider-store.ts | 10 ++++++- 7 files changed, 71 insertions(+), 25 deletions(-) diff --git a/packages/app/src/ai/providers/factory.ts b/packages/app/src/ai/providers/factory.ts index d2b11f13..3c1d595b 100644 --- a/packages/app/src/ai/providers/factory.ts +++ b/packages/app/src/ai/providers/factory.ts @@ -1,4 +1,4 @@ -import { useProviderStore } from "@/store/provider-store"; +import { type SelectedModel, useProviderStore } from "@/store/provider-store"; import { createDeepSeek } from "@ai-sdk/deepseek"; import { createGoogleGenerativeAI } from "@ai-sdk/google"; import { createOpenAI } from "@ai-sdk/openai"; @@ -103,6 +103,16 @@ export function createModelInstance(providerId: string, modelId: string) { return providerInstance(modelId); } +/** + * 获取用于轻量任务(生成对话标题、语义上下文、AI 标签等)的辅助模型 + * 未配置辅助模型时回落到当前聊天选中模型 + * _task 为将来按任务类型分配模型预留,当前忽略 + */ +export function getUtilityModel(_task?: string): SelectedModel | null { + const { utilityModel, selectedModel } = useProviderStore.getState(); + return utilityModel ?? selectedModel; +} + /** * Hook: 获取可用的模型列表 */ diff --git a/packages/app/src/components/settings/providers.tsx b/packages/app/src/components/settings/providers.tsx index 5ad98999..ab12bfe6 100644 --- a/packages/app/src/components/settings/providers.tsx +++ b/packages/app/src/components/settings/providers.tsx @@ -1,3 +1,4 @@ +import ModelSelector from "@/components/side-chat/model-selector"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; @@ -10,7 +11,7 @@ interface ProvidersSettingsProps { } export default function ProvidersSettings({ onProviderSelect }: ProvidersSettingsProps) { - const { modelProviders, setModelProviders, addProvider } = useProviderStore(); + const { modelProviders, utilityModel, setModelProviders, setUtilityModel, addProvider } = useProviderStore(); const toggleProviderEnabled = (providerId: string) => { const updatedProviders = modelProviders.map((provider) => @@ -25,7 +26,29 @@ export default function ProvidersSettings({ onProviderSelect }: ProvidersSetting }; return ( -
+
+
+

辅助模型

+
+

+ 用于生成对话标题、语义上下文、AI 标签等轻量任务,推荐选择便宜快速的模型;留空则跟随当前聊天模型 +

+
+ setUtilityModel(model)} + placeholder="跟随聊天模型" + className="w-48" + /> + {utilityModel && ( + + )} +
+
+
+

模型提供商

diff --git a/packages/app/src/components/side-chat/model-selector.tsx b/packages/app/src/components/side-chat/model-selector.tsx index 0ad0ce08..c85140a8 100644 --- a/packages/app/src/components/side-chat/model-selector.tsx +++ b/packages/app/src/components/side-chat/model-selector.tsx @@ -26,9 +26,15 @@ interface ModelSelectorProps { selectedModel: SelectedModel | null; onModelSelect: (model: SelectedModel) => void; className?: string; + placeholder?: string; } -export default function ModelSelector({ selectedModel, onModelSelect, className }: ModelSelectorProps) { +export default function ModelSelector({ + selectedModel, + onModelSelect, + className, + placeholder = "选择模型", +}: ModelSelectorProps) { const { modelProviders } = useProviderStore(); const [searchTerm, setSearchTerm] = useState(""); const [open, setOpen] = useState(false); @@ -119,7 +125,7 @@ export default function ModelSelector({ selectedModel, onModelSelect, className ) : ( - 选择模型 + {placeholder} )}
diff --git a/packages/app/src/services/ai-context-service.ts b/packages/app/src/services/ai-context-service.ts index 9692e92f..558cc62c 100644 --- a/packages/app/src/services/ai-context-service.ts +++ b/packages/app/src/services/ai-context-service.ts @@ -1,5 +1,4 @@ -import { createModelInstance } from "@/ai/providers/factory"; -import { useProviderStore } from "@/store/provider-store"; +import { createModelInstance, getUtilityModel } from "@/ai/providers/factory"; import { generateText } from "ai"; export interface AIContextResponse { @@ -20,13 +19,14 @@ export async function generateContextWithAI( try { let modelConfig = selectedModel; if (!modelConfig) { - const { selectedModel: storeModel } = useProviderStore.getState(); - if (!storeModel) { + // 显式传参优先,否则用辅助模型(未配置时回落当前聊天模型) + const utilityModel = getUtilityModel(); + if (!utilityModel) { throw new Error("没有选中的AI模型,请先在设置中配置AI模型"); } modelConfig = { - providerId: storeModel.providerId, - modelId: storeModel.modelId, + providerId: utilityModel.providerId, + modelId: utilityModel.modelId, }; } diff --git a/packages/app/src/services/ai-tag-service.ts b/packages/app/src/services/ai-tag-service.ts index 1eb48255..4f27b140 100644 --- a/packages/app/src/services/ai-tag-service.ts +++ b/packages/app/src/services/ai-tag-service.ts @@ -1,5 +1,4 @@ -import { createModelInstance } from "@/ai/providers/factory"; -import { useProviderStore } from "@/store/provider-store"; +import { createModelInstance, getUtilityModel } from "@/ai/providers/factory"; import type { SimpleBook } from "@/types/simple-book"; import { generateText } from "ai"; import type { Tag } from "./tag-service"; @@ -25,16 +24,16 @@ export async function generateTagsWithAI( selectedModel?: { providerId: string; modelId: string }, ): Promise { try { - // 获取当前选中的模型,如果没有传入则从store获取 + // 获取辅助模型,如果没有传入则回落(未配置辅助模型时用当前聊天模型) let modelConfig = selectedModel; if (!modelConfig) { - const { selectedModel: storeModel } = useProviderStore.getState(); - if (!storeModel) { + const utilityModel = getUtilityModel(); + if (!utilityModel) { throw new Error("没有选中的AI模型,请先在设置中配置AI模型"); } modelConfig = { - providerId: storeModel.providerId, - modelId: storeModel.modelId, + providerId: utilityModel.providerId, + modelId: utilityModel.modelId, }; } diff --git a/packages/app/src/services/thread-title-service.ts b/packages/app/src/services/thread-title-service.ts index 4ac7bf42..f578dbb9 100644 --- a/packages/app/src/services/thread-title-service.ts +++ b/packages/app/src/services/thread-title-service.ts @@ -1,5 +1,4 @@ -import { createModelInstance } from "@/ai/providers/factory"; -import { useProviderStore } from "@/store/provider-store"; +import { createModelInstance, getUtilityModel } from "@/ai/providers/factory"; import { type UIMessage, generateText } from "ai"; // 防御性长度上限,prompt 中要求的是 10 字以内 @@ -45,11 +44,12 @@ export async function generateThreadTitleWithAI( let modelConfig = selectedModel; if (!modelConfig) { - const { selectedModel: storeModel } = useProviderStore.getState(); - if (!storeModel) return null; + // 显式传参优先,否则用辅助模型(未配置时回落当前聊天模型) + const utilityModel = getUtilityModel(); + if (!utilityModel) return null; modelConfig = { - providerId: storeModel.providerId, - modelId: storeModel.modelId, + providerId: utilityModel.providerId, + modelId: utilityModel.modelId, }; } diff --git a/packages/app/src/store/provider-store.ts b/packages/app/src/store/provider-store.ts index 1846d716..87a18b90 100644 --- a/packages/app/src/store/provider-store.ts +++ b/packages/app/src/store/provider-store.ts @@ -14,11 +14,13 @@ export interface SelectedModel { interface ProviderState { modelProviders: ModelProvider[]; selectedModel: SelectedModel | null; + utilityModel: SelectedModel | null; setModelProviders: (modelProviders: ModelProvider[]) => void; updateProvider: (providerId: string, updates: Partial) => void; addProvider: () => string; removeProvider: (providerId: string) => void; setSelectedModel: (model: SelectedModel | null) => void; + setUtilityModel: (model: SelectedModel | null) => void; } export const useProviderStore = create()( @@ -26,6 +28,7 @@ export const useProviderStore = create()( (set, get) => ({ modelProviders: predefinedProviders, selectedModel: null, + utilityModel: null, setModelProviders: (modelProviders: ModelProvider[]) => set({ modelProviders }), updateProvider: (providerId: string, updates: Partial) => { const { modelProviders } = get(); @@ -60,11 +63,16 @@ export const useProviderStore = create()( set({ modelProviders: updatedProviders, selectedModel: newSelectedModel }); }, setSelectedModel: (selectedModel: SelectedModel | null) => set({ selectedModel }), + setUtilityModel: (utilityModel: SelectedModel | null) => set({ utilityModel }), }), { name: tauriStorageKey.modelProvider, storage: createJSONStorage(() => tauriStorage), - partialize: (state) => ({ modelProviders: state.modelProviders, selectedModel: state.selectedModel }), + partialize: (state) => ({ + modelProviders: state.modelProviders, + selectedModel: state.selectedModel, + utilityModel: state.utilityModel, + }), }, ), ); From 374bc5820877a09f9fab33dcfa6264432ae2a219 Mon Sep 17 00:00:00 2001 From: Feplus2 <202785243+Feplus2@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:47:24 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=E6=A0=87=E9=A2=98=E7=94=9F=E6=88=90?= =?UTF-8?q?=E9=80=82=E9=85=8D=E6=8E=A8=E7=90=86=E6=A8=A1=E5=9E=8B=20token?= =?UTF-8?q?=20=E9=A2=84=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app/src/services/thread-title-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/services/thread-title-service.ts b/packages/app/src/services/thread-title-service.ts index f578dbb9..47756be1 100644 --- a/packages/app/src/services/thread-title-service.ts +++ b/packages/app/src/services/thread-title-service.ts @@ -58,7 +58,7 @@ export async function generateThreadTitleWithAI( const { text } = await generateText({ model: modelInstance, prompt: buildTitlePrompt(userText, assistantText, latestUserText, latestAssistantText), - maxOutputTokens: 30, + maxOutputTokens: 500, temperature: 0.3, }); From 3175a03537721f90d09288cbf8405335e946b0b9 Mon Sep 17 00:00:00 2001 From: Feplus2 <202785243+Feplus2@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:47:24 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=E6=B8=B2=E6=9F=93=E5=BC=8F?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=AF=BC=E5=87=BA=E3=80=81HTML=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E4=B8=8E=E5=A4=9A=E9=80=89=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/side-chat/chat-messages.tsx | 370 +++++++++++------ .../src/components/side-chat/chat-threads.tsx | 38 +- .../app/src/components/side-chat/index.tsx | 104 ++++- packages/app/src/lib/export-thread-html.ts | 202 ++++++++++ packages/app/src/lib/export-thread-image.ts | 380 ++++++++++++++++++ .../app/src/lib/export-thread-markdown.ts | 80 +++- 6 files changed, 1027 insertions(+), 147 deletions(-) create mode 100644 packages/app/src/lib/export-thread-html.ts create mode 100644 packages/app/src/lib/export-thread-image.ts diff --git a/packages/app/src/components/side-chat/chat-messages.tsx b/packages/app/src/components/side-chat/chat-messages.tsx index d4f7aa06..eee44cf5 100644 --- a/packages/app/src/components/side-chat/chat-messages.tsx +++ b/packages/app/src/components/side-chat/chat-messages.tsx @@ -6,13 +6,27 @@ import { Button } from "@/components/ui/button"; import { useIsChatPage } from "@/hooks/use-is-chat-page"; import { type ReasoningTimes, useReasoningTimer } from "@/hooks/use-reasoning-timer"; import { useTextSelection } from "@/hooks/use-text-selection"; +import { exportMessagesToImage } from "@/lib/export-thread-image"; +import { exportMessageToMarkdown } from "@/lib/export-thread-markdown"; import { cn } from "@/lib/utils"; import { audioPlayerManager, synthesizeSpeechChunked } from "@/services/tts-service"; +import { useThreadStore } from "@/store/thread-store"; import { useTTSStore } from "@/store/tts-store"; import { getReasoningTimes } from "@/types/message"; import type { UIMessage, UIMessagePart } from "ai"; import dayjs from "dayjs"; -import { Brain, Check, Copy, Loader2, Pause, Quote, RefreshCw, Volume2 } from "lucide-react"; +import { + Brain, + Check, + Copy, + Download, + Image as ImageIcon, + Loader2, + Pause, + Quote, + RefreshCw, + Volume2, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useStickToBottomContext } from "use-stick-to-bottom"; @@ -42,6 +56,10 @@ interface ChatMessagesProps { canRetry?: boolean; onAskSelection?: (text: string) => void; onViewToolDetail?: (toolPart: any) => void; + /** 多选导出模式:显示勾选框、隐藏单条操作、整行点击切换选中 */ + selectionMode?: boolean; + selectedIds?: Set; + onToggleSelect?: (messageId: string) => void; } export function reorderTextAndReasoning(message: UIMessage): UIMessage { @@ -74,6 +92,9 @@ export function ChatMessages({ canRetry = true, onAskSelection, onViewToolDetail, + selectionMode = false, + selectedIds, + onToggleSelect, }: ChatMessagesProps) { const { scrollToBottom } = useStickToBottomContext(); const isChatPage = useIsChatPage(); @@ -361,118 +382,173 @@ export function ChatMessages({ key={message.id} className={cn("mx-auto flex w-full max-w-3xl flex-col items-start gap-2", isChatPage ? "px-4" : "px-2")} > - {isAssistant ? ( -
- {renderMessageParts(reorderedMessage.parts, isLastMessage, true, message.id)} - {((!isStreaming && isLastMessage) || !isLastMessage) && ( -
- - {canShowRetry && ( - - - - )} - - - - - - + )} - - - - {message.metadata && ( -
- {message.metadata.totalUsage && ( - {message.metadata.totalUsage.totalTokens} tokens - )} - {message.metadata.updatedAt && ( - - {dayjs(message.metadata.updatedAt * 1000).format("YYYY-MM-DD HH:mm:ss")} - + + + + + + + + + + + + + + + + + {message.metadata && ( +
+ {message.metadata.totalUsage && ( + {message.metadata.totalUsage.totalTokens} tokens + )} + {message.metadata.updatedAt && ( + + {dayjs(message.metadata.updatedAt * 1000).format("YYYY-MM-DD HH:mm:ss")} + + )} +
)}
)} + {showError && ( +
+ 错误: {errorMessage} +
+ )}
- )} - {showError && ( -
- 错误: {errorMessage} -
- )} -
- ) : ( -
- {renderMessageParts(reorderedMessage.parts, isLastMessage, false, message.id)} - - - {canShowRetry && ( - - - - )} - {/* TODO: 实现编辑功能 + {canShowRetry && ( + + + + )} + {/* TODO: 实现编辑功能 - - - {showError && ( -
- {errorMessage} + + + + + + + + + + + )} + {showError && ( +
+ {errorMessage} +
+ )}
)}
- )} +
); })} diff --git a/packages/app/src/components/side-chat/chat-threads.tsx b/packages/app/src/components/side-chat/chat-threads.tsx index 13c26d73..8bf96e5b 100644 --- a/packages/app/src/components/side-chat/chat-threads.tsx +++ b/packages/app/src/components/side-chat/chat-threads.tsx @@ -2,6 +2,8 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { useThreads } from "@/hooks/use-threads"; +import { exportThreadToHtml } from "@/lib/export-thread-html"; +import { exportThreadToImage } from "@/lib/export-thread-image"; import { exportThreadToMarkdown } from "@/lib/export-thread-markdown"; import { getThreadById } from "@/services/thread-service"; import type { ThreadSummary } from "@/types/thread"; @@ -86,6 +88,26 @@ export function ChatThreads({ bookId, onBack, onSelectThread }: ChatThreadsProps } }, []); + const handleExportHtml = useCallback(async (thread: ThreadSummary) => { + try { + const fullThread = await getThreadById(thread.id); + await exportThreadToHtml(fullThread); + } catch (error) { + console.error("导出对话失败:", error); + toast.error("导出对话失败"); + } + }, []); + + const handleExportImage = useCallback(async (thread: ThreadSummary) => { + try { + const fullThread = await getThreadById(thread.id); + await exportThreadToImage(fullThread); + } catch (error) { + console.error("导出对话失败:", error); + toast.error("导出对话失败"); + } + }, []); + const handleAiRename = useCallback( (thread: ThreadSummary) => { if (!thread.message_count) { @@ -126,6 +148,20 @@ export function ChatThreads({ bookId, onBack, onSelectThread }: ChatThreadsProps handleExportThread(thread); }, }, + { + id: "export-html", + text: "导出为 HTML", + action: () => { + handleExportHtml(thread); + }, + }, + { + id: "export-image", + text: "导出为图片", + action: () => { + handleExportImage(thread); + }, + }, { id: "delete", text: "删除", @@ -141,7 +177,7 @@ export function ChatThreads({ bookId, onBack, onSelectThread }: ChatThreadsProps console.error("显示菜单失败:", error); } }, - [handleNativeDelete, handleOpenRename, handleExportThread, handleAiRename], + [handleNativeDelete, handleOpenRename, handleExportThread, handleExportHtml, handleExportImage, handleAiRename], ); if (status === "pending") { diff --git a/packages/app/src/components/side-chat/index.tsx b/packages/app/src/components/side-chat/index.tsx index 2b79c4f6..4ff5f8f5 100644 --- a/packages/app/src/components/side-chat/index.tsx +++ b/packages/app/src/components/side-chat/index.tsx @@ -1,6 +1,9 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useChatState } from "@/hooks/use-chat-state"; +import { exportMessagesToHtml } from "@/lib/export-thread-html"; +import { exportMessagesToImage } from "@/lib/export-thread-image"; +import { exportMessagesToMarkdown } from "@/lib/export-thread-markdown"; import { useReaderStore } from "@/pages/reader/components/reader-provider"; import { useAppSettingsStore } from "@/store/app-settings-store"; import { useThemeStore } from "@/store/theme-store"; @@ -8,6 +11,7 @@ import { CircleQuestionMark, History, Lightbulb, + ListChecks, MessageCirclePlus, NotebookPen, ScrollText, @@ -15,7 +19,7 @@ import { Settings, UserSearch, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { ChatContainerRoot } from "../prompt-kit/chat-container"; import { ScrollButton } from "../prompt-kit/scroll-button"; import { MindmapDialog } from "../tools/mindmap-dialog"; @@ -33,6 +37,9 @@ function ChatContent({ bookId }: ChatContentProps) { const { autoScroll } = useThemeStore(); const [toolDetail, setToolDetail] = useState(null); const [showMindmapDialog, setShowMindmapDialog] = useState(false); + // 多选导出:组件内状态,切换对话自动退出 + const [selectionMode, setSelectionMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); const setActiveContext = useReaderStore((state) => state.setActiveContext)!; const progress = useReaderStore((state) => state.progress); const activeContext = useReaderStore((state) => state.activeContext)!; @@ -79,6 +86,50 @@ function ChatContent({ bookId }: ChatContentProps) { setShowMindmapDialog(true); }; + // 多选导出 + const exitSelectionMode = () => { + setSelectionMode(false); + setSelectedIds(new Set()); + }; + + const handleToggleSelect = (messageId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(messageId)) { + next.delete(messageId); + } else { + next.add(messageId); + } + return next; + }); + }; + + const getSelectedMessages = () => messages.filter((m) => selectedIds.has(m.id)); + + const buildSelectionMeta = () => ({ + title: `${currentThread?.title || "未命名对话"}-节选`, + bookId: currentThread?.book_id ?? bookId ?? null, + }); + + // 切换对话时退出选择模式 + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + exitSelectionMode(); + }, [currentThread?.id]); + + // Esc 退出选择模式 + useEffect(() => { + if (!selectionMode) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setSelectionMode(false); + setSelectedIds(new Set()); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectionMode]); + const promptSuggestions = [ { text: "总结这一页的内容", icon: ScrollText, isNew: true }, { text: "解释这个概念", icon: Lightbulb, isNew: false }, @@ -132,7 +183,7 @@ function ChatContent({ bookId }: ChatContentProps) { ); return ( -
+
@@ -143,6 +194,19 @@ function ChatContent({ bookId }: ChatContentProps) { />
+ {messages.length > 0 && !showThreads && ( + + )} + + + +
+ )} + {!showThreads && bookId && ( /g, ">").replace(/"/g, """); +} + +/** + * 轻量净化:去掉 script/iframe 等危险标签、on* 事件属性和 javascript: 链接。 + * 项目无 DOMPurify 类依赖,此为正则级防护,内容来自用户自己的对话记录,威胁模型有限。 + */ +function sanitizeHtml(html: string): string { + return html + .replace(/<(script|iframe|object|embed|form|link|meta|style)\b[\s\S]*?(<\/\s*\1\s*>|\/?>)/gi, "") + .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "") + .replace(/(href|src)\s*=\s*(["']?)\s*javascript:[^"'>]*\2/gi, "$1=$2#$2"); +} + +/** + * 导出文档的共享样式(HTML 导出与图片导出共用,单一事实源) + */ +export const EXPORT_HTML_CSS = ` + * { box-sizing: border-box; } + body { margin: 0; padding: 32px 16px; background: #f5f1e8; color: #3a3226; + font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; line-height: 1.7; } + .container { max-width: 760px; margin: 0 auto; } + header { margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #ddd3b8; } + h1 { font-size: 22px; margin: 0 0 8px; } + .meta { font-size: 13px; color: #8a7c60; } + .meta span + span::before { content: " · "; } + .message { margin-bottom: 16px; display: flex; flex-direction: column; } + .message.user { align-items: flex-end; } + .message.assistant { align-items: flex-start; } + .role { font-size: 12px; color: #8a7c60; margin-bottom: 4px; padding: 0 4px; } + .bubble { max-width: 88%; padding: 12px 16px; border-radius: 12px; box-shadow: 0 1px 3px rgba(60, 50, 30, 0.08); } + .user .bubble { background: #e9d6a6; border-radius: 12px 12px 4px 12px; } + .assistant .bubble { background: #fffdf7; border: 1px solid #e5dcc4; border-radius: 12px 12px 12px 4px; } + .bubble > :first-child { margin-top: 0; } + .bubble > :last-child { margin-bottom: 0; } + blockquote { margin: 8px 0; padding: 4px 12px; border-left: 3px solid #a05a2c; + background: rgba(160, 90, 44, 0.07); color: #6b5c42; border-radius: 0 6px 6px 0; } + pre { background: #3a2e1e; color: #f0e6d0; padding: 12px 14px; border-radius: 8px; overflow-x: auto; font-size: 13px; } + code { font-family: Consolas, "Courier New", monospace; } + p code, li code { background: rgba(160, 90, 44, 0.1); padding: 1px 5px; border-radius: 4px; font-size: 90%; } + pre code { background: none; padding: 0; } + table { border-collapse: collapse; margin: 8px 0; } + th, td { border: 1px solid #ddd3b8; padding: 6px 10px; } + th { background: #eee2c2; } + img { max-width: 100%; } + a { color: #a05a2c; } + footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid #ddd3b8; + font-size: 12px; color: #8a7c60; text-align: center; } +`; + +/** + * 导出文档头(标题 + 元信息行),HTML 导出与图片导出共用 + */ +export function buildExportHeaderHtml(meta: { title: string; bookTitle?: string; messageCount: number }): string { + return `
+

${escapeHtml(meta.title || "未命名对话")}

+
+ ${meta.bookTitle ? `书名:《${escapeHtml(meta.bookTitle)}》` : ""} + 导出时间:${dayjs().format("YYYY-MM-DD HH:mm:ss")} + 消息数:${meta.messageCount} +
+
`; +} + +/** + * 将单条消息的 parts 渲染为 HTML 片段(text → marked,quote → blockquote) + */ +export function renderMessageHtml(message: UIMessage): string { + const parts = Array.isArray(message.parts) ? message.parts : []; + let html = ""; + let textBuffer = ""; + + const flushText = () => { + const text = textBuffer.trim(); + if (text) { + html += sanitizeHtml(marked.parse(text, { async: false })); + } + textBuffer = ""; + }; + + for (const part of parts as any[]) { + if (part?.type === "text") { + textBuffer += part.text ?? ""; + continue; + } + + if (part?.type === "quote") { + flushText(); + const quote = escapeHtml(String(part.text ?? "")).replace(/\n/g, "
"); + if (quote.trim()) { + html += `
${quote}
`; + } + } + } + + flushText(); + return html; +} + +/** + * 将一组消息渲染为消息流 HTML(用户/AI 气泡分区) + */ +export function buildMessagesHtml(messages: UIMessage[]): string { + return messages + .map((message) => { + const body = renderMessageHtml(message); + if (!body) return ""; + const isUser = message.role === "user"; + return `
+
${isUser ? "用户" : "AI"}
+
${body}
+
`; + }) + .filter(Boolean) + .join("\n"); +} + +/** + * 将一组消息构建为自包含单文件 HTML 文档(样式全内联,无外部依赖) + */ +export function buildThreadHtml(messages: UIMessage[], meta: { title: string; bookTitle?: string }): string { + const title = meta.title || "未命名对话"; + + return ` + + + + +${escapeHtml(title)} + + + +
+ ${buildExportHeaderHtml({ title, bookTitle: meta.bookTitle, messageCount: messages.length })} +
+${buildMessagesHtml(messages)} +
+
由 SageRead 导出
+
+ + +`; +} + +/** + * 弹出保存对话框并将一组消息导出为自包含 HTML 文件 + */ +export async function exportMessagesToHtml(messages: UIMessage[], meta: ExportMeta): Promise { + try { + const exportable = messages.filter((m) => renderMessageHtml(m)); + if (exportable.length === 0) { + toast.error("没有可导出的内容"); + return false; + } + + const bookTitle = await resolveBookTitle(meta.bookId); + const html = buildThreadHtml(exportable, { title: meta.title, bookTitle }); + + const path = await save({ + defaultPath: `${toSafeFileName(meta.title)}.html`, + filters: [ + { + name: "HTML", + extensions: ["html"], + }, + ], + }); + + // 用户取消保存,不视为失败 + if (!path) { + return false; + } + + await writeTextFile(path, html); + toast.success(meta.successText ?? "对话导出成功"); + return true; + } catch (error) { + console.error("导出对话失败:", error); + toast.error("导出对话失败"); + return false; + } +} + +/** + * 导出整个对话为自包含 HTML 文件 + */ +export async function exportThreadToHtml(thread: Thread): Promise { + return exportMessagesToHtml(thread.messages, { + title: thread.title || "未命名对话", + bookId: thread.book_id, + }); +} diff --git a/packages/app/src/lib/export-thread-image.ts b/packages/app/src/lib/export-thread-image.ts new file mode 100644 index 00000000..627cf306 --- /dev/null +++ b/packages/app/src/lib/export-thread-image.ts @@ -0,0 +1,380 @@ +import type { Thread } from "@/types/thread"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeFile } from "@tauri-apps/plugin-fs"; +import type { UIMessage } from "ai"; +import dayjs from "dayjs"; +import { toast } from "sonner"; +import { EXPORT_HTML_CSS, buildExportHeaderHtml, buildMessagesHtml } from "./export-thread-html"; +import { type ExportMeta, resolveBookTitle, toSafeFileName } from "./export-thread-markdown"; + +const MAX_HEIGHT = 16000; // Chromium 画布高度上限 16384,留余量 +const RENDER_WIDTH = 880; +const RENDER_SCALE = 2; // 2x 渲染提升清晰度 + +/** + * 迷你 html-to-image:把导出 HTML 填进离屏隐藏容器(有布局、不可见), + * 用 XMLSerializer 序列化进 SVG foreignObject,再绘制到 canvas。 + * 内容全内联无外部资源,canvas 不会被污染。 + */ +export async function renderMessagesToPngBlob( + messages: UIMessage[], + meta: { title: string; bookTitle?: string }, +): Promise { + // 离屏定位放在外层包装元素上;被序列化的容器自身不能有 left:-9999px, + // 否则 SVG 视口内同样偏移 -9999px 渲染空白(已踩坑验证) + const offscreen = document.createElement("div"); + offscreen.style.cssText = "position:absolute;left:-9999px;top:0;"; + const container = document.createElement("div"); + // 容器内联 body 等价样式(EXPORT_HTML_CSS 里的 body 选择器对 div 不生效) + container.style.cssText = `width:${RENDER_WIDTH}px;background:#f5f1e8;color:#3a3226;font-family:"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;line-height:1.7;padding:32px 16px;`; + container.innerHTML = `
${buildExportHeaderHtml({ + title: meta.title, + bookTitle: meta.bookTitle, + messageCount: messages.length, + })}
${buildMessagesHtml(messages)}
`; + offscreen.appendChild(container); + document.body.appendChild(offscreen); + + try { + // 高度超限:从尾部移除消息直到放得下,再补截断提示 + if (container.offsetHeight > MAX_HEIGHT) { + const messageEls = Array.from(container.querySelectorAll(".message")); + for (let i = messageEls.length - 1; i >= 0 && container.offsetHeight > MAX_HEIGHT - 60; i--) { + messageEls[i].remove(); + } + const notice = document.createElement("div"); + notice.style.cssText = "text-align:center;font-size:12px;color:#8a7c60;padding:12px 0;"; + notice.textContent = "对话过长,已截断(完整内容请导出 Markdown)"; + container.querySelector("main")?.appendChild(notice); + } + + const height = Math.min(container.offsetHeight, MAX_HEIGHT); + const serialized = new XMLSerializer().serializeToString(container); + const svg = `${serialized}`; + // 注意用 data: URL 而非 blob: URL——blob 加载的 foreignObject SVG 会被 Chromium 标记污染,无法 toBlob + const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + + const img = await new Promise((resolve, reject) => { + const el = new Image(); + el.onload = () => resolve(el); + el.onerror = () => reject(new Error("SVG 图像加载失败")); + el.src = svgUrl; + }); + + const canvas = document.createElement("canvas"); + canvas.width = RENDER_WIDTH * RENDER_SCALE; + canvas.height = height * RENDER_SCALE; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("无法创建画布上下文"); + ctx.scale(RENDER_SCALE, RENDER_SCALE); + ctx.drawImage(img, 0, 0); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); + if (!blob) throw new Error("图片生成失败"); + return blob; + } finally { + offscreen.remove(); + } +} + +/* ------------------------------------------------------------ + * 以下为纯 Canvas 手绘实现(foreignObject 路径失败时的回落),请勿删除 + * ---------------------------------------------------------- */ + +const PADDING = 40; +const CONTENT_WIDTH = RENDER_WIDTH - PADDING * 2; +const BUBBLE_MAX_WIDTH = 640; +const BUBBLE_PADDING_X = 16; +const BUBBLE_PADDING_Y = 12; +const BADGE_HEIGHT = 20; +const LINE_HEIGHT = 22; + +const FONT_BODY = "14px 'Segoe UI', 'Microsoft YaHei', sans-serif"; +const FONT_TITLE = "bold 20px 'Segoe UI', 'Microsoft YaHei', sans-serif"; +const FONT_META = "12px 'Segoe UI', 'Microsoft YaHei', sans-serif"; +const FONT_BADGE = "12px 'Segoe UI', 'Microsoft YaHei', sans-serif"; + +interface DrawBlock { + kind: "text" | "quote"; + lines: string[]; +} + +interface LayoutMessage { + isUser: boolean; + blocks: DrawBlock[]; +} + +/** 逐字断行:中文按字断、英文到边也按字断,measureText 实测宽度 */ +function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { + const lines: string[] = []; + for (const paragraph of text.split("\n")) { + if (!paragraph) { + lines.push(""); + continue; + } + let line = ""; + for (const char of paragraph) { + if (line && ctx.measureText(line + char).width > maxWidth) { + lines.push(line); + line = char; + } else { + line += char; + } + } + if (line) { + lines.push(line); + } + } + return lines; +} + +function collectBlocks(message: UIMessage, ctx: CanvasRenderingContext2D): DrawBlock[] { + const parts = Array.isArray(message.parts) ? message.parts : []; + const blocks: DrawBlock[] = []; + let textBuffer = ""; + + const flushText = () => { + const text = textBuffer.trim(); + if (text) { + blocks.push({ kind: "text", lines: wrapText(ctx, text, BUBBLE_MAX_WIDTH - BUBBLE_PADDING_X * 2) }); + } + textBuffer = ""; + }; + + for (const part of parts as any[]) { + if (part?.type === "text") { + textBuffer += part.text ?? ""; + continue; + } + if (part?.type === "quote") { + flushText(); + const quote = String(part.text ?? "").trim(); + if (quote) { + blocks.push({ kind: "quote", lines: wrapText(ctx, quote, CONTENT_WIDTH - 32) }); + } + } + } + + flushText(); + return blocks; +} + +function drawRoundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, +) { + ctx.beginPath(); + if (typeof ctx.roundRect === "function") { + ctx.roundRect(x, y, width, height, radius); + } else { + ctx.rect(x, y, width, height); + } + ctx.fill(); +} + +function blockHeight(block: DrawBlock): number { + if (block.kind === "quote") { + return block.lines.length * LINE_HEIGHT + 16; + } + return block.lines.length * LINE_HEIGHT + BUBBLE_PADDING_Y * 2; +} + +/** 纯 Canvas 手绘渲染(回落路径):输出为源码级排版,仅在 foreignObject 不可用时使用 */ +async function renderMessagesToPngBlobFallback( + messages: UIMessage[], + meta: { title: string; bookTitle?: string }, +): Promise { + const title = meta.title || "未命名对话"; + const metaLine = [meta.bookTitle ? `《${meta.bookTitle}》` : "", dayjs().format("YYYY-MM-DD HH:mm:ss")].filter( + Boolean, + ); + + const measureCanvas = document.createElement("canvas"); + const measureCtx = measureCanvas.getContext("2d"); + if (!measureCtx) throw new Error("无法创建画布上下文"); + measureCtx.font = FONT_BODY; + + const layoutMessages: LayoutMessage[] = messages + .map((message) => ({ + isUser: message.role === "user", + blocks: collectBlocks(message, measureCtx), + })) + .filter((m) => m.blocks.length > 0); + + const HEADER_HEIGHT = 84; + const FOOTER_HEIGHT = 48; + let y = PADDING + HEADER_HEIGHT; + let truncated = false; + const visibleMessages: LayoutMessage[] = []; + + for (const message of layoutMessages) { + const messageHeight = + BADGE_HEIGHT + 6 + message.blocks.reduce((sum, block) => sum + blockHeight(block) + 8, 0) + 12; + if (y + messageHeight > MAX_HEIGHT - FOOTER_HEIGHT) { + truncated = true; + break; + } + visibleMessages.push(message); + y += messageHeight; + } + + const totalHeight = Math.min(y + FOOTER_HEIGHT, MAX_HEIGHT); + + const canvas = document.createElement("canvas"); + canvas.width = RENDER_WIDTH; + canvas.height = totalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("无法创建画布上下文"); + + ctx.fillStyle = "#f7f4ee"; + ctx.fillRect(0, 0, RENDER_WIDTH, totalHeight); + ctx.textBaseline = "top"; + + let cursorY = PADDING; + ctx.font = FONT_TITLE; + ctx.fillStyle = "#3a3226"; + ctx.fillText(title, PADDING, cursorY, CONTENT_WIDTH); + cursorY += 30; + ctx.font = FONT_META; + ctx.fillStyle = "#8a7c60"; + ctx.fillText(metaLine.join(" · "), PADDING, cursorY, CONTENT_WIDTH); + cursorY += 24; + ctx.strokeStyle = "#ddd3b8"; + ctx.beginPath(); + ctx.moveTo(PADDING, cursorY); + ctx.lineTo(RENDER_WIDTH - PADDING, cursorY); + ctx.stroke(); + cursorY += 30; + + ctx.font = FONT_BODY; + for (const message of visibleMessages) { + const badgeText = message.isUser ? "用户" : "AI"; + ctx.font = FONT_BADGE; + const badgeWidth = ctx.measureText(badgeText).width + 16; + const badgeX = message.isUser ? RENDER_WIDTH - PADDING - badgeWidth : PADDING; + ctx.fillStyle = "#a05a2c"; + drawRoundRect(ctx, badgeX, cursorY, badgeWidth, BADGE_HEIGHT, 9); + ctx.fillStyle = "#fffdf7"; + ctx.fillText(badgeText, badgeX + 8, cursorY + 4); + cursorY += BADGE_HEIGHT + 6; + ctx.font = FONT_BODY; + + for (const block of message.blocks) { + if (block.kind === "quote") { + const height = blockHeight(block); + ctx.fillStyle = "rgba(160, 90, 44, 0.08)"; + drawRoundRect(ctx, PADDING, cursorY, CONTENT_WIDTH, height, 6); + ctx.fillStyle = "#a05a2c"; + ctx.fillRect(PADDING, cursorY, 3, height); + ctx.fillStyle = "#6b5c42"; + block.lines.forEach((line, i) => { + ctx.fillText(line, PADDING + 14, cursorY + 8 + i * LINE_HEIGHT); + }); + cursorY += height + 8; + continue; + } + + const maxLineWidth = Math.max(...block.lines.map((line) => ctx.measureText(line).width), 0); + const bubbleWidth = Math.min(maxLineWidth + BUBBLE_PADDING_X * 2, BUBBLE_MAX_WIDTH); + const bubbleHeight = blockHeight(block); + const bubbleX = message.isUser ? RENDER_WIDTH - PADDING - bubbleWidth : PADDING; + + ctx.fillStyle = message.isUser ? "#e9d6a6" : "#fffdf7"; + drawRoundRect(ctx, bubbleX, cursorY, bubbleWidth, bubbleHeight, 12); + if (!message.isUser) { + ctx.strokeStyle = "#e5dcc4"; + ctx.beginPath(); + if (typeof ctx.roundRect === "function") { + ctx.roundRect(bubbleX, cursorY, bubbleWidth, bubbleHeight, 12); + } else { + ctx.rect(bubbleX, cursorY, bubbleWidth, bubbleHeight); + } + ctx.stroke(); + } + + ctx.fillStyle = "#3a3226"; + block.lines.forEach((line, i) => { + ctx.fillText(line, bubbleX + BUBBLE_PADDING_X, cursorY + BUBBLE_PADDING_Y + i * LINE_HEIGHT); + }); + cursorY += bubbleHeight + 8; + } + + cursorY += 12; + } + + ctx.font = FONT_META; + ctx.fillStyle = "#8a7c60"; + const footerText = truncated ? "对话过长,已截断(完整内容请导出 Markdown)" : "由 SageRead 导出"; + const footerWidth = ctx.measureText(footerText).width; + ctx.fillText(footerText, (RENDER_WIDTH - footerWidth) / 2, totalHeight - FOOTER_HEIGHT + 14); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); + if (!blob) throw new Error("图片生成失败"); + return blob; +} + +/** + * 弹出保存对话框并将一组消息渲染为 PNG 长图。 + * 主路径为 HTML 渲染(排版与 HTML 导出一致),失败时回落纯 Canvas 手绘。 + */ +export async function exportMessagesToImage(messages: UIMessage[], meta: ExportMeta): Promise { + if (!buildMessagesHtml(messages)) { + toast.error("没有可导出的内容"); + return false; + } + + const bookTitle = await resolveBookTitle(meta.bookId); + + let blob: Blob; + try { + blob = await renderMessagesToPngBlob(messages, { title: meta.title, bookTitle }); + } catch (error) { + console.warn("[导出图片] foreignObject 渲染失败,回落纯 Canvas 手绘:", error); + try { + blob = await renderMessagesToPngBlobFallback(messages, { title: meta.title, bookTitle }); + } catch (fallbackError) { + console.error("导出对话失败:", fallbackError); + toast.error("导出对话失败"); + return false; + } + } + + try { + const path = await save({ + defaultPath: `${toSafeFileName(meta.title)}.png`, + filters: [ + { + name: "PNG 图片", + extensions: ["png"], + }, + ], + }); + + // 用户取消保存,不视为失败 + if (!path) { + return false; + } + + await writeFile(path, new Uint8Array(await blob.arrayBuffer())); + toast.success(meta.successText ?? "对话导出成功"); + return true; + } catch (error) { + console.error("导出对话失败:", error); + toast.error("导出对话失败"); + return false; + } +} + +/** + * 导出整个对话为 PNG 长图 + */ +export async function exportThreadToImage(thread: Thread): Promise { + return exportMessagesToImage(thread.messages, { + title: thread.title || "未命名对话", + bookId: thread.book_id, + }); +} diff --git a/packages/app/src/lib/export-thread-markdown.ts b/packages/app/src/lib/export-thread-markdown.ts index 6619c87e..993bd34b 100644 --- a/packages/app/src/lib/export-thread-markdown.ts +++ b/packages/app/src/lib/export-thread-markdown.ts @@ -6,11 +6,30 @@ import type { UIMessage } from "ai"; import dayjs from "dayjs"; import { toast } from "sonner"; +/** 导出元信息:标题 + 书籍(用于解析书名),successText 可定制成功提示 */ +export interface ExportMeta { + title: string; + bookId?: string | null; + successText?: string; +} + +/** 按 bookId 解析书名,失败返回 undefined(不阻断导出) */ +export async function resolveBookTitle(bookId?: string | null): Promise { + if (!bookId) return undefined; + const book = await getBookById(bookId).catch(() => null); + return book?.title; +} + +/** 文件名清洗:去掉文件系统非法字符 */ +export function toSafeFileName(name: string, fallback = "未命名对话"): string { + return name.replace(/[<>:"/\\|?*]/g, "").trim() || fallback; +} + /** * 将单条消息的 parts 渲染为 Markdown。 * 只导出 text 和 quote part;reasoning / tool 等过程性 part 与聊天页的定位一致,不进入导出文档。 */ -function renderMessageMarkdown(message: UIMessage): string { +export function renderMessageMarkdown(message: UIMessage): string { const parts = Array.isArray(message.parts) ? message.parts : []; const blocks: string[] = []; let textBuffer = ""; @@ -33,7 +52,7 @@ function renderMessageMarkdown(message: UIMessage): string { flushText(); const quote = String(part.text ?? "") .split("\n") - .map((line: string) => `> ${line}`.trimEnd()) + .map((line) => `> ${line}`.trimEnd()) .join("\n"); if (quote.trim()) { blocks.push(quote); @@ -46,23 +65,23 @@ function renderMessageMarkdown(message: UIMessage): string { } /** - * 将整个对话构建为 Markdown 文档(含元信息头) + * 将一组消息构建为 Markdown 文档(含元信息头) */ -export function buildThreadMarkdown(thread: Thread, bookTitle?: string): string { +export function buildThreadMarkdown(messages: UIMessage[], meta: { title: string; bookTitle?: string }): string { const lines: string[] = []; - lines.push(`# ${thread.title || "未命名对话"}`); + lines.push(`# ${meta.title || "未命名对话"}`); lines.push(""); - if (bookTitle) { - lines.push(`- 书名:《${bookTitle}》`); + if (meta.bookTitle) { + lines.push(`- 书名:《${meta.bookTitle}》`); } lines.push(`- 导出时间:${dayjs().format("YYYY-MM-DD HH:mm:ss")}`); - lines.push(`- 消息数:${thread.messages.length}`); + lines.push(`- 消息数:${messages.length}`); lines.push(""); lines.push("---"); lines.push(""); - for (const message of thread.messages) { + for (const message of messages) { const body = renderMessageMarkdown(message); if (!body) continue; lines.push(message.role === "user" ? "## 🧑 用户" : "## 🤖 AI"); @@ -75,17 +94,21 @@ export function buildThreadMarkdown(thread: Thread, bookTitle?: string): string } /** - * 弹出保存对话框并将对话导出为 Markdown 文件 + * 弹出保存对话框并将一组消息导出为 Markdown 文件 */ -export async function exportThreadToMarkdown(thread: Thread): Promise { +export async function exportMessagesToMarkdown(messages: UIMessage[], meta: ExportMeta): Promise { try { - const book = thread.book_id ? await getBookById(thread.book_id).catch(() => null) : null; - const markdown = buildThreadMarkdown(thread, book?.title); + const exportable = messages.filter((m) => renderMessageMarkdown(m)); + if (exportable.length === 0) { + toast.error("没有可导出的内容"); + return false; + } - const safeFileName = (thread.title || "未命名对话").replace(/[<>:"/\\|?*]/g, "").trim() || "未命名对话"; + const bookTitle = await resolveBookTitle(meta.bookId); + const markdown = buildThreadMarkdown(exportable, { title: meta.title, bookTitle }); const path = await save({ - defaultPath: `${safeFileName}.md`, + defaultPath: `${toSafeFileName(meta.title)}.md`, filters: [ { name: "Markdown", @@ -100,7 +123,7 @@ export async function exportThreadToMarkdown(thread: Thread): Promise { } await writeTextFile(path, markdown); - toast.success("对话导出成功"); + toast.success(meta.successText ?? "对话导出成功"); return true; } catch (error) { console.error("导出对话失败:", error); @@ -108,3 +131,28 @@ export async function exportThreadToMarkdown(thread: Thread): Promise { return false; } } + +/** + * 导出整个对话为 Markdown 文件 + */ +export async function exportThreadToMarkdown(thread: Thread): Promise { + return exportMessagesToMarkdown(thread.messages, { + title: thread.title || "未命名对话", + bookId: thread.book_id, + }); +} + +/** + * 导出单条消息为 Markdown 文件(含元信息头,标题 = 对话标题 + 消息序号) + */ +export async function exportMessageToMarkdown( + message: UIMessage, + options: { threadTitle?: string; bookId?: string | null; index: number }, +): Promise { + const title = `${options.threadTitle || "未命名对话"}-第${options.index + 1}条`; + return exportMessagesToMarkdown([message], { + title, + bookId: options.bookId, + successText: "消息导出成功", + }); +} From b0c3c8cccfe471bfd2b14a4459e0cd280cfd9d20 Mon Sep 17 00:00:00 2001 From: Feplus2 <202785243+Feplus2@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:47:24 +0800 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=E5=AF=B9=E8=AF=9D=E6=98=9F?= =?UTF-8?q?=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app/src-tauri/src/core/database.rs | 19 ++++++++++++ packages/app/src-tauri/src/core/schema.sql | 1 + .../src-tauri/src/core/threads/commands.rs | 21 +++++++++---- .../app/src-tauri/src/core/threads/models.rs | 3 ++ .../src/components/side-chat/chat-threads.tsx | 31 ++++++++++++++++--- packages/app/src/hooks/use-threads.ts | 24 ++++++++++++++ packages/app/src/services/thread-service.ts | 2 ++ packages/app/src/types/thread.ts | 3 ++ 8 files changed, 94 insertions(+), 10 deletions(-) diff --git a/packages/app/src-tauri/src/core/database.rs b/packages/app/src-tauri/src/core/database.rs index 507bb710..916264fe 100644 --- a/packages/app/src-tauri/src/core/database.rs +++ b/packages/app/src-tauri/src/core/database.rs @@ -43,6 +43,8 @@ pub async fn initialize(app_handle: &AppHandle) -> Result Result Result<(), Box> { + // threads.starred(对话星标):已存在时忽略 duplicate column 错误 + let result = sqlx::query("ALTER TABLE threads ADD COLUMN starred INTEGER NOT NULL DEFAULT 0") + .execute(pool) + .await; + + match result { + Ok(_) => println!("Migration applied: threads.starred added."), + Err(e) if e.to_string().contains("duplicate column name") => {} + Err(e) => return Err(e.into()), + } + + Ok(()) +} + async fn initialize_default_skills(pool: &SqlitePool) -> Result<(), Box> { let default_skills_json = include_str!("./default-skills.json"); let default_skills: Vec = serde_json::from_str(default_skills_json)?; diff --git a/packages/app/src-tauri/src/core/schema.sql b/packages/app/src-tauri/src/core/schema.sql index e07435e2..4e8a47ab 100644 --- a/packages/app/src-tauri/src/core/schema.sql +++ b/packages/app/src-tauri/src/core/schema.sql @@ -1,3 +1,4 @@ +-- 注意:threads.starred 列由 database.rs 的 fork 专属迁移添加,勿在此定义(避免与 ALTER 重复) CREATE TABLE IF NOT EXISTS threads ( id TEXT PRIMARY KEY NOT NULL, book_id TEXT, diff --git a/packages/app/src-tauri/src/core/threads/commands.rs b/packages/app/src-tauri/src/core/threads/commands.rs index f173b6f5..0bbc293f 100644 --- a/packages/app/src-tauri/src/core/threads/commands.rs +++ b/packages/app/src-tauri/src/core/threads/commands.rs @@ -39,6 +39,7 @@ pub async fn create_thread( metadata: payload.metadata, title: payload.title, messages: payload.messages_json, + starred: false, created_at: current_timestamp, updated_at: current_timestamp, }; @@ -56,7 +57,7 @@ pub async fn edit_thread( let pool = db_pool_guard.as_ref().ok_or("Database not initialized")?; let row = sqlx::query( - "SELECT id, book_id, metadata, title, messages, created_at, updated_at FROM threads WHERE id = ?" + "SELECT id, book_id, metadata, title, messages, starred, created_at, updated_at FROM threads WHERE id = ?" ) .bind(&payload.id) .fetch_one(pool) @@ -72,6 +73,7 @@ pub async fn edit_thread( metadata: row.get("metadata"), title: row.get("title"), messages: row.get("messages"), + starred: row.get::("starred") != 0, created_at: row.get("created_at"), updated_at: row.get("updated_at"), }; @@ -79,13 +81,15 @@ pub async fn edit_thread( let new_title = payload.title.unwrap_or(existing_thread.title); let new_metadata = payload.metadata.unwrap_or(existing_thread.metadata); let new_messages = payload.messages_json.unwrap_or(existing_thread.messages); + let new_starred = payload.starred.unwrap_or(existing_thread.starred); sqlx::query( - "UPDATE threads SET title = ?, metadata = ?, messages = ?, updated_at = ? WHERE id = ?", + "UPDATE threads SET title = ?, metadata = ?, messages = ?, starred = ?, updated_at = ? WHERE id = ?", ) .bind(&new_title) .bind(&new_metadata) .bind(&new_messages) + .bind(if new_starred { 1 } else { 0 }) .bind(current_timestamp) .bind(&payload.id) .execute(pool) @@ -101,6 +105,7 @@ pub async fn edit_thread( metadata: new_metadata, title: new_title, messages: new_messages, + starred: new_starred, created_at: existing_thread.created_at, updated_at: current_timestamp, }; @@ -117,7 +122,7 @@ pub async fn get_latest_thread_by_book_id( let pool = db_pool_guard.as_ref().ok_or("Database not initialized")?; let row_result = sqlx::query( - "SELECT id, book_id, metadata, title, messages, created_at, updated_at FROM threads WHERE book_id IS ? ORDER BY updated_at DESC LIMIT 1" + "SELECT id, book_id, metadata, title, messages, starred, created_at, updated_at FROM threads WHERE book_id IS ? ORDER BY updated_at DESC LIMIT 1" ) .bind(&book_id) .fetch_optional(pool) @@ -134,6 +139,7 @@ pub async fn get_latest_thread_by_book_id( metadata: row.get("metadata"), title: row.get("title"), messages: row.get("messages"), + starred: row.get::("starred") != 0, created_at: row.get("created_at"), updated_at: row.get("updated_at"), }; @@ -152,7 +158,7 @@ pub async fn get_threads_by_book_id( let pool = db_pool_guard.as_ref().ok_or("Database not initialized")?; let rows = sqlx::query( - "SELECT id, book_id, metadata, title, messages, created_at, updated_at FROM threads WHERE book_id IS ? ORDER BY updated_at DESC" + "SELECT id, book_id, metadata, title, messages, starred, created_at, updated_at FROM threads WHERE book_id IS ? ORDER BY updated_at DESC" ) .bind(&book_id) .fetch_all(pool) @@ -183,6 +189,7 @@ pub async fn get_threads_by_book_id( metadata: row.get("metadata"), title: row.get("title"), message_count, + starred: row.get::("starred") != 0, created_at: row.get("created_at"), updated_at: row.get("updated_at"), } @@ -198,7 +205,7 @@ pub async fn get_all_threads(state: State<'_, AppState>) -> Result) -> Result("starred") != 0, created_at: row.get("created_at"), updated_at: row.get("updated_at"), } @@ -246,7 +254,7 @@ pub async fn get_thread_by_id( let pool = db_pool_guard.as_ref().ok_or("Database not initialized")?; let row = sqlx::query( - "SELECT id, book_id, metadata, title, messages, created_at, updated_at FROM threads WHERE id = ?" + "SELECT id, book_id, metadata, title, messages, starred, created_at, updated_at FROM threads WHERE id = ?" ) .bind(&thread_id) .fetch_one(pool) @@ -262,6 +270,7 @@ pub async fn get_thread_by_id( metadata: row.get("metadata"), title: row.get("title"), messages: row.get("messages"), + starred: row.get::("starred") != 0, created_at: row.get("created_at"), updated_at: row.get("updated_at"), }; diff --git a/packages/app/src-tauri/src/core/threads/models.rs b/packages/app/src-tauri/src/core/threads/models.rs index ee3f6f36..0342034d 100644 --- a/packages/app/src-tauri/src/core/threads/models.rs +++ b/packages/app/src-tauri/src/core/threads/models.rs @@ -7,6 +7,7 @@ pub struct Thread { pub metadata: String, pub title: String, pub messages: String, + pub starred: bool, pub created_at: i64, pub updated_at: i64, } @@ -18,6 +19,7 @@ pub struct ThreadSummary { pub metadata: String, pub title: String, pub message_count: i32, + pub starred: bool, pub created_at: i64, pub updated_at: i64, } @@ -36,4 +38,5 @@ pub struct EditThreadPayload { pub title: Option, pub metadata: Option, pub messages_json: Option, + pub starred: Option, } diff --git a/packages/app/src/components/side-chat/chat-threads.tsx b/packages/app/src/components/side-chat/chat-threads.tsx index 8bf96e5b..072bedba 100644 --- a/packages/app/src/components/side-chat/chat-threads.tsx +++ b/packages/app/src/components/side-chat/chat-threads.tsx @@ -11,8 +11,8 @@ import { Menu } from "@tauri-apps/api/menu"; import { LogicalPosition } from "@tauri-apps/api/window"; import { ask } from "@tauri-apps/plugin-dialog"; import dayjs from "dayjs"; -import { ArrowLeft, MessageCircle } from "lucide-react"; -import { useCallback, useState } from "react"; +import { ArrowLeft, MessageCircle, Star } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; interface ChatThreadsProps { @@ -29,12 +29,19 @@ export function ChatThreads({ bookId, onBack, onSelectThread }: ChatThreadsProps handleDeleteThread: deleteThreadFn, handleRenameThread: renameThreadFn, handleAiRenameThread: aiRenameThreadFn, + handleToggleStar: toggleStarFn, } = useThreads({ bookId }); const [renameTarget, setRenameTarget] = useState(null); const [renameTitle, setRenameTitle] = useState(""); const [isRenaming, setIsRenaming] = useState(false); + // 星标对话排在前面,其余保持原顺序(sort 稳定) + const sortedThreads = useMemo( + () => [...threads].sort((a, b) => Number(b.starred ?? false) - Number(a.starred ?? false)), + [threads], + ); + const handleNativeDelete = useCallback( async (thread: ThreadSummary) => { try { @@ -266,17 +273,33 @@ export function ChatThreads({ bookId, onBack, onSelectThread }: ChatThreadsProps
) : (
- {threads.map((thread) => ( + {sortedThreads.map((thread) => (