Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528d2c9fd7 |
No files matched your search
@@ -2980,13 +2980,11 @@ def create_app(
|
||||
"""
|
||||
try:
|
||||
client = await _sdk_client_handle(app).client()
|
||||
# The map visualizes the active source scope, independently of the
|
||||
# left list's 20-row pagination window.
|
||||
# The map draws the page the list shows, so it reads the same
|
||||
# source filter and window rather than the newest memories overall.
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id="local",
|
||||
scene="all",
|
||||
statuses=["active"],
|
||||
include_sources=[source] if source else None,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
|
||||
@@ -1375,8 +1375,6 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
|
||||
|
||||
graph = asyncio.run(scenario())
|
||||
assert listed[0]["include_sources"] == ["cursor"]
|
||||
assert listed[0]["scene"] == "all"
|
||||
assert listed[0]["statuses"] == ["active"]
|
||||
assert (listed[0]["offset"], listed[0]["limit"]) == (100, 100)
|
||||
assert (graph["total_memories"], graph["projected_memories"]) == (7, 4)
|
||||
assert {node["node_id"] for node in graph["nodes"]} == {
|
||||
@@ -1401,71 +1399,6 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
|
||||
assert graph["hy_graph_available"] is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_sdk_memory_map_still_draws_memories_from_a_revoked_source(tmp_path) -> None:
|
||||
# Revoking a source stops new reads from it; memories already distilled
|
||||
# from it stay visible, so the map agrees with the list.
|
||||
async def scenario():
|
||||
app = create_app(_app_settings(tmp_path), secret_store=InMemorySecretStore())
|
||||
|
||||
class Client:
|
||||
def list_memories(self, **_kwargs):
|
||||
return {
|
||||
"vdb": {
|
||||
"memories": [
|
||||
{
|
||||
"memory_id": "kept",
|
||||
"content": "distilled before revocation",
|
||||
"layer": "l2_fact",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "s1",
|
||||
"source": "cursor",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
}
|
||||
|
||||
class Handle:
|
||||
async def client(self):
|
||||
return Client()
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer authority-test"},
|
||||
) as client,
|
||||
):
|
||||
app.state.sdk_client_handle = Handle()
|
||||
database = app.state.settings.data_dir / "one-memory.sqlite3"
|
||||
with sqlite3.connect(database) as db:
|
||||
db.execute(
|
||||
"INSERT INTO source_grants "
|
||||
"(user_id,source_id,root_path,requested_path,root_identity,"
|
||||
"candidate_kind,granted_at,revoked_at,revision) VALUES "
|
||||
"('local','cursor','/fixture','/fixture','fixture','default',"
|
||||
"'2026-10-01T00:00:00Z','2026-10-02T00:00:00Z',1)"
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/v2/memories/graph",
|
||||
params={"source": "cursor", "limit": 100},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
graph = asyncio.run(scenario())
|
||||
assert (graph["total_memories"], graph["projected_memories"]) == (1, 1)
|
||||
assert "memory:kept" in {node["node_id"] for node in graph["nodes"]}
|
||||
|
||||
|
||||
def test_a_completed_sdk_write_does_not_touch_retired_evidence(store) -> None:
|
||||
"""Build 21 logged RetiredAuthorityAccess after every successful write."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -172,7 +172,7 @@ it.each([
|
||||
expect(sedimentation).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
it("localizes startup recovery and empty states without leaking errors", async () => {
|
||||
it("localizes failure and empty states without leaking errors", async () => {
|
||||
summary.mockRejectedValue(new Error("synthetic_private_error"));
|
||||
Object.assign(window.oneMemory ?? {}, {
|
||||
getLanguagePreference: vi
|
||||
@@ -191,15 +191,12 @@ it("localizes startup recovery and empty states without leaking errors", async (
|
||||
</LanguageProvider>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"El servicio se está iniciando; se actualizará automáticamente",
|
||||
),
|
||||
await screen.findByText("No se puede leer el estado de las memorias"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("0")).toBeTruthy();
|
||||
expect(screen.getByText("Aún no hay aplicaciones para mostrar")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Reintentar lectura" }),
|
||||
).toBeNull();
|
||||
screen.getByRole("button", { name: "Reintentar lectura" }),
|
||||
).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain("synthetic_private_error");
|
||||
});
|
||||
it("shows latest recorded call separately without exposing recent-update detail entries", async () => {
|
||||
@@ -768,7 +765,7 @@ it("keeps authoritative home and processing data when only the source snapshot b
|
||||
expect(screen.getAllByText("整理中")).toHaveLength(2);
|
||||
expect(screen.queryByText("状态待核对")).toBeNull();
|
||||
});
|
||||
it("keeps a neutral zero while the first home summary read recovers", async () => {
|
||||
it("does not turn a failed home summary into zero or expose a private error", async () => {
|
||||
summary.mockRejectedValue(new Error("private offline detail"));
|
||||
render(
|
||||
<ApplicationsHome
|
||||
@@ -778,117 +775,10 @@ it("keeps a neutral zero while the first home summary read recovers", async () =
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getByText("0")).toBeTruthy();
|
||||
expect(screen.getByText("正在准备记忆概览")).toBeTruthy();
|
||||
expect(screen.queryByText(/暂时?无法读取|读取失败/)).toBeNull();
|
||||
await waitFor(() => expect(screen.getByText("暂无法读取")).toBeTruthy());
|
||||
expect(screen.queryByText("0")).toBeNull();
|
||||
expect(document.body.textContent).not.toContain("private offline detail");
|
||||
});
|
||||
it("offers a calm retry after repeated home summary failures", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
summary.mockRejectedValue(new Error("private offline detail"));
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
|
||||
);
|
||||
await act(async () => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(await screen.findByText("记忆概览还没准备好")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("后台恢复后会自动更新,也可以重新读取。"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "重新读取" })).toBeTruthy();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it("counts repeated stale summaries and recovers on the next confirmed read", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const stale = {
|
||||
memory_count: 0,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:00:00Z",
|
||||
stale: true,
|
||||
};
|
||||
summary
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValue({
|
||||
memory_count: 4,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:01:00Z",
|
||||
});
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
|
||||
);
|
||||
await act(async () => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(await screen.findByText("记忆概览还没准备好")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "重新读取" }));
|
||||
expect(await screen.findByText("4")).toBeTruthy();
|
||||
expect(screen.queryByText("记忆概览还没准备好")).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it("labels a retained count as previous data after repeated stale reads", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const stale = {
|
||||
memory_count: 0,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:00:00Z",
|
||||
stale: true,
|
||||
};
|
||||
summary
|
||||
.mockResolvedValueOnce({
|
||||
memory_count: 601,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:00:00Z",
|
||||
})
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValue({
|
||||
memory_count: 4,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:01:00Z",
|
||||
});
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("601")).toBeTruthy();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(20_000));
|
||||
expect(screen.getByText("601")).toBeTruthy();
|
||||
expect(screen.getByText("正在显示上次数据,稍后自动更新")).toBeTruthy();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(await screen.findByText("4")).toBeTruthy();
|
||||
expect(screen.queryByText("正在显示上次数据,稍后自动更新")).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it("never calls a recall event a success when only stored memories are available", async () => {
|
||||
render(
|
||||
<ApplicationsHome
|
||||
@@ -1310,7 +1200,7 @@ it("keeps the conversation-sync checking copy before the first home read", () =>
|
||||
expect(screen.queryByText(/个应用已开启/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a provisional zero while the first summary read is pending", async () => {
|
||||
it("does not claim there are no memories while the first summary read is pending", async () => {
|
||||
summary.mockImplementation(() => new Promise(() => {}));
|
||||
history.mockResolvedValue({
|
||||
available: true,
|
||||
@@ -1326,9 +1216,7 @@ it("shows a provisional zero while the first summary read is pending", async ()
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("正在准备记忆概览");
|
||||
expect(screen.getByText("0")).toBeTruthy();
|
||||
expect(screen.getByText("正在核对本机记忆…")).toBeTruthy();
|
||||
await screen.findByText("正在读取动态");
|
||||
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ export function ApplicationsHome({
|
||||
null,
|
||||
);
|
||||
const [syncHealth, setSyncHealth] = useState<LiveSyncHealth[]>([]);
|
||||
const [summaryFailures, setSummaryFailures] = useState(0);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [recentCall, setRecentCall] = useState<CallEvent | null>(null);
|
||||
const [callEvents, setCallEvents] = useState<CallEvent[]>([]);
|
||||
const [callState, setCallState] = useState<
|
||||
@@ -554,17 +554,13 @@ export function ApplicationsHome({
|
||||
busy = false;
|
||||
if (stopped) return;
|
||||
if (home.status === "fulfilled") {
|
||||
setFailed(false);
|
||||
// stale=true 表示这轮读取超时,数字不代表现状:保留上一次的概览,
|
||||
// 等 10 秒后的下一轮轮询补上。重复 stale 仍要进入可恢复状态,
|
||||
// 不能永远停在“正在核对”。
|
||||
if (!("stale" in home.value) || !home.value.stale) {
|
||||
setSummaryFailures(0);
|
||||
// 等 10 秒后的下一轮轮询补上,绝不清零也不报错。
|
||||
if (!("stale" in home.value) || !home.value.stale)
|
||||
setSummary(home.value);
|
||||
} else {
|
||||
setSummaryFailures((count) => count + 1);
|
||||
}
|
||||
} else {
|
||||
setSummaryFailures((count) => count + 1);
|
||||
setFailed(true);
|
||||
}
|
||||
if (state.status === "fulfilled") setProcessing(state.value);
|
||||
if (sync.status === "fulfilled") setSyncHealth(sync.value);
|
||||
@@ -582,8 +578,7 @@ export function ApplicationsHome({
|
||||
}, [active, requestKey]);
|
||||
const visible = summary;
|
||||
const latest = visible?.latest;
|
||||
const summaryUnavailable = visible === null && summaryFailures >= 2;
|
||||
const summaryStale = visible !== null && summaryFailures >= 2;
|
||||
const summaryUnavailable = failed;
|
||||
const hasCall =
|
||||
recentCall !== null &&
|
||||
(callState === "ready" || callState === "incomplete");
|
||||
@@ -647,7 +642,7 @@ export function ApplicationsHome({
|
||||
? t("apps.running")
|
||||
: sedimentation?.enabled === false
|
||||
? t("apps.paused")
|
||||
: t("apps.checkingStatus");
|
||||
: t("apps.unchecked");
|
||||
const stateDetail =
|
||||
sedimentation?.enabled === false
|
||||
? t("apps.stoppedNew")
|
||||
@@ -826,27 +821,42 @@ export function ApplicationsHome({
|
||||
<h2>{t("apps.overview")}</h2>
|
||||
</header>
|
||||
<div className="r6-home-metrics">
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<div>
|
||||
<p>{t("apps.memories")}</p>
|
||||
<p className="r6-home-figure">
|
||||
<strong>
|
||||
{new Intl.NumberFormat(locale).format(
|
||||
visible?.memory_count ?? 0,
|
||||
)}
|
||||
</strong>
|
||||
{t("apps.unit") ? <i>{t("apps.unit")}</i> : null}
|
||||
</p>
|
||||
<small>
|
||||
{visible
|
||||
? summaryStale
|
||||
? t("apps.showingPrevious")
|
||||
: t("apps.scope")
|
||||
: summaryUnavailable
|
||||
? t("apps.countWillUpdate")
|
||||
: summaryFailures
|
||||
? t("apps.startingRetry")
|
||||
: t("apps.readingCount")}
|
||||
</small>
|
||||
{/* The figure slot used to hold the word 读取中 while the first
|
||||
read was in flight, which is how "大数字不可见" (D51) shows up
|
||||
on a slow link: the biggest type on the page spends the first
|
||||
frame saying something that is not a number. A bar of the same
|
||||
height reserves the digits instead, and the wording moves to a
|
||||
live region so screen readers still hear it. 暂无法读取 stays a
|
||||
word, because that is a settled state rather than progress. */}
|
||||
{!visible && !summaryUnavailable ? (
|
||||
<>
|
||||
<p className="r6-home-figure">
|
||||
<span
|
||||
className="r6-home-figure-pending"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</p>
|
||||
<small role="status">{t("apps.readingCount")}</small>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="r6-home-figure">
|
||||
<strong className={visible ? "" : "r6-home-word"}>
|
||||
{visible
|
||||
? new Intl.NumberFormat(locale).format(
|
||||
visible.memory_count,
|
||||
)
|
||||
: t("apps.unreadable")}
|
||||
</strong>
|
||||
{visible && t("apps.unit") ? (
|
||||
<i>{t("apps.unit")}</i>
|
||||
) : null}
|
||||
</p>
|
||||
<small>{t("apps.scope")}</small>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p>{t("apps.processing")}</p>
|
||||
@@ -857,16 +867,12 @@ export function ApplicationsHome({
|
||||
<p>{t("apps.recentUpdate")}</p>
|
||||
<strong className={time ? "r6-home-time" : "r6-home-word"}>
|
||||
{time ??
|
||||
(visible
|
||||
? t("apps.notGenerated")
|
||||
: t("apps.checkingRecent"))}
|
||||
(visible ? t("apps.notGenerated") : t("apps.unchecked"))}
|
||||
</strong>
|
||||
<small>
|
||||
{time
|
||||
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t("apps.saved")}`
|
||||
: visible
|
||||
? t("apps.separate")
|
||||
: t("apps.recentWillUpdate")}
|
||||
: t("apps.separate")}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -966,9 +972,7 @@ export function ApplicationsHome({
|
||||
.join(" · ")
|
||||
: activity === "importing"
|
||||
? t("apps.savingConversations")
|
||||
: activity === "loading"
|
||||
? t("apps.recentWillUpdate")
|
||||
: t("apps.waitingMemories")}
|
||||
: t("apps.waitingMemories")}
|
||||
</small>
|
||||
</section>
|
||||
{(activity === "unavailable" ||
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { translate } from "@one-memory/i18n";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { optionMenuPosition } from "./OptionMenu";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -166,141 +159,3 @@ it("announces storage errors without replacing the confirmed choice", async () =
|
||||
"zh-CN",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a right-edge menu inside the viewport", () => {
|
||||
expect(
|
||||
optionMenuPosition(
|
||||
{ top: 8, right: 1016, bottom: 40, left: 872, width: 144 },
|
||||
160,
|
||||
1024,
|
||||
600,
|
||||
),
|
||||
).toMatchObject({
|
||||
top: 44,
|
||||
left: 868,
|
||||
width: 144,
|
||||
maxHeight: 280,
|
||||
placement: "below",
|
||||
});
|
||||
});
|
||||
|
||||
it("flips the menu above when the lower edge has no room", () => {
|
||||
expect(
|
||||
optionMenuPosition(
|
||||
{ top: 540, right: 500, bottom: 572, left: 356, width: 144 },
|
||||
180,
|
||||
1024,
|
||||
600,
|
||||
),
|
||||
).toMatchObject({
|
||||
top: 356,
|
||||
left: 356,
|
||||
width: 144,
|
||||
maxHeight: 280,
|
||||
placement: "above",
|
||||
});
|
||||
});
|
||||
|
||||
it("limits the panel to the actual space when neither side has 280px", () => {
|
||||
expect(
|
||||
optionMenuPosition(
|
||||
{ top: 40, right: 160, bottom: 60, left: 16, width: 144 },
|
||||
180,
|
||||
320,
|
||||
100,
|
||||
),
|
||||
).toMatchObject({
|
||||
top: 64,
|
||||
left: 16,
|
||||
width: 144,
|
||||
maxHeight: 24,
|
||||
placement: "below",
|
||||
});
|
||||
});
|
||||
|
||||
it("portals the open menu outside clipping layout containers", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<div data-testid="clipping-parent">
|
||||
<LanguageSelector compact />
|
||||
</div>
|
||||
</LanguageProvider>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
const listbox = screen.getByRole("listbox");
|
||||
expect(listbox.parentElement).toBe(document.body);
|
||||
expect(
|
||||
listbox.classList.contains("r6-language-option-menu-panel--compact"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the menu open while its portal scrollbar or padding is pressed", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector compact />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox");
|
||||
await userEvent.click(selector);
|
||||
const listbox = screen.getByRole("listbox");
|
||||
fireEvent.mouseDown(listbox);
|
||||
fireEvent.blur(selector, { relatedTarget: null });
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("true");
|
||||
fireEvent.mouseUp(document);
|
||||
});
|
||||
|
||||
it("clears the portal pointer guard when the menu closes mid-press", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector compact />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox");
|
||||
await userEvent.click(selector);
|
||||
fireEvent.mouseDown(screen.getByRole("listbox"));
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
await userEvent.click(selector);
|
||||
fireEvent.blur(selector, { relatedTarget: null });
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("clears the portal pointer guard when the window loses focus", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector compact />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox");
|
||||
await userEvent.click(selector);
|
||||
fireEvent.mouseDown(screen.getByRole("listbox"));
|
||||
fireEvent.blur(window);
|
||||
fireEvent.blur(selector, { relatedTarget: null });
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("closes the portal when its anchor scrolls completely out of view", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector compact />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox");
|
||||
vi.spyOn(selector, "getBoundingClientRect").mockReturnValue({
|
||||
top: 20,
|
||||
right: 1168,
|
||||
bottom: 52,
|
||||
left: 1024,
|
||||
width: 144,
|
||||
height: 32,
|
||||
x: 1024,
|
||||
y: 20,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
await userEvent.click(selector);
|
||||
await waitFor(() =>
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false"),
|
||||
);
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
});
|
||||
@@ -54,11 +54,6 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
||||
disabled={pending}
|
||||
options={options}
|
||||
className="r6-language-option-menu"
|
||||
panelClassName={
|
||||
compact
|
||||
? "r6-language-option-menu-panel--compact"
|
||||
: "r6-language-option-menu-panel--settings"
|
||||
}
|
||||
onChange={async (value) => {
|
||||
if (!isLanguagePreference(value)) return;
|
||||
setPending(true);
|
||||
|
||||
@@ -348,7 +348,6 @@ function Host({
|
||||
processing = null,
|
||||
loaded = true,
|
||||
deferMemoryId,
|
||||
graphValue = graph,
|
||||
onViewHistory,
|
||||
onConfigureModel = () => {},
|
||||
libraryView,
|
||||
@@ -364,7 +363,6 @@ function Host({
|
||||
processing?: MemoryProcessingSummary | null;
|
||||
loaded?: boolean;
|
||||
deferMemoryId?: string;
|
||||
graphValue?: MemoryGraphResponse;
|
||||
onViewHistory?(): void;
|
||||
onConfigureModel?(): void;
|
||||
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
|
||||
@@ -377,7 +375,7 @@ function Host({
|
||||
hidden={hidden}
|
||||
overlayBlocked={overlayBlocked}
|
||||
memories={items}
|
||||
graph={graphValue}
|
||||
graph={graph}
|
||||
sources={sources}
|
||||
arrival={arrival}
|
||||
onViewHistory={onViewHistory}
|
||||
@@ -469,57 +467,6 @@ it("does not present an unverified empty page as zero memories", () => {
|
||||
expect(screen.queryByText(/0 条/)).toBeNull();
|
||||
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show the previous source graph while the new scope's graph loads", () => {
|
||||
render(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...pagedView({
|
||||
items: memories,
|
||||
total: memories.length,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
}),
|
||||
query: "项目",
|
||||
graphLoading: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
expect(screen.getByText("正在读取记忆地图…")).toBeTruthy();
|
||||
// The list keeps its own state: graph loading is not page staleness.
|
||||
expect(screen.queryByText("正在核对来源…")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a retry instead of collapsing a failed graph to the current page", async () => {
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...pagedView({
|
||||
items: memories,
|
||||
total: memories.length,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
}),
|
||||
graphFailed: true,
|
||||
onRetry,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
expect(screen.getByText("记忆暂时无法读取")).toBeTruthy();
|
||||
expect(screen.getByRole("status").textContent).toBe("记忆暂时无法读取");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "重新读取地图记忆" }),
|
||||
);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("starts with receipt-backed source grouping, keeping topics secondary", () => {
|
||||
render(<Host />);
|
||||
expect(
|
||||
@@ -531,118 +478,6 @@ it("starts with receipt-backed source grouping, keeping topics secondary", () =>
|
||||
expect(screen.getByRole("button", { name: "Codex2" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("restores the full graph scope when the source filter returns to all applications", async () => {
|
||||
const workbuddyMemory = {
|
||||
...memories[1],
|
||||
memory_id: "workbuddy-memory",
|
||||
content: "较早的 WorkBuddy 记忆",
|
||||
};
|
||||
const allGraph = {
|
||||
total_memories: 2,
|
||||
projected_memories: 2,
|
||||
hy_graph_available: false,
|
||||
nodes: [
|
||||
graph.nodes.find((node) => node.node_id === "memory:m0"),
|
||||
graph.nodes.find((node) => node.node_id === "source:codex"),
|
||||
{
|
||||
node_id: "memory:workbuddy-memory",
|
||||
node_type: "memory",
|
||||
memory_id: "workbuddy-memory",
|
||||
label: workbuddyMemory.content,
|
||||
},
|
||||
{
|
||||
node_id: "source:workbuddy",
|
||||
node_type: "source",
|
||||
source_id: "workbuddy",
|
||||
label: "WorkBuddy",
|
||||
},
|
||||
].filter(Boolean),
|
||||
edges: [
|
||||
{
|
||||
edge_id: "cursor-memory",
|
||||
source_node_id: "source:codex",
|
||||
target_node_id: "memory:m0",
|
||||
relation: "evidence_source",
|
||||
origin: "provenance",
|
||||
},
|
||||
{
|
||||
edge_id: "workbuddy-memory",
|
||||
source_node_id: "source:workbuddy",
|
||||
target_node_id: "memory:workbuddy-memory",
|
||||
relation: "evidence_source",
|
||||
origin: "provenance",
|
||||
},
|
||||
],
|
||||
} as MemoryGraphResponse;
|
||||
const cursorGraph = {
|
||||
...allGraph,
|
||||
total_memories: 1,
|
||||
projected_memories: 1,
|
||||
nodes: allGraph.nodes.filter(
|
||||
(node) => node.node_id === "memory:m0" || node.node_id === "source:codex",
|
||||
),
|
||||
edges: [allGraph.edges[0]],
|
||||
};
|
||||
|
||||
function ScopeHarness() {
|
||||
const [source, setSource] = useState("codex");
|
||||
return (
|
||||
<Host
|
||||
simplified
|
||||
items={[memories[0]]}
|
||||
graphValue={source ? cursorGraph : allGraph}
|
||||
sources={
|
||||
[
|
||||
{
|
||||
source_id: "codex",
|
||||
display_name: "Cursor",
|
||||
authorized: true,
|
||||
},
|
||||
{
|
||||
source_id: "workbuddy",
|
||||
display_name: "WorkBuddy",
|
||||
authorized: true,
|
||||
},
|
||||
{
|
||||
source_id: "retired",
|
||||
display_name: "Retired",
|
||||
authorized: false,
|
||||
},
|
||||
] as SourceResponse[]
|
||||
}
|
||||
libraryView={{
|
||||
...pagedView({
|
||||
items: [memories[0]],
|
||||
total: source ? 1 : 2,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"] },
|
||||
}),
|
||||
source,
|
||||
onSource: setSource,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<ScopeHarness />);
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "图中选择 较早的 WorkBuddy 记忆",
|
||||
}),
|
||||
).toBeNull();
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "来源" }));
|
||||
await userEvent.click(screen.getByRole("option", { name: "全部应用" }));
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "图中选择 较早的 WorkBuddy 记忆",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "图中选择 WorkBuddy" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
function pagedView(
|
||||
page: Parameters<typeof MemoryMap>[0]["libraryView"] extends infer V
|
||||
? V extends { page: infer P }
|
||||
@@ -1403,8 +1238,7 @@ it("drops a pending source selection when that source is revoked", async () => {
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
// Memories already distilled from a revoked source stay on the map, but the
|
||||
// revoked source can no longer be the selection.
|
||||
expect(screen.queryByRole("button", { name: "图中选择 Codex" })).toBeNull();
|
||||
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
|
||||
"source:codex",
|
||||
);
|
||||
|
||||
@@ -95,9 +95,6 @@ type Props = {
|
||||
page: MemoryLibraryPage | null;
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
/** The graph for the current source/query scope has not arrived yet. */
|
||||
graphLoading?: boolean;
|
||||
graphFailed?: boolean;
|
||||
stale: boolean;
|
||||
settledSource?: string;
|
||||
settledQuery?: string;
|
||||
@@ -319,31 +316,6 @@ export function MemoryMap(props: Props) {
|
||||
[active, group, search, serverPaged, axis, groupKey],
|
||||
);
|
||||
const projection = useMemo(() => {
|
||||
// Until the new scope's graph arrives (or after it failed), drawing the
|
||||
// previous scope's graph would show the wrong source.
|
||||
if (serverPaged && (library?.graphLoading || library?.graphFailed))
|
||||
return {
|
||||
total_memories: library.page?.total ?? 0,
|
||||
projected_memories: 0,
|
||||
hy_graph_available: false,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
};
|
||||
// Source filtering owns the graph scope, while pagination owns only the
|
||||
// left-hand list window. Projecting the graph back down to the current
|
||||
// 20-row page made "All applications" look like whichever source happened
|
||||
// to dominate that page. Keyword search is intentionally page-scoped.
|
||||
if (serverPaged && !library?.query) {
|
||||
return (
|
||||
baseGraph ??
|
||||
projectMap(
|
||||
graph,
|
||||
active,
|
||||
axis,
|
||||
filtered.map((m) => m.memory_id),
|
||||
)
|
||||
);
|
||||
}
|
||||
const result = projectMap(
|
||||
graph,
|
||||
active,
|
||||
@@ -375,21 +347,7 @@ export function MemoryMap(props: Props) {
|
||||
confidence: null,
|
||||
})),
|
||||
};
|
||||
}, [
|
||||
baseGraph,
|
||||
graph,
|
||||
axis,
|
||||
filtered,
|
||||
active,
|
||||
groupKey,
|
||||
search,
|
||||
sources,
|
||||
serverPaged,
|
||||
library?.query,
|
||||
library?.graphLoading,
|
||||
library?.graphFailed,
|
||||
library?.page?.total,
|
||||
]);
|
||||
}, [graph, axis, filtered, active, groupKey, search, sources, serverPaged]);
|
||||
const visibleGroupNode = groupNode
|
||||
? (projection.nodes.find(
|
||||
(node) =>
|
||||
@@ -1101,33 +1059,22 @@ export function MemoryMap(props: Props) {
|
||||
) : (
|
||||
<div className="map-empty-stage">
|
||||
<Network size={32} strokeWidth={1} />
|
||||
<p
|
||||
{...(library?.graphFailed || library?.graphLoading
|
||||
? { role: "status", "aria-live": "polite" as const }
|
||||
: {})}
|
||||
>
|
||||
<p>
|
||||
{library?.stale
|
||||
? text("可在沉淀记忆后查看视图")
|
||||
: library?.graphFailed || library?.failed
|
||||
: library?.failed
|
||||
? text("记忆暂时无法读取")
|
||||
: library?.graphLoading
|
||||
? text("正在读取记忆地图…")
|
||||
: library?.page &&
|
||||
(library.query || library.source || groupKey)
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: !loaded
|
||||
? text("正在读取记忆…")
|
||||
: active.length
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: arrival.importedMessages > 0
|
||||
? text("已保存的对话还未生成记忆")
|
||||
: text("还没有可显示的记忆")}
|
||||
: library?.page &&
|
||||
(library.query || library.source || groupKey)
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: !loaded
|
||||
? text("正在读取记忆…")
|
||||
: active.length
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: arrival.importedMessages > 0
|
||||
? text("已保存的对话还未生成记忆")
|
||||
: text("还没有可显示的记忆")}
|
||||
</p>
|
||||
{library?.graphFailed ? (
|
||||
<R6Button onClick={library.onRetry}>
|
||||
{text("重新读取地图记忆")}
|
||||
</R6Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
SdkMemoryRecord,
|
||||
} from "@one-memory/contracts";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -64,9 +63,6 @@ vi.mock("./MemoryMap", () => ({
|
||||
source_ids_by_memory?: Record<string, string[]>;
|
||||
offset: number;
|
||||
} | null;
|
||||
graphLoading?: boolean;
|
||||
graphFailed?: boolean;
|
||||
stale: boolean;
|
||||
unattributedSource?: string;
|
||||
onSource(value: string): void;
|
||||
onQuery(value: string): void;
|
||||
@@ -95,13 +91,6 @@ vi.mock("./MemoryMap", () => ({
|
||||
<span data-testid="map-busy">{String(busy)}</span>
|
||||
<span data-testid="map-detail">{detail?.memory.memory_id ?? ""}</span>
|
||||
<span data-testid="map-offset">{libraryView?.page?.offset ?? ""}</span>
|
||||
<span data-testid="map-stale">{String(libraryView?.stale)}</span>
|
||||
<span data-testid="map-graph-loading">
|
||||
{String(libraryView?.graphLoading)}
|
||||
</span>
|
||||
<span data-testid="map-graph-failed">
|
||||
{String(libraryView?.graphFailed)}
|
||||
</span>
|
||||
<button type="button" onClick={() => onOpenMemory("e1")}>
|
||||
open e1
|
||||
</button>
|
||||
@@ -144,15 +133,6 @@ vi.mock("./MemoryMap", () => ({
|
||||
>
|
||||
pick workbuddy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
libraryView?.onSource("");
|
||||
libraryView?.resetPage();
|
||||
}}
|
||||
>
|
||||
pick all
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -234,23 +214,11 @@ it("draws the map from SDK memories on SDK authority, without legacy reads", asy
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(api.sdkGraph).toHaveBeenCalledWith(
|
||||
{ source: undefined, offset: 0, limit: 300 },
|
||||
{ source: undefined, offset: 0, limit: 100 },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a graph read failure explicit instead of presenting the list page as the graph", async () => {
|
||||
sdkAuthority();
|
||||
api.sdkGraph.mockRejectedValue(new Error("graph unavailable"));
|
||||
render(<MemorySpace simplified active />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
|
||||
);
|
||||
expect(screen.getByTestId("map-graph-failed").textContent).toBe("true");
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
|
||||
});
|
||||
|
||||
it("keeps rows, sources, count and graph on the one filtered page", async () => {
|
||||
sdkAuthority();
|
||||
render(<MemorySpace simplified active />);
|
||||
@@ -283,150 +251,12 @@ it("keeps rows, sources, count and graph on the one filtered page", async () =>
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(api.sdkGraph).toHaveBeenLastCalledWith(
|
||||
{ source: "workbuddy", offset: 0, limit: 300 },
|
||||
{ source: "workbuddy", offset: 0, limit: 100 },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(await screen.findByText(/566/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the previous source graph until the all-applications graph settles", async () => {
|
||||
sdkAuthority();
|
||||
render(<MemorySpace simplified active />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("3"),
|
||||
);
|
||||
|
||||
const workbuddyPage = {
|
||||
memories: [{ ...record("w1"), source: "workbuddy" }],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
const workbuddyGraph: MemoryGraphResponse = {
|
||||
total_memories: 1,
|
||||
projected_memories: 1,
|
||||
nodes: [
|
||||
{
|
||||
node_id: "memory:w1",
|
||||
node_type: "memory",
|
||||
label: "w1",
|
||||
memory_id: "w1",
|
||||
source_id: null,
|
||||
kind: null,
|
||||
status: "active",
|
||||
confidence: null,
|
||||
},
|
||||
{
|
||||
node_id: "source:workbuddy",
|
||||
node_type: "source",
|
||||
label: "WorkBuddy",
|
||||
source_id: "workbuddy",
|
||||
memory_id: null,
|
||||
kind: null,
|
||||
status: null,
|
||||
confidence: null,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
hy_graph_available: false,
|
||||
};
|
||||
api.sdkLibrary.mockResolvedValue(workbuddyPage);
|
||||
api.sdkGraph.mockResolvedValue(workbuddyGraph);
|
||||
fireEvent.click(screen.getByText("pick workbuddy"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("2"),
|
||||
);
|
||||
|
||||
const allPage = {
|
||||
memories: [record("a"), { ...record("w1"), source: "workbuddy" }],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
const allGraph: MemoryGraphResponse = {
|
||||
...workbuddyGraph,
|
||||
total_memories: 2,
|
||||
projected_memories: 2,
|
||||
nodes: [
|
||||
...workbuddyGraph.nodes,
|
||||
{
|
||||
node_id: "memory:a",
|
||||
node_type: "memory",
|
||||
label: "a",
|
||||
memory_id: "a",
|
||||
source_id: null,
|
||||
kind: null,
|
||||
status: "active",
|
||||
confidence: null,
|
||||
},
|
||||
{
|
||||
node_id: "source:cursor",
|
||||
node_type: "source",
|
||||
label: "Cursor",
|
||||
source_id: "cursor",
|
||||
memory_id: null,
|
||||
kind: null,
|
||||
status: null,
|
||||
confidence: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
let releasePage: (value: typeof allPage) => void = () => {};
|
||||
let releaseGraph: (value: MemoryGraphResponse) => void = () => {};
|
||||
api.sdkLibrary.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
releasePage = resolve;
|
||||
}),
|
||||
);
|
||||
api.sdkGraph.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
releaseGraph = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("pick all"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
|
||||
);
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
|
||||
// Waiting for the graph is not page staleness: the list stays usable.
|
||||
expect(screen.getByTestId("map-stale").textContent).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
releasePage(allPage);
|
||||
releaseGraph(allGraph);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("4"),
|
||||
);
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
|
||||
|
||||
const searchPage = {
|
||||
memories: [record("search-result")],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
let releaseSearch: (value: typeof searchPage) => void = () => {};
|
||||
api.sdkLibrary.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
releaseSearch = resolve;
|
||||
}),
|
||||
);
|
||||
const graphCalls = api.sdkGraph.mock.calls.length;
|
||||
fireEvent.click(screen.getByText("search deploy"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
|
||||
);
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
|
||||
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
|
||||
|
||||
await act(async () => releaseSearch(searchPage));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false"),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens an SDK memory in place instead of switching away from the map", async () => {
|
||||
sdkAuthority();
|
||||
api.sdkMemory.mockResolvedValue(record("a"));
|
||||
|
||||
@@ -166,14 +166,11 @@ export function MemorySpace({
|
||||
// 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 graph covers the active source scope (up to its visual cap), while the
|
||||
// list pages through that scope. Keep source+query with the value so a
|
||||
// filter change cannot paint the previous filter's graph during the new read.
|
||||
const [sdkGraph, setSdkGraph] = useState<{
|
||||
scope: string;
|
||||
status: "ready" | "failed";
|
||||
value: MemoryGraphResponse | null;
|
||||
} | null>(null);
|
||||
// The list, its count, its source and time labels and the graph are all one
|
||||
// page: the library page and the graph for that same source and window,
|
||||
// read together. Reading the graph from a different, unfiltered window made
|
||||
// rows, sources and totals disagree.
|
||||
const [sdkGraph, setSdkGraph] = useState<MemoryGraphResponse | null>(null);
|
||||
// Bumping this re-reads the page and graph after an edit or a delete.
|
||||
const [sdkMapRevision, setSdkMapRevision] = useState(0);
|
||||
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
|
||||
@@ -194,17 +191,6 @@ export function MemorySpace({
|
||||
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
||||
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
||||
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
||||
const sdkRequestedScope = JSON.stringify([
|
||||
sdkLibraryQuery.trim(),
|
||||
sdkLibrarySource,
|
||||
]);
|
||||
const sdkGraphSettled = sdkGraph?.scope === sdkRequestedScope;
|
||||
const visibleSdkGraph =
|
||||
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "ready"
|
||||
? sdkGraph.value
|
||||
: null;
|
||||
const sdkGraphFailed =
|
||||
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "failed";
|
||||
// A keyword search pages through one server-side result window. The id is
|
||||
// kept for the query and source it was searched with; a new query or
|
||||
// source starts without one, so the server searches afresh.
|
||||
@@ -247,26 +233,22 @@ export function MemorySpace({
|
||||
request.signal,
|
||||
),
|
||||
// The overlay is decoration: the list stands without it. It reads
|
||||
// the full active/source-filtered scene up to the API's visual cap.
|
||||
// Reading only the first list-page prefix made "All applications"
|
||||
// look identical to Cursor whenever newer Cursor memories occupied
|
||||
// that prefix.
|
||||
query
|
||||
? Promise.resolve({
|
||||
status: "ready" as const,
|
||||
value: null,
|
||||
})
|
||||
: getSdkMemoryGraph(
|
||||
{
|
||||
source,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
},
|
||||
request.signal,
|
||||
).then(
|
||||
(value) => ({ status: "ready" as const, value }),
|
||||
() => ({ status: "failed" as const, value: null }),
|
||||
),
|
||||
// chat memories only, newest first; a mixed page's chat rows are
|
||||
// among the newest offset+page chat memories, so that prefix holds
|
||||
// every relation the page can draw.
|
||||
getSdkMemoryGraph(
|
||||
{
|
||||
source,
|
||||
offset: 0,
|
||||
limit: query
|
||||
? 100
|
||||
: Math.min(
|
||||
300,
|
||||
Math.max(100, sdkLibraryOffset + SDK_MEMORY_PAGE_SIZE),
|
||||
),
|
||||
},
|
||||
request.signal,
|
||||
).catch(() => null),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (sdkSearchWindow.current.key === windowKey)
|
||||
@@ -283,7 +265,7 @@ export function MemorySpace({
|
||||
settledSource: sdkLibrarySource,
|
||||
settledQuery: sdkLibraryQuery,
|
||||
});
|
||||
setSdkGraph({ scope: windowKey, ...graph });
|
||||
setSdkGraph(graph);
|
||||
} catch {
|
||||
if (cancelled || request.signal.aborted) return;
|
||||
setSdkLibrary((current) => ({
|
||||
@@ -1044,8 +1026,6 @@ export function MemorySpace({
|
||||
page: sdkLibrary.page,
|
||||
loading: sdkLibrary.loading,
|
||||
failed: sdkLibrary.failed,
|
||||
graphLoading: !sdkGraphSettled,
|
||||
graphFailed: sdkGraphFailed,
|
||||
stale: false,
|
||||
settledSource: sdkLibrary.settledSource,
|
||||
settledQuery: sdkLibrary.settledQuery,
|
||||
@@ -1060,7 +1040,7 @@ export function MemorySpace({
|
||||
}
|
||||
hidden={!active || displayedMode !== "map"}
|
||||
memories={sdkLibrary.page?.items ?? []}
|
||||
graph={visibleSdkGraph}
|
||||
graph={sdkGraph}
|
||||
sources={sources}
|
||||
processing={processing}
|
||||
arrival={memoryArrival}
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
|
||||
export interface OptionMenuItem {
|
||||
@@ -17,43 +8,6 @@ export interface OptionMenuItem {
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
const PANEL_GAP = 4;
|
||||
const MAX_PANEL_HEIGHT = 280;
|
||||
|
||||
export function optionMenuPosition(
|
||||
anchor: Pick<DOMRect, "top" | "right" | "bottom" | "left" | "width">,
|
||||
panelHeight: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
) {
|
||||
const width = Math.min(
|
||||
anchor.width,
|
||||
Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2),
|
||||
);
|
||||
const left = Math.min(
|
||||
Math.max(VIEWPORT_MARGIN, anchor.left),
|
||||
Math.max(VIEWPORT_MARGIN, viewportWidth - VIEWPORT_MARGIN - width),
|
||||
);
|
||||
const below = Math.max(
|
||||
0,
|
||||
viewportHeight - VIEWPORT_MARGIN - anchor.bottom - PANEL_GAP,
|
||||
);
|
||||
const above = Math.max(0, anchor.top - VIEWPORT_MARGIN - PANEL_GAP);
|
||||
const placement: "above" | "below" =
|
||||
below >= Math.min(panelHeight, 120) || below >= above ? "below" : "above";
|
||||
const maxHeight = Math.min(
|
||||
MAX_PANEL_HEIGHT,
|
||||
placement === "below" ? below : above,
|
||||
);
|
||||
const height = Math.min(panelHeight, maxHeight);
|
||||
const top =
|
||||
placement === "below"
|
||||
? anchor.bottom + PANEL_GAP
|
||||
: anchor.top - PANEL_GAP - height;
|
||||
return { top, left, width, maxHeight, placement };
|
||||
}
|
||||
|
||||
/** Replaces a native select so the open list follows the product field, not the OS menu. */
|
||||
export function OptionMenu(props: {
|
||||
id?: string;
|
||||
@@ -63,7 +17,6 @@ export function OptionMenu(props: {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
options: readonly OptionMenuItem[];
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
@@ -79,14 +32,8 @@ export function OptionMenu(props: {
|
||||
? selectedIndex
|
||||
: firstEnabledIndex;
|
||||
const [activeIndex, setActiveIndex] = useState(preferredIndex);
|
||||
const [panelStyle, setPanelStyle] = useState<CSSProperties>({
|
||||
visibility: "hidden",
|
||||
});
|
||||
const [placement, setPlacement] = useState<"above" | "below">("below");
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const panelMouseDown = useRef(false);
|
||||
const selected = props.options.find((item) => item.value === props.value);
|
||||
const visibleLabel = selected?.label ?? t("wb.select");
|
||||
const controlId = props.id ?? `${generatedId}-control`;
|
||||
@@ -115,77 +62,20 @@ export function OptionMenu(props: {
|
||||
button.current?.focus();
|
||||
}
|
||||
|
||||
const placePanel = useCallback(() => {
|
||||
const anchor = button.current?.getBoundingClientRect();
|
||||
const menu = panel.current;
|
||||
if (!anchor || !menu) return;
|
||||
if (
|
||||
anchor.width > 0 &&
|
||||
(anchor.bottom <= 0 ||
|
||||
anchor.top >= window.innerHeight ||
|
||||
anchor.right <= 0 ||
|
||||
anchor.left >= window.innerWidth)
|
||||
) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const measuredWidth = Math.min(
|
||||
anchor.width,
|
||||
Math.max(0, window.innerWidth - VIEWPORT_MARGIN * 2),
|
||||
);
|
||||
menu.style.width = `${measuredWidth}px`;
|
||||
menu.style.maxHeight = `${MAX_PANEL_HEIGHT}px`;
|
||||
const next = optionMenuPosition(
|
||||
anchor,
|
||||
menu.scrollHeight,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
);
|
||||
setPlacement(next.placement);
|
||||
setPanelStyle({
|
||||
top: next.top,
|
||||
left: next.left,
|
||||
width: next.width,
|
||||
maxHeight: next.maxHeight,
|
||||
visibility: "visible",
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
placePanel();
|
||||
window.addEventListener("resize", placePanel);
|
||||
window.addEventListener("scroll", placePanel, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", placePanel);
|
||||
window.removeEventListener("scroll", placePanel, true);
|
||||
};
|
||||
}, [open, placePanel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setActiveIndex(preferredIndex);
|
||||
const close = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!root.current?.contains(target) && !panel.current?.contains(target))
|
||||
setOpen(false);
|
||||
if (!root.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
const releasePanelPointer = () => {
|
||||
panelMouseDown.current = false;
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
document.addEventListener("mouseup", releasePanelPointer);
|
||||
document.addEventListener("keydown", onKey);
|
||||
window.addEventListener("blur", releasePanelPointer);
|
||||
return () => {
|
||||
panelMouseDown.current = false;
|
||||
document.removeEventListener("mousedown", close);
|
||||
document.removeEventListener("mouseup", releasePanelPointer);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("blur", releasePanelPointer);
|
||||
};
|
||||
}, [open, preferredIndex]);
|
||||
return (
|
||||
@@ -215,12 +105,7 @@ export function OptionMenu(props: {
|
||||
}
|
||||
disabled={props.disabled}
|
||||
onBlur={(event) => {
|
||||
if (panelMouseDown.current) return;
|
||||
const target = event.relatedTarget as Node | null;
|
||||
if (
|
||||
!root.current?.contains(target) &&
|
||||
!panel.current?.contains(target)
|
||||
)
|
||||
if (!root.current?.contains(event.relatedTarget as Node | null))
|
||||
setOpen(false);
|
||||
}}
|
||||
onClick={() => {
|
||||
@@ -267,43 +152,29 @@ export function OptionMenu(props: {
|
||||
>
|
||||
{visibleLabel}
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panel}
|
||||
id={panelId}
|
||||
className={["om-option-menu-panel", props.panelClassName]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="listbox"
|
||||
data-placement={placement}
|
||||
style={panelStyle}
|
||||
onMouseDownCapture={() => {
|
||||
panelMouseDown.current = true;
|
||||
}}
|
||||
>
|
||||
{props.options.map((item, index) => (
|
||||
<button
|
||||
key={item.value || item.label}
|
||||
id={`${panelId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
lang={item.lang}
|
||||
data-active={activeIndex === index || undefined}
|
||||
aria-selected={item.value === props.value}
|
||||
disabled={item.disabled}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => {
|
||||
if (!item.disabled) setActiveIndex(index);
|
||||
}}
|
||||
onClick={() => choose(index)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
{open && (
|
||||
<div id={panelId} className="om-option-menu-panel" role="listbox">
|
||||
{props.options.map((item, index) => (
|
||||
<button
|
||||
key={item.value || item.label}
|
||||
id={`${panelId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
lang={item.lang}
|
||||
data-active={activeIndex === index || undefined}
|
||||
aria-selected={item.value === props.value}
|
||||
disabled={item.disabled}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => {
|
||||
if (!item.disabled) setActiveIndex(index);
|
||||
}}
|
||||
onClick={() => choose(index)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -144,12 +144,13 @@ button svg {
|
||||
background: #edf0f3;
|
||||
}
|
||||
.om-option-menu-panel {
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #c0ccd5;
|
||||
@@ -177,8 +178,10 @@ button svg {
|
||||
.om-option-menu-panel [role="option"]:hover:not(:disabled),
|
||||
button.om-option-menu-button:hover:not(:disabled),
|
||||
.cold-start button.om-option-menu-button:hover:not(:disabled),
|
||||
.cold-start .om-option-menu-panel button[role="option"]:hover:not(:disabled),
|
||||
.r6-settings button.om-option-menu-button:hover:not(:disabled),
|
||||
.r6-memory button.om-option-menu-button:hover:not(:disabled) {
|
||||
.r6-settings .om-option-menu-panel button[role="option"]:hover:not(:disabled),
|
||||
.r6-memory .om-option-menu-panel button[role="option"]:hover:not(:disabled) {
|
||||
color: #262a2e;
|
||||
background: #e7eef2;
|
||||
border-color: transparent;
|
||||
|
||||
@@ -72,27 +72,37 @@
|
||||
border-color: #7892a6;
|
||||
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
|
||||
}
|
||||
.r6-language-option-menu-panel--settings {
|
||||
.r6-settings .r6-language-option-menu .om-option-menu-panel {
|
||||
top: calc(100% + 6px);
|
||||
padding: 6px;
|
||||
border-color: #c6d2da;
|
||||
box-shadow: 0 12px 32px rgb(48 71 87 / 13%);
|
||||
}
|
||||
.r6-language-option-menu-panel--settings [role="option"] {
|
||||
.r6-settings .r6-language-option-menu .om-option-menu-panel [role="option"] {
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
color: #304757;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-language-option-menu-panel--settings [role="option"][aria-selected="true"] {
|
||||
.r6-settings
|
||||
.r6-language-option-menu
|
||||
.om-option-menu-panel
|
||||
[role="option"][aria-selected="true"] {
|
||||
background: #dfe9ef;
|
||||
font-weight: 500;
|
||||
}
|
||||
.r6-language-option-menu-panel--settings [role="option"][data-active="true"] {
|
||||
.r6-settings
|
||||
.r6-language-option-menu
|
||||
.om-option-menu-panel
|
||||
[role="option"][data-active="true"] {
|
||||
color: #304757;
|
||||
background: #e9f0f4;
|
||||
}
|
||||
.r6-language-option-menu-panel--settings [role="option"]:active:not(:disabled) {
|
||||
.r6-settings
|
||||
.r6-language-option-menu
|
||||
.om-option-menu-panel
|
||||
[role="option"]:active:not(:disabled) {
|
||||
color: #304757;
|
||||
background: #d4e2ea;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# TODO: recall imported conversations before a model is configured
|
||||
|
||||
Status: open. Found 2026-10-28 by the Linux lifecycle run on build 35.
|
||||
|
||||
## Problem
|
||||
|
||||
A new user who has imported conversations but not configured a generation
|
||||
model gets nothing from `MemHub_recall` or `memhub recall`.
|
||||
|
||||
`recall_with_fallback` (`apps/core/src/one_memory/recall_fallback.py`) is
|
||||
meant to cover this case: without a model it searches the SDK raw layer
|
||||
(`l1_raw`), the imported conversations themselves. But under SDK authority
|
||||
nothing writes that layer until a model is active:
|
||||
|
||||
- File import and live capture only stage conversations into
|
||||
`memory_write_jobs` (assembling, then pending).
|
||||
- The durable write drain claims a job only when
|
||||
`_sedimentation_claim_enabled` holds, which requires an active model
|
||||
profile (`api.py`).
|
||||
- The SDK writes `l1_raw` only inside `client.add`, which the drain calls.
|
||||
|
||||
So until a model is configured, the raw layer is empty and the fallback has
|
||||
nothing to search. Before the SDK cutover, imported Evidence lived in
|
||||
MemHub's own database and was searchable without a model.
|
||||
|
||||
The Linux lifecycle verification used to assert "import, then recall finds
|
||||
it" on a machine with no model. It now asserts that the import reached the
|
||||
write queue instead (`scripts/platform/linux/verify-linux-lifecycle.mjs`,
|
||||
`waitForQueued`). Restore the recall assertion once this is fixed.
|
||||
|
||||
## Direction
|
||||
|
||||
Write imported conversations to the SDK raw layer without a generation
|
||||
model, and leave extraction for when a model is configured.
|
||||
|
||||
- The raw layer needs embeddings only. The bundled local ONNX model
|
||||
(`builtin_retrieval_snapshot`) can produce them, so no network model is
|
||||
needed.
|
||||
- SDK: a public raw-only write, for example `add(..., infer=False)` or
|
||||
`add_raw(messages, ...)`, that stores `l1_raw` with source, session,
|
||||
workspace and `memory_at`, and returns its raw ids. It must be idempotent
|
||||
by `request_id`, like `add`.
|
||||
- MemHub: split the claim gate. Without a model, the drain may claim a
|
||||
pending job for a raw-only write. Once a model is active, the same job
|
||||
still needs extraction, and that write must not store `l1_raw` again.
|
||||
Either the job records that its raw part is done, or extraction runs
|
||||
through `add_extracted` / a mode that skips the raw store.
|
||||
- The source sync switch and grant still apply, exactly as for extraction.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should raw-only ingestion run at all when the user has turned
|
||||
sedimentation off, or does that switch mean "store nothing"?
|
||||
- Storage cost: raw ingestion of a full history import without a model
|
||||
embeds every turn up front. Check the time and disk cost on a large
|
||||
history.
|
||||
- When the user configures a model later, extract from the stored raw rows,
|
||||
or re-read the jobs' frozen inputs? The second keeps one write path.
|
||||
|
||||
## Done when
|
||||
|
||||
- On a fresh install with no model, `memhub recall "<phrase from an
|
||||
imported conversation>"` returns that conversation with `mode: "raw"`.
|
||||
- Configuring a model afterwards extracts memories from the same
|
||||
conversations without duplicating their raw rows.
|
||||
- `verify-linux-lifecycle.mjs` asserts recall again.
|
||||
@@ -7,10 +7,7 @@ export const applicationsChineseMessages = {
|
||||
"apps.back": "返回首页",
|
||||
"apps.overview": "记忆概览",
|
||||
"apps.memories": "已沉淀记忆",
|
||||
"apps.readingCount": "正在核对本机记忆…",
|
||||
"apps.startingRetry": "后台正在启动,稍后自动更新",
|
||||
"apps.countWillUpdate": "记忆数量稍后自动更新",
|
||||
"apps.showingPrevious": "正在显示上次数据,稍后自动更新",
|
||||
"apps.readingCount": "正在读取已沉淀记忆数量…",
|
||||
"apps.unreadable": "暂无法读取",
|
||||
"apps.unit": "条",
|
||||
"apps.scope": "按应用授权范围使用",
|
||||
@@ -19,12 +16,9 @@ export const applicationsChineseMessages = {
|
||||
"apps.paused": "已暂停",
|
||||
"apps.stoppedNew": "已停止生成新记忆",
|
||||
"apps.checkingProcessing": "正在核对整理状态",
|
||||
"apps.checkingStatus": "正在核对",
|
||||
"apps.unchecked": "待核对",
|
||||
"apps.organizingNew": "新的对话正在整理",
|
||||
"apps.recentUpdate": "最近更新",
|
||||
"apps.checkingRecent": "正在核对",
|
||||
"apps.recentWillUpdate": "最近更新会自动显示",
|
||||
"apps.notGenerated": "尚未生成",
|
||||
"apps.saved": "记忆已保存",
|
||||
"apps.separate": "历史与记忆分别处理",
|
||||
@@ -32,14 +26,14 @@ export const applicationsChineseMessages = {
|
||||
"apps.activityUnavailable": "动态暂不可用",
|
||||
"apps.firstBatch": "首批沉淀",
|
||||
"apps.callsUnavailable": "暂时无法读取调用记录",
|
||||
"apps.memoryUnavailable": "记忆概览还没准备好",
|
||||
"apps.memoryUnavailable": "暂时无法读取记忆状态",
|
||||
"apps.importing": "对话持续导入中,即将沉淀你的记忆",
|
||||
"apps.firstRunning": "正在沉淀第一批记忆",
|
||||
"apps.firstQueued": "准备沉淀第一批记忆",
|
||||
"apps.readingActivity": "正在准备记忆概览",
|
||||
"apps.readingActivity": "正在读取动态",
|
||||
"apps.emptyMemories": "还没有已生成的记忆",
|
||||
"apps.callReadFailure": "已有记忆仍可查看;重试不会发起新的调用。",
|
||||
"apps.memoryReadFailure": "后台恢复后会自动更新,也可以重新读取。",
|
||||
"apps.memoryReadFailure": "读取失败不会当作没有记忆。",
|
||||
"apps.savingConversations": "对话正在后台持续保存。",
|
||||
"apps.waitingMemories": "历史已保存,记忆生成后会显示在这里。",
|
||||
"apps.reread": "重新读取",
|
||||
@@ -127,12 +121,7 @@ export const applicationsSpanishMessages: Record<
|
||||
"apps.back": "Volver al inicio",
|
||||
"apps.overview": "Resumen de memorias",
|
||||
"apps.memories": "Memorias guardadas",
|
||||
"apps.readingCount": "Comprobando las memorias locales…",
|
||||
"apps.startingRetry":
|
||||
"El servicio se está iniciando; se actualizará automáticamente",
|
||||
"apps.countWillUpdate": "El total de memorias se actualizará automáticamente",
|
||||
"apps.showingPrevious":
|
||||
"Se muestran los datos anteriores; se actualizarán automáticamente",
|
||||
"apps.readingCount": "Leyendo el total de memorias…",
|
||||
"apps.unreadable": "No disponible",
|
||||
"apps.unit": "",
|
||||
"apps.scope": "Según los permisos de cada aplicación",
|
||||
@@ -141,12 +130,9 @@ export const applicationsSpanishMessages: Record<
|
||||
"apps.paused": "En pausa",
|
||||
"apps.stoppedNew": "Se dejó de generar memorias nuevas",
|
||||
"apps.checkingProcessing": "Comprobando el estado del procesamiento",
|
||||
"apps.checkingStatus": "Verificando",
|
||||
"apps.unchecked": "Sin verificar",
|
||||
"apps.organizingNew": "Organizando nuevas conversaciones",
|
||||
"apps.recentUpdate": "Última actualización",
|
||||
"apps.checkingRecent": "Verificando",
|
||||
"apps.recentWillUpdate": "La última actualización aparecerá automáticamente",
|
||||
"apps.notGenerated": "Sin generar",
|
||||
"apps.saved": "Memoria guardada",
|
||||
"apps.separate": "Historial y memorias se procesan por separado",
|
||||
@@ -154,16 +140,16 @@ export const applicationsSpanishMessages: Record<
|
||||
"apps.activityUnavailable": "Actividad no disponible",
|
||||
"apps.firstBatch": "Primeras memorias",
|
||||
"apps.callsUnavailable": "No se pueden leer las consultas",
|
||||
"apps.memoryUnavailable": "El resumen de memorias aún no está listo",
|
||||
"apps.memoryUnavailable": "No se puede leer el estado de las memorias",
|
||||
"apps.importing": "Importando conversaciones para generar memorias",
|
||||
"apps.firstRunning": "Generando las primeras memorias",
|
||||
"apps.firstQueued": "Preparando las primeras memorias",
|
||||
"apps.readingActivity": "Preparando el resumen de memorias",
|
||||
"apps.readingActivity": "Leyendo actividad",
|
||||
"apps.emptyMemories": "Aún no hay memorias generadas",
|
||||
"apps.callReadFailure":
|
||||
"Puedes ver las memorias guardadas. Reintentar no inicia otra consulta.",
|
||||
"apps.memoryReadFailure":
|
||||
"Se actualizará cuando el servicio se recupere. También puedes volver a leerlo.",
|
||||
"Un error de lectura no significa que no haya memorias.",
|
||||
"apps.savingConversations": "Las conversaciones se guardan en segundo plano.",
|
||||
"apps.waitingMemories":
|
||||
"El historial está guardado. Las memorias aparecerán cuando se generen.",
|
||||
|
||||
@@ -1305,12 +1305,7 @@ const baseEnglishMessages = {
|
||||
"apps.back": "Back to home",
|
||||
"apps.overview": "Memory overview",
|
||||
"apps.memories": "Saved memories",
|
||||
"apps.readingCount": "Checking local memories…",
|
||||
"apps.startingRetry":
|
||||
"The background service is starting; this will update automatically",
|
||||
"apps.countWillUpdate": "The memory count will update automatically",
|
||||
"apps.showingPrevious":
|
||||
"Showing the previous data; this will update automatically",
|
||||
"apps.readingCount": "Loading memory count…",
|
||||
"apps.unreadable": "Unavailable",
|
||||
"apps.unit": "items",
|
||||
"apps.scope": "Used within app permissions",
|
||||
@@ -1319,12 +1314,9 @@ const baseEnglishMessages = {
|
||||
"apps.paused": "Paused",
|
||||
"apps.stoppedNew": "No new memories are being created",
|
||||
"apps.checkingProcessing": "Checking processing status",
|
||||
"apps.checkingStatus": "Checking",
|
||||
"apps.unchecked": "Needs review",
|
||||
"apps.organizingNew": "Processing new conversations",
|
||||
"apps.recentUpdate": "Recent update",
|
||||
"apps.checkingRecent": "Checking",
|
||||
"apps.recentWillUpdate": "The latest update will appear automatically",
|
||||
"apps.notGenerated": "Not created yet",
|
||||
"apps.saved": "Memory saved",
|
||||
"apps.separate": "History and memories are processed separately",
|
||||
@@ -1332,16 +1324,16 @@ const baseEnglishMessages = {
|
||||
"apps.activityUnavailable": "Activity unavailable",
|
||||
"apps.firstBatch": "First memories",
|
||||
"apps.callsUnavailable": "Could not read call history",
|
||||
"apps.memoryUnavailable": "The memory overview is not ready yet",
|
||||
"apps.memoryUnavailable": "Could not read memory status",
|
||||
"apps.importing": "Importing conversations before creating memories",
|
||||
"apps.firstRunning": "Creating the first memories",
|
||||
"apps.firstQueued": "Preparing the first memories",
|
||||
"apps.readingActivity": "Preparing memory overview",
|
||||
"apps.readingActivity": "Loading activity",
|
||||
"apps.emptyMemories": "No memories created yet",
|
||||
"apps.callReadFailure":
|
||||
"You can still view saved memories. Retrying will not make a new call.",
|
||||
"apps.memoryReadFailure":
|
||||
"This will update when the service recovers. You can also retry now.",
|
||||
"A failed read does not mean there are no memories.",
|
||||
"apps.savingConversations":
|
||||
"Conversations are still being saved in the background.",
|
||||
"apps.waitingMemories": "History is saved. New memories will appear here.",
|
||||
@@ -1621,7 +1613,6 @@ const baseEnglishMessages = {
|
||||
"Newly imported original text, not yet memories",
|
||||
"memory:来源归属": "Source connections",
|
||||
"memory:可在沉淀记忆后查看视图": "Create memories to view the graph",
|
||||
"memory:正在读取记忆地图…": "Loading memory map…",
|
||||
"memory:查看选中来源原文": "View selected source text",
|
||||
"memory:查看选中记忆原文": "View selected memory text",
|
||||
"memory:查看原文": "View original conversation",
|
||||
|
||||
@@ -187,7 +187,6 @@ export const memoryChineseMessages = {
|
||||
"memory:刚导入的原话,还不是沉淀后的记忆": "刚导入的原话,还不是沉淀后的记忆",
|
||||
"memory:来源归属": "来源归属",
|
||||
"memory:可在沉淀记忆后查看视图": "可在沉淀记忆后查看视图",
|
||||
"memory:正在读取记忆地图…": "正在读取记忆地图…",
|
||||
"memory:查看选中来源原文": "查看选中来源原文",
|
||||
"memory:查看选中记忆原文": "查看选中记忆原文",
|
||||
"memory:查看原文": "查看原文",
|
||||
@@ -590,7 +589,6 @@ export const memorySpanishMessages: Record<
|
||||
"memory:来源归属": "Relación con el origen",
|
||||
"memory:可在沉淀记忆后查看视图":
|
||||
"La vista estará disponible cuando se generen memorias",
|
||||
"memory:正在读取记忆地图…": "Leyendo el mapa de memorias…",
|
||||
"memory:查看选中来源原文": "Ver texto del origen seleccionado",
|
||||
"memory:查看选中记忆原文": "Ver texto de la memoria seleccionada",
|
||||
"memory:查看原文": "Ver texto original",
|
||||
|
||||
@@ -66,7 +66,6 @@ describe("Linux runtime scripts", () => {
|
||||
'"stop"',
|
||||
'"init"',
|
||||
'"scan"',
|
||||
'"recall"',
|
||||
'"uninstall"',
|
||||
]) {
|
||||
assert.match(script, new RegExp(command));
|
||||
@@ -88,9 +87,11 @@ describe("Linux runtime scripts", () => {
|
||||
script,
|
||||
/Exit 1 is a valid transient receipt[\s\S]{0,300}\[0, 1, 3\]/,
|
||||
);
|
||||
assert.match(script, /append_recalled/);
|
||||
assert.match(script, /initial_queued/);
|
||||
assert.match(script, /append_queued/);
|
||||
assert.match(script, /FROM memory_write_jobs/);
|
||||
assert.match(script, /idempotent_upgrade_instance_id_changed/);
|
||||
assert.match(script, /post_upgrade_recalled/);
|
||||
assert.match(script, /post_upgrade_queued/);
|
||||
assert.match(script, /uninstall_preserved_data/);
|
||||
assert.match(script, /is-enabled/);
|
||||
assert.match(script, /is-active/);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
function fail(message, receipt) {
|
||||
if (receipt) process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`);
|
||||
@@ -146,18 +147,35 @@ function doctorCheck(document, id) {
|
||||
return check;
|
||||
}
|
||||
|
||||
async function waitForRecall(prefix, phrase) {
|
||||
/**
|
||||
* Whether an imported phrase reached Core's durable write queue.
|
||||
*
|
||||
* Recall cannot prove an import here: this runner has no generation model,
|
||||
* and with SDK authority nothing reaches the SDK (not even its raw layer)
|
||||
* until a model is active. What the lifecycle can prove is that the import
|
||||
* was read and queued for the SDK, durably, in the Runtime's own database.
|
||||
*/
|
||||
async function waitForQueued(phrase) {
|
||||
const deadline = Date.now() + 90_000;
|
||||
let attempt = 0;
|
||||
const database = path.join(dataDir, "one-memory.sqlite3");
|
||||
while (Date.now() < deadline) {
|
||||
attempt += 1;
|
||||
const result = run(
|
||||
`${prefix}-${attempt}`,
|
||||
["recall", phrase, "--json"],
|
||||
[0, 1, 3],
|
||||
15_000,
|
||||
);
|
||||
if (result.stdout.includes(phrase)) return true;
|
||||
if (existsSync(database)) {
|
||||
let connection;
|
||||
try {
|
||||
connection = new DatabaseSync(database, { readOnly: true });
|
||||
const row = connection
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS count FROM memory_write_jobs " +
|
||||
"WHERE instr(payload_json, ?) > 0",
|
||||
)
|
||||
.get(phrase);
|
||||
if (Number(row?.count) > 0) return true;
|
||||
} catch {
|
||||
// The queue table appears once Core has migrated its schema.
|
||||
} finally {
|
||||
connection?.close();
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
}
|
||||
return false;
|
||||
@@ -320,12 +338,11 @@ try {
|
||||
"--import",
|
||||
"--json",
|
||||
]);
|
||||
observations.initial_recalled = await waitForRecall(
|
||||
"recall-initial",
|
||||
observations.initial_queued = await waitForQueued(
|
||||
"Linux lifecycle initial phrase",
|
||||
);
|
||||
if (!observations.initial_recalled)
|
||||
throw new Error("initial import was not recalled");
|
||||
if (!observations.initial_queued)
|
||||
throw new Error("initial import was not queued for the SDK");
|
||||
status.import = "passed";
|
||||
|
||||
writeFileSync(
|
||||
@@ -338,12 +355,11 @@ try {
|
||||
[0],
|
||||
30_000,
|
||||
);
|
||||
observations.append_recalled = await waitForRecall(
|
||||
"recall-appended",
|
||||
observations.append_queued = await waitForQueued(
|
||||
"Linux lifecycle appended phrase",
|
||||
);
|
||||
if (!observations.append_recalled)
|
||||
throw new Error("appended turn was not recalled");
|
||||
if (!observations.append_queued)
|
||||
throw new Error("appended turn was not queued for the SDK");
|
||||
status.append_sync = "passed";
|
||||
|
||||
const beforeUpgrade = await waitForRunningStatus("status-before-upgrade");
|
||||
@@ -378,12 +394,11 @@ try {
|
||||
if (!observations.idempotent_upgrade_instance_id_changed) {
|
||||
throw new Error("idempotent upgrade did not restart the Runtime");
|
||||
}
|
||||
observations.post_upgrade_recalled = await waitForRecall(
|
||||
"recall-after-upgrade",
|
||||
observations.post_upgrade_queued = await waitForQueued(
|
||||
"Linux lifecycle appended phrase",
|
||||
);
|
||||
if (!observations.post_upgrade_recalled) {
|
||||
throw new Error("idempotent upgrade lost previously recalled data");
|
||||
if (!observations.post_upgrade_queued) {
|
||||
throw new Error("idempotent upgrade lost previously queued data");
|
||||
}
|
||||
status.upgrade_idempotent = "passed";
|
||||
|
||||
|
||||
Reference in new issue
Block a user