fix(ai): make current note context optional

This commit is contained in:
tianma-if committed 2026-09-30 23:29:42 +08:00
1 parent 0de31ff8e7
commit 22b0188837
6 files changed
+131 -46

No files matched your search

@@ -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<string>;
onLaunch: (message: string, options?: { includeCurrentNote?: boolean }) => Promise<string>;
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({
<button
type="button"
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left hover:bg-slate-50"
onClick={() => void launch(skillPrompt(skill.id), draft).catch(() => undefined)}
onClick={() => void launch(skillPrompt(skill.id), draft, true).catch(() => undefined)}
>
<span className="font-medium text-slate-800">{t(`aiAssistant.sidebar.skills.${skill.id}`)}</span>
<span className="truncate text-xs text-slate-400">{skill.command}</span>
@@ -636,7 +643,7 @@ function SidebarComposer({
const raw = text.trim();
if (!raw) throw new Error("empty");
const skill = matchSkill(raw);
await launch(skill ? skillPrompt(skill.id, skill.rest) : raw);
await launch(skill ? skillPrompt(skill.id, skill.rest) : raw, undefined, Boolean(skill));
}}
>
{attachments.length ? (
@@ -672,6 +679,20 @@ function SidebarComposer({
>
<Paperclip className="h-4 w-4" />
</Button>
{currentNoteAvailable ? (
<Button
type="button"
size="sm"
variant="outline"
aria-pressed={includeCurrentNote}
className={includeCurrentNote ? "border-emerald-600 bg-emerald-50 text-emerald-800 hover:bg-emerald-100" : undefined}
disabled={busy || locked}
onClick={() => onIncludeCurrentNoteChange(!includeCurrentNote)}
>
<FileText className="size-3.5" aria-hidden="true" />
{t("aiAssistant.sidebar.includeCurrentNote")}
</Button>
) : null}
</PromptInputTools>
{busy ? (
<Button type="button" size="sm" variant="outline" onClick={onStop}>
@@ -753,6 +774,7 @@ function AiSidebarSession({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [useMemory, setUseMemory] = useState(false);
const [includeCurrentNote, setIncludeCurrentNote] = useState(false);
const [composerFocusToken, setComposerFocusToken] = useState(0);
const [selectionTurnKinds, setSelectionTurnKinds] = useState<Record<string, "explain" | "translate">>(readSelectionTurnKinds);
const alive = useRef(true);
@@ -772,6 +794,10 @@ function AiSidebarSession({
const focusRef = useRef({ selectionMarkdown, contentMarkdown, memoId, notebookId, notebookTitle, noteTitle, companionAvailable });
focusRef.current = { selectionMarkdown, contentMarkdown, memoId, notebookId, notebookTitle, noteTitle, companionAvailable };
useEffect(() => {
setIncludeCurrentNote(false);
}, [memoId]);
const explainError = useCallback((cause: unknown) => {
const code = cause instanceof ApiRequestError ? cause.code : "";
if (code === "companion_attachment_unsupported") return t("aiAssistant.sidebar.attachmentUnsupported");
@@ -1026,31 +1052,6 @@ function AiSidebarSession({
await onCompanionNotesChanged?.()?.catch(() => undefined);
}, [onCompanionNotesChanged, reloadCompanion]);
const buildFocus = useCallback((): CompanionTurnInput["focus"] => {
const focus = focusRef.current;
const selection = focus.selectionMarkdown?.trim().slice(0, 2000) ?? "";
const content = focus.contentMarkdown?.trim() ?? "";
const excerpt = content.slice(0, 4000);
const next: NonNullable<CompanionTurnInput["focus"]> = {};
if (focus.memoId) next.memoId = focus.memoId;
if (focus.notebookId) next.notebookId = focus.notebookId;
if (focus.notebookTitle) next.notebookTitle = focus.notebookTitle;
if (focus.noteTitle?.trim()) next.title = focus.noteTitle.trim();
if (selection) next.selectionMarkdown = selection;
if (excerpt) {
next.contentMarkdown = excerpt;
if (content.length > 4000) next.contentTruncated = true;
}
return Object.keys(next).length ? next : undefined;
}, []);
const contextText = useCallback(() => {
const focus = focusRef.current;
const selection = focus.selectionMarkdown?.trim().slice(0, 2000) ?? "";
const excerpt = focus.contentMarkdown?.trim().slice(0, 2000) ?? "";
return [selection, excerpt].filter(Boolean).join("\n\n");
}, []);
const skillPrompt = useCallback((id: SkillId, rest = "") => {
const prompt = t(`aiAssistant.sidebar.skillPrompts.${id}`);
return rest ? `${prompt}\n\n${rest}` : prompt;
@@ -1088,12 +1089,14 @@ function AiSidebarSession({
onStopReady.current = () => { void stop(); };
}, [onStopReady, stop]);
const launch = useCallback(async (message: string): Promise<string> => {
const launch = useCallback(async (message: string, options?: { includeCurrentNote?: boolean }): Promise<string> => {
const text = message.trim();
if (!text || locked.current) throw new Error("busy");
const useCurrentNote = options?.includeCurrentNote ?? includeCurrentNote;
const focusAtSend = focusRef.current;
const mode = readAiSidebarSource();
setSource(mode);
if (mode === "builtin" && !focusRef.current.companionAvailable) {
if (mode === "builtin" && !focusAtSend.companionAvailable) {
setError(t("aiAssistant.sidebar.unavailable"));
throw new Error("unavailable");
}
@@ -1122,7 +1125,7 @@ function AiSidebarSession({
}));
const activeLocalThreadId = localThreadIdRef.current;
const transcript = localAgentTranscript(localTurnsRef.current, activeLocalThreadId);
const noteContext = contextText();
const noteContext = sidebarLocalContextText(focusAtSend, useCurrentNote);
writeStorage(AI_SIDEBAR_LOCAL_THREAD_KEY, activeLocalThreadId);
setLocalTurns((previous) => [...previous, {
id,
@@ -1167,6 +1170,7 @@ function AiSidebarSession({
const queued = acpBuffer.current.filter((event) => event.requestId === result.requestId);
acpBuffer.current = acpBuffer.current.filter((event) => event.requestId !== result.requestId);
for (const event of queued) applyAcpEventRef.current(event);
setIncludeCurrentNote(false);
return id;
}
@@ -1196,6 +1200,7 @@ function AiSidebarSession({
})),
}, ...previous]);
if (active.current?.id === id) active.current.started = true;
const focus = sidebarCompanionFocus(focusAtSend, useCurrentNote);
const payload: CompanionTurnInput = {
id,
threadId: activeThreadId,
@@ -1205,8 +1210,9 @@ function AiSidebarSession({
allowWrites: true,
locale: companionLocale(i18n.resolvedLanguage),
...(uploaded.length ? { attachmentIds: uploaded.map((item) => item.id) } : {}),
...(buildFocus() ? { focus: buildFocus() } : {}),
...(focus ? { focus } : {}),
};
setIncludeCurrentNote(false);
void (async () => {
let completed = false;
try {
@@ -1253,7 +1259,7 @@ function AiSidebarSession({
setError(explainError(cause));
throw cause;
}
}, [applyStreamEvent, attachments, buildFocus, contextText, explainError, i18n.resolvedLanguage, recoverTurn, restoreAttachments, t, threadId, useMemory]);
}, [applyStreamEvent, attachments, explainError, i18n.resolvedLanguage, includeCurrentNote, recoverTurn, restoreAttachments, t, threadId, useMemory]);
const rememberSelectionTurn = useCallback((id: string, kind: "explain" | "translate") => {
setSelectionTurnKinds((current) => {
@@ -1694,7 +1700,7 @@ function AiSidebarSession({
size="sm"
variant="outline"
disabled={running || acting}
onClick={() => void launch(skillPrompt(skill.id)).catch(() => undefined)}
onClick={() => void launch(skillPrompt(skill.id), { includeCurrentNote: true }).catch(() => undefined)}
>
{t(`aiAssistant.sidebar.skills.${skill.id}`)}
</Button>
@@ -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))}
@@ -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);
});
});
+38
View File
@@ -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<CompanionTurnInput["focus"]> = {};
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");
};
+4 -3
View File
@@ -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: {
+4 -3
View File
@@ -1309,9 +1309,10 @@ export const ja = {
historyEmpty: "一致するチャットがありません",
resize: "幅を調整",
stop: "停止",
placeholder: "このノートについて聞くか、/ でスキルを入力",
emptyTitle: "このノートから始める",
emptyDescription: "要約、推敲、翻訳は返信として返ります。ノートは自動では変わりません。",
placeholder: "質問するか、/ でノート用スキルを入力",
emptyTitle: "会話を始める",
emptyDescription: "ノートを参照したいときは、送信前に「現在のノートを含める」を選択してください。要約・推敲・翻訳では自動的に含まれます。",
includeCurrentNote: "現在のノートを含める",
working: "返信しています…",
selectionBadge: "{{count}} 文字を選択 · 現在のノート",
selection: {
+4 -3
View File
@@ -1309,9 +1309,10 @@ export const zhCN = {
historyEmpty: "没有匹配的会话",
resize: "调整助手宽度",
stop: "停止",
placeholder: "问问这篇笔记,或输入 / 使用技能",
emptyTitle: "从当前笔记开始",
emptyDescription: "总结、润色和翻译会作为回复出现,不会直接改掉笔记。",
placeholder: "提问,或输入 / 使用笔记技能",
emptyTitle: "开始对话",
emptyDescription: "需要笔记内容时,发送前选择“引用当前笔记”。总结、润色和翻译会自动引用。",
includeCurrentNote: "引用当前笔记",
working: "正在回复…",
selectionBadge: "已选 {{count}} 字 · 当前笔记",
selection: {