diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 0684c43060..03659a0ed0 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -897,6 +897,38 @@ export type DelegationInventory = { has_more: boolean; next_cursor: string | null; page_readback_complete: boolean; }; export type {DelegationPreflight} from "./delegation-preflight.js"; +export type DelegationDependency = { + operation_id: string; ref: string; sha256: string; input_ref: string; + relation: "responds_to" | "revises" | "uses"; state: "current" | "unavailable"; +}; +export type DelegationAdoption = { + requester_agent_id: string; consumer_operation_id: string; consumer_request_id: string; + consumer_agent_id: string; consumer_todo_id: string; state: "current" | "unavailable"; + source_artifacts: Array<{ref: string; sha256: string}>; + consumer_artifacts: Array<{ref: string; sha256: string}>; +}; +export type DelegationReadback = { + operation_id: string; request_id: string; agent_id: string; todo_id: string; + status: string; worker_active: boolean; recovery_required: boolean; + artifacts?: Array<{ref: string; sha256: string; text: string}>; error?: string; + dependencies?: DelegationDependency[]; adoptions?: DelegationAdoption[]; +}; +export function readLoopXTeamWork(sessionId: string, operationId: string) { + return requestJson(`/api/chat/sessions/${sessionId}/loopx`, { + method: "POST", body: JSON.stringify({operation: "read", operation_id: operationId}), + }); +} +// Keep inventory and selected-operation labels consistent; unknown states stay unknown. +export function delegationStateLabel(row: {status: string; worker_active?: boolean; recovery_required: boolean | null}, zh: boolean) { + if (row.status === "unavailable") return zh ? "无法核验" : "Unavailable"; + if (row.status === "accepted") return zh ? "已通过当前验收" : "Currently accepted"; + if (row.status === "rejected") return zh ? "未通过验收" : "Rejected"; + if (row.recovery_required) return zh ? "需要恢复原执行" : "Original execution needs recovery"; + if (row.status === "running" && row.worker_active) return zh ? "执行中" : "Executing"; + if (row.status === "turn_returned" && row.worker_active) return zh ? "正在验收" : "Validating"; + if (["prepared", "running", "turn_returned"].includes(row.status)) return zh ? "已派发,等待执行回读" : "Dispatched; awaiting execution readback"; + return zh ? "状态未知" : "Unknown state"; +} export function fetchLoopXTeamWork(sessionId: string, cursor?: string) { return requestJson(`/api/chat/sessions/${sessionId}/loopx`, { method: "POST", body: JSON.stringify({operation: "operations", limit: 10, ...(cursor ? {cursor} : {})}), @@ -912,9 +944,9 @@ export function updateLoopXMode(sessionId: string, operation: string, settings?: method: "POST", body: JSON.stringify({operation, operation_id: operationId, ...(settings ? {settings} : {})}), }); } -export function sendLoopXMessage(sessionId: string, message: string, deliveryMode: "queue" | "inbox" | "steer") { +export function sendLoopXMessage(sessionId: string, message: string, deliveryMode: "queue" | "inbox" | "steer", operationId: string = crypto.randomUUID()) { return requestJson<{ok: true; status: string; delivery_mode: string}>(`/api/chat/sessions/${sessionId}/loopx`, { - method: "POST", body: JSON.stringify({operation: "message", operation_id: crypto.randomUUID(), message, delivery_mode: deliveryMode}), + method: "POST", body: JSON.stringify({operation: "message", operation_id: operationId, message, delivery_mode: deliveryMode}), }); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css index 31769aa96d..d36b533963 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css @@ -39,3 +39,17 @@ .goal-loopx-mode-settings { grid-template-columns: 1fr; } .goal-loopx-dialog-content { padding: 16px; } } + +.goal-team-evidence { margin-block: 16px; padding: 16px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 12px; background: var(--pw-surface, #fff); } +.goal-team-evidence h3, .goal-team-evidence h4 { margin: 0; font-size: 13px; } +.goal-team-evidence article { margin-block: 16px; } +.goal-team-evidence pre { max-height: 320px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.6 var(--font-mono, monospace); padding: 12px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; } +.goal-team-evidence label { display: grid; gap: 8px; font-weight: 500; } +.goal-team-evidence textarea { box-sizing: border-box; resize: vertical; min-height: 88px; width: 100%; padding: 8px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; background: var(--pw-bg, #fafafa); color: inherit; font: inherit; } +.goal-team-evidence textarea:focus-visible, .goal-team-evidence pre:focus-visible { outline: 2px solid #0070f3; outline-offset: 2px; } +.goal-team-control { border-top: 1px solid var(--pw-line, #ebebeb); padding-top: 16px; } +.goal-team-evidence [role="alert"] { color: var(--pw-red, #b42318); } +.goal-team-lineage { margin-block: 16px; padding-block: 16px; border-block: 1px solid var(--pw-line, #ebebeb); } +.goal-team-lineage ul { list-style: none; padding: 0; margin: 12px 0; } +.goal-team-lineage li { display: grid; gap: 8px; padding: 0 0 16px 16px; border-left: 2px solid var(--pw-line, #ebebeb); overflow-wrap: anywhere; } +.goal-team-lineage button { justify-self: start; max-width: 100%; overflow-wrap: anywhere; text-align: start; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx index 088e20212d..d7bbe73ba0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.tsx @@ -77,7 +77,7 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { if (!sessionId) return; setBusy(true); setError(""); try {const result = await updateLoopXMode(sessionId, operation, operation === "configure" ? settings : undefined); - setSnapshot(result); onChange(result); setEditing(false); + setSnapshot(result); onChange(result); if (operation === "configure" || operation === "exit") setEditing(false); } catch (failure) {setError(failure instanceof Error ? failure.message : String(failure));} finally {setBusy(false);} } @@ -104,7 +104,12 @@ export function GoalLoopXMode({sessionId, onPrepare, onExecute, onChange}: { {panel ?

