Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 234 additions & 1 deletion crates/agent-gateway/web/src/app/GatewayApp.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
sidebarShallowEqual,
} from "@liveagent/ui/lib/sidebar/selectors";
import type { SidebarSnapshot, SidebarStore } from "@liveagent/ui/lib/sidebar/store";
import type { TransientSidebarRunningConversation } from "@liveagent/ui/lib/sidebar/transientActivity";
import { mergeTransientSidebarRunningActivity } from "@liveagent/ui/lib/sidebar/transientActivity";
import type { SidebarErrorCode } from "@liveagent/ui/lib/sidebar/types";
import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector";
import { sortWorkspaceProjectsByActivity } from "@liveagent/ui/lib/workspaceProjects";
Expand Down Expand Up @@ -73,6 +75,8 @@ function useStableCallback<Args extends unknown[], Return>(

export type GatewaySidebarContainerProps = {
store: SidebarStore;
// 手动压缩 pending 已按会话 id 键化,多个会话可同时“转圈”(issue #359 缺陷 #3)。
transientRunningConversations?: readonly TransientSidebarRunningConversation[];
currentConversationId: string;
isOpen: boolean;
fontScale?: number;
Expand Down Expand Up @@ -155,6 +159,19 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) {
sidebarShallowEqual,
);
const conversationIndex = useSidebarSelector(store, selectConversationIndex);
const effectiveRunningActivity = useMemo(
() =>
mergeTransientSidebarRunningActivity(
runningConversationIds,
projectActivityInputs.runningWorkdirPathKeys,
props.transientRunningConversations,
),
[
projectActivityInputs.runningWorkdirPathKeys,
props.transientRunningConversations,
runningConversationIds,
],
);

// --- Rename UI state (moved out of GatewayApp) ---------------------------
const [renamingId, setRenamingId] = useState<string | null>(null);
Expand Down Expand Up @@ -336,17 +353,21 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) {
() =>
sortWorkspaceProjectsByActivity(projects, {
projectActivityUpdatedAts: projectActivityInputs.workdirActivity,
runningProjectPathKeys: projectActivityInputs.runningWorkdirPathKeys,
runningProjectPathKeys: effectiveRunningActivity.runningProjectPathKeys,
}),
[projectActivityInputs.runningWorkdirPathKeys, projectActivityInputs.workdirActivity, projects],
[
effectiveRunningActivity.runningProjectPathKeys,
projectActivityInputs.workdirActivity,
projects,
],
);

