Compare commits

...
6 changed files with 225 additions and 45 deletions

No files matched your search

+75 -17
View File
@@ -17,16 +17,24 @@ import {
import * as api from "./api";
import { saveConnectionPreferences } from "./connection-preferences";
import { resetConnectionReconcile } from "./connection-reconcile";
import { queryClient } from "./query-client";
import { queryClient, resetClient } from "./query-client";
import { commitSedimentation } from "./sedimentation-state";
const { summary, processing, history, liveSync, recentConversations } =
vi.hoisted(() => ({
summary: vi.fn(),
processing: vi.fn(),
history: vi.fn(),
liveSync: vi.fn(),
recentConversations: vi.fn(),
}));
const {
summary,
processing,
sedimentation,
history,
liveSync,
recentConversations,
} = vi.hoisted(() => ({
summary: vi.fn(),
processing: vi.fn(),
sedimentation: vi.fn(),
history: vi.fn(),
liveSync: vi.fn(),
recentConversations: vi.fn(),
}));
vi.mock("./call-history-client", async (original) => ({
...(await original<typeof import("./call-history-client")>()),
getCallHistory: history,
@@ -35,6 +43,7 @@ vi.mock("./api", () => ({
getHomeSummary: summary,
getLiveSyncHealth: liveSync,
getMemoryProcessing: processing,
getSedimentation: sedimentation,
getRecentConversations: recentConversations,
getSources: vi.fn().mockResolvedValue([]),
}));
@@ -74,6 +83,11 @@ beforeEach(() => {
observed_at: "2026-09-19T00:00:00Z",
});
processing.mockReset().mockResolvedValue({ running: 0, pending: 0 });
sedimentation.mockReset().mockResolvedValue({
enabled: true,
pending_retained: true,
imports_unaffected: true,
});
liveSync.mockReset().mockResolvedValue([]);
recentConversations.mockReset().mockResolvedValue({
items: [],
@@ -538,6 +552,7 @@ it.each([
afterEach(() => {
cleanup();
resetClient();
resetConnectionReconcile();
window.localStorage.clear();
vi.restoreAllMocks();
@@ -616,7 +631,7 @@ it("keeps authoritative home and processing data when only the source snapshot b
expect(screen.getByText("601")).toBeTruthy();
expect(screen.getByText("后台运行中")).toBeTruthy();
expect(screen.getByText("2 个应用已接入")).toBeTruthy();
expect(screen.getAllByText("一切就绪")).toHaveLength(2);
expect(screen.getAllByText("整理中")).toHaveLength(2);
expect(screen.queryByText("状态待核对")).toBeNull();
});
it("does not turn a failed home summary into zero or expose a private error", async () => {
@@ -647,11 +662,11 @@ it("never calls a recall event a success when only stored memories are available
});
it.each([
[{ running: 0, pending: 0 }, "一切就绪"],
[{ running: 0, pending: 0, waiting_for_model: 1 }, "等待模型"],
[{ running: 0, pending: 0, blocked_stages: [{ count: 1 }] }, "排队处理中"],
[{ running: 0, pending: 0, failed: 1 }, "需要处理"],
])("does not confuse task state with readiness: %j", async (value, label) => {
{ running: 0, pending: 0 },
{ running: 0, pending: 0, waiting_for_model: 1 },
{ running: 0, pending: 0, blocked_stages: [{ count: 1 }] },
{ running: 0, pending: 0, failed: 1 },
])("shows the enabled switch instead of task counters: %j", async (value) => {
processing.mockResolvedValue(value);
render(
<ApplicationsHome
@@ -661,8 +676,51 @@ it.each([
onSettings={() => {}}
/>,
);
expect(await screen.findAllByText(label)).toHaveLength(2);
expect(screen.queryByText("已就绪")).toBeNull();
expect(await screen.findAllByText("整理中")).toHaveLength(2);
});
it("shows a paused switch immediately even when task rows still say running", async () => {
processing.mockResolvedValue({ running: 3, pending: 10 });
sedimentation.mockResolvedValue({
enabled: false,
pending_retained: true,
imports_unaffected: true,
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
expect(await screen.findAllByText("已暂停")).toHaveLength(2);
expect(screen.getByText("已停止生成新记忆")).toBeTruthy();
expect(screen.queryByText("新的对话正在整理")).toBeNull();
});
it("updates an already mounted home as soon as settings commits the switch receipt", async () => {
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
expect(await screen.findAllByText("整理中")).toHaveLength(2);
act(() => {
commitSedimentation({
enabled: false,
pending_retained: true,
imports_unaffected: true,
});
});
await waitFor(() => expect(screen.getAllByText("已暂停")).toHaveLength(2));
});
it("keeps the import-in-progress empty state stable without a progress link", async () => {
+16 -20
View File
@@ -40,6 +40,7 @@ import {
import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { prefetchDataRuntime } from "./runtime-state";
import { refreshSedimentation, useSedimentation } from "./sedimentation-state";
import type { useImportSources } from "./use-import-sources";
import "./applications-home.css";
@@ -192,6 +193,7 @@ export function ApplicationsHome({
// A failed poll keeps the last answer on screen; the page must say it is old.
const hostStale = hostQuery.isError && hostQuery.data !== undefined;
const auto = useAutoConnect();
const sedimentation = useSedimentation();
const [summary, setSummary] = useState<HomeSummary | null>(null);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
@@ -554,6 +556,7 @@ export function ApplicationsHome({
getMemoryProcessing(),
getLiveSyncHealth(),
getRecentConversations({ limit: 1, recentActivity: true }),
refreshSedimentation(),
]);
busy = false;
if (stopped) return;
@@ -651,19 +654,18 @@ export function ApplicationsHome({
const apps = imports.sources.filter(
(s) => s.authorized || s.detected === true || s.location_state === "found",
);
const stateLabel = processing?.running
? "整理中"
: processing?.blocked_stages?.some((stage) => stage.count > 0)
? "排队处理中"
: processing?.waiting_for_model
? "等待模型"
: processing?.failed
? "需要处理"
: processing?.pending
? "准备中"
: processing
? "一切就绪"
: "待核对";
const stateLabel =
sedimentation?.enabled === true
? "整理中"
: sedimentation?.enabled === false
? "已暂停"
: "待核对";
const stateDetail =
sedimentation?.enabled === false
? "已停止生成新记忆"
: sedimentation?.enabled === true
? "新的对话正在整理"
: "正在核对整理状态";
const latestClock = latest ? clockParts(latest.updated_at) : null;
const time = latestClock?.clock ?? (latest ? "时间未知" : null);
const checked = visible ? clockParts(visible.observed_at) : null;
@@ -835,13 +837,7 @@ export function ApplicationsHome({
<div>
<p>沉淀状态</p>
<strong className="r6-home-word">{stateLabel}</strong>
<small>
{processing?.running
? "新的对话正在整理"
: processing?.pending
? "等待队列开始处理"
: "以当前处理状态为准"}
</small>
<small>{stateDetail}</small>
</div>
<div>
<p>最近更新</p>
+7
View File
@@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event";
import type { Ref } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { getModelProfiles, getSedimentation, setSedimentation } from "./api";
import { queryClient, resetClient } from "./query-client";
import { R6Settings } from "./R6Settings";
vi.mock("./api", () => ({
@@ -54,6 +55,7 @@ vi.mock("./diagnostics-export", () => ({
}));
afterEach(() => {
cleanup();
resetClient();
vi.clearAllMocks();
window.oneMemory = undefined;
});
@@ -94,6 +96,11 @@ it("stops model sedimentation on one switch flip without touching the model conf
await user.click(toggle);
expect(setSedimentation).toHaveBeenCalledExactlyOnceWith(false);
expect(queryClient.getQueryData(["sedimentation"])).toEqual({
enabled: false,
pending_retained: true,
imports_unaffected: true,
});
await screen.findByText(/停止后不再调用模型/);
});
+12 -8
View File
@@ -8,7 +8,7 @@ import {
useRef,
useState,
} from "react";
import { getModelProfiles, getSedimentation, setSedimentation } from "./api";
import { getModelProfiles, setSedimentation } from "./api";
import { ConnectionFallback } from "./ConnectionFallback";
import { exportDiagnosticsFile } from "./diagnostics-export";
import { ModelSettings, type ModelSettingsActions } from "./ModelSettings";
@@ -18,6 +18,11 @@ import {
setLoginPreference,
useLoginPreference,
} from "./runtime-state";
import {
commitSedimentation,
refreshSedimentation,
useSedimentation,
} from "./sedimentation-state";
import "./r6-settings.css";
type Group = "model" | "connections" | "help";
@@ -72,7 +77,8 @@ export function R6Settings({
commit: "unknown",
},
);
const [sedimentation, setSedimentationState] = useState<boolean | null>(null);
const sedimentationState = useSedimentation();
const sedimentation = sedimentationState?.enabled ?? null;
const [sedimentationPending, setSedimentationPending] = useState(false);
const [sedimentationError, setSedimentationError] = useState("");
const [sedimentationUnavailable, setSedimentationUnavailable] =
@@ -119,13 +125,11 @@ export function R6Settings({
const readSedimentation = useCallback(async () => {
try {
const state = await getSedimentation();
setSedimentationState(state.enabled === true);
await refreshSedimentation();
setSedimentationUnavailable(false);
} catch {
// Left unknown rather than assumed on: showing "已停止" when the switch
// could not be read would hide a running model.
setSedimentationState(null);
// Keep the last confirmed state visible, but disable writes until the
// server can confirm it again.
setSedimentationUnavailable(true);
}
}, []);
@@ -141,7 +145,7 @@ export function R6Settings({
setSedimentationError("");
try {
const state = await setSedimentation(enabled);
setSedimentationState(state.enabled === true);
commitSedimentation(state);
} catch {
setSedimentationError(
enabled
@@ -0,0 +1,61 @@
import { afterEach, expect, it, vi } from "vitest";
import { queryClient, resetClient } from "./query-client";
import {
commitSedimentation,
refreshSedimentation,
} from "./sedimentation-state";
const { getSedimentation } = vi.hoisted(() => ({
getSedimentation: vi.fn(),
}));
vi.mock("./api", () => ({ getSedimentation }));
const enabled = {
enabled: true,
pending_retained: true,
imports_unaffected: true,
};
const paused = { ...enabled, enabled: false };
afterEach(() => {
resetClient();
vi.clearAllMocks();
});
it("keeps a newer switch receipt when an older read lands later", async () => {
let finishRead: ((value: typeof enabled) => void) | undefined;
getSedimentation.mockImplementationOnce(
() =>
new Promise<typeof enabled>((resolve) => {
finishRead = resolve;
}),
);
const olderRead = refreshSedimentation();
commitSedimentation(paused);
finishRead?.(enabled);
expect(await olderRead).toEqual(paused);
expect(queryClient.getQueryData(["sedimentation"])).toEqual(paused);
});
it("keeps the newest read when concurrent reads finish out of order", async () => {
let finishFirst: ((value: typeof enabled) => void) | undefined;
getSedimentation
.mockImplementationOnce(
() =>
new Promise<typeof enabled>((resolve) => {
finishFirst = resolve;
}),
)
.mockResolvedValueOnce(paused);
const first = refreshSedimentation();
const second = refreshSedimentation();
expect(await second).toEqual(paused);
finishFirst?.(enabled);
expect(await first).toEqual(paused);
expect(queryClient.getQueryData(["sedimentation"])).toEqual(paused);
});
@@ -0,0 +1,54 @@
import { useQuery } from "@tanstack/react-query";
import { getSedimentation, type SedimentationState } from "./api";
import { onClientReset, queryClient } from "./query-client";
const SEDIMENTATION_KEY = ["sedimentation"] as const;
const FRESH_MS = 5_000;
queryClient.setQueryDefaults(SEDIMENTATION_KEY, {
gcTime: Number.POSITIVE_INFINITY,
staleTime: FRESH_MS,
});
let revision = 0;
let readsStarted = 0;
let readsLanded = 0;
onClientReset(() => {
revision = 0;
readsStarted = 0;
readsLanded = 0;
});
/**
* Reads the server switch without allowing an older request to overwrite a
* newer PUT receipt committed by the settings page.
*/
export async function refreshSedimentation(): Promise<SedimentationState> {
const order = ++readsStarted;
const startedAtRevision = revision;
const next = await getSedimentation();
const current =
queryClient.getQueryData<SedimentationState>(SEDIMENTATION_KEY);
if (startedAtRevision !== revision && current) return current;
if (order < readsLanded && current) return current;
readsLanded = order;
queryClient.setQueryData(SEDIMENTATION_KEY, next);
return next;
}
/** The PUT response is the latest confirmed server state. */
export function commitSedimentation(value: SedimentationState) {
revision += 1;
queryClient.setQueryData(SEDIMENTATION_KEY, value);
}
export function useSedimentation() {
return useQuery(
{
queryKey: SEDIMENTATION_KEY,
queryFn: refreshSedimentation,
enabled: false,
},
queryClient,
).data;
}