Compare commits

...
Author SHA1 Message Date
kristinhe 01394b75da fix(memhub): open an SDK memory where the map put it
On SDK authority, clicking a memory switched the whole page to the SDK
library — a second screen made of status tabs and a ledger, where the memory
page's design has one list, one graph and one inspector. Those three already
exist: they are the components the mock-data screen was built from. This wires
the real SDK reads into them instead of standing a second screen beside them.

- opening a memory reads it through v2 and draws it in the map's inspector;
  edits and deletes go to updateSdkMemory / deleteSdkMemory, and a write
  re-reads the page instead of waiting for the poll
- the left list gets a real library page (50 a page, with the total and each
  memory's source and conversation time), so the count, the paging footer and
  the provenance line read the way the design reads
- a memory with no cited excerpt no longer renders an evidence block and a
  source line that can only say "not available"

The SDK library panel is left as it was; it is no longer on this path.
2026-10-25 01:35:39 +08:00
kristinhe 667c8754c6 fix(memhub): draw the SDK memory library with the shared memory ledger
On SDK authority the memory page's library panel rendered SdkMemoryLibrary,
a stopgap that printed `<li><button>{content}</button></li>` with no styling:
`.sdk-memory-library` had no rule in any stylesheet, so every row fell back to
the global button look (dark slab, centred text) with the browser's list
markers. #170 made the list reachable, which is when it became visible.

Render the panel with the markup the governed page already uses — the status
tabs, the `共 N 条 / 第 N 页 / 上一页 / 下一页` row, `memory-ledger` rows with a
`kind · status · time · tags` line, and `memory-inspector` for the detail. No
new CSS: the classes are the shipped design, so both authorities now read the
same. The inspector keeps its empty state instead of collapsing the column.

Search: `/api/v2/memory-library` takes no text query and `refresh()` returns
early off legacy authority, so the header box silently did nothing. Scan the
pages once (500 rows per call, cached per tab) and filter locally, with the
count showing matches.

Details open through v2 and stay editable only while active, as before.
2026-10-25 01:00:42 +08:00
4 changed files with 253 additions and 64 deletions

No files matched your search

@@ -21,6 +21,7 @@ const api = vi.hoisted(() => ({
legacyGraph: vi.fn(),
sdkLibrary: vi.fn(),
sdkGraph: vi.fn(),
sdkMemory: vi.fn(),
}));
vi.mock("./api", () => ({
getMemoryAuthority: api.authority,
@@ -29,6 +30,9 @@ vi.mock("./api", () => ({
getMemoryGraph: api.legacyGraph,
getSdkMemoryLibrary: api.sdkLibrary,
getSdkMemoryGraph: api.sdkGraph,
getSdkMemory: api.sdkMemory,
updateSdkMemory: vi.fn(),
deleteSdkMemory: vi.fn(),
getMemoryProcessing: async () => null,
getModelProfiles: async () => [],
apiErrorCode: () => null,
@@ -140,8 +144,9 @@ it("draws the map from SDK memories on SDK authority, without legacy reads", asy
expect(api.legacyGraph).not.toHaveBeenCalled();
});
it("opens a memory from the SDK map in the SDK library", async () => {
it("opens an SDK memory in place instead of switching away from the map", async () => {
sdkAuthority();
api.sdkMemory.mockResolvedValue(record("a"));
render(<MemorySpace simplified active />);
await waitFor(() =>
@@ -149,10 +154,10 @@ it("opens a memory from the SDK map in the SDK library", async () => {
);
fireEvent.click(screen.getByText("open a"));
const library = await screen.findByTestId("sdk-library");
expect(library.textContent).toBe("a");
expect(library.closest("[hidden]")).toBeNull();
expect(screen.getByTestId("map").hidden).toBe(true);
await waitFor(() => expect(api.sdkMemory).toHaveBeenCalledWith("a"));
// The inspector stays on the map; the reader is never moved to a second list.
expect(screen.getByTestId("map").hidden).toBe(false);
expect(screen.getByTestId("sdk-library").textContent).toBe("");
});
it("keeps asking for the authority until Core answers", async () => {
+152 -32
View File
@@ -5,6 +5,7 @@ import type {
ImportJob,
MemoryDetail,
MemoryGraphResponse,
MemoryLibraryPage,
MemoryProcessingSummary,
SdkAskMemory,
SourceResponse,
@@ -32,6 +33,7 @@ import {
apiErrorCode,
askMemoriesStream,
correctMemory,
deleteSdkMemory,
digestMemoryGraph,
getMemories,
getMemory,
@@ -39,6 +41,7 @@ import {
getMemoryGraph,
getMemoryProcessing,
getModelProfiles,
getSdkMemory,
getSdkMemoryGraph,
getSdkMemoryLibrary,
manualExtract,
@@ -47,6 +50,7 @@ import {
saveMemoryNote,
setExtractionWindow,
syncMemoryIndex,
updateSdkMemory,
} from "./api";
import { ConversationSearch } from "./ConversationSearch";
import { clockStamp } from "./clock-stamp";
@@ -58,7 +62,7 @@ import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
import { SdkRawHistory } from "./SdkRawHistory";
import { sdkMapMemory } from "./sdk-memory-map";
import { sdkDetail, sdkLibraryPage, sdkMapMemory } from "./sdk-memory-map";
import { sourceNameOr } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { useMemoryLibrary } from "./use-memory-library";
@@ -296,9 +300,9 @@ export function MemorySpace({
text: string;
memories: AskMemory[];
} | null>(null);
const [sdkFocusedMemoryId, setSdkFocusedMemoryId] = useState<string | null>(
null,
);
// The SDK library is no longer where a memory opens from the map; the id is
// kept so the panel still has a focus target for its own tests.
const [sdkFocusedMemoryId] = useState<string | null>(null);
// The map on SDK authority. Legacy reads (library page, governed graph)
// are refused there, so it draws SDK memories and the v2 graph instead.
const [sdkMap, setSdkMap] = useState<{
@@ -306,6 +310,20 @@ export function MemorySpace({
graph: MemoryGraphResponse;
} | null>(null);
const [sdkMapFailed, setSdkMapFailed] = useState(false);
// A write on SDK authority has no governed refresh to ride on; bumping this
// re-reads the SDK page and graph after an edit or a delete.
const [sdkMapRevision, setSdkMapRevision] = useState(0);
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
// The map's left list pages through the SDK library the same way it pages
// through the governed one: the list is always one page, and the count above
// it is the library's total, not the page's length.
const [sdkLibrary, setSdkLibrary] = useState<{
page: MemoryLibraryPage | null;
loading: boolean;
failed: boolean;
}>({ page: null, loading: false, failed: false });
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
const sdkMapVisible =
memoryAuthority === "sdk" &&
simplified &&
@@ -313,6 +331,9 @@ export function MemorySpace({
displayedMode === "map";
useEffect(() => {
if (!sdkMapVisible) return;
// A write bumps the revision; reading it here re-runs the read at once
// instead of waiting for the 30s poll.
void sdkMapRevision;
let cancelled = false;
let controller: AbortController | null = null;
const read = async () => {
@@ -341,7 +362,46 @@ export function MemorySpace({
clearInterval(timer);
controller?.abort();
};
}, [sdkMapVisible]);
}, [sdkMapVisible, sdkMapRevision]);
useEffect(() => {
if (memoryAuthority !== "sdk" || !simplified || !active) return;
if (displayedMode !== "map") return;
void sdkMapRevision;
let cancelled = false;
const controller = new AbortController();
setSdkLibrary((current) => ({ ...current, loading: true }));
void getSdkMemoryLibrary(
{ statuses: ["active"], offset: sdkLibraryOffset, limit: 50 },
controller.signal,
)
.then((page) => {
if (cancelled) return;
setSdkLibrary({
page: sdkLibraryPage(page),
loading: false,
failed: false,
});
})
.catch(() => {
if (cancelled || controller.signal.aborted) return;
setSdkLibrary((current) => ({
...current,
loading: false,
failed: true,
}));
});
return () => {
cancelled = true;
controller.abort();
};
}, [
memoryAuthority,
simplified,
active,
displayedMode,
sdkLibraryOffset,
sdkMapRevision,
]);
const [draft, setDraft] = useState("");
const [selection, setSelection] = useState<Set<string>>(new Set());
const bulkInFlight = useRef(false);
@@ -1025,8 +1085,9 @@ export function MemorySpace({
open(memory.memory_id);
return;
}
setSdkFocusedMemoryId(memory.memory_id);
setMode("library");
// An SDK answer cites SDK memories; they open in the inspector like any
// other rather than switching the reader to a second list.
void open(memory.memory_id);
}
async function saveAnswer(draft: { content: string }) {
@@ -1075,7 +1136,13 @@ export function MemorySpace({
setDetailFreshness(null);
setDetailLoad({ memoryId, status: "loading" });
try {
const result = await getMemory(memoryId);
// Governed detail is refused on SDK authority, so the inspector reads the
// same memory through v2 and renders it in place. Staying here keeps the
// map's list, graph and detail where the design put them.
const result =
memoryAuthority === "sdk"
? sdkDetail(await getSdkMemory(memoryId))
: await getMemory(memoryId);
if (epoch !== detailEpoch.current || result.memory.memory_id !== memoryId)
return;
setDetail(result);
@@ -1123,6 +1190,28 @@ export function MemorySpace({
return;
setBusy(true);
setError(null);
if (memoryAuthority === "sdk") {
// An SDK memory is removed outright: there is no revision to undo.
if (actionName !== "delete") {
setBusy(false);
return;
}
const trace = traceUiAction("memory", "confirm");
try {
await deleteSdkMemory(detail.memory.memory_id);
trace.done("succeeded");
closeDetail();
bumpSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: "memory_action_failed",
});
setError(message(reason, t));
} finally {
setBusy(false);
}
return;
}
const intent = `${detail.memory.memory_id}:${actionName}:${detail.memory.revision}`;
const requestId = requestIdFor(requestIds.current, intent);
const trace = traceUiAction("memory", "confirm");
@@ -1329,6 +1418,28 @@ export function MemorySpace({
return;
setBusy(true);
setError(null);
if (memoryAuthority === "sdk") {
// A rewrite replaces the SDK row instead of adding a revision, so there
// is no revision to send and nothing to reconcile afterwards.
const trace = traceUiAction("memory", "submit");
try {
const saved = await updateSdkMemory(detail.memory.memory_id, draft);
trace.done("succeeded");
setDetail(sdkDetail(saved));
setDraft(saved.content);
bumpSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: isDefiniteRefusal(reason)
? "memory_correct_failed"
: "memory_correct_result_unknown",
});
setError(message(reason, t));
} finally {
setBusy(false);
}
return;
}
const intent = `${detail.memory.memory_id}:correct:${detail.memory.revision}`;
const requestId = requestIdFor(requestIds.current, intent);
const trace = traceUiAction("memory", "submit");
@@ -1510,7 +1621,8 @@ export function MemorySpace({
{
filter: "",
shown: new Intl.NumberFormat(locale).format(
sdkMap.memories.length,
sdkLibrary.page?.items.length ??
sdkMap.memories.length,
),
},
)
@@ -1887,23 +1999,39 @@ export function MemorySpace({
focusDialogue={focusDialogue}
onFocusDialogueConsumed={onFocusDialogueConsumed}
libraryView={
simplified && memoryAuthority !== "sdk"
simplified && memoryAuthority === "sdk"
? {
query: mapQuery,
source: mapSource,
page: mapLibrary.page,
loading: mapLibrary.loading,
failed: mapLibrary.failed,
stale: sourceSnapshotStale,
onQuery: setMapQuery,
onSource: setMapSource,
resetPage: () => mapLibrary.go(0),
onPage: mapLibrary.go,
onRetry: () => {
void mapLibrary.refresh().catch(() => {});
},
query: sdkLibraryQuery,
source: "",
page: sdkLibrary.page,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
stale: false,
onQuery: setSdkLibraryQuery,
// SDK memories carry one source each and the list is paged by
// the endpoint; a source filter would have to page separately.
onSource: () => {},
resetPage: () => setSdkLibraryOffset(0),
onPage: (offset) => setSdkLibraryOffset(offset),
onRetry: bumpSdkMap,
}
: undefined
: simplified
? {
query: mapQuery,
source: mapSource,
page: mapLibrary.page,
loading: mapLibrary.loading,
failed: mapLibrary.failed,
stale: sourceSnapshotStale,
onQuery: setMapQuery,
onSource: setMapSource,
resetPage: () => mapLibrary.go(0),
onPage: mapLibrary.go,
onRetry: () => {
void mapLibrary.refresh().catch(() => {});
},
}
: undefined
}
onMutationComplete={(id) => {
void refresh(query)
@@ -1951,14 +2079,6 @@ export function MemorySpace({
streaming={streaming}
onDraft={setDraft}
onOpenMemory={(memoryId) => {
if (memoryAuthority === "sdk") {
// Governed detail is refused on SDK authority; the SDK library
// opens, edits and deletes the same memory through v2.
setSdkFocusedMemoryId(memoryId);
if (simplified) setManagementOpen(true);
else setMode("library");
return;
}
const recalled = answerMemories.current.find(
(memory) => memory.memory_id === memoryId,
);
+34 -24
View File
@@ -249,9 +249,14 @@ export function SelectedMemoryDetail({
</p>
<h3>{heading}</h3>
{body && <p className="selected-memory-body">{body}</p>}
<p className="selected-memory-source">
{t("selectedMemory.sourceLine", { source, time: timestamp })}
</p>
{/* The source line names the conversation a memory was read from.
Without a cited excerpt there is no such conversation to name, and
the row would say "unknown" about a memory that is right here. */}
{detail.evidence.length ? (
<p className="selected-memory-source">
{t("selectedMemory.sourceLine", { source, time: timestamp })}
</p>
) : null}
<p className="selected-memory-times">
{t("selectedMemory.timestamps", {
conversation: clockStamp(
@@ -276,27 +281,32 @@ export function SelectedMemoryDetail({
{t("selectedMemory.edit")}
</R6Button>
)}
<section className="selected-memory-evidence">
<h4>{t("selectedMemory.evidence")}</h4>
<small>
{t("selectedMemory.evidenceLine", { author, time: timestamp })}
</small>
<blockquote>
{evidence?.available
? evidence.verbatim
: t("selectedMemory.evidenceUnavailable")}
</blockquote>
{!hideActions && (
<R6Button
onClick={(event) => {
trigger.current = event.currentTarget;
setPage("original");
}}
>
{t("selectedMemory.viewOriginal")}
</R6Button>
)}
</section>
{/* An SDK memory carries no cited excerpt, so the section would only
say "not available". The design shows the memory itself; keep it
that way rather than opening a block about evidence nobody kept. */}
{detail.evidence.length ? (
<section className="selected-memory-evidence">
<h4>{t("selectedMemory.evidence")}</h4>
<small>
{t("selectedMemory.evidenceLine", { author, time: timestamp })}
</small>
<blockquote>
{evidence?.available
? evidence.verbatim
: t("selectedMemory.evidenceUnavailable")}
</blockquote>
{!hideActions && (
<R6Button
onClick={(event) => {
trigger.current = event.currentTarget;
setPage("original");
}}
>
{t("selectedMemory.viewOriginal")}
</R6Button>
)}
</section>
) : null}
{!hideActions && (
<R6Button variant="quiet" onClick={() => setShowHistory((v) => !v)}>
{t("selectedMemory.history")}
+57 -3
View File
@@ -1,6 +1,9 @@
import type {
GovernedMemory,
MemoryDetail,
MemoryKind,
MemoryLibraryPage,
SdkMemoryPage,
SdkMemoryRecord,
} from "@one-memory/contracts";
@@ -20,9 +23,11 @@ function stamp(seconds: number | null | undefined): string {
/** An SDK memory in the shape the memory map draws.
*
* The map groups and labels by id, content, kind, status and tags only. It
* never writes through this shape: opening a memory goes to the SDK library,
* so the governed-only fields are inert placeholders here.
* The map groups and labels by id, content, kind, status and tags only, so the
* governed-only fields are inert placeholders here. Opening, editing and
* deleting still go through the SDK (v2) endpoints — `sdkDetail()` below wraps
* the same projection for the map's inspector, which is why the inspector can
* stay where the design put it instead of sending the reader to another view.
*/
export function sdkMapMemory(record: SdkMemoryRecord): GovernedMemory {
const created = stamp(record.gmt_created ?? record.memory_at);
@@ -49,3 +54,52 @@ export function sdkMapMemory(record: SdkMemoryRecord): GovernedMemory {
deleted_at: null,
};
}
/** One SDK memory as the map's inspector reads it.
*
* The inspector was built for a governed detail (memory + cited evidence +
* versions). The SDK keeps none of the latter two per memory — a rewrite
* replaces the row — so they are empty rather than faked, and the panel shows
* the memory itself where the design put it.
*/
export function sdkDetail(record: SdkMemoryRecord): MemoryDetail {
return {
memory: sdkMapMemory(record),
evidence: [],
history: [],
index_sync: null,
};
}
/** One SDK page as the map's left list reads it.
*
* The list asks a library page for its rows, its total and — per row — when
* the cited conversation happened. The SDK page carries the row itself, so the
* time is taken from the memory: `memory_at` is the moment the conversation
* happened, `gmt_created` only the moment it was imported. Naming which one it
* is keeps "对话于" from silently becoming "沉淀于".
*/
export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
const sourceTime: Record<
string,
{ at: string; basis: "conversation" | "import" }
> = {};
const sourceIds: Record<string, string[]> = {};
for (const record of page.memories) {
if (record.source) sourceIds[record.memory_id] = [record.source];
const at = record.memory_at ?? record.gmt_created;
if (typeof at !== "number" || !Number.isFinite(at)) continue;
sourceTime[record.memory_id] = {
at: new Date(at * 1_000).toISOString(),
basis: record.memory_at ? "conversation" : "import",
};
}
return {
items: page.memories.map(sdkMapMemory),
total: page.total,
offset: page.offset,
limit: page.limit,
source_ids_by_memory: sourceIds,
source_time_by_memory: sourceTime,
};
}