mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
feat(dashboard): parent-link, genuine-continuation badge, and modal perf fixes (#12448)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem. O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva. Obrigado, @hartmark.
This commit is contained in:
@@ -26,6 +26,11 @@ interface ConversationRow {
|
||||
// streaming (call_logs only gets its row on completion). Used to poll
|
||||
// /api/logs/[id] for this conversation's live partial assistant text.
|
||||
activeCallLogId: string | null;
|
||||
// Whether the latest turn actually used previous_response_id and it
|
||||
// resolved server-side — distinct from this row existing at all, which
|
||||
// only means the client-side content-hash tracker saw >= 2 turns
|
||||
// regardless of transport (see isGenuineContinuationTurn).
|
||||
isGenuineContinuation: boolean;
|
||||
}
|
||||
|
||||
// Same spinner used for an in-flight request on /dashboard/logs
|
||||
@@ -101,6 +106,23 @@ function StatusBadge({ status }: { status: number | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Distinguishes a conversation whose latest turn actually used
|
||||
// previous_response_id (server-verified — see isGenuineContinuationTurn)
|
||||
// from one the content-hash tracker merely counts as multi-turn while still
|
||||
// resending full history each request.
|
||||
function ContinuationBadge({ isGenuine }: { isGenuine: boolean }) {
|
||||
if (!isGenuine) return null;
|
||||
return (
|
||||
<span
|
||||
title="Latest turn used previous_response_id and it resolved server-side"
|
||||
className="inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full text-[9px] font-bold bg-emerald-500/15 text-emerald-500 border border-emerald-500/25"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[11px] leading-none">bolt</span>
|
||||
continuation
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the exact NormalizedBlock (src/mitm/inspector/types.ts) the
|
||||
* request-detail panel already builds from buildRequestTurns/
|
||||
@@ -268,13 +290,15 @@ function ConversationsPageContent() {
|
||||
// itself in the poll effect's dependency array (which would tear down and
|
||||
// restart the interval on every single appended turn).
|
||||
const newestSeqRef = useRef<number | null>(null);
|
||||
// Tracks the PREVIOUS render's activeCallLogId truthiness, so the
|
||||
// reply-just-finished effect below can detect the true->false transition
|
||||
// specifically (not "is currently falsy", which would also fire on mount
|
||||
// / switching conversations).
|
||||
const wasReplyActiveRef = useRef(false);
|
||||
|
||||
// Extracted so openConversation can force an immediate refresh instead of
|
||||
// waiting for the next scheduled tick — see its call site for why: a
|
||||
// conversation opened right after a new reply starts streaming otherwise
|
||||
// shows no live text until this poll's own interval happens to land,
|
||||
// because activeCallLogId only updates via the resync effect below, which
|
||||
// depends on this list actually having been refetched.
|
||||
// The background list poll below only runs this while no conversation
|
||||
// modal is open — see loadActiveConversationSummary and the poll effect
|
||||
// for the lighter single-row path used while one is open.
|
||||
const loadConversations = useCallback(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
return fetch("/api/conversations?limit=100", { cache: "no-store" })
|
||||
@@ -290,22 +314,51 @@ function ConversationsPageContent() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// While the modal is open, only the one open conversation's summary needs
|
||||
// to stay live (see the resync effect below) — refetching and
|
||||
// re-annotating the whole up-to-100-row list every poll tick just to pluck
|
||||
// that one row back out is pure waste, and at a 1s poll interval it's
|
||||
// waste on every tick. Patches the row in place so the existing resync
|
||||
// effect (keyed on `conversations`) picks it up unchanged.
|
||||
const loadActiveConversationSummary = useCallback((id: string) => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
return fetch(`/api/conversations/${id}`, { cache: "no-store" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
const fresh = data?.conversation;
|
||||
if (!fresh) return;
|
||||
setConversations((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === fresh.id);
|
||||
if (idx === -1) return prev;
|
||||
const next = prev.slice();
|
||||
next[idx] = fresh;
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
const interval = setInterval(loadConversations, pollSeconds * 1000);
|
||||
const poll = () =>
|
||||
activeConversationId
|
||||
? loadActiveConversationSummary(activeConversationId)
|
||||
: loadConversations();
|
||||
poll();
|
||||
const interval = setInterval(poll, pollSeconds * 1000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [pollSeconds, loadConversations]);
|
||||
}, [pollSeconds, loadConversations, loadActiveConversationSummary, activeConversationId]);
|
||||
|
||||
// activeConversation is a snapshot taken once at openConversation() time —
|
||||
// it's never touched again while the modal stays open (the turns-poll
|
||||
// effect below only appends conversationNodes). Without this, "Goto latest
|
||||
// request" and any other displayed summary field (lastModel/lastStatus/
|
||||
// turnCount) go stale the moment a new request lands in this conversation
|
||||
// while you're still reading it, even though the list poll above (which
|
||||
// runs regardless of whether the modal is open) already has the fresh
|
||||
// row. Re-sync from it whenever the list refreshes.
|
||||
// while you're still reading it. Re-synced from `conversations` whenever
|
||||
// that refreshes — the effect above keeps it fresh whether the modal is
|
||||
// closed (full list poll) or open (single-conversation poll patches this
|
||||
// same row in place).
|
||||
useEffect(() => {
|
||||
if (!activeConversationId) return;
|
||||
const fresh = conversations.find((c) => c.id === activeConversationId);
|
||||
@@ -461,13 +514,13 @@ function ConversationsPageContent() {
|
||||
// ignore navigation errors
|
||||
}
|
||||
// `row` is a snapshot from whenever the list last polled — if a reply
|
||||
// started streaming after that tick, row.activeCallLogId is still
|
||||
// null and the live-text poll effect never starts until the next
|
||||
// scheduled list refresh happens to land (the exact "opened it and
|
||||
// saw nothing, closed and reopened and saw it live" report). Force
|
||||
// one now so activeConversation resyncs with the current isActive/
|
||||
// activeCallLogId immediately instead of waiting on pollSeconds.
|
||||
loadConversations();
|
||||
// started streaming after that tick, row.activeCallLogId is still null
|
||||
// and the live-text poll effect never starts until a fresh summary
|
||||
// lands (the exact "opened it and saw nothing, closed and reopened and
|
||||
// saw it live" report). setActiveConversation above already changes
|
||||
// activeConversationId, which is a dependency of the poll effect below
|
||||
// — it tears down and re-fires immediately on that change, forcing the
|
||||
// single-row resync here for free without a second explicit call.
|
||||
fetchConversationPage(row.id, `limit=${CONVERSATION_PAGE_SIZE}`)
|
||||
.then((page) => {
|
||||
setConversationNodes(page?.nodes ?? []);
|
||||
@@ -480,7 +533,7 @@ function ConversationsPageContent() {
|
||||
scrollToBottom();
|
||||
});
|
||||
},
|
||||
[router, fetchConversationPage, scrollToBottom, loadConversations]
|
||||
[router, fetchConversationPage, scrollToBottom]
|
||||
);
|
||||
|
||||
const closeConversation = useCallback(() => {
|
||||
@@ -583,6 +636,35 @@ function ConversationsPageContent() {
|
||||
return () => clearInterval(interval);
|
||||
}, [activeConversationId, pollSeconds, fetchConversationPage]);
|
||||
|
||||
// Live incident (2026-09-02): resolveConversationId reassigns a node's
|
||||
// last_correlation_id to the CURRENT request at request-START (before its
|
||||
// reply streams), but that request's call-log artifact -- what
|
||||
// resolveTurnDisplayContent needs to show real text -- is only written at
|
||||
// completion. A node touched by a still-in-flight request therefore
|
||||
// legitimately resolves empty if fetched during that window; the afterSeq
|
||||
// poll above only ever APPENDS strictly newer nodes, so one already
|
||||
// rendered empty stays empty in local state forever, even once its
|
||||
// artifact exists moments later -- the exact "empty until you close and
|
||||
// reopen the conversation" symptom. Once a reply that was streaming
|
||||
// finishes (activeCallLogId's true -> false transition -- see the
|
||||
// wasReplyActiveRef doc comment), re-fetch the recent page and merge it in
|
||||
// by id (never drop older "Load more" history) so any node that resolved
|
||||
// empty during the race gets its real content without a manual reopen.
|
||||
useEffect(() => {
|
||||
const wasActive = wasReplyActiveRef.current;
|
||||
wasReplyActiveRef.current = Boolean(activeCallLogId);
|
||||
if (!wasActive || activeCallLogId || !activeConversationId) return;
|
||||
|
||||
fetchConversationPage(activeConversationId, `limit=${CONVERSATION_PAGE_SIZE}`).then((page) => {
|
||||
if (!page || page.nodes.length === 0) return;
|
||||
setConversationNodes((prev) => {
|
||||
const byId = new Map(prev.map((n) => [n.id, n] as const));
|
||||
for (const n of page.nodes) byId.set(n.id, n);
|
||||
return [...byId.values()].sort((a, b) => a.seq - b.seq);
|
||||
});
|
||||
});
|
||||
}, [activeCallLogId, activeConversationId, fetchConversationPage]);
|
||||
|
||||
// Live preview of the CURRENTLY streaming reply, if any: conversation_turn_nodes
|
||||
// only gains a node for an assistant turn once the client resends it as
|
||||
// history on its NEXT request (resolveConversationId reads only the request
|
||||
@@ -655,6 +737,7 @@ function ConversationsPageContent() {
|
||||
lastStatus: null,
|
||||
isActive: false,
|
||||
activeCallLogId: null,
|
||||
isGenuineContinuation: false,
|
||||
}
|
||||
);
|
||||
}, [initialConversationParam, loading, conversations, openConversation]);
|
||||
@@ -759,6 +842,7 @@ function ConversationsPageContent() {
|
||||
>
|
||||
{row.id.slice(0, 16)}…
|
||||
</span>
|
||||
<ContinuationBadge isGenuine={row.isGenuineContinuation} />
|
||||
</span>
|
||||
<span className="font-mono text-xs text-text-muted shrink-0">
|
||||
{row.turnCount} turns
|
||||
@@ -785,6 +869,7 @@ function ConversationsPageContent() {
|
||||
<tr className="border-b border-border bg-bg-subtle text-left text-[11px] uppercase tracking-wider text-text-muted">
|
||||
<th className="px-3 py-2">Conversation</th>
|
||||
<th className="px-3 py-2 text-right">Turns</th>
|
||||
<th className="px-3 py-2">Continuation</th>
|
||||
<th className="px-3 py-2">Last Model</th>
|
||||
<th className="px-3 py-2">Provider</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
@@ -816,6 +901,9 @@ function ConversationsPageContent() {
|
||||
<td className="px-3 py-2 text-right font-mono text-text-main">
|
||||
{row.turnCount}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<ContinuationBadge isGenuine={row.isGenuineContinuation} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-text-main">{row.lastModel ?? "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
<ProviderBadge provider={row.lastProvider} />
|
||||
|
||||
36
src/app/api/conversations/[id]/route.ts
Normal file
36
src/app/api/conversations/[id]/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getMultiTurnConversationById } from "@/lib/db/agenticConversations";
|
||||
import { annotateConversationRow, buildActiveCallLogIdByConversation } from "../route";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Single-conversation summary — used by the dashboard's conversation modal
|
||||
* to keep lastModel/lastStatus/isActive/activeCallLogId fresh on the auto-
|
||||
* refresh interval while it's open, instead of the list route re-fetching
|
||||
* and re-annotating up to 200 rows just to pluck one back out. The turns
|
||||
* themselves live-update through the separate .../tree poll; this only
|
||||
* covers the summary fields the modal header and "Goto latest request"
|
||||
* read off the row.
|
||||
*/
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const authError = await requireManagementAuth(req);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
|
||||
const row = getMultiTurnConversationById(id);
|
||||
if (!row) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const activeCallLogIdByConversation = buildActiveCallLogIdByConversation();
|
||||
const conversation = annotateConversationRow(row, activeCallLogIdByConversation);
|
||||
|
||||
return NextResponse.json({ conversation });
|
||||
} catch (err) {
|
||||
console.error("[API ERROR] /api/conversations/[id] failed:", err);
|
||||
return NextResponse.json({ error: "Failed to fetch conversation" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import type { MultiTurnConversationRow } from "@/lib/db/agenticConversations";
|
||||
import { listMultiTurnConversations } from "@/lib/db/agenticConversations";
|
||||
import { getPendingById } from "@/lib/usage/usageHistory";
|
||||
import { isGenuineContinuationTurn } from "@/lib/db/responsesContinuationStore";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Shared row -> API-shape annotation for both the list route and the
|
||||
* single-conversation route below: isActive/activeCallLogId (pending-request
|
||||
* cross reference) and isGenuineContinuation (artifact-backed, cached — see
|
||||
* isGenuineContinuationTurn) need the exact same computation regardless of
|
||||
* whether the caller asked for one row or many. Strips the internal-only
|
||||
* lastArtifactRelPath/lastApiKeyId fields before they reach the client.
|
||||
*/
|
||||
export function annotateConversationRow(
|
||||
row: MultiTurnConversationRow,
|
||||
activeCallLogIdByConversation: ReadonlyMap<string, string>
|
||||
) {
|
||||
const { lastArtifactRelPath, lastApiKeyId, ...rest } = row;
|
||||
return {
|
||||
...rest,
|
||||
isActive: activeCallLogIdByConversation.has(row.id),
|
||||
activeCallLogId: activeCallLogIdByConversation.get(row.id) ?? null,
|
||||
isGenuineContinuation: isGenuineContinuationTurn(lastArtifactRelPath, lastApiKeyId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending (still-streaming) request's sessionTag is the conversation's own
|
||||
* id (agentic_conversations.id === call_logs.session_tag) — cross reference
|
||||
* so a conversation row can show "in progress" without a separate poll.
|
||||
* `call_logs` only gets its row on completion (src/lib/usage/callLogs.ts's
|
||||
* INSERT needs duration/status/tokens, none of which exist yet), so
|
||||
* lastCallLogId always lags one request behind while a reply is still
|
||||
* streaming — it can't be used to fetch the in-flight response. Surfacing
|
||||
* the pending request's own id separately lets the conversation panel poll
|
||||
* /api/logs/[id] for it directly (same live-partial-text path
|
||||
* RequestLoggerDetail already uses).
|
||||
*/
|
||||
export function buildActiveCallLogIdByConversation(): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const pending of getPendingById().values()) {
|
||||
if (pending.sessionTag) map.set(pending.sessionTag, pending.id);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const authError = await requireManagementAuth(req);
|
||||
if (authError) return authError;
|
||||
@@ -19,25 +62,10 @@ export async function GET(req: Request) {
|
||||
offset: Number.isFinite(offset) ? offset : undefined,
|
||||
});
|
||||
|
||||
// A pending (still-streaming) request's sessionTag is the conversation's
|
||||
// own id (agentic_conversations.id === call_logs.session_tag) — cross
|
||||
// reference so the list can show "in progress" without a separate poll.
|
||||
// `call_logs` only gets its row on completion (src/lib/usage/callLogs.ts's
|
||||
// INSERT needs duration/status/tokens, none of which exist yet), so
|
||||
// `lastCallLogId` from listMultiTurnConversations always lags one request
|
||||
// behind while a reply is still streaming — it can't be used to fetch the
|
||||
// in-flight response. Surface the pending request's own id separately so
|
||||
// the conversation panel can poll /api/logs/[id] for it directly (same
|
||||
// live-partial-text path RequestLoggerDetail already uses).
|
||||
const activeCallLogIdByConversation = new Map<string, string>();
|
||||
for (const pending of getPendingById().values()) {
|
||||
if (pending.sessionTag) activeCallLogIdByConversation.set(pending.sessionTag, pending.id);
|
||||
}
|
||||
const conversations = rows.map((row) => ({
|
||||
...row,
|
||||
isActive: activeCallLogIdByConversation.has(row.id),
|
||||
activeCallLogId: activeCallLogIdByConversation.get(row.id) ?? null,
|
||||
}));
|
||||
const activeCallLogIdByConversation = buildActiveCallLogIdByConversation();
|
||||
const conversations = rows.map((row) =>
|
||||
annotateConversationRow(row, activeCallLogIdByConversation)
|
||||
);
|
||||
|
||||
return NextResponse.json({ conversations, total });
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getCallLogById } from "@/lib/usageDb";
|
||||
import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory";
|
||||
import {
|
||||
extractPreviousResponseId,
|
||||
resolveCallLogIdByResponseId,
|
||||
} from "@/lib/db/responsesContinuationStore";
|
||||
|
||||
// Each logged chunk-array element is one raw network read, timestamp-prefixed
|
||||
// for the debug display — NOT one complete SSE `data:` line. A single JSON
|
||||
@@ -159,7 +163,20 @@ export async function GET(
|
||||
|
||||
if (!persistedRequest) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
return NextResponse.json(persistedRequest);
|
||||
// "Continues from" link for the dashboard's conversation panel: resolve
|
||||
// this entry's own previous_response_id back to the call-log row that
|
||||
// produced it. Persisted-only (apiKeyId isn't plumbed onto the
|
||||
// pending/in-memory branches above) -- required for the same tenant
|
||||
// scoping resolveCallLogIdByResponseId enforces, so an active/in-memory
|
||||
// entry simply renders no parent link rather than resolving unscoped.
|
||||
const previousResponseId = extractPreviousResponseId(
|
||||
persistedRequest.pipelinePayloads as Record<string, unknown> | null | undefined
|
||||
);
|
||||
const parentLogId = previousResponseId
|
||||
? resolveCallLogIdByResponseId(previousResponseId, persistedRequest.apiKeyId ?? null)
|
||||
: null;
|
||||
|
||||
return NextResponse.json({ ...persistedRequest, previousResponseId, parentLogId });
|
||||
} catch (err) {
|
||||
console.error("[API ERROR] /api/logs/[id] failed:", err);
|
||||
return NextResponse.json({ error: "Failed to fetch log" }, { status: 500 });
|
||||
|
||||
@@ -357,6 +357,12 @@ export interface MultiTurnConversationRow extends AgenticConversationRow {
|
||||
lastModel: string | null;
|
||||
lastProvider: string | null;
|
||||
lastStatus: number | null;
|
||||
// Exposed so the API layer can check whether the latest turn genuinely
|
||||
// used HTTP continuation (see isGenuineContinuationTurn in
|
||||
// responsesContinuationStore.ts) without a second query — this row's own
|
||||
// artifact/tenant already identify it, no separate lookup needed.
|
||||
lastArtifactRelPath: string | null;
|
||||
lastApiKeyId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,16 +406,7 @@ export function listMultiTurnConversations(
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT ac.*, latest.id as last_call_log_id, latest.model as last_model,
|
||||
latest.provider as last_provider, latest.status as last_status
|
||||
FROM agentic_conversations ac
|
||||
LEFT JOIN (
|
||||
SELECT cl1.id, cl1.session_tag, cl1.model, cl1.provider, cl1.status
|
||||
FROM call_logs cl1
|
||||
WHERE cl1.timestamp = (
|
||||
SELECT MAX(cl2.timestamp) FROM call_logs cl2 WHERE cl2.session_tag = cl1.session_tag
|
||||
)
|
||||
) latest ON latest.session_tag = ac.id
|
||||
`${MULTI_TURN_CONVERSATION_SELECT}
|
||||
WHERE (SELECT COUNT(*) FROM conversation_turn_nodes n WHERE n.conversation_id = ac.id) >= 2
|
||||
ORDER BY ac.last_seen_at DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
@@ -418,15 +415,52 @@ export function listMultiTurnConversations(
|
||||
|
||||
return {
|
||||
total: Number(total ?? 0),
|
||||
rows: rows.map((r) => {
|
||||
const rec = asRecord(r);
|
||||
return {
|
||||
...toRow(rec),
|
||||
lastCallLogId: typeof rec.last_call_log_id === "string" ? rec.last_call_log_id : null,
|
||||
lastModel: typeof rec.last_model === "string" ? rec.last_model : null,
|
||||
lastProvider: typeof rec.last_provider === "string" ? rec.last_provider : null,
|
||||
lastStatus: typeof rec.last_status === "number" ? rec.last_status : null,
|
||||
};
|
||||
}),
|
||||
rows: rows.map(toMultiTurnConversationRow),
|
||||
};
|
||||
}
|
||||
|
||||
function toMultiTurnConversationRow(value: unknown): MultiTurnConversationRow {
|
||||
const rec = asRecord(value);
|
||||
return {
|
||||
...toRow(rec),
|
||||
lastCallLogId: typeof rec.last_call_log_id === "string" ? rec.last_call_log_id : null,
|
||||
lastModel: typeof rec.last_model === "string" ? rec.last_model : null,
|
||||
lastProvider: typeof rec.last_provider === "string" ? rec.last_provider : null,
|
||||
lastStatus: typeof rec.last_status === "number" ? rec.last_status : null,
|
||||
lastArtifactRelPath:
|
||||
typeof rec.last_artifact_relpath === "string" ? rec.last_artifact_relpath : null,
|
||||
lastApiKeyId: typeof rec.last_api_key_id === "string" ? rec.last_api_key_id : null,
|
||||
};
|
||||
}
|
||||
|
||||
const MULTI_TURN_CONVERSATION_SELECT = `
|
||||
SELECT ac.*, latest.id as last_call_log_id, latest.model as last_model,
|
||||
latest.provider as last_provider, latest.status as last_status,
|
||||
latest.artifact_relpath as last_artifact_relpath,
|
||||
latest.api_key_id as last_api_key_id
|
||||
FROM agentic_conversations ac
|
||||
LEFT JOIN (
|
||||
SELECT cl1.id, cl1.session_tag, cl1.model, cl1.provider, cl1.status,
|
||||
cl1.artifact_relpath, cl1.api_key_id
|
||||
FROM call_logs cl1
|
||||
WHERE cl1.timestamp = (
|
||||
SELECT MAX(cl2.timestamp) FROM call_logs cl2 WHERE cl2.session_tag = cl1.session_tag
|
||||
)
|
||||
) latest ON latest.session_tag = ac.id
|
||||
`;
|
||||
|
||||
/**
|
||||
* Single-conversation equivalent of listMultiTurnConversations, for the
|
||||
* dashboard's conversation modal: while it's open, polling this one row on
|
||||
* the refresh interval (instead of the whole up-to-200-row list just to
|
||||
* pluck one row back out of it) is what actually needs to stay live —
|
||||
* lastCallLogId/lastStatus for "Goto latest request" and isActive detection.
|
||||
* Unlike the list, this intentionally has no turn-count floor: a
|
||||
* specifically-requested conversation should resolve even if it hasn't (yet)
|
||||
* reached 2 turn nodes.
|
||||
*/
|
||||
export function getMultiTurnConversationById(id: string): MultiTurnConversationRow | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare(`${MULTI_TURN_CONVERSATION_SELECT} WHERE ac.id = ?`).get(id);
|
||||
return row ? toMultiTurnConversationRow(row) : null;
|
||||
}
|
||||
|
||||
@@ -129,3 +129,107 @@ export function resolvePreviousResponseState(
|
||||
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the call-log id that produced `responseId`, for the dashboard's
|
||||
* "continues from" link. Reuses the same `call_logs.response_id` index and
|
||||
* `api_key_id` tenant scoping as `resolvePreviousResponseState` above -- a
|
||||
* parent link must never point across API keys, even just to surface its id.
|
||||
* Returns null on any lookup miss so the caller renders no link rather than
|
||||
* a broken one.
|
||||
*/
|
||||
export function resolveCallLogIdByResponseId(
|
||||
responseId: string,
|
||||
apiKeyId: string | null | undefined
|
||||
): string | null {
|
||||
if (!responseId || !apiKeyId) return null;
|
||||
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id FROM call_logs
|
||||
WHERE response_id = ? AND api_key_id = ?
|
||||
ORDER BY timestamp DESC LIMIT 1`
|
||||
)
|
||||
.get(responseId, apiKeyId) as { id: string } | undefined;
|
||||
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `previous_response_id` from a call-log's own pipeline payload.
|
||||
* Persisted artifacts key the client's own request `clientRawRequest`; the
|
||||
* pending/in-flight in-memory shape keys the same thing `clientRequest`
|
||||
* instead (RequestLoggerDetail.tsx's payloadSections list carries both keys
|
||||
* for the same reason) -- check both so callers get the same answer
|
||||
* regardless of which shape the payload came back as.
|
||||
*/
|
||||
export function extractPreviousResponseId(
|
||||
pipelinePayloads: Record<string, unknown> | null | undefined
|
||||
): string | null {
|
||||
if (!pipelinePayloads) return null;
|
||||
for (const key of ["clientRawRequest", "clientRequest"]) {
|
||||
const envelope = pipelinePayloads[key];
|
||||
const body = isPlainRecord(envelope) && "body" in envelope ? envelope.body : envelope;
|
||||
if (isPlainRecord(body) && typeof body.previous_response_id === "string") {
|
||||
return body.previous_response_id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// isGenuineContinuationTurn is a pure function of one call-log's own
|
||||
// artifact, which is immutable once written (see callLogs.ts -- detailState
|
||||
// only flips to "ready" after the artifact is fully persisted) -- the same
|
||||
// artifactRelPath always answers the same way, forever. Without this cache,
|
||||
// the dashboard's own default auto-refresh polls the whole conversation list
|
||||
// on an interval the operator controls (down to 1s), so every tick re-reads
|
||||
// and re-parses one artifact per visible row for an answer that can never
|
||||
// change once computed. Keyed on artifactRelPath alone (1:1 with the owning
|
||||
// call-log row, so apiKeyId never varies for a given key) with simple FIFO
|
||||
// eviction -- correctness never depends on which entries survive, only on
|
||||
// staying bounded.
|
||||
const GENUINE_CONTINUATION_CACHE_MAX = 5000;
|
||||
const genuineContinuationCache = new Map<string, boolean>();
|
||||
|
||||
function cacheGenuineContinuation(key: string, value: boolean): boolean {
|
||||
genuineContinuationCache.set(key, value);
|
||||
if (genuineContinuationCache.size > GENUINE_CONTINUATION_CACHE_MAX) {
|
||||
const oldest = genuineContinuationCache.keys().next().value;
|
||||
if (oldest !== undefined) genuineContinuationCache.delete(oldest);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a call-log's own request genuinely continued a prior response
|
||||
* server-side: it carried `previous_response_id` AND that id resolved to a
|
||||
* real, same-tenant prior call-log row. Backs the /dashboard/conversations
|
||||
* "genuine continuation" badge -- a conversation the client-side turn
|
||||
* tracker counts as multi-turn (conversationTracker.ts's content-hash chain,
|
||||
* independent of transport) is not necessarily one actually running on the
|
||||
* `previous_response_id` wire optimization; this checks the transport fact,
|
||||
* not the content-hash one.
|
||||
*/
|
||||
export function isGenuineContinuationTurn(
|
||||
artifactRelPath: string | null | undefined,
|
||||
apiKeyId: string | null | undefined
|
||||
): boolean {
|
||||
if (!artifactRelPath) return false;
|
||||
const cached = genuineContinuationCache.get(artifactRelPath);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const { artifact, state } = readCallArtifact(artifactRelPath);
|
||||
if (state !== "ready" || !artifact?.pipeline) {
|
||||
return cacheGenuineContinuation(artifactRelPath, false);
|
||||
}
|
||||
const previousResponseId = extractPreviousResponseId(
|
||||
artifact.pipeline as Record<string, unknown>
|
||||
);
|
||||
if (!previousResponseId) return cacheGenuineContinuation(artifactRelPath, false);
|
||||
|
||||
return cacheGenuineContinuation(
|
||||
artifactRelPath,
|
||||
resolveCallLogIdByResponseId(previousResponseId, apiKeyId) !== null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,6 +259,20 @@ export function ConversationContextSection({ log, detail }) {
|
||||
{open ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
{liveDetail?.parentLogId && (
|
||||
// Full navigation, not client-side routing: the logs page only reads
|
||||
// ?id from a fresh mount (useState(() => searchParams.get("id")) in
|
||||
// dashboard/logs/page.tsx), so an in-page route change wouldn't load
|
||||
// the parent entry if the user is already on this page.
|
||||
<a
|
||||
href={`/dashboard/logs?id=${encodeURIComponent(liveDetail.parentLogId)}`}
|
||||
className="flex items-center gap-1 text-[11px] text-text-muted hover:text-primary transition-colors"
|
||||
title={`Continues from ${liveDetail.parentLogId}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">reply</span>
|
||||
continues from parent
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
Reference in New Issue
Block a user