From 7b247b4e99d20ca259e69386f83112a5d6023a09 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:12:25 +0800 Subject: [PATCH 1/5] feat(decision-context): add transient workspace evidence recall Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../dashboard/src/data/decision-context.ts | 47 +++++ .../capability-localization.ts | 2 + .../content-reference-workbench.css | 4 +- .../decision-context-workbench.tsx | 126 +++++++++++++ .../goal-capability-settings.tsx | 7 +- .../decision_context/catalog_entry.py | 15 ++ .../capabilities/decision_context/profile.py | 40 +++- .../capabilities/decision_context/runtime.py | 71 +++++++- loopx/configuration_catalog.py | 2 + loopx/presentation/configuration_api.py | 3 + loopx/presentation/decision_context_api.py | 76 ++++++++ .../presentation/test_decision_context_api.py | 171 ++++++++++++++++++ 12 files changed, 553 insertions(+), 11 deletions(-) create mode 100644 apps/presentation/dashboard/src/data/decision-context.ts create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/decision-context-workbench.tsx create mode 100644 loopx/presentation/decision_context_api.py create mode 100644 tests/presentation/test_decision_context_api.py diff --git a/apps/presentation/dashboard/src/data/decision-context.ts b/apps/presentation/dashboard/src/data/decision-context.ts new file mode 100644 index 0000000000..50565750f1 --- /dev/null +++ b/apps/presentation/dashboard/src/data/decision-context.ts @@ -0,0 +1,47 @@ +import {requestJson} from "./chat"; + +/** Native activation/recall packets. Profile and raw hits are never cached. */ +export type DecisionContextActivation = Readonly<{ + ok: boolean; + status: string; + reason_code?: string | null; + available: boolean; + enabled: boolean; + context_provider_configured: boolean; +}>; + +export type DecisionContextRecall = Readonly<{ + ok: boolean; + status: string; + reason_code?: string | null; + provider?: string; + provider_version?: string | null; + observed_at?: string; + results: ReadonlyArray>; +}>; + +type ContextTarget = Readonly<{ + goal_id: string; + agent_id: string; + profile: Record | null; +}>; + +export function inspectDecisionContext(target: ContextTarget, signal: AbortSignal) { + return requestJson("/api/chat/decision-context", { + method: "POST", body: JSON.stringify({...target, action: "inspect"}), signal, + }); +} + +export function recallDecisionContext( + request: ContextTarget & Readonly<{query: string; context_scope_ref: string; max_results: number}>, + signal: AbortSignal, +) { + return requestJson("/api/chat/decision-context", { + method: "POST", body: JSON.stringify({...request, action: "recall"}), signal, + }); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts index 74bc1c4ccb..edb6355925 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts @@ -12,6 +12,7 @@ type FieldCopy = Record> = { en: { + decision_context: {displayName: "Decision evidence recall", description: "Recall advisory evidence through an existing private profile. This page does not save profiles or enable automatic hooks."}, content_ops: {displayName: "Reference styles", description: "Retrieve source styles from an existing private catalog and prepare attributed outlines. No source or material-store writes."}, goal_storage: { displayName: "New Goal authority", description: "New Goals default to canonical SQLite with hard leases. Choose storage and execution policy for future Goals. Existing Goals require a separate backed-up migration." }, manager_runtime: { @@ -79,6 +80,7 @@ const capabilityCopy: Record> = { }, }, "zh-CN": { + decision_context: {displayName: "决策证据召回", description: "通过现有私有 profile 召回参考证据。本页不保存 profile,也不启用自动 hook。"}, content_ops: {displayName: "素材与风格", description: "从原有私有目录检索表达结构,准备带来源署名的提纲;不执行来源或素材库写入。"}, goal_storage: { displayName: "新 Goal 的权威存储", description: "新 Goal 默认使用 canonical SQLite 与 hard lease;可选择之后新 Goal 的存储与执行策略。已有 Goal 仍需单独备份、迁移。" }, manager_runtime: { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/content-reference-workbench.css b/apps/presentation/dashboard/src/features/personal-workspace/content-reference-workbench.css index 65e85ac690..ed858579c1 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/content-reference-workbench.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/content-reference-workbench.css @@ -1,6 +1,8 @@ .personal-content-reference { overflow-wrap: anywhere; } .personal-content-reference label { display: grid; gap: 6px; margin-block: 12px; } -.personal-content-reference input:not([type="file"]), +.personal-content-reference input:not([type="file"]):not([type="checkbox"]), .personal-content-reference textarea { box-sizing: border-box; min-width: 0; width: 100%; padding: 10px; border: 1px solid var(--pw-line-strong, #ebebeb); border-radius: 6px; color: inherit; background: transparent; font: inherit; } .personal-content-reference textarea { resize: vertical; } .personal-content-reference .personal-capability-linked-setting { margin-block: 12px; } +.personal-content-reference label:has(input[type="checkbox"]) { display: flex; align-items: center; } +.personal-decision-context .decision-context-hit { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--pw-text); font-size: var(--text-body); line-height: var(--leading-body); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/decision-context-workbench.tsx b/apps/presentation/dashboard/src/features/personal-workspace/decision-context-workbench.tsx new file mode 100644 index 0000000000..f1391812ea --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/decision-context-workbench.tsx @@ -0,0 +1,126 @@ +import {useEffect, useRef, useState} from "react"; +import {inspectDecisionContext, recallDecisionContext, type DecisionContextActivation, type DecisionContextRecall} from "../../data/decision-context"; +import {useWorkspaceI18n} from "./i18n"; +import "./content-reference-workbench.css"; + +/** Explicit per-page profile copy; no browser storage or durable binding. */ +export function DecisionContextWorkbench({goalId}: Readonly<{goalId: string}>) { + const {locale} = useWorkspaceI18n(); + const copy = (cn: string, en: string) => locale === "zh-CN" ? cn : en; + const [profile, setProfile] = useState | null>(null); + const [agent, setAgent] = useState(""); + const [scope, setScope] = useState(""); + const [query, setQuery] = useState(""); + const [limit, setLimit] = useState(4); + const [activation, setActivation] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const generation = useRef(0); + const pending = useRef(null); + const input = useRef(null); + const importGeneration = useRef(0); + const recallReady = activation?.available && activation.context_provider_configured; + + function invalidate() { + generation.current += 1; + pending.current?.abort(); pending.current = null; + setResult(null); setError(""); setBusy(false); + } + useEffect(() => () => {generation.current += 1; importGeneration.current += 1; pending.current?.abort();}, []); + + useEffect(() => { + let current = true; + const controller = new AbortController(); + setActivation(null); setResult(null); setError(""); setBusy(false); + if (!profile || !agent.trim()) return () => controller.abort(); + void inspectDecisionContext({goal_id: goalId, agent_id: agent, profile}, controller.signal) + .then(value => {if (current) setActivation(value);}) + .catch(() => {if (current) setError(copy("配置检查失败,请重试。", "Profile inspection failed. Retry."));}); + return () => {current = false; controller.abort();}; + }, [profile, agent, goalId]); + + function clear({keepFile = false, keepQuery = false} = {}) { + importGeneration.current += 1; invalidate(); setProfile(null); setAgent(""); + setActivation(null); if (!keepQuery) setQuery(""); setScope(""); setLimit(4); + if (!keepFile && input.current) input.current.value = ""; + } + async function importProfile(file?: File) { + clear({keepFile: true, keepQuery: true}); const revision = importGeneration.current; + if (!file) return; + try { + if (file.size > 240 * 1024) throw new Error(); + const value: unknown = JSON.parse(await file.text()); + if (revision !== importGeneration.current) return; + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(); + const next = value as Record; + const agents = Array.isArray(next.enabled_agents) ? next.enabled_agents.filter((item): item is string => typeof item === "string") : []; + const context = next.context_provider && typeof next.context_provider === "object" ? next.context_provider as Record : {}; + setProfile(next); setAgent(agents[0] ?? ""); + setScope(typeof context.scope_ref === "string" ? context.scope_ref : "goal"); + setLimit(typeof context.max_results === "number" ? context.max_results : 4); + } catch { + if (revision === importGeneration.current) setError(copy("无法导入 profile。请使用不超过 240 KiB 的 JSON 对象。", "Cannot import profile. Use a JSON object up to 240 KiB.")); + } + } + async function recall() { + if (pending.current || busy || !profile || !recallReady || !query.trim()) return; + invalidate(); const revision = generation.current; + const controller = new AbortController(); pending.current = controller; setBusy(true); + try { + const value = await recallDecisionContext({goal_id: goalId, agent_id: agent, profile, query, context_scope_ref: scope, max_results: limit}, controller.signal); + if (revision === generation.current) setResult(value); + } catch { + if (revision === generation.current) setError(copy("召回失败。配置或服务恢复后可重试。", "Recall failed. Restore the profile or service and retry.")); + } finally {if (revision === generation.current) {setBusy(false); pending.current = null;}} + } + const status = result?.status ?? activation?.status; + const reason = result?.reason_code ?? activation?.reason_code; + const statusCopy: Record = { + available: copy("配置检查通过,可以尝试召回", "Profile checks passed; recall can be attempted"), + disabled: copy("本页召回已停用", "Recall disabled in this page"), + agent_not_enabled: copy("此 Agent 未在 profile 中启用", "Agent is not enabled by the profile"), + profile_invalid: copy("Profile 无效或与当前 Goal 不符", "Profile invalid or outside the selected Goal"), + provider_unavailable: copy("Provider 暂不可用", "Provider unavailable"), + unavailable: copy("Provider 暂不可用", "Provider unavailable"), + failed: copy("Provider 召回失败,可恢复后重试", "Provider recall failed; restore and retry"), + completed: copy("召回完成", "Recall completed"), + }; + const reasonCopy: Record = { + scope_mismatch: copy("本次 Scope 与语料不匹配", "This scope does not match the corpus"), + corpus_unavailable: copy("语料缺失或不可读,恢复后可重试", "Corpus missing or unreadable; restore and retry"), + context_provider_not_configured: copy("Profile 尚未配置召回 provider", "No recall provider configured in this profile"), + }; + return
+

{copy("导入现有私有 profile,为当前问题召回证据。配置发给本机服务;发起召回时,查询才发送给所选 provider。离开或清除本页后移除配置与结果。", "Import an existing private profile to recall evidence for your question. The profile goes to this local service; only recall sends the query to the selected provider. Leaving or clearing the page removes its profile and results.")}

+ + {!profile ?

{copy("尚未导入 profile;不会调用 provider。", "No profile loaded; the provider is not called.")}

: <> + + +