feat(canvas): 云端 Agent 交互 - 展示错误动作并支持连接器与提示词预填

This commit is contained in:
ddcat666
2026-10-04 02:22:42 +08:00
parent e70430e3e2
commit 8a08663d99
19 changed files with 779 additions and 94 deletions

View File

@@ -4,7 +4,10 @@ export type AgentContextUsage = {
readingSeq: number;
readingStale: boolean;
compactionPending: Record<string, unknown> | null;
compactionSeq: number;
compactionId: string | null;
lastCompaction: Record<string, unknown> | null;
compactionError: string | null;
};
export type AgentContextUsageEvent = {
@@ -41,14 +44,23 @@ export type AgentContextUsageView = {
compactAtTokens?: number;
contextWindowTokens?: number;
protocolBytes?: number;
tokenSource?: "provider" | "estimate";
estimate: boolean;
tokenSource?: "pi";
breakdown: AgentContextBreakdownItem[];
lastCompaction: Record<string, unknown> | null;
compactionError?: string;
};
export function emptyAgentContextUsage(runId: string): AgentContextUsage {
return { runId, reading: null, readingSeq: 0, readingStale: false, compactionPending: null, lastCompaction: null };
return { runId, reading: null, readingSeq: 0, readingStale: false, compactionPending: null, compactionSeq: 0, compactionId: null, lastCompaction: null, compactionError: null };
}
function eventSequence(event: AgentContextUsageEvent): number | undefined {
return typeof event.seq === "number" && Number.isFinite(event.seq) && event.seq > 0 ? event.seq : undefined;
}
function payloadString(payload: Record<string, unknown>, key: string): string | undefined {
const value = payload[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
/** Reduces durable Agent events without mixing readings from different runs. */
@@ -56,13 +68,35 @@ export function reduceAgentContextUsage(current: AgentContextUsage, event: Agent
const scoped = current.runId === event.runId ? current : emptyAgentContextUsage(event.runId);
const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
if (event.type === "context_pressure") {
return { ...scoped, reading: payload, readingSeq: event.seq || 0, readingStale: false, compactionPending: null };
// Pi SDK is the only supported context-usage source. Ignore legacy Go/provider
// readings instead of letting them overwrite a valid Pi session reading.
if (payload.tokenSource !== "pi") return scoped;
const seq = eventSequence(event);
// A reading without a durable sequence cannot be ordered against a
// reconnect replay. Never let it move the ring backwards.
if (seq === undefined || seq <= scoped.readingSeq) return scoped;
return { ...scoped, reading: payload, readingSeq: seq, readingStale: false, compactionError: null };
}
if (event.type === "context_compaction_requested") {
return { ...scoped, compactionPending: payload };
const seq = eventSequence(event);
if (seq === undefined || seq <= scoped.compactionSeq) return scoped;
return { ...scoped, compactionPending: payload, compactionSeq: seq, compactionId: payloadString(payload, "compactionId") ?? null, compactionError: null };
}
if (event.type === "context_compacted") {
return { ...scoped, readingStale: Boolean(scoped.reading), compactionPending: null, lastCompaction: payload };
const seq = eventSequence(event);
if (seq === undefined || seq <= scoped.compactionSeq) return scoped;
const compactionId = payloadString(payload, "compactionId");
if (scoped.compactionId && compactionId && scoped.compactionId !== compactionId) return scoped;
return { ...scoped, readingStale: Boolean(scoped.reading), compactionPending: null, compactionSeq: seq, compactionId: compactionId ?? scoped.compactionId, lastCompaction: payload, compactionError: null };
}
if (event.type === "context_compaction_failed") {
const seq = eventSequence(event);
if (seq === undefined || seq <= scoped.compactionSeq) return scoped;
const compactionId = payloadString(payload, "compactionId");
if (scoped.compactionId && compactionId && scoped.compactionId !== compactionId) return scoped;
const aborted = payload.cancelled === true || payload.aborted === true;
const message = typeof payload.errorMessage === "string" ? payload.errorMessage : aborted ? "上下文压缩已取消,原有对话历史已保留" : "上下文压缩失败,原有对话历史已保留";
return { ...scoped, compactionPending: null, compactionSeq: seq, compactionId: compactionId ?? scoped.compactionId, compactionError: message };
}
return scoped;
}
@@ -73,20 +107,14 @@ export function finiteContextNumber(value: unknown): number | undefined {
/** Returns a ratio only when the backend has a trustworthy configured model budget. */
export function contextPressureRatio(reading: Record<string, unknown> | null): number | undefined {
if (!reading || reading.modelLimitConfigured !== true) return undefined;
if (!reading || reading.tokenSource !== "pi" || reading.modelLimitConfigured !== true) return undefined;
const contextWindow = finiteContextNumber(reading.contextWindowTokens);
if (!contextWindow || contextWindow <= 0) return undefined;
const ratio = reading.tokenSource === "provider"
? finiteContextNumber(reading.projectedPressureRatio)
: finiteContextNumber(reading.pressureRatio);
return ratio;
return finiteContextNumber(reading.pressureRatio);
}
export function contextInputTokens(reading: Record<string, unknown> | null): number | undefined {
if (!reading) return undefined;
if (reading.tokenSource === "provider") {
return finiteContextNumber(reading.projectedNextInputTokens) ?? finiteContextNumber(reading.estimatedInputTokens);
}
if (!reading || reading.tokenSource !== "pi") return undefined;
return finiteContextNumber(reading.estimatedInputTokens);
}
@@ -113,7 +141,6 @@ function contextBreakdown(reading: Record<string, unknown> | null): AgentContext
});
}
function contextProtocolBytes(reading: Record<string, unknown> | null): number | undefined {
if (!reading?.breakdown || typeof reading.breakdown !== "object") return undefined;
return finiteContextNumber((reading.breakdown as { envelopeBytes?: unknown }).envelopeBytes);
@@ -131,18 +158,15 @@ function formatContextTokens(tokens: number | undefined): string {
* All visual scales use the model context window; the marker shows the shared 80% compaction line.
*/
export function presentAgentContextUsage(usage: AgentContextUsage): AgentContextUsageView {
const reading = usage.reading;
const reading = usage.reading?.tokenSource === "pi" ? usage.reading : null;
const inputTokens = contextInputTokens(reading);
const contextWindowTokens = finiteContextNumber(reading?.contextWindowTokens);
const usableTokens = contextWindowTokens;
const compactAtTokens = finiteContextNumber(reading?.compactAtTokens);
const ratio = contextPressureRatio(reading);
const compactRatio = usableTokens && compactAtTokens ? Math.min(1, compactAtTokens / usableTokens) : undefined;
const tokenSource = reading?.tokenSource === "provider" ? "provider" : reading ? "estimate" : undefined;
const estimate = tokenSource !== "provider";
const remainingTokens = usableTokens !== undefined && inputTokens !== undefined
? Math.max(0, usableTokens - inputTokens)
: undefined;
const tokenSource = reading ? "pi" : undefined;
const remainingTokens = usableTokens !== undefined && inputTokens !== undefined ? Math.max(0, usableTokens - inputTokens) : undefined;
const base: AgentContextUsageView = {
phase: "idle",
ratio,
@@ -157,9 +181,9 @@ export function presentAgentContextUsage(usage: AgentContextUsage): AgentContext
contextWindowTokens,
protocolBytes: contextProtocolBytes(reading),
tokenSource,
estimate,
breakdown: contextBreakdown(reading),
lastCompaction: usage.lastCompaction,
compactionError: usage.compactionError ?? undefined,
};
if (usage.compactionPending) {
const basis = usage.compactionPending.basis === "bytes" ? "消息体积已到兜底线" : "已到上下文压缩线";
@@ -176,9 +200,15 @@ export function presentAgentContextUsage(usage: AgentContextUsage): AgentContext
const line = compactRatio && compactRatio > 0 ? compactRatio : 0.8;
const ring = Math.max(0, Math.min(1, ratio));
const percent = Math.round(ratio * 100);
const source = estimate ? "本地估算" : "模型实测校准";
const source = "Pi 会话估算";
if (ratio >= line) {
return { ...base, phase: "compress", ring, label: `${percent}%`, detail: `已到压缩线(模型窗口的 ${Math.round(line * 100)}%)。下一次调用前会暂停,把历史收成检查点后再继续。当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source})。` };
return {
...base,
phase: "compress",
ring,
label: `${percent}%`,
detail: `已到压缩线(模型窗口的 ${Math.round(line * 100)}%)。下一次调用前会暂停,把历史收成检查点后再继续。当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source})。`,
};
}
if (ring >= 0.72) {
return { ...base, phase: "watch", ring, label: `${percent}%`, detail: `接近压缩。当前 ${formatContextTokens(inputTokens)} / ${formatContextTokens(usableTokens)}(${source}),到 ${formatContextTokens(compactAtTokens)} 时开始压缩。` };

View File

@@ -20,6 +20,13 @@ export function agentErrorPresentation(cause: unknown, fallback = "Agent 执行
return { title: fallback, text: text || fallback };
}
export function agentErrorContent(text: string, title: string) {
// 只拆分服务端公开提示中的诊断尾注,不据此推断错误类型或改变业务语义。
const diagnostic = text.trim().match(/^([\s\S]*?);如反复出现,请把诊断号\s+([a-zA-Z0-9:_-]+)\s+反馈给管理员[。.]?$/);
const description = (diagnostic?.[1] ?? text).trim();
return { description: description === title.trim() ? "" : description, diagnosticId: diagnostic?.[2] };
}
export function agentSubmissionErrorTitle(cause: unknown, accepted: boolean) {
if (accepted) return "运行已接收,但本地提交记录清理失败";
const status = cause instanceof ApiError ? cause.status : undefined;

View File

@@ -107,7 +107,11 @@ export function agentOperationSegmentLabel(items: readonly AgentFeedRecord[]): s
*/
export function buildAgentFeedSegments<T extends AgentFeedRecord>(messages: readonly T[]): AgentFeedSegment<T>[] {
const segments: AgentFeedSegment<T>[] = [];
// 缓存中的失败事件(runId:seq)与终态提示属于同一运行,不重复展示。
const terminalErrors = new Set(messages.filter((item) => item.role === "error" && item.id.startsWith("terminal-")).map((item) => item.id));
for (const item of messages) {
const failureRunId = item.role === "error" ? item.id.match(/^(.+):\d+$/)?.[1] : undefined;
if (failureRunId && terminalErrors.has(`terminal-${failureRunId}`)) continue;
if (isAgentCarrierRecord(item)) continue;
if (isAgentReasoningRecord(item)) {
const last = segments[segments.length - 1];

View File

@@ -0,0 +1,8 @@
export function appendAgentPromptPrefill(current: string, prefill: string) {
if (!prefill.trim()) return current;
if (!current.trim()) return prefill;
// 追加到末尾,用空格分隔而非换行,保持在同一段落中
const separator = current.endsWith(" ") ? "" : " ";
return `${current}${separator}${prefill.trim()}`;
}