Compare commits

..
Author SHA1 Message Date
kristinheandCursor 413b9033f9 fix(memhub): inline model configuration panels
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 15:49:20 +08:00
13 changed files with 721 additions and 235 deletions

No files matched your search

+1 -17
View File
@@ -6,11 +6,7 @@ import type {
} from "@one-memory/contracts";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import {
graphNodeTooltip,
isGraphNodeSelectable,
MemoryGraph3D,
} from "./MemoryGraph3D";
import { MemoryGraph3D } from "./MemoryGraph3D";
afterEach(cleanup);
@@ -52,18 +48,6 @@ function makeGraph(nodeCount: number): MemoryGraphResponse {
}
describe("MemoryGraph3D", () => {
it("keeps topic annotations hover-only", () => {
const topic = {
...makeGraph(1).nodes[0],
node_type: "topic" as const,
label: "CI",
};
expect(isGraphNodeSelectable(topic)).toBe(false);
expect(graphNodeTooltip(topic, "主题:")).toBe("主题:CI");
expect(graphNodeTooltip(topic, "Topic: ")).toBe("Topic: CI");
expect(isGraphNodeSelectable({ ...topic, node_type: "memory" })).toBe(true);
});
it("没有 WebGL 时给出可访问降级提示", () => {
render(
<MemoryGraph3D
+24 -58
View File
@@ -27,20 +27,6 @@ const SELECT_DURATION_MS = 180;
const SELECTED_COLOR = new THREE.Color(0x294355);
const SELECTED_EMISSIVE = new THREE.Color(0x607e97);
/** Topic nodes explain the graph; unlike memories and sources, they do not own a detail view. */
export function isGraphNodeSelectable(node: MemoryGraphNode): boolean {
return node.node_type !== "topic";
}
export function graphNodeTooltip(
node: MemoryGraphNode,
topicPrefix: string,
): string {
return node.node_type === "topic"
? `${topicPrefix}${node.label}`
: node.label;
}
export function MemoryGraph3D({
graph,
selectedNodeId,
@@ -50,20 +36,10 @@ export function MemoryGraph3D({
selectedNodeId: string | null;
onSelectNode(node: MemoryGraphNode): void;
}) {
const { locale, text } = useMemoryLanguage();
const { text } = useMemoryLanguage();
const host = useRef<HTMLDivElement>(null);
const labelHost = useRef<HTMLDivElement>(null);
const selectRef = useRef(onSelectNode);
const nodesByIdRef = useRef(
new Map(graph.nodes.map((node) => [node.node_id, node])),
);
nodesByIdRef.current = new Map(
graph.nodes.map((node) => [node.node_id, node]),
);
const topicPrefixRef = useRef("");
topicPrefixRef.current = `${text("主题")}${
locale.startsWith("zh") || locale.startsWith("ja") ? ":" : ": "
}`;
// 选中态通过 ref 传入渲染循环,而不是进 useEffect 依赖。
// 依赖里带 selectedNodeId 会导致每次点击重建整个场景、重跑布局、重建全部 mesh。
const selectedRef = useRef(selectedNodeId);
@@ -181,7 +157,9 @@ export function MemoryGraph3D({
}
}
const labels = graph.nodes
.filter((node) => node.node_type === "source")
.filter(
(node) => node.node_type === "source" || node.node_type === "topic",
)
.slice(0, 4)
.map((node) => {
const element = document.createElement("span");
@@ -216,7 +194,7 @@ export function MemoryGraph3D({
}
};
const hittable: THREE.Mesh[] = [];
const selectable: THREE.Mesh[] = [];
const materials = new Set<THREE.Material>();
const geometryByType = {
source: new THREE.SphereGeometry(0.24, 18, 12),
@@ -259,7 +237,7 @@ export function MemoryGraph3D({
mesh.scale.setScalar(1);
mesh.visible = returning || extending;
scene.add(mesh);
hittable.push(mesh);
selectable.push(mesh);
visuals.push({
mesh,
material,
@@ -320,6 +298,7 @@ export function MemoryGraph3D({
});
}
const nodesById = new Map(graph.nodes.map((node) => [node.node_id, node]));
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const hitAt = (event: PointerEvent) => {
@@ -328,7 +307,7 @@ export function MemoryGraph3D({
pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
return raycaster.intersectObjects(hittable, false)[0];
return raycaster.intersectObjects(selectable, false)[0];
};
let pressed: { x: number; y: number; id: number; moved: boolean } | null =
null;
@@ -354,9 +333,8 @@ export function MemoryGraph3D({
)
return;
const nodeId = hitAt(event)?.object.userData.nodeId;
const node =
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
if (node && isGraphNodeSelectable(node)) selectRef.current(node);
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
if (node) selectRef.current(node);
};
const hoverNode = (event: PointerEvent) => {
if (pressed) {
@@ -366,18 +344,12 @@ export function MemoryGraph3D({
return;
}
const nodeId = hitAt(event)?.object.userData.nodeId;
const node =
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
renderer.domElement.style.cursor =
node && isGraphNodeSelectable(node)
? "pointer"
: node
? "default"
: "grab";
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
renderer.domElement.style.cursor = node ? "pointer" : "grab";
setHovered(
node
? {
label: graphNodeTooltip(node, topicPrefixRef.current),
label: node.label,
x: event.clientX - element.getBoundingClientRect().left,
y: event.clientY - element.getBoundingClientRect().top,
}
@@ -657,23 +629,17 @@ export function MemoryGraph3D({
{!available ? (
<div className="memory-graph-fallback">
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
{graph.nodes.map((node) =>
isGraphNodeSelectable(node) ? (
<button
type="button"
className="secondary"
key={node.node_id}
aria-pressed={selectedNodeId === node.node_id}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
) : (
<span className="memory-graph-fallback-topic" key={node.node_id}>
{graphNodeTooltip(node, topicPrefixRef.current)}
</span>
),
)}
{graph.nodes.map((node) => (
<button
type="button"
className="secondary"
key={node.node_id}
aria-pressed={selectedNodeId === node.node_id}
onClick={() => onSelectNode(node)}
>
{node.label}
</button>
))}
</div>
) : null}
{available && (
+7 -7
View File
@@ -149,7 +149,7 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
});
it("keeps topic annotations out of the inspector and opens detail-bearing graph nodes", async () => {
it("replaces a topic detail with an off-page graph memory detail", async () => {
const onOpen = vi.fn();
const topicGraph = {
total_memories: 2,
@@ -221,13 +221,13 @@ it("keeps topic annotations out of the inspector and opens detail-bearing graph
/>,
);
await act(async () => {});
onOpen.mockClear();
await userEvent.click(screen.getByRole("button", { name: "图中选择 git" }));
expect(onOpen).not.toHaveBeenCalled();
expect(screen.queryByRole("heading", { name: "git" })).toBeNull();
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:m0");
expect(screen.getByRole("heading", { name: "git" })).toBeTruthy();
expect(screen.getAllByText("第一条").length).toBeGreaterThan(0);
expect(screen.queryByText("对应记忆")).toBeNull();
expect(screen.queryByText("属于主题")).toBeNull();
expect(screen.queryByText("主题")).toBeNull();
expect(screen.queryByText("两跳")).toBeNull();
await userEvent.click(
screen.getByRole("button", { name: "图中选择 先验证,再推送" }),
);
+5 -3
View File
@@ -684,9 +684,6 @@ export function MemoryMap(props: Props) {
setGroupKey(key);
}
function pickNode(node: MemoryGraphNode) {
// Topics are graph annotations, not detail-bearing records. They remain
// hoverable in the canvas but must not replace the memory inspector.
if (node.node_type === "topic") return;
if (node.memory_id) {
pick(node.memory_id);
return;
@@ -711,6 +708,11 @@ export function MemoryMap(props: Props) {
if (first) pick(first, false);
}
}
if (node.node_type === "topic") {
if (props.simplified) return;
setAxis("topic");
setGroupKey(`topic:${node.label}`);
}
}
const label =
group?.label ??
+280 -9
View File
@@ -72,6 +72,7 @@ const probeProfile = vi.fn();
const activate = vi.fn();
const remove = vi.fn();
const update = vi.fn();
const refreshProfiles = vi.fn();
const planActivation = vi.fn();
const startPlannedSwitch = vi.fn();
let switchPlan: EmbeddingSwitchPlan | null = null;
@@ -224,6 +225,7 @@ vi.mock("./use-model-profiles", () => ({
activate,
remove,
update,
refreshProfiles,
}),
}));
vi.mock("./use-embedding-switch", () => ({
@@ -279,6 +281,7 @@ describe("ModelSettings", () => {
switchCurrent = null;
create.mockResolvedValue(profile);
update.mockResolvedValue({ ...preset, has_api_key: true, revision: 2 });
refreshProfiles.mockResolvedValue(undefined);
planActivation.mockResolvedValue({
needed: false,
reason: "same_embedding",
@@ -576,7 +579,8 @@ describe("ModelSettings", () => {
name: "高级:自行配置向量模型",
});
await user.click(disclosure);
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
await user.click(screen.getByLabelText("向量配置模式"));
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
await user.type(screen.getByLabelText("向量模型名"), "custom-vector");
await user.type(
screen.getByLabelText("向量模型 Base URL"),
@@ -666,9 +670,9 @@ describe("ModelSettings", () => {
];
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
await userEvent.selectOptions(
screen.getByLabelText("向量配置模式"),
"local",
await userEvent.click(screen.getByLabelText("向量配置模式"));
await userEvent.click(
screen.getByRole("option", { name: "本地内置(推荐)" }),
);
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledWith(profile.profile_id, {
@@ -687,9 +691,9 @@ describe("ModelSettings", () => {
name: "高级:自行配置向量模型",
});
await userEvent.click(disclosure);
await userEvent.selectOptions(
screen.getByLabelText("向量配置模式"),
"custom",
await userEvent.click(screen.getByLabelText("向量配置模式"));
await userEvent.click(
screen.getByRole("option", { name: "自定义搜索模型" }),
);
await userEvent.click(disclosure);
await userEvent.click(screen.getByRole("button", { name: "保存配置" }));
@@ -850,6 +854,190 @@ describe("ModelSettings", () => {
expect(activate).not.toHaveBeenCalled();
});
it("completes an embedding change only after the switch commit activates the target", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() =>
expect(startPlannedSwitch).toHaveBeenCalledExactlyOnceWith(),
);
expect(onSetupComplete).not.toHaveBeenCalled();
refreshProfiles.mockImplementationOnce(async () => {
profiles = [{ ...profiles[0], is_active: true }];
});
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: { ...runningJob, state: "done" },
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
});
it("does not complete a committed switch until the target revision is active", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() => expect(startPlannedSwitch).toHaveBeenCalledOnce());
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: { ...runningJob, state: "done" },
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
expect(
await screen.findByText(
"配置已保存,尚未确认启用成功。请核对配置后重试。",
),
).toBeTruthy();
expect(onSetupComplete).not.toHaveBeenCalled();
expect(
(
screen.getByRole("button", {
name: "保存并测试启用",
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
it("keeps embedding configuration open when the switch rolls back", async () => {
const onSetupComplete = vi.fn();
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
const runningJob: EmbeddingSwitchJob = {
...oldFinishedJob,
job_id: "switch-job",
state: "reembedding",
progress: { ...oldFinishedJob.progress, phase: "vectors", done: 0 },
};
startPlannedSwitch.mockResolvedValue(runningJob);
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
const view = render(<ModelSettings onSetupComplete={onSetupComplete} />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始更新索引" }),
);
await waitFor(() => expect(startPlannedSwitch).toHaveBeenCalledOnce());
switchCurrent = {
...switchCurrent,
phase: "idle",
job: null,
last_finished: {
...runningJob,
state: "rolled_back",
error_code: "embedding_switch_failed",
},
};
view.rerender(<ModelSettings onSetupComplete={onSetupComplete} />);
expect(
await screen.findByText(
"索引更新没有完成,仍在使用原来的搜索模型,记忆没有丢失。",
),
).toBeTruthy();
expect(onSetupComplete).not.toHaveBeenCalled();
});
it("hands a successful save to its card, clears only the key, and never tests or activates", async () => {
profiles = [];
const user = userEvent.setup();
@@ -1044,14 +1232,16 @@ describe("ModelSettings", () => {
await user.click(
screen.getByRole("button", { name: "高级:自行配置向量模型" }),
);
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
await user.click(screen.getByLabelText("向量配置模式"));
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
await user.type(
screen.getByLabelText("配置名称"),
"Remote with local embedding",
);
await user.type(screen.getByLabelText("生成模型名"), "remote-model");
await user.type(screen.getByLabelText("向量模型名"), "nomic-embed-text");
await user.selectOptions(screen.getByLabelText("向量模型来源"), "ollama");
await user.click(screen.getByLabelText("向量模型来源"));
await user.click(screen.getByRole("option", { name: "本机 Ollama" }));
await user.type(
screen.getByLabelText("向量模型 Base URL"),
"http://127.0.0.1:11434",
@@ -1278,6 +1468,87 @@ describe("ModelSettings", () => {
expect(nameField().disabled).toBe(false);
});
});
it("only completes compact setup after the backend confirms the profile is active", async () => {
const user = userEvent.setup();
const onSetupComplete = vi.fn();
const capabilities = {
endpoint_reachable: true,
model_available: true,
structured_output: false,
embedding_available: true,
};
const created = {
...profile,
profile_id: "created",
revision: 1,
capabilities,
};
profiles = [];
create.mockImplementation(async () => {
profiles = [created];
return created;
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: true });
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Current model");
await user.type(screen.getByLabelText("生成模型名"), "model-v1");
await user.type(screen.getByLabelText(/^API Key/), "secret-value");
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
expect(planActivation).toHaveBeenCalledWith(
created.profile_id,
created.revision,
);
expect(activate).toHaveBeenCalledWith(created.profile_id);
});
it("keeps compact setup open when activation is not confirmed", async () => {
const user = userEvent.setup();
const onSetupComplete = vi.fn();
const capabilities = {
endpoint_reachable: true,
model_available: true,
structured_output: false,
embedding_available: true,
};
const created = {
...profile,
profile_id: "created",
revision: 1,
capabilities,
};
profiles = [];
create.mockImplementation(async () => {
profiles = [created];
return created;
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: false });
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Inactive model");
await user.type(screen.getByLabelText("生成模型名"), "model-v1");
await user.type(screen.getByLabelText(/^API Key/), "secret-value");
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await screen.findByText("配置已保存,尚未确认启用成功。请核对配置后重试。");
expect(onSetupComplete).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "测试并完成配置" })).toBeTruthy();
});
});
describe("把一张预置卡设为当前模型", () => {
+113 -34
View File
@@ -11,6 +11,7 @@ import {
type FormEvent,
type Ref,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useRef,
@@ -26,6 +27,7 @@ import {
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { R6StateRecovery } from "./R6StateRecovery";
@@ -238,6 +240,7 @@ export function ModelSettings({
onSetupComplete?(): void;
} = {}) {
const { text, t } = useOnboardingTranslation();
const fieldId = useId();
const models = useModelProfiles();
const embeddingSwitch = useEmbeddingSwitch();
const [provider, setProvider] = useState<ProviderKind>("openai_compatible");
@@ -275,6 +278,9 @@ export function ModelSettings({
const [setupError, setSetupError] = useState<string | null>(null);
const [switchTarget, setSwitchTarget] = useState<ModelProfile | null>(null);
const [switchJobId, setSwitchJobId] = useState<string | null>(null);
const [switchRefreshCompleted, setSwitchRefreshCompleted] = useState<
string | null
>(null);
const settledSwitchJob = useRef<string | null>(null);
const flight = useRef(false);
const alive = useRef(true);
@@ -308,10 +314,11 @@ export function ModelSettings({
return;
settledSwitchJob.current = finished.job_id;
if (finished.state === "done") {
void models.refreshProfiles();
setSetupPhase("activating");
setSetupError(null);
setSwitchTarget(null);
setSwitchJobId(null);
void models.refreshProfiles().then(() => {
if (alive.current) setSwitchRefreshCompleted(finished.job_id);
});
onProfilesChanged?.();
return;
}
@@ -323,6 +330,7 @@ export function ModelSettings({
);
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
}
}, [
embeddingSwitch.current?.last_finished,
@@ -331,6 +339,45 @@ export function ModelSettings({
switchJobId,
switchTarget,
]);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
if (
!switchTarget ||
!switchJobId ||
finished?.job_id !== switchJobId ||
finished.state !== "done" ||
switchRefreshCompleted !== switchJobId
)
return;
const active = models.profiles.find(
(profile) =>
profile.profile_id === switchTarget.profile_id &&
profile.revision >= switchTarget.revision &&
profile.is_active,
);
if (!active) {
setSetupPhase(null);
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
return;
}
if (active.name !== PRESET_PROFILE_NAME) setSaved(active);
setSetupPhase(null);
setSetupError(null);
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
onSetupComplete?.();
}, [
embeddingSwitch.current?.last_finished,
models.profiles,
onSetupComplete,
switchJobId,
switchRefreshCompleted,
switchTarget,
]);
useLayoutEffect(() => {
if (saved) savedCard.current?.focus({ preventScroll: true });
}, [saved]);
@@ -381,6 +428,7 @@ export function ModelSettings({
saved);
const editorLocked =
saving ||
switchJobId !== null ||
models.busy !== null ||
!!models.pendingCreation ||
!!models.pendingProbe ||
@@ -864,6 +912,9 @@ export function ModelSettings({
if (!alive.current) return;
if (created) {
if (thenSetup) {
// Retrying after probe, activation, or switch confirmation failed
// must update this saved revision instead of creating a duplicate.
setEditing(created);
// The probe decides whether this form closes. A rejected key or a
// failed endpoint leaves the filled-in form on screen with the
// reason; only a passing test returns to the saved card.
@@ -1339,20 +1390,37 @@ export function ModelSettings({
)}
{focusSection === "embedding" || advancedEmbedding ? (
<>
<label>
{text(
focusSection === "embedding"
? "搜索模型"
: "向量配置模式",
)}
<select
name="embedding-mode"
<div className="model-form-field">
<label
className="model-option-label"
id={`${fieldId}-embedding-mode-label`}
htmlFor={`${fieldId}-embedding-mode`}
>
{text(
focusSection === "embedding"
? "搜索模型"
: "向量配置模式",
)}
</label>
<OptionMenu
id={`${fieldId}-embedding-mode`}
labelledBy={`${fieldId}-embedding-mode-label`}
value={embeddingMode}
disabled={formLocked}
onChange={(event) => {
setEmbeddingMode(
event.target.value as "local" | "custom",
);
className="model-option-menu"
panelClassName="model-option-menu-panel"
options={[
{
value: "local",
label: text("本地内置(推荐)"),
},
{
value: "custom",
label: text("自定义搜索模型"),
},
]}
onChange={(value) => {
setEmbeddingMode(value as "local" | "custom");
setEmbeddingModeChanged(true);
setFields((previous) => ({
...previous,
@@ -1360,11 +1428,8 @@ export function ModelSettings({
"embedding-base-url": undefined,
}));
}}
>
<option value="local">{text("本地内置(推荐)")}</option>
<option value="custom">{text("自定义搜索模型")}</option>
</select>
</label>
/>
</div>
<small>{text("技术类型:Embedding")}</small>
{embeddingMode === "custom" ? (
<>
@@ -1397,25 +1462,38 @@ export function ModelSettings({
</small>
)}
</label>
<label>
{text("向量模型来源")}
<select
name="embedding-provider"
<div className="model-form-field">
<label
className="model-option-label"
id={`${fieldId}-embedding-provider-label`}
htmlFor={`${fieldId}-embedding-provider`}
>
{text("向量模型来源")}
</label>
<OptionMenu
id={`${fieldId}-embedding-provider`}
labelledBy={`${fieldId}-embedding-provider-label`}
value={embeddingProvider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as
className="model-option-menu"
panelClassName="model-option-menu-panel"
options={[
{ value: "ollama", label: text("本机 Ollama") },
{
value: "openai_compatible",
label: text(
"同源 OpenAI-compatible(共用 API Key)",
),
},
]}
onChange={(value) => {
const next = value as
| "openai_compatible"
| "ollama";
setEmbeddingProvider(next);
}}
>
<option value="ollama">{text("本机 Ollama")}</option>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 API Key)")}
</option>
</select>
</label>
/>
</div>
<label>
{text("向量模型 Base URL")}
<input
@@ -1669,13 +1747,14 @@ export function ModelSettings({
embeddingSwitch.clearError();
setSwitchTarget(null);
setSwitchJobId(null);
setSwitchRefreshCompleted(null);
}}
onStart={() => {
void embeddingSwitch.startPlannedSwitch().then((job) => {
if (!job || !alive.current) return;
setSwitchJobId(job.job_id);
setSwitchRefreshCompleted(null);
setSetupError(null);
onSetupComplete?.();
});
}}
/>
+1 -1
View File
@@ -111,7 +111,7 @@ export function OptionMenu(props: {
function choose(index: number) {
const item = props.options[index];
if (!item || item.disabled) return;
props.onChange(item.value);
if (item.value !== props.value) props.onChange(item.value);
setOpen(false);
button.current?.focus();
}
+47 -5
View File
@@ -21,6 +21,9 @@ import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { queryClient, resetClient } from "./query-client";
import { R6Settings } from "./R6Settings";
const editProfileAction = vi.fn(() => true);
const addProfileAction = vi.fn(() => true);
it("changes settings copy live without rereading profiles or changing permissions", async () => {
const user = userEvent.setup();
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
@@ -112,8 +115,8 @@ vi.mock("./ModelSettings", async () => {
}) => {
useImperativeHandle(controlsRef, () => ({
edit: () => false,
editProfile: () => false,
add: () => false,
editProfile: editProfileAction,
add: addProfileAction,
test: () => Promise.resolve(undefined),
}));
return createElement(
@@ -304,6 +307,7 @@ it("reports a stop that did not confirm instead of showing it stopped", async ()
});
it("opens the model area even when a pending probe fences the editor, so recovery stays reachable", async () => {
editProfileAction.mockReturnValueOnce(false);
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "模型配置" }));
@@ -321,10 +325,13 @@ it("opens the model area even when a pending probe fences the editor, so recover
expect(alert.textContent).toContain("待核对");
// What changed is that the recovery entry no longer hides with the form it
// is supposed to unlock.
expect(screen.getByRole("button", { name: "核对测试结果" })).toBeTruthy();
expect(
within(generationLane).getByRole("button", { name: "核对测试结果" }),
).toBeTruthy();
});
it("reaches the same recovery entry when no model has been configured yet", async () => {
addProfileAction.mockReturnValueOnce(false);
vi.mocked(getModelProfiles).mockResolvedValueOnce([]);
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
@@ -368,11 +375,46 @@ it("shows separate compact lanes and opens the requested presentation", async ()
expect(embeddingLane.textContent).toContain("Embedding");
await user.click(within(embeddingLane).getByRole("button", { name: "配置" }));
expect(screen.getByText("embedding")).toBeTruthy();
expect(await within(embeddingLane).findByText("embedding")).toBeTruthy();
expect(editProfileAction).toHaveBeenCalledTimes(1);
expect(editProfileAction).toHaveBeenLastCalledWith(
expect.objectContaining({ profile_id: "profile-1" }),
);
expect(embeddingLane.dataset.expanded).toBe("true");
expect(
within(embeddingLane)
.getByRole("button", { name: "收起" })
.getAttribute("aria-expanded"),
).toBe("true");
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
expect(screen.getByText("generation")).toBeTruthy();
expect(await within(generationLane).findByText("generation")).toBeTruthy();
expect(editProfileAction).toHaveBeenCalledTimes(2);
expect(generationLane.dataset.expanded).toBe("true");
expect(within(embeddingLane).queryByText("embedding")).toBeNull();
await user.click(
within(generationLane).getByRole("button", { name: "收起" }),
);
expect(
within(generationLane)
.getByText("generation")
.closest(".r6-model-owner")
?.hasAttribute("hidden"),
).toBe(true);
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
expect(editProfileAction).toHaveBeenCalledTimes(2);
expect(
within(generationLane)
.getByText("generation")
.closest(".r6-model-owner")
?.hasAttribute("hidden"),
).toBe(false);
expect(screen.queryByRole("alert")).toBeNull();
});
it("leaves the switch unusable when the state cannot be read", async () => {
+187 -82
View File
@@ -78,6 +78,7 @@ export function R6Settings({
const [configurationOpen, setConfigurationOpen] = useState(false);
const [configurationFocus, setConfigurationFocus] =
useState<ModelSettingsFocusSection>("generation");
const [configurationRequest, setConfigurationRequest] = useState(0);
const [memoryMode, setMemoryModeState] = useState<MemoryPipelineMode | null>(
null,
);
@@ -101,6 +102,10 @@ export function R6Settings({
const [sedimentationUnavailable, setSedimentationUnavailable] =
useState(false);
const modelActions = useRef<ModelSettingsActions>(null);
const configurationStarted = useRef(false);
const configurationStartedFocus = useRef<ModelSettingsFocusSection | null>(
null,
);
const quitPanel = useRef<HTMLElement>(null);
const quitOpener = useRef<HTMLElement | null>(null);
const currentModel = profiles?.find((profile) => profile.is_active) ?? null;
@@ -224,18 +229,49 @@ export function R6Settings({
function openConfiguration(
focusSection: ModelSettingsFocusSection = "generation",
) {
if (
configurationStarted.current &&
configurationStartedFocus.current === focusSection
) {
setConfigurationFocus(focusSection);
setConfigurationOpen(true);
return;
}
setConfigurationRefusal(false);
setConfigurationFocus(focusSection);
configurationStarted.current = true;
configurationStartedFocus.current = focusSection;
setConfigurationOpen(true);
setConfigurationRequest((value) => value + 1);
}
// The focused editor can move between the two lanes. Fill it only after
// React has mounted it in the requested lane, otherwise an edit sent to the
// old lane would be lost during the move.
// biome-ignore lint/correctness/useExhaustiveDependencies: configurationRequest is the explicit intent trigger.
useEffect(() => {
if (!configurationOpen || configurationRequest === 0) return;
const opened = currentModel
? modelActions.current?.editProfile(currentModel)
: modelActions.current?.add();
// The area opens even when the editor refuses: the only recovery entry
// lives inside it, and hiding both is what locked people out of their own
// settings. They needed the form open to reach “核对测试结果”, and that
// button to clear the pending record that kept the form shut. The fence
// itself is untouched (see ModelSettings): refusing still returns false.
setConfigurationOpen(true);
// The area stays open even when the editor refuses: its recovery entry is
// the only way to reconcile the pending operation.
if (!opened) setConfigurationRefusal(true);
}, [configurationRequest]);
function toggleConfiguration(focusSection: ModelSettingsFocusSection) {
if (configurationOpen && configurationFocus === focusSection) {
setConfigurationOpen(false);
return;
}
openConfiguration(focusSection);
}
function completeConfiguration() {
configurationStarted.current = false;
configurationStartedFocus.current = null;
setConfigurationRefusal(false);
setConfigurationOpen(false);
}
async function quitApplication() {
@@ -384,67 +420,156 @@ export function R6Settings({
</div>
)}
<div className="r6-model-overview">
<div className="r6-model-lane">
<div className="r6-model-lane-copy">
<h3>{text("记忆整理")}</h3>
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
<strong>
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
<section
className="r6-model-lane"
data-expanded={
configurationOpen && configurationFocus === "generation"
? "true"
: undefined
}
>
<div className="r6-model-lane-header">
<div className="r6-model-lane-copy">
<h3>{text("记忆整理")}</h3>
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
<strong>
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
)}
</strong>
{currentModel && (
<small>
LLM · {currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
</small>
)}
</div>
<R6Button
variant="quiet"
disabled={profiles === null}
aria-expanded={
configurationOpen && configurationFocus === "generation"
}
aria-controls={
configurationFocus === "generation"
? `${id}-generation-configuration`
: undefined
}
onClick={() => toggleConfiguration("generation")}
>
{configurationOpen && configurationFocus === "generation"
? t("wb.collapse")
: text(currentModel ? "配置" : "配置模型")}
</R6Button>
</div>
{configurationFocus === "generation" && (
<section
id={`${id}-generation-configuration`}
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() =>
setModelVersion((value) => value + 1)
}
onSetupComplete={completeConfiguration}
/>
</section>
)}
</section>
<section
className="r6-model-lane"
data-expanded={
configurationOpen && configurationFocus === "embedding"
? "true"
: undefined
}
>
<div className="r6-model-lane-header">
<div className="r6-model-lane-copy">
<h3>{text("记忆搜索")}</h3>
<p>{text("在需要时找到与你的问题最相关的记忆。")}</p>
<strong>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !==
"local_onnx" &&
currentModel.embedding_model
? currentModel.embedding_model
: "本地内置(推荐)",
)}
</strong>
{currentModel && (
</strong>
<small>
LLM · {currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx"
? `Embedding · ${currentModel.embedding_provider_kind}`
: profiles !== null && !currentModel
? "Embedding · 配置记忆整理后可自定义"
: "Embedding · 本机处理,无需额外配置",
)}
</small>
)}
</div>
<R6Button
variant="quiet"
disabled={profiles === null || !currentModel}
aria-expanded={
configurationOpen && configurationFocus === "embedding"
}
aria-controls={
configurationFocus === "embedding"
? `${id}-embedding-configuration`
: undefined
}
onClick={() => toggleConfiguration("embedding")}
>
{configurationOpen && configurationFocus === "embedding"
? t("wb.collapse")
: text("配置")}
</R6Button>
</div>
<R6Button
variant="quiet"
disabled={profiles === null}
onClick={() => openConfiguration("generation")}
>
{text(currentModel ? "配置" : "配置模型")}
</R6Button>
</div>
<div className="r6-model-lane">
<div className="r6-model-lane-copy">
<h3>{text("记忆搜索")}</h3>
<p>{text("在需要时找到与你的问题最相关的记忆。")}</p>
<strong>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx" &&
currentModel.embedding_model
? currentModel.embedding_model
: "本地内置(推荐)",
{configurationFocus === "embedding" && (
<section
id={`${id}-embedding-configuration`}
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
</strong>
<small>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx"
? `Embedding · ${currentModel.embedding_provider_kind}`
: profiles !== null && !currentModel
? "Embedding · 配置记忆整理后可自定义"
: "Embedding · 本机处理,无需额外配置",
)}
</small>
</div>
<R6Button
variant="quiet"
disabled={profiles === null || !currentModel}
onClick={() => openConfiguration("embedding")}
>
{text("配置")}
</R6Button>
</div>
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() =>
setModelVersion((value) => value + 1)
}
onSetupComplete={completeConfiguration}
/>
</section>
)}
</section>
</div>
{modelError && (
<R6StateRecovery
@@ -456,13 +581,6 @@ export function R6Settings({
onRetry={() => setModelVersion((value) => value + 1)}
/>
)}
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
{currentModel && memoryMode !== "lite" && (
<div className="r6-setting-row">
<div>
@@ -499,19 +617,6 @@ export function R6Settings({
)}
</p>
)}
<section
className="r6-model-owner"
hidden={!configurationOpen}
aria-label={text("模型配置表单")}
>
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() => setModelVersion((value) => value + 1)}
onSetupComplete={() => setConfigurationOpen(false)}
/>
</section>
</section>
<section
id={`${id}-panel-connections`}
+7 -10
View File
@@ -132,12 +132,17 @@ button svg {
content: "";
position: absolute;
right: 18px;
top: 22px;
top: 50%;
width: 8px;
height: 8px;
border-right: 1.5px solid #4e6478;
border-bottom: 1.5px solid #4e6478;
transform: rotate(45deg);
transform: translateY(-65%) rotate(45deg);
transform-origin: 50% 50%;
transition: transform 150ms ease-out;
}
.om-option-menu-button[aria-expanded="true"]::after {
transform: translateY(-35%) rotate(225deg);
}
.om-option-menu-button:disabled {
color: #66727b;
@@ -981,14 +986,6 @@ button.map-tool {
background: #fff;
box-shadow: var(--shadow-pop);
}
.memory-graph-fallback-topic {
display: block;
width: 100%;
margin-block: 6px;
padding: 8px 14px;
color: var(--tx2);
overflow-wrap: anywhere;
}
.graph-labels {
position: absolute;
inset: 0;
-1
View File
@@ -76,7 +76,6 @@
text-overflow: ellipsis;
}
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button::after {
top: 10px;
right: 11px;
}
.onboarding-r6-titlebar
+49 -7
View File
@@ -66,7 +66,6 @@
text-overflow: ellipsis;
}
.r6-settings .r6-language-option-menu .om-option-menu-button::after {
top: 16px;
right: 16px;
}
.r6-settings
@@ -596,6 +595,12 @@
background: #fff;
}
.r6-settings .r6-model-lane {
min-height: 108px;
}
.r6-settings .r6-model-lane[data-expanded="true"] {
background: #f8fafb;
}
.r6-settings .r6-model-lane-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
@@ -639,7 +644,7 @@
font-size: 12px;
line-height: 20px;
}
.r6-settings .r6-model-lane > .r6-button {
.r6-settings .r6-model-lane-header > .r6-button {
min-width: 72px;
}
.r6-settings .r6-model-recipient {
@@ -690,8 +695,9 @@
line-height: 28px;
}
.r6-settings .r6-model-owner {
padding-top: 24px;
padding: 24px 20px;
border-top: 1px solid #d8e2ea;
background: #f8fafb;
}
.r6-settings .r6-model-owner[hidden] {
display: none;
@@ -847,7 +853,6 @@
font-size: 14px;
}
.r6-settings .model-form .om-option-menu-button::after {
top: 16px;
right: 16px;
}
.r6-settings .model-form .om-option-menu-button[aria-expanded="true"] {
@@ -866,6 +871,39 @@
outline: 2px solid var(--r6-focus);
outline-offset: 2px;
}
.r6-settings .model-option-label {
display: block;
margin: 0;
}
.model-option-menu-panel {
padding: 6px;
border-color: var(--r6-control-edge, #c0ccd5);
box-shadow: 0 12px 32px rgb(48 71 87 / 13%);
}
.model-option-menu-panel [role="option"] {
min-height: 40px;
padding: 8px 10px;
color: #304757;
font-size: 14px;
line-height: 22px;
}
.model-option-menu-panel [role="option"][aria-selected="true"] {
background: #dfe9ef;
font-weight: 500;
}
.om-option-menu-panel.model-option-menu-panel
[role="option"][data-active="true"] {
color: #304757;
background: #e9f0f4;
}
.om-option-menu-panel.model-option-menu-panel
[role="option"]:hover:not(:disabled) {
color: #304757;
background: #e9f0f4;
}
.model-option-menu-panel [role="option"]:active:not(:disabled) {
background: #d4e2ea;
}
.r6-settings .model-advanced {
grid-column: 1 / -1;
}
@@ -884,7 +922,8 @@
font-size: 13px;
line-height: 21px;
}
.r6-settings .model-advanced label {
.r6-settings .model-advanced label,
.r6-settings .model-advanced .model-form-field {
display: flex;
flex-direction: column;
gap: 8px;
@@ -988,14 +1027,17 @@
padding: 24px 20px;
border-radius: 0 0 11px 11px;
}
.r6-settings .r6-model-lane {
.r6-settings .r6-model-lane-header {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
padding: 16px;
}
.r6-settings .r6-model-lane > .r6-button {
.r6-settings .r6-model-lane-header > .r6-button {
justify-self: start;
}
.r6-settings .r6-model-owner {
padding: 20px 16px;
}
.r6-settings .r6-language-setting-row {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
-1
View File
@@ -97,7 +97,6 @@
border-color: transparent;
}
.selected-source-filter .om-option-menu-button::after {
top: 18px;
right: 4px;
}
.selected-source-filter details {