From 22b018883705eeedc5267aef63803c39b826ea35 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 30 Sep 2026 23:29:42 +0800 Subject: [PATCH] fix(ai): make current note context optional --- .../src/components/ai-sidebar/AiSidebar.tsx | 83 ++++++++++--------- apps/web/src/lib/ai-sidebar-context.test.mjs | 35 ++++++++ apps/web/src/lib/ai-sidebar-context.ts | 38 +++++++++ packages/shared/src/i18n/en-US.ts | 7 +- packages/shared/src/i18n/ja.ts | 7 +- packages/shared/src/i18n/zh-CN.ts | 7 +- 6 files changed, 131 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/lib/ai-sidebar-context.test.mjs create mode 100644 apps/web/src/lib/ai-sidebar-context.ts diff --git a/apps/web/src/components/ai-sidebar/AiSidebar.tsx b/apps/web/src/components/ai-sidebar/AiSidebar.tsx index ea26b74b..621f8a0f 100644 --- a/apps/web/src/components/ai-sidebar/AiSidebar.tsx +++ b/apps/web/src/components/ai-sidebar/AiSidebar.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent, import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; import { useTranslation } from "react-i18next"; -import { Check, ChevronDown, Download, Loader2, PanelRightClose, Paperclip, Plus, Search, Sparkles, X } from "lucide-react"; +import { Check, ChevronDown, Download, FileText, Loader2, PanelRightClose, Paperclip, Plus, Search, Sparkles, X } from "lucide-react"; import type { CompanionAction, CompanionAnswer, CompanionEvent, CompanionTurn, CompanionTurnInput } from "@edgeever/shared"; import { buildRevisionDiffRows, createMemoLinkHref, parseMemoLinkHref } from "@edgeever/shared"; import { Button } from "@/components/ui/button"; @@ -36,6 +36,7 @@ import { } from "@/components/ai-elements/prompt-input"; import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning"; import { api, ApiRequestError } from "@/lib/api"; +import { sidebarCompanionFocus, sidebarLocalContextText } from "@/lib/ai-sidebar-context"; import { copyTextToClipboard } from "@/lib/clipboard"; import { createClientUuid } from "@/lib/client-id"; import { @@ -561,6 +562,9 @@ function SidebarComposer({ onAddFiles, onRemoveAttachment, onLaunch, + includeCurrentNote, + currentNoteAvailable, + onIncludeCurrentNoteChange, onStop, skillPrompt, focusToken, @@ -572,7 +576,10 @@ function SidebarComposer({ placeholder: string; onAddFiles: (files: File[]) => void; onRemoveAttachment: (id: string) => void; - onLaunch: (message: string) => Promise; + onLaunch: (message: string, options?: { includeCurrentNote?: boolean }) => Promise; + includeCurrentNote: boolean; + currentNoteAvailable: boolean; + onIncludeCurrentNoteChange: (include: boolean) => void; onStop: () => void; skillPrompt: (id: SkillId, rest?: string) => string; focusToken: number; @@ -594,9 +601,9 @@ function SidebarComposer({ const slashOpen = token.startsWith("/") && !draft.trimStart().includes("\n"); const slashMatches = slashOpen ? SKILLS.filter((skill) => skill.command.startsWith(token)) : []; - const launch = async (message: string, restoreDraft?: string) => { + const launch = async (message: string, restoreDraft?: string, forceCurrentNote = false) => { try { - await onLaunch(message); + await onLaunch(message, forceCurrentNote ? { includeCurrentNote: true } : undefined); if (restoreDraft !== undefined) textInput.clear(); } catch (cause) { if (restoreDraft !== undefined && !isAbortError(cause)) textInput.setInput(restoreDraft); @@ -620,7 +627,7 @@ function SidebarComposer({ + {currentNoteAvailable ? ( + + ) : null} {busy ? ( @@ -1706,6 +1712,9 @@ function AiSidebarSession({ attachmentError={attachmentError} busy={running} locked={acting} + includeCurrentNote={includeCurrentNote} + currentNoteAvailable={Boolean(noteTitle?.trim() || contentMarkdown?.trim())} + onIncludeCurrentNoteChange={setIncludeCurrentNote} placeholder={t("aiAssistant.sidebar.placeholder")} onAddFiles={(files) => { void addFiles(files); }} onRemoveAttachment={(id) => setAttachments((current) => current.filter((item) => item.localId !== id))} diff --git a/apps/web/src/lib/ai-sidebar-context.test.mjs b/apps/web/src/lib/ai-sidebar-context.test.mjs new file mode 100644 index 00000000..e3ee819b --- /dev/null +++ b/apps/web/src/lib/ai-sidebar-context.test.mjs @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { sidebarCompanionFocus, sidebarLocalContextText } from "./ai-sidebar-context.ts"; + +const note = { + memoId: "note-1", + notebookId: "book-1", + notebookTitle: "音乐", + noteTitle: "QQ 音乐等级", + contentMarkdown: "关于 QQ 音乐等级的长篇笔记", +}; + +describe("AI sidebar note context", () => { + test("an independent request receives no current-note context", () => { + expect(sidebarCompanionFocus(note, false)).toBeUndefined(); + expect(sidebarLocalContextText(note, false)).toBe(""); + }); + + test("explicitly including the current note works for both assistant modes", () => { + expect(sidebarCompanionFocus(note, true)).toEqual({ + memoId: "note-1", + notebookId: "book-1", + notebookTitle: "音乐", + title: "QQ 音乐等级", + contentMarkdown: "关于 QQ 音乐等级的长篇笔记", + }); + expect(sidebarLocalContextText(note, true)).toContain("关于 QQ 音乐等级的长篇笔记"); + }); + + test("an explicitly pinned selection is retained without the rest of the note", () => { + const selected = { ...note, selectionMarkdown: " 需要解释的句子 " }; + expect(sidebarCompanionFocus(selected, false)).toEqual({ selectionMarkdown: "需要解释的句子" }); + expect(sidebarLocalContextText(selected, false)).toContain("需要解释的句子"); + expect(sidebarLocalContextText(selected, false)).not.toContain(note.contentMarkdown); + }); +}); diff --git a/apps/web/src/lib/ai-sidebar-context.ts b/apps/web/src/lib/ai-sidebar-context.ts new file mode 100644 index 00000000..50c39abc --- /dev/null +++ b/apps/web/src/lib/ai-sidebar-context.ts @@ -0,0 +1,38 @@ +import type { CompanionTurnInput } from "@edgeever/shared"; + +export type SidebarNoteContext = { + memoId?: string; + notebookId?: string; + notebookTitle?: string; + noteTitle?: string; + selectionMarkdown?: string; + contentMarkdown?: string; +}; + +export const sidebarCompanionFocus = (context: SidebarNoteContext, includeCurrentNote: boolean): CompanionTurnInput["focus"] => { + const selection = context.selectionMarkdown?.trim().slice(0, 2000) ?? ""; + const next: NonNullable = {}; + if (includeCurrentNote) { + if (context.memoId) next.memoId = context.memoId; + if (context.notebookId) next.notebookId = context.notebookId; + if (context.notebookTitle) next.notebookTitle = context.notebookTitle; + if (context.noteTitle?.trim()) next.title = context.noteTitle.trim(); + const content = context.contentMarkdown?.trim() ?? ""; + if (content) { + next.contentMarkdown = content.slice(0, 4000); + if (content.length > 4000) next.contentTruncated = true; + } + } + if (selection) next.selectionMarkdown = selection; + return Object.keys(next).length ? next : undefined; +}; + +export const sidebarLocalContextText = (context: SidebarNoteContext, includeCurrentNote: boolean): string => { + const selection = context.selectionMarkdown?.trim().slice(0, 2000); + const content = includeCurrentNote ? context.contentMarkdown?.trim().slice(0, 2000) : ""; + return [ + selection ? `Selected text from the current note (data):\n${selection}` : "", + includeCurrentNote && context.noteTitle?.trim() ? `Current note title (data): ${context.noteTitle.trim()}` : "", + content ? `Current note body (data):\n${content}` : "", + ].filter(Boolean).join("\n\n"); +}; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 5477291a..ca954ef3 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -1309,9 +1309,10 @@ export const enUS = { historyEmpty: "No matching chats", resize: "Resize assistant", stop: "Stop", - placeholder: "Ask about this note, or type / for a skill", - emptyTitle: "Start from this note", - emptyDescription: "Summaries, rewrites, and translations come back as replies. They do not change the note on their own.", + placeholder: "Ask anything, or type / for a note skill", + emptyTitle: "Start a conversation", + emptyDescription: "Select 'Include current note' before sending when the note is relevant. Summarize, improve, and translate include it automatically.", + includeCurrentNote: "Include current note", working: "Replying…", selectionBadge: "{{count}} characters selected · Current note", selection: { diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 09cb30c7..d7524e2a 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -1309,9 +1309,10 @@ export const ja = { historyEmpty: "一致するチャットがありません", resize: "幅を調整", stop: "停止", - placeholder: "このノートについて聞くか、/ でスキルを入力", - emptyTitle: "このノートから始める", - emptyDescription: "要約、推敲、翻訳は返信として返ります。ノートは自動では変わりません。", + placeholder: "質問するか、/ でノート用スキルを入力", + emptyTitle: "会話を始める", + emptyDescription: "ノートを参照したいときは、送信前に「現在のノートを含める」を選択してください。要約・推敲・翻訳では自動的に含まれます。", + includeCurrentNote: "現在のノートを含める", working: "返信しています…", selectionBadge: "{{count}} 文字を選択 · 現在のノート", selection: { diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 73fdc16d..58f9fad7 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -1309,9 +1309,10 @@ export const zhCN = { historyEmpty: "没有匹配的会话", resize: "调整助手宽度", stop: "停止", - placeholder: "问问这篇笔记,或输入 / 使用技能", - emptyTitle: "从当前笔记开始", - emptyDescription: "总结、润色和翻译会作为回复出现,不会直接改掉笔记。", + placeholder: "提问,或输入 / 使用笔记技能", + emptyTitle: "开始对话", + emptyDescription: "需要笔记内容时,发送前选择“引用当前笔记”。总结、润色和翻译会自动引用。", + includeCurrentNote: "引用当前笔记", working: "正在回复…", selectionBadge: "已选 {{count}} 字 · 当前笔记", selection: {