Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

390 lines
11 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import i18n from "i18next";
import { apiFetch, apiUrl } from "@/lib/api";
import type { LLMSelection } from "@/lib/unified-ws";
export type RunMode = "update" | "audit" | "dedup";
export type RunStatus = "queued" | "running" | "cancelled" | "done" | "error";
export interface RunHandle {
id: string;
layer: "L2" | "L3";
key: string;
mode: RunMode;
status: RunStatus;
started_at: string;
ended_at: string | null;
error: string | null;
event_count: number;
undo_count: number;
}
export interface RunEventPayload {
stage: string;
[key: string]: unknown;
}
export interface RunEvent {
seq: number;
ts: string;
payload: RunEventPayload;
}
export interface StartArgs {
mode: RunMode;
budget?: number;
iterations?: number;
llmSelection?: LLMSelection | null;
language?: string;
}
interface State {
run: RunHandle | null;
events: RunEvent[];
status: RunStatus | "idle";
error: string | null;
reconnecting: boolean;
}
// One row per `(layer, key)` survives a page refresh by persisting the
// active run_id in localStorage. The hook reads this on mount and tries
// to re-attach to a still-running run on the server.
const STORAGE_PREFIX = "dt:memory:active-run";
function storageKey(layer: string, key: string) {
return `${STORAGE_PREFIX}:${layer}:${key}`;
}
function readPersistedRunId(layer: string, key: string): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(storageKey(layer, key));
}
function writePersistedRunId(layer: string, key: string, runId: string | null) {
if (typeof window === "undefined") return;
if (runId) {
window.localStorage.setItem(storageKey(layer, key), runId);
} else {
window.localStorage.removeItem(storageKey(layer, key));
}
}
export function useMemoryRun(layer: "L2" | "L3", key: string) {
const [state, setState] = useState<State>({
run: null,
events: [],
status: "idle",
error: null,
reconnecting: false,
});
const abortRef = useRef<AbortController | null>(null);
const cursorRef = useRef<number>(0);
// Track if we've initialised for this (layer, key) so a parent re-render
// doesn't trigger duplicate reconnects.
const initialisedFor = useRef<string>("");
const close = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
}, []);
// Reset when the doc changes; re-attach to that doc's persisted run if any.
useEffect(() => {
const tag = `${layer}:${key}`;
if (initialisedFor.current === tag) return;
initialisedFor.current = tag;
close();
cursorRef.current = 0;
setState({
run: null,
events: [],
status: "idle",
error: null,
reconnecting: false,
});
void (async () => {
const persistedId = readPersistedRunId(layer, key);
let runId: string | null = persistedId;
// Verify the persisted run exists; otherwise see if the server thinks
// this doc has an active run.
try {
if (runId) {
const res = await apiFetch(apiUrl(`/api/v1/memory/runs/${runId}`));
if (!res.ok) runId = null;
}
if (!runId) {
const res = await apiFetch(
apiUrl(
`/api/v1/memory/runs?layer=${layer}&key=${encodeURIComponent(key)}`,
),
);
if (res.ok) {
const data = (await res.json()) as { runs: RunHandle[] };
const active = data.runs.find(
(r) => r.status === "running" || r.status === "queued",
);
if (active) {
runId = active.id;
writePersistedRunId(layer, key, runId);
}
}
}
} catch {
// best-effort recovery — if the API is unreachable we'll just start
// a new run when the user clicks Run.
}
if (runId) {
await attach(runId);
}
})();
return () => {
close();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layer, key]);
const attach = useCallback(
async (runId: string, since = 0): Promise<void> => {
// Pull the run handle first.
try {
const res = await apiFetch(apiUrl(`/api/v1/memory/runs/${runId}`));
if (!res.ok) {
writePersistedRunId(layer, key, null);
return;
}
const run = (await res.json()) as RunHandle;
setState((s) => ({
...s,
run,
status: run.status,
reconnecting: false,
}));
} catch {
return;
}
abortRef.current?.abort();
const ctl = new AbortController();
abortRef.current = ctl;
cursorRef.current = since;
try {
const res = await apiFetch(
apiUrl(`/api/v1/memory/runs/${runId}/events?since=${since}`),
{ signal: ctl.signal, cache: "no-store" },
);
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (reader) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let nl = buffer.indexOf("\n\n");
while (nl !== -1) {
const chunk = buffer.slice(0, nl);
buffer = buffer.slice(nl + 2);
const line = chunk
.split("\n")
.find((l) => l.startsWith("data:"))
?.replace(/^data:\s?/, "");
if (line) {
try {
const parsed = JSON.parse(line);
const seq =
typeof parsed.seq === "number"
? parsed.seq
: cursorRef.current;
const ts =
typeof parsed.ts === "string"
? parsed.ts
: new Date().toISOString();
const { seq: _s, ts: _t, ...payload } = parsed;
cursorRef.current = seq + 1;
setState((s) => {
const undoDepth =
typeof payload.undo_depth === "number"
? payload.undo_depth
: null;
const endedStatus =
payload.stage === "run_ended" &&
typeof payload.status === "string"
? (payload.status as RunStatus)
: null;
return {
...s,
status: endedStatus ?? s.status,
run: s.run
? {
...s.run,
status: endedStatus ?? s.run.status,
undo_count: undoDepth ?? s.run.undo_count,
}
: s.run,
events: s.events.some((ev) => ev.seq === seq)
? s.events
: [
...s.events,
{ seq, ts, payload: payload as RunEventPayload },
],
};
});
if (
payload.stage === "run_ended" &&
typeof payload.status === "string"
) {
writePersistedRunId(layer, key, null);
}
} catch {
// ignore malformed chunks
}
}
nl = buffer.indexOf("\n\n");
}
}
} catch (e) {
if ((e as Error).name === "AbortError") return;
setState((s) => ({
...s,
error: e instanceof Error ? e.message : i18n.t("stream failed"),
}));
}
},
[layer, key],
);
const start = useCallback(
async (args: StartArgs): Promise<void> => {
setState((s) => ({ ...s, error: null, events: [], status: "queued" }));
try {
const res = await apiFetch(apiUrl("/api/v1/memory/runs/start"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
layer,
key,
mode: args.mode,
budget: args.budget ?? null,
iterations: args.iterations ?? null,
llm_selection: args.llmSelection ?? null,
language: args.language ?? "en",
}),
});
if (!res.ok) {
const detail = await res.text();
throw new Error(
detail ||
i18n.t("start failed: {{status}}", { status: res.status }),
);
}
const run = (await res.json()) as RunHandle;
writePersistedRunId(layer, key, run.id);
await attach(run.id, 0);
} catch (e) {
setState((s) => ({
...s,
status: "error",
error: e instanceof Error ? e.message : i18n.t("start failed"),
}));
}
},
[layer, key, attach],
);
const cancel = useCallback(async (): Promise<void> => {
if (!state.run) return;
try {
await apiFetch(apiUrl(`/api/v1/memory/runs/${state.run.id}/cancel`), {
method: "POST",
});
} catch {
// ignore: the server may already have terminated naturally.
}
}, [state.run]);
const undo = useCallback(async (): Promise<boolean> => {
if (!state.run) return false;
try {
const res = await apiFetch(
apiUrl(`/api/v1/memory/runs/${state.run.id}/undo`),
{
method: "POST",
},
);
if (!res.ok) {
const detail = await res.text();
throw new Error(
detail || i18n.t("undo failed: {{status}}", { status: res.status }),
);
}
const data = (await res.json()) as {
undo_count?: number;
event?: { seq: number; ts: string; [key: string]: unknown };
};
const ev = data.event;
setState((s) => {
const nextRun = s.run
? { ...s.run, undo_count: data.undo_count ?? s.run.undo_count }
: s.run;
if (!ev || typeof ev.seq !== "number") return { ...s, run: nextRun };
const { seq, ts, ...payload } = ev;
return {
...s,
run: nextRun,
events: s.events.some((item) => item.seq === seq)
? s.events
: [
...s.events,
{
seq,
ts: typeof ts === "string" ? ts : new Date().toISOString(),
payload: payload as RunEventPayload,
},
],
};
});
return true;
} catch (e) {
setState((s) => ({
...s,
error: e instanceof Error ? e.message : i18n.t("undo failed"),
}));
return false;
}
}, [state.run]);
const clear = useCallback(() => {
setState({
run: null,
events: [],
status: "idle",
error: null,
reconnecting: false,
});
writePersistedRunId(layer, key, null);
close();
}, [layer, key, close]);
const isRunning = useMemo(
() => state.status === "queued" || state.status === "running",
[state.status],
);
return {
run: state.run,
events: state.events,
status: state.status,
error: state.error,
isRunning,
start,
cancel,
undo,
clear,
};
}