feat: streamline companion discovery notifications

This commit is contained in:
tianma-if committed 2026-09-04 17:25:57 +08:00
1 parent 1a1429cde0
commit 69a7facd75
8 files changed
+42 -48

No files matched your search

@@ -1,5 +1,6 @@
import { expect, test } from "bun:test";
import { MockLanguageModelV4 } from "ai/test";
import { CompanionDiscoveryOutputSchema } from "@edgeever/shared";
import { discoveryContext, discoveryInputHash } from "./companion-discovery-context.ts";
import { generateCompanionDiscovery } from "./companion-discovery-runtime.ts";
@@ -31,6 +32,15 @@ test("only exact supplied aliases can resolve to real note IDs", () => {
expect(() => discoveryContext({ ...input, candidates: [candidates[0], candidates[0]] })).toThrow();
});
test("discovery copy is constrained to compact three-line content", () => {
const context = discoveryContext(input);
expect(context.instructions).toContain("at most 3 short lines");
const base = { kind: "insight", title: "Short title", sourceIds: ["n1", "n2"], targetId: null };
expect(CompanionDiscoveryOutputSchema.safeParse({ suggestion: { ...base, body: "One\nTwo\nThree" } }).success).toBe(true);
expect(CompanionDiscoveryOutputSchema.safeParse({ suggestion: { ...base, body: "One\nTwo\nThree\nFour" } }).success).toBe(false);
expect(CompanionDiscoveryOutputSchema.safeParse({ suggestion: { ...base, body: "x".repeat(181) } }).success).toBe(false);
});
test("analysis fingerprint includes content, real IDs, context and model configuration", async () => {
const request = { ...input, settingsVersion: 1, contextRevision: 0, modelConfiguration: { default_model_id: "model-a" } };
const hash = await discoveryInputHash(request);
+2 -1
View File
@@ -20,7 +20,8 @@ Every suggestion must reference the current anchor and at least one other suppli
merge: only fragments of the SAME concrete idea, not just similar topics. Sources will move to trash, so prefer null when uncertain.
append: exactly two plainText notes; targetId is the existing longer note; the other must be the anchor, a useful new fragment. Existing text and source are preserved.
insight: explain a specific useful connection to older knowledge, with supporting note IDs; no action is required.
body must explain the actual connection and benefit, not describe your process. Distinguish inference from evidence.
Keep the title to one short line. Keep body compact: at most 3 short lines, no bullets or headings, and at most 180 characters total.
For Simplified Chinese, aim for no more than 90 Chinese characters. body must explain only the actual connection and benefit, not your process. Distinguish inference from evidence.
For merge and insight targetId must be null. Reply in ${args.locale === "zh-CN" ? "Simplified Chinese" : "English"}.`,
prompt: JSON.stringify({ anchorId: aliases.get(args.anchorId), notes: args.candidates.map(note => ({ ...note, id: aliases.get(note.id) })) }),
decode(output: CompanionDiscoveryOutput): CompanionDiscoveryOutput {
@@ -17,12 +17,10 @@ export function CompanionActionCard({ action, busy, onApply, onDismiss, onOpenNo
return <Card className="shadow-none" aria-label={t(merge ? "companion.actions.merge" : "companion.actions.tag")}>
<CardHeader className="p-3">
<CardTitle className="text-sm">{t(merge ? "companion.actions.merge" : "companion.actions.tag")}</CardTitle>
<CardDescription className="break-words">{action.plan.reason}</CardDescription>
<CardDescription className="line-clamp-3 break-words">{action.plan.reason}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 p-3 pt-0 text-sm">
{action.status === "pending" ? <details>
<summary className="cursor-pointer rounded text-emerald-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500">{t("companion.actions.review")}</summary>
<div className="mt-3 space-y-3">
{action.status === "pending" ? <div className="space-y-3">
{action.plan.kind === "merge" ? <p className="break-words font-medium">{t("companion.actions.resultTitle", { title: action.plan.title })}</p> : null}
<ol className="list-inside list-decimal space-y-3">
{action.notes.map(note => <li key={note.id}>
@@ -33,12 +31,11 @@ export function CompanionActionCard({ action, busy, onApply, onDismiss, onOpenNo
</ol>
{action.plan.kind === "tag" ? <p className="break-words text-emerald-700">{t("companion.actions.addTags", { tags: action.plan.tags.join(" · ") })}</p> : null}
<p className="text-xs leading-relaxed text-slate-600">{t(merge ? "companion.actions.mergeHelp" : "companion.actions.tagHelp")}</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy} onClick={() => onApply(action)}>{t(merge ? "companion.actions.confirmMerge" : "companion.actions.confirmTags")}</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onDismiss(action)}>{t("companion.actions.dismiss")}</Button>
</div>
</div>
</details> : <p role="status" className="text-xs text-slate-500">{t(`companion.actions.status.${action.status}`)}</p>}
</div> : <p role="status" className="text-xs text-slate-500">{t(`companion.actions.status.${action.status}`)}</p>}
{action.status === "pending" ? <div className="flex flex-wrap gap-2 border-t border-slate-200/60 pt-3 dark:border-slate-800">
<Button size="sm" disabled={busy} onClick={() => onApply(action)}>{t("companion.actions.confirm")}</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onDismiss(action)}>{t("companion.actions.dismiss")}</Button>
</div> : null}
{action.status === "applied" && action.resultMemoId ? <Button size="sm" variant="outline" disabled={busy} onClick={() => onOpenNote(action.resultMemoId!, action.notes[0].notebookId)}>{t("companion.actions.openResult")}</Button> : null}
</CardContent>
</Card>;
@@ -12,7 +12,7 @@ import {
} from "lucide-react";
import type { CompanionAction, CompanionDiscoveryItem } from "@edgeever/shared";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { api, ApiRequestError } from "@/lib/api";
import { assertCompanionChangesSynced } from "@/lib/companion-actions";
@@ -120,7 +120,7 @@ export default function CompanionDiscoveryHub({ scope, onOpenNote, onNotesChange
</Tooltip>
</TooltipProvider>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[85dvh] w-[calc(100%-2rem)] max-w-lg overflow-y-auto p-5 sm:p-6">
<DialogContent className="max-h-[85dvh] w-[calc(100%-2rem)] max-w-xl overflow-y-auto p-5 sm:p-6">
<DialogHeader className="space-y-2 pb-1">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600 ring-1 ring-emerald-500/20 dark:bg-emerald-950/50 dark:text-emerald-400 dark:ring-emerald-500/30">
@@ -137,9 +137,6 @@ export default function CompanionDiscoveryHub({ scope, onOpenNote, onNotesChange
) : null}
</div>
</div>
<DialogDescription className="text-xs leading-relaxed text-slate-500 dark:text-slate-400">
{t("companion.discovery.panelDescription")}
</DialogDescription>
</DialogHeader>
{error ? (
<div role="alert" className="rounded-lg border border-red-200 bg-red-50/50 p-2.5 text-xs text-red-600 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400">
@@ -165,17 +162,11 @@ export default function CompanionDiscoveryHub({ scope, onOpenNote, onNotesChange
onApply={apply} onDismiss={() => dismiss(item)} onOpenNote={openNote}
onSeen={() => { void api.acknowledgeCompanionDiscovery(item.id).then(() => client.setQueryData<CompanionDiscoveryItem[]>(discoveryFeedKey(scope),
current => current?.map(entry => entry.id === item.id ? { ...entry, seen: true } : entry))).catch(() => {}); }} />)}</div>
<div className="mt-2 flex flex-col gap-2.5 border-t border-slate-100 pt-3 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-1.5 text-xs text-slate-400 dark:text-slate-500">
<Sparkles className="h-3.5 w-3.5 shrink-0 text-emerald-500/70" />
<span className="line-clamp-2 sm:line-clamp-1">
{t(`companion.discovery.checkStatus.${settings.data?.lastStatus ?? "quiet"}`)}
</span>
</div>
<div className="mt-2 flex justify-end border-t border-slate-100 pt-3 dark:border-slate-800">
<Button
size="sm"
variant="ghost"
className="h-8 shrink-0 self-end sm:self-auto gap-1.5 text-xs text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
className="h-8 shrink-0 gap-1.5 text-xs text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
onClick={() => { setOpen(false); onOpenSettings(); }}
>
<Settings className="h-3.5 w-3.5" />
@@ -228,8 +219,8 @@ function DiscoveryCard({ item, busy, open, onApply, onDismiss, onOpenNote, onSee
</span>
) : null}
</div>
<h3 className="break-words text-sm font-semibold leading-snug text-slate-900 dark:text-slate-100">{item.title}</h3>
{!item.action ? <p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-600 dark:text-slate-300">{item.body}</p> : null}
<h3 className="line-clamp-1 break-words text-sm font-semibold leading-snug text-slate-900 dark:text-slate-100">{item.title}</h3>
{!item.action ? <p className="line-clamp-3 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-600 dark:text-slate-300">{item.body}</p> : null}
{item.kind === "append" ? <p className="rounded-lg border border-emerald-100 bg-emerald-50/50 p-2.5 text-xs leading-relaxed text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-400">{t("companion.discovery.appendHelp")}</p> : null}
{item.action ? <CompanionActionCard action={item.action} busy={busy} onApply={onApply} onDismiss={onDismiss} onOpenNote={onOpenNote} />
: item.sources.length > 0 ? (
@@ -248,10 +239,10 @@ function DiscoveryCard({ item, busy, open, onApply, onDismiss, onOpenNote, onSee
))}
</div>
) : null}
<div className="flex items-center justify-end border-t border-slate-200/60 pt-2.5 dark:border-slate-800">
{!item.action ? <div className="flex items-center justify-end border-t border-slate-200/60 pt-2.5 dark:border-slate-800">
<Button size="sm" variant="ghost" disabled={busy} onClick={onDismiss} className="h-7 px-2 text-xs text-slate-400 hover:text-slate-700 dark:hover:text-slate-200">
{t("companion.discovery.dismiss")}
</Button>
</div>
</div> : null}
</article>;
}
@@ -22,11 +22,9 @@ export function CompanionToolActionCard({ action, busy, onApply, onDismiss, onOp
const special = ["merge_memos", "trash_memos", "rename_tag", "delete_tag", "update_memo", "restore_memo_revision"].includes(name);
return <Card className="shadow-none" aria-label={title}>
<CardHeader className="p-3"><CardTitle className="text-sm">{title}</CardTitle>
<CardDescription className="break-words">{action.plan.reason}</CardDescription></CardHeader>
<CardDescription className="line-clamp-3 break-words">{action.plan.reason}</CardDescription></CardHeader>
<CardContent className="space-y-3 p-3 pt-0 text-sm">
{action.status === "pending" ? <details>
<summary className="cursor-pointer rounded text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">{t("companion.actions.review")}</summary>
<div className="mt-3 space-y-3">
{action.status === "pending" ? <div className="space-y-3">
{action.preview?.affectedCount ? <p>{t("companion.actions.affected", { count: action.preview.affectedCount })}</p> : null}
{action.notes.map(note => <div key={note.id}>
<Button variant="ghost" size="sm" className="h-auto max-w-full whitespace-normal break-words text-left" disabled={busy}
@@ -39,12 +37,11 @@ export function CompanionToolActionCard({ action, busy, onApply, onDismiss, onOp
</div>)}</dl>
{special ? <p className="text-xs text-destructive">{t(`companion.actions.effects.${name}`)}</p> : null}
<p className="text-xs text-muted-foreground">{t("companion.actions.toolHelp")}</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy} onClick={() => onApply(action)}>{t("companion.actions.confirmTool")}</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onDismiss(action)}>{t("companion.actions.dismiss")}</Button>
</div>
</div>
</details> : <p role="status" className="text-xs text-muted-foreground">{t(`companion.actions.status.${action.status}`)}</p>}
</div> : <p role="status" className="text-xs text-muted-foreground">{t(`companion.actions.status.${action.status}`)}</p>}
{action.status === "pending" ? <div className="flex flex-wrap gap-2 border-t pt-3">
<Button size="sm" disabled={busy} onClick={() => onApply(action)}>{t("companion.actions.confirm")}</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onDismiss(action)}>{t("companion.actions.dismiss")}</Button>
</div> : null}
{action.status === "applied" && action.resultMemoId && action.resultNotebookId ? <Button variant="outline" size="sm" disabled={busy}
onClick={() => onOpenNote(action.resultMemoId!, action.resultNotebookId!)}>{t("companion.actions.openResult")}</Button> : null}
{action.status === "applied" && action.result ? <details><summary className="cursor-pointer">{t("companion.actions.receipt")}</summary>
+6 -2
View File
@@ -37,11 +37,15 @@ export type CompanionDiscoverySettings = CompanionDiscoverySettingsInput & {
lastCheckAt: string | null;
lastStatus: "quiet" | "ready" | "failed" | "running";
};
const compactDiscoveryBody = z.string().trim().min(1).max(180).refine(
value => value.split(/\r?\n/).length <= 3,
"Discovery body must contain at most 3 lines.",
);
export const CompanionDiscoveryOutputSchema = z.object({
suggestion: z.object({
kind: z.enum(["insight", "merge", "append"]),
title: z.string().trim().min(1).max(120),
body: z.string().trim().min(1).max(400),
title: z.string().trim().min(1).max(60),
body: compactDiscoveryBody,
sourceIds: z.array(z.string().min(1).max(100)).min(2).max(5),
targetId: z.string().max(100).nullable(),
}).nullable(),
+1 -4
View File
@@ -37,7 +37,7 @@ export const enUS = {
},
},
"actions": {
"confirmTool": "Confirm this operation",
"confirm": "Confirm",
"receipt": "View execution record",
"affected": "Affects {{count}} notes",
"toolHelp": "Only the listed arguments will execute; AI does not decide again at confirmation. Suggestions expire after 24 hours. Note or notebook changes require a fresh proposal. Multi-write operations do not guarantee a complete rollback; uncertain results are never automatically replayed.",
@@ -90,14 +90,11 @@ export const enUS = {
},
"merge": "Suggested idea merge",
"tag": "Suggested additional tags",
"review": "Review organization plan",
"resultTitle": "Merged title: {{title}}",
"existingTags": "Existing tags: {{tags}}",
"addTags": "Add only: {{tags}}",
"mergeHelp": "Preserves full bodies and attachments in the order above, without AI rewriting or omissions, and keeps existing tags. The result goes into the first note's notebook. Originals move to Trash and their public shares are revoked. Originals can be restored from Trash; this is not a one-click undo of all merge effects. Suggestions expire after 24 hours.",
"tagHelp": "Only adds the listed tags to this note, without removing existing tags or changing its body. The note version is rechecked on confirmation. Suggestions expire after 24 hours.",
"confirmMerge": "Confirm merge",
"confirmTags": "Confirm additional tags",
"dismiss": "Dismiss suggestion",
"openResult": "Open updated note",
"conflict": "The notes, memory or suggestion state changed, or the suggestion expired. No new changes were applied. Request a fresh organization plan.",
+1 -4
View File
@@ -37,7 +37,7 @@ export const zhCN = {
},
},
"actions": {
"confirmTool": "确认执行此操作",
"confirm": "确认",
"receipt": "查看执行记录",
"affected": "涉及 {{count}} 篇笔记",
"toolHelp": "仅执行上面列出的参数,不会在确认时让 AI 重新决定。建议 24 小时后失效;生成方案后笔记或笔记本发生变化,需要重新生成。多次写入的操作不保证整体撤销,结果不确定时不会自动重跑。",
@@ -90,14 +90,11 @@ export const zhCN = {
},
"merge": "建议合并这些点子",
"tag": "建议补充标签",
"review": "查看整理方案",
"resultTitle": "合并后的标题:{{title}}",
"existingTags": "已有标签:{{tags}}",
"addTags": "仅追加:{{tags}}",
"mergeHelp": "按上方顺序保留全文与附件,不做 AI 改写或删减;保留已有标签,结果放在第一篇笔记所在的笔记本。原笔记进入回收站,原公开分享失效。可从回收站恢复原笔记,但这不等于一键撤销全部合并效果。建议 24 小时后失效。",
"tagHelp": "只给这篇笔记追加列出的标签,不删除已有标签,也不修改正文。确认时会重新校验笔记版本;建议 24 小时后失效。",
"confirmMerge": "确认合并",
"confirmTags": "确认追加标签",
"dismiss": "忽略建议",
"openResult": "打开处理后的笔记",
"conflict": "笔记、记忆或建议状态已变化,或建议已过期。没有执行新的修改,请重新生成整理方案。",