return (
<ChatHistorySidebar
items={items}
currentConversationId={props.currentConversationId}
busyConversationIds={mutations}
runningConversationIds={runningConversationIds}
runningConversationIds={effectiveRunningActivity.runningConversationIds}
listStatus={listState.status}
scopeKey={scopeKey}
totalItems={listState.totalCount}
Expand All @@ -364,7 +385,7 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) {
projects={sortedProjects}
activeProjectId={props.activeProjectId}
missingProjectPathKeys={props.missingProjectPathKeys}
runningProjectPathKeys={projectActivityInputs.runningWorkdirPathKeys}
runningProjectPathKeys={effectiveRunningActivity.runningProjectPathKeys}
projectRenamingId={props.projectRenamingId}
projectRenameDraft={props.projectRenameDraft}
projectsCollapsed={props.projectsCollapsed}
Expand Down
10 changes: 8 additions & 2 deletions crates/agent-gateway/web/src/components/GatewayTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,16 @@ function CheckpointCard(props: {
readOnly?: boolean;
}) {
const { item, readOnly = false } = props;
const { t } = useLocale();
const [expanded, setExpanded] = useState(false);
const isExpanded = expanded;
const messageCountLabel =
item.coveredMessageCount > 0 ? `${item.coveredMessageCount} 条消息` : "已压缩";
item.coveredMessageCount > 0
? t("chat.contextCheckpoint.messageCount").replace(
"{count}",
String(item.coveredMessageCount),
)
: t("chat.contextCheckpoint.compressed");
const headerContent = (
<>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] bg-black/[0.04] dark:bg-white/[0.08]">
Expand All @@ -213,7 +219,7 @@ function CheckpointCard(props: {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[calc(13px*var(--zone-font-scale,1))] font-medium text-foreground/90">
上下文检查点
{t("chat.contextCheckpoint.title")}
</span>
<span className="inline-flex items-center rounded-md bg-black/[0.05] px-1.5 py-[1px] text-[calc(11px*var(--zone-font-scale,1))] font-normal tabular-nums text-muted-foreground dark:bg-white/[0.08]">
{messageCountLabel}
Expand Down
26 changes: 26 additions & 0 deletions crates/agent-gateway/web/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,18 @@ export const translations: Record<Locale, Record<string, string>> = {
"chat.changedFiles.expand": "展开文件列表",
"chat.compactingContext": "正在压缩上下文",
"chat.compactingContextWait": "正在压缩上下文,请稍候...",
"chat.contextCheckpoint.title": "上下文检查点",
"chat.contextCheckpoint.messageCount": "{count} 条消息",
"chat.contextCheckpoint.compressed": "已压缩",
"chat.manualCompactTitle": "手动压缩上下文?",
"chat.manualCompactDescription": "将历史消息折叠为摘要检查点,释放上下文空间。",
"chat.manualCompactConfirm": "压缩",
"chat.manualCompactRejected": "桌面端暂无法压缩(会话运行中或正在压缩)",
"chat.manualCompactBelowThreshold": "上下文占用尚未达到 50%,暂不需要压缩",
"chat.manualCompactEmpty": "当前会话没有可压缩的上下文",
"chat.manualCompactUnavailable": "当前模型缺少上下文配置,无法压缩",
"chat.manualCompactFailed": "上下文压缩失败,请稍后重试",
"chat.manualCompactTimedOut": "上下文压缩未返回结果,请确认桌面端在线后重试",
"chat.editMessage": "编辑消息",
"chat.cancel": "取消",
"chat.queue.title": "等待队列 {count}",
Expand Down Expand Up @@ -2423,6 +2435,20 @@ export const translations: Record<Locale, Record<string, string>> = {
"chat.changedFiles.expand": "Expand file list",
"chat.compactingContext": "Compressing context",
"chat.compactingContextWait": "Compressing context, please wait...",
"chat.contextCheckpoint.title": "Context Checkpoint",
"chat.contextCheckpoint.messageCount": "{count} msgs",
"chat.contextCheckpoint.compressed": "Compressed",
"chat.manualCompactTitle": "Compact context manually?",
"chat.manualCompactDescription":
"Folds earlier messages into a summary checkpoint to free context space.",
"chat.manualCompactConfirm": "Compact",
"chat.manualCompactRejected": "Desktop cannot compact right now (busy or already compacting)",
"chat.manualCompactBelowThreshold": "Context usage is below 50%; compaction is not needed yet",
"chat.manualCompactEmpty": "This conversation has no context to compact",
"chat.manualCompactUnavailable": "Context compaction is unavailable for the current model",
"chat.manualCompactFailed": "Context compaction failed. Please try again",
"chat.manualCompactTimedOut":
"Context compaction did not return a result. Check the desktop connection and try again",
"chat.editMessage": "Edit Message",
"chat.cancel": "Cancel",
"chat.queue.title": "Queue {count}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const EMPTY_TRANSCRIPT: TranscriptSnapshot = {
toolStatus: null,
toolStatusIsCompaction: false,
retryAttempts: [],
manualCompactionResult: null,
needsHistoryRefresh: false,
foldRevision: 0,
revision: 0,
Expand Down
31 changes: 22 additions & 9 deletions crates/agent-gateway/web/src/lib/chat/transcript/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export function buildRowsFromEntries(
summaryId: entry.summaryId,
coveredMessageCount: entry.coveredMessageCount,
generatedBy: entry.generatedBy,
contextUsageTokens: entry.contextUsageTokens,
timestamp: entry.timestamp,
});
} else {
Expand Down Expand Up @@ -391,24 +392,36 @@ export function buildTurnRows(turn: Turn): TranscriptRow[] {
// construction, but this single canonical pass makes the guarantee local to
// the builder instead of distributed across every producer. Pass `seen` to
// dedupe one region against keys already taken by another.
//
// Checkpoint rows are the exception to rename-on-collision: their id is a
// content identity (`checkpoint-<summaryId>`), so a colliding checkpoint is
// the SAME logical card seen from two sources — the history region and the
// (user-less) manual-compaction turn that streamed it. Renaming would render
// the card twice; the later copy is dropped instead (region order puts the
// history copy first, and the shared key keeps React/measurement identity
// stable when the rendering source flips).
export function dedupeRowKeys(rows: TranscriptRow[], seen = new Set<string>()): TranscriptRow[] {
let next: TranscriptRow[] | null = null;
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index];
if (!row) continue;
let key = row.key;
if (seen.has(key)) {
if (seen.has(row.key)) {
if (row.kind === "checkpoint") {
next ??= rows.slice(0, index);
continue;
}
let suffix = 2;
while (seen.has(`${key}#${suffix}`)) {
while (seen.has(`${row.key}#${suffix}`)) {
suffix += 1;
}
key = `${key}#${suffix}`;
if (!next) {
next = rows.slice();
}
next[index] = { ...row, key };
const key = `${row.key}#${suffix}`;
next ??= rows.slice(0, index);
next.push({ ...row, key });
seen.add(key);
continue;
}
seen.add(key);
seen.add(row.key);
next?.push(row);
}
return next ?? rows;
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "./turnReducer";
import type {
HistoryApplyMode,
ManualCompactionResult,
RetryAttemptRecord,
TranscriptRow,
TranscriptSnapshot,
Expand Down Expand Up @@ -95,6 +96,7 @@ const EMPTY_SNAPSHOT: TranscriptSnapshot = {
toolStatus: null,
toolStatusIsCompaction: false,
retryAttempts: EMPTY_RETRY_ATTEMPTS,
manualCompactionResult: null,
needsHistoryRefresh: false,
foldRevision: 0,
revision: 0,
Expand Down Expand Up @@ -192,6 +194,7 @@ export function createTranscriptStore(options?: {
let toolStatus: string | null = null;
let toolStatusIsCompaction = false;
let retryAttempts: readonly RetryAttemptRecord[] = EMPTY_RETRY_ATTEMPTS;
let manualCompactionResult: ManualCompactionResult | null = null;
let foldRevision = 0;
let localTurnSeq = 0;
// Idempotency cursor: the highest log seq already applied. Re-subscribe
Expand Down Expand Up @@ -357,6 +360,7 @@ export function createTranscriptStore(options?: {
toolStatus,
toolStatusIsCompaction,
retryAttempts,
manualCompactionResult,
needsHistoryRefresh: rebaseDivergent || turns.some((turn) => turn.contentStale === true),
foldRevision,
revision: snapshot.revision + 1,
Expand Down Expand Up @@ -1019,6 +1023,32 @@ export function createTranscriptStore(options?: {
}
return;
}
case "manual_compaction_result": {
// 本分支是 switch 里唯一直读载荷形状的地方,必须与相邻 case 一样防御式
// 解构:可靠 ingress journal 会重放本帧,缺字段的畸形帧若抛 TypeError 会
// 在每次重订阅时复现并打断整条应用链。operationId 非字符串/空、status 不在
// 白名单内、message 非字符串——一律降级(丢帧或空串),永不抛错。
const rawOperationId = (event as { operationId?: unknown }).operationId;
const operationId = typeof rawOperationId === "string" ? rawOperationId.trim() : "";
if (!operationId) return;
const rawStatus = (event as { status?: unknown }).status;
if (
rawStatus !== "compacted" &&
rawStatus !== "failed" &&
rawStatus !== "busy" &&
rawStatus !== "skipped"
) {
return;
}
const rawMessage = (event as { message?: unknown }).message;
manualCompactionResult = {
operationId,
status: rawStatus,
message: typeof rawMessage === "string" ? rawMessage.trim() : "",
};
schedule(true);
return;
}
default: {
applyDelta(event, runId);
}
Expand Down
19 changes: 18 additions & 1 deletion crates/agent-gateway/web/src/lib/chat/transcript/turnReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { toolArgsProgress } from "@/lib/chat/toolPreview";
import { summarizeToolCall } from "@/lib/chat/uiMessages";
import {
type AssistantMeta,
buildAssistantMeta,
buildHostedSearchEntry,
buildToolCallEntry,
Expand All @@ -29,6 +30,20 @@ import type { ChatEvent } from "@/lib/gatewayTypes";

import type { Turn, TurnPhase } from "./types";

// 合并 assistant meta:只用已定义字段覆盖,值为 undefined 的键一律跳过。
// buildAssistantMeta 现已增量构建(不再物化 own-undefined 键),这里再加一道
// 防御,保证未来任何生产者送来的 meta 都不会用 undefined 抹掉此前事件送达的
// contextUsageTokens/usageTotalTokens 等锚点(issue #359 缺陷 #2)。
function mergeAssistantMeta(base: AssistantMeta | undefined, patch: AssistantMeta): AssistantMeta {
const merged: AssistantMeta = { ...(base ?? {}) };
for (const [key, value] of Object.entries(patch)) {
if (value !== undefined) {
(merged as Record<string, unknown>)[key] = value;
}
}
return merged;
}

// Applies one assistant-side stream event to a turn. Every merge/dedup scan
// is bounded by the turn (and, within it, the segment since the last
// checkpoint/error entry) — the turn boundary replaces every "walk the tail
Expand Down Expand Up @@ -335,6 +350,8 @@ function applyTokenEvent(turn: Turn, event: Extract<ChatEvent, { type: "token" }
api: event.api,
stopReason: event.stopReason,
usage: event.usage,
contextUsageTokens: event.contextUsageTokens,
contextRelevant: event.contextRelevant,
});

if (text === "" && !meta) {
Expand All @@ -357,7 +374,7 @@ function applyTokenEvent(turn: Turn, event: Extract<ChatEvent, { type: "token" }
...target,
text: target.text + text,
round: round ?? target.round,
meta: meta ? { ...(target.meta ?? {}), ...meta } : target.meta,
meta: meta ? mergeAssistantMeta(target.meta, meta) : target.meta,
};
return withEntries(turn, enrichSegmentHostedSearches(next));
}
Expand Down
8 changes: 8 additions & 0 deletions crates/agent-gateway/web/src/lib/chat/transcript/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ export type Turn = {

export type TranscriptRowOrigin = "history" | "stream";

export type ManualCompactionResult = {
operationId: string;
status: "compacted" | "failed" | "busy" | "skipped";
message: string;
};

// One rendered transcript row of the single virtualized row list; keys are
// unique by construction so an entry can never render twice.
export type TranscriptRow =
Expand Down Expand Up @@ -93,6 +99,7 @@ export type TranscriptRow =
model: string;
promptVersion?: string;
};
contextUsageTokens?: number;
timestamp?: number;
}
| { key: string; origin: TranscriptRowOrigin; kind: "error"; text: string };
Expand Down Expand Up @@ -122,6 +129,7 @@ export type TranscriptSnapshot = {
// Live run's stream-retry history (cleared at run boundaries and whenever
// the desktop starts a fresh network attempt).
retryAttempts: readonly RetryAttemptRecord[];
manualCompactionResult: ManualCompactionResult | null;
// At least one settled streamed turn may be incomplete and should keep
// retrying the quiet history enrich while the conversation is idle.
needsHistoryRefresh: boolean;
Expand Down
2 changes: 2 additions & 0 deletions crates/agent-gateway/web/src/lib/chat/uiMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export type UiRound = {
stopReason?: string;
usage?: Usage;
usageTotalTokens?: number;
contextUsageTokens?: number;
contextRelevant?: boolean;
};
};

Expand Down
Loading
Loading