feat(canvas): 云端 Agent 交互 - 展示错误动作并支持连接器与提示词预填
This commit is contained in:
@@ -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)} 时开始压缩。` };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
8
web/src/lib/canvas/agent-prompt-prefill.ts
Normal file
8
web/src/lib/canvas/agent-prompt-prefill.ts
Normal 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()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user