{panel === "team" ? (zh ? "团队执行情况" : "Team execution") : (zh ? "运行设置" : "Execution settings")}

{error || readError ?

{error || readError}

: null} - {panel === "team" && sessionId ? : null} + {panel === "team" && sessionId && !readError ? : null} + {panel === "team" ?
+ +

{snapshot?.paused ? (active ? (zh ? "已暂停后续调度,等待当前协调轮次停止回读。" : "Further dispatch paused; awaiting coordinator turn stop readback.") : (zh ? "协调员已暂停。" : "Coordinator paused.")) : null} + {zh ? "此操作不会停止已派发成员;成员状态以上次执行回读为准。当前入口不支持停止整个团队。" : "This does not stop dispatched members; their states are last-read observations. Whole-team stop is unavailable here."}

+
: null} {editing ?
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx new file mode 100644 index 0000000000..054b9f4588 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx @@ -0,0 +1,86 @@ +import {GoalTeamLineage} from "./goal-team-lineage"; +import {useEffect, useRef, useState} from "react"; +import {delegationStateLabel, readLoopXTeamWork, sendLoopXMessage, type DelegationReadback, type LoopXModeSnapshot} from "../../data/chat"; + +/** Owner-only artifact readback. Text is evidence, never rendered as executable markup. */ +export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingress, onInspect}: { + sessionId: string; operationId: string; zh: boolean; canMessage: boolean; + ingress: LoopXModeSnapshot["ingress"]; onInspect: (operationId: string) => void; +}) { + const [result, setResult] = useState(null); + const [observedAt, setObservedAt] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(""); + const [receipt, setReceipt] = useState<{id: string; status: string} | null>(null); + const submission = useRef<{id: string; text: string} | null>(null); + const generation = useRef(0); + useEffect(() => { + void read(); + return () => {generation.current++;}; + }, [sessionId, operationId]); + async function read() { + const current = ++generation.current; + setResult(null); setObservedAt(""); setError(""); setBusy(true); + try { + const value = await readLoopXTeamWork(sessionId, operationId); + if (current === generation.current) { + setResult(value); setObservedAt(new Date().toLocaleTimeString()); + } + } catch (failure) { + if (current === generation.current) setError(failure instanceof Error ? failure.message : String(failure)); + } finally {if (current === generation.current) setBusy(false);} + } + async function send() { + if ((!result || !message.trim()) && !submission.current) return; + // A retry after transport failure must carry the original identity and exact evidence. + const attempt = submission.current ?? {id: crypto.randomUUID(), text: [ + `${zh ? "针对团队执行" : "Regarding team execution"}: ${operationId}`, + ...(result?.artifacts ?? []).map(artifact => `${artifact.ref} · sha256:${artifact.sha256}`), + message.trim(), + ].join("\n")}; + submission.current = attempt; + setSending(true); setSendError(""); + try { + const value = await sendLoopXMessage(sessionId, attempt.text, "inbox", attempt.id); + setReceipt({id: attempt.id, status: value.status}); + } catch (failure) {setSendError(failure instanceof Error ? failure.message : String(failure));} + finally {setSending(false);} + } + const status = receipt ? ingress.find(row => row.client_ingress_id === receipt.id)?.status ?? receipt.status : ""; + const receiptLabel = status === "pending" ? (zh ? "已进入协调员收件箱,等待读取" : "In the coordinator inbox; awaiting read") + : status === "delivered" ? (zh ? "已交给协调员;尚无应用回执" : "Delivered to coordinator; application not confirmed") + : (zh ? "投递状态待核实" : "Delivery requires reconciliation"); + return
+

{zh ? "执行证据" : "Execution evidence"}

+
+ {operationId} + {busy ?

{zh ? "正在核验绑定、验收与文件…" : "Checking bindings, acceptance and files…"}

: null} + {error ?

{zh ? "无法核验,已清除上次证据。" : "Cannot verify; previous evidence cleared."} {error}

: null} + {result ? <> +

{result.agent_id} · {delegationStateLabel(result, zh)}{" · "}{observedAt}

+

{zh ? "按需读取的当前观察,不是持续在线状态;验收不代表协调员已采用。" : "An on-demand observation, not continuous liveness; acceptance does not establish coordinator adoption."}

+ + {result.error ?

{result.error}

: null} + {result.status === "accepted" && result.artifacts?.length ? result.artifacts.map(artifact =>
+

{artifact.ref}

+
{artifact.text}
+
{zh ? "版本与来源标识" : "Version and source identifiers"} + sha256:{artifact.sha256}{result.request_id}{result.todo_id} +
+
) :

{zh ? "本次读取没有可展示的已验收产物。" : "No accepted artifact is available in this readback."}

} +
{event.preventDefault(); void send();}}> +