diff --git a/apps/api/src/companion-discovery-context.test.mjs b/apps/api/src/companion-discovery-context.test.mjs index 56941844..e3c5bbe2 100644 --- a/apps/api/src/companion-discovery-context.test.mjs +++ b/apps/api/src/companion-discovery-context.test.mjs @@ -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); diff --git a/apps/api/src/companion-discovery-context.ts b/apps/api/src/companion-discovery-context.ts index 4586e738..8dd792c3 100644 --- a/apps/api/src/companion-discovery-context.ts +++ b/apps/api/src/companion-discovery-context.ts @@ -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 { diff --git a/apps/web/src/components/CompanionActionCard.tsx b/apps/web/src/components/CompanionActionCard.tsx index e4233b76..1f0d7837 100644 --- a/apps/web/src/components/CompanionActionCard.tsx +++ b/apps/web/src/components/CompanionActionCard.tsx @@ -17,12 +17,10 @@ export function CompanionActionCard({ action, busy, onApply, onDismiss, onOpenNo return {t(merge ? "companion.actions.merge" : "companion.actions.tag")} - {action.plan.reason} + {action.plan.reason} - {action.status === "pending" ?
- {t("companion.actions.review")} -
+ {action.status === "pending" ?
{action.plan.kind === "merge" ?

{t("companion.actions.resultTitle", { title: action.plan.title })}

: null}
    {action.notes.map(note =>
  1. @@ -33,12 +31,11 @@ export function CompanionActionCard({ action, busy, onApply, onDismiss, onOpenNo
{action.plan.kind === "tag" ?

{t("companion.actions.addTags", { tags: action.plan.tags.join(" · ") })}

: null}

{t(merge ? "companion.actions.mergeHelp" : "companion.actions.tagHelp")}

-
- - -
-
-
:

{t(`companion.actions.status.${action.status}`)}

} + :

{t(`companion.actions.status.${action.status}`)}

} + {action.status === "pending" ?
+ + +
: null} {action.status === "applied" && action.resultMemoId ? : null}
; diff --git a/apps/web/src/components/CompanionDiscoveryHub.tsx b/apps/web/src/components/CompanionDiscoveryHub.tsx index 355f315e..cdfc4a6e 100644 --- a/apps/web/src/components/CompanionDiscoveryHub.tsx +++ b/apps/web/src/components/CompanionDiscoveryHub.tsx @@ -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 - +
@@ -137,9 +137,6 @@ export default function CompanionDiscoveryHub({ scope, onOpenNote, onNotesChange ) : null}
- - {t("companion.discovery.panelDescription")} -
{error ? (
@@ -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(discoveryFeedKey(scope), current => current?.map(entry => entry.id === item.id ? { ...entry, seen: true } : entry))).catch(() => {}); }} />)}
-
-
- - - {t(`companion.discovery.checkStatus.${settings.data?.lastStatus ?? "quiet"}`)} - -
+
-

{item.title}

- {!item.action ?

{item.body}

: null} +

{item.title}

+ {!item.action ?

{item.body}

: null} {item.kind === "append" ?

{t("companion.discovery.appendHelp")}

: null} {item.action ? : item.sources.length > 0 ? ( @@ -248,10 +239,10 @@ function DiscoveryCard({ item, busy, open, onApply, onDismiss, onOpenNote, onSee ))}
) : null} -
+ {!item.action ?
-
+
: null} ; } diff --git a/apps/web/src/components/CompanionToolActionCard.tsx b/apps/web/src/components/CompanionToolActionCard.tsx index e77d011a..a8cff99d 100644 --- a/apps/web/src/components/CompanionToolActionCard.tsx +++ b/apps/web/src/components/CompanionToolActionCard.tsx @@ -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 {title} - {action.plan.reason} + {action.plan.reason} - {action.status === "pending" ?
- {t("companion.actions.review")} -
+ {action.status === "pending" ?
{action.preview?.affectedCount ?

{t("companion.actions.affected", { count: action.preview.affectedCount })}

: null} {action.notes.map(note =>
- -
-
-
:

{t(`companion.actions.status.${action.status}`)}

} + :

{t(`companion.actions.status.${action.status}`)}

} + {action.status === "pending" ?
+ + +
: null} {action.status === "applied" && action.resultMemoId && action.resultNotebookId ? : null} {action.status === "applied" && action.result ?
{t("companion.actions.receipt")} diff --git a/packages/shared/src/companion.ts b/packages/shared/src/companion.ts index 9b99b809..338a6354 100644 --- a/packages/shared/src/companion.ts +++ b/packages/shared/src/companion.ts @@ -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(), diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index ac5fc9e8..b6a14cdb 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -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.", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 57bdc54d..6b9d5ea5 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -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": "笔记、记忆或建议状态已变化,或建议已过期。没有执行新的修改,请重新生成整理方案。",