Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01394b75da | ||
|
|
667c8754c6 |
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 () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user