Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38ad2292d1 |
No files matched your search
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { legacyMemoryModePlan } from "@one-memory/operations";
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
@@ -64,31 +65,9 @@ function prepare(mode: "lite" | "pro" | "ultra" = "pro") {
|
||||
running: [],
|
||||
});
|
||||
vi.mocked(getMemoryProcessing).mockResolvedValue({} as never);
|
||||
vi.mocked(planMemoryMode).mockImplementation(async (target) => ({
|
||||
current: mode,
|
||||
target,
|
||||
effective_target: target,
|
||||
capabilities: {
|
||||
content_plane: target === "lite" ? "raw" : "memory",
|
||||
generation_required: target !== "lite",
|
||||
graph_enabled: target === "ultra",
|
||||
evolve_available: target === "ultra",
|
||||
},
|
||||
effects: [
|
||||
...(target === "lite" ? (["higher_mode_memories_hidden"] as const) : []),
|
||||
...(mode === "lite" && target !== "lite"
|
||||
? (["earlier_raw_not_extracted", "lite_search_stops"] as const)
|
||||
: []),
|
||||
...(target === "ultra" && mode !== "ultra"
|
||||
? (["existing_memories_added_to_graph"] as const)
|
||||
: []),
|
||||
...(mode === "ultra" && target !== "ultra"
|
||||
? (["evolve_stopped"] as const)
|
||||
: []),
|
||||
],
|
||||
warnings: [],
|
||||
blockers: [],
|
||||
}));
|
||||
vi.mocked(planMemoryMode).mockImplementation(async (target) =>
|
||||
legacyMemoryModePlan(mode, target, { hasActiveModel: true }),
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseMode(
|
||||
@@ -227,6 +206,20 @@ it("uses the isolated legacy plan only for a route-level old-Core 404", async ()
|
||||
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps downgrade effects when an old Core falls back from ultra to pro", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
|
||||
vi.mocked(memoryModePlanUnsupported).mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /智能记忆(pro)/);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
|
||||
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("explains model cost, search scope and the next step before switching to ultra", async () => {
|
||||
prepare("lite");
|
||||
const user = userEvent.setup();
|
||||
@@ -238,6 +231,8 @@ it("explains model cost, search scope and the next step before switching to ultr
|
||||
expect(screen.getByText("搜索范围")).toBeTruthy();
|
||||
expect(screen.getByText("切换后")).toBeTruthy();
|
||||
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
|
||||
expect(screen.getByText("知识图谱")).toBeTruthy();
|
||||
expect(screen.getAllByText("已有数据")).toHaveLength(1);
|
||||
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -248,9 +243,27 @@ it("distinguishes regular memories from higher-level Ultra data when switching t
|
||||
|
||||
await chooseMode(user, /智能记忆(pro)/);
|
||||
|
||||
expect(screen.getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
|
||||
expect(screen.getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
|
||||
expect(screen.getByText(/每天自动整合会停止/)).toBeTruthy();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
|
||||
expect(within(dialog).getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
|
||||
expect(within(dialog).getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
|
||||
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByText("每天自动整合会停止;已生成的整合结果继续保留。"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show automatic consolidation when switching from lite to pro", async () => {
|
||||
prepare("lite");
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /智能记忆(pro)/);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
|
||||
expect(within(dialog).getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
|
||||
expect(within(dialog).queryByText("每天自动整合")).toBeNull();
|
||||
});
|
||||
|
||||
it("focuses the Evolve settings after switching to ultra", async () => {
|
||||
|
||||
@@ -85,18 +85,20 @@ function planRows(plan: MemoryModePlan) {
|
||||
if (plan.effects.includes("higher_mode_memories_hidden"))
|
||||
rows.push({
|
||||
label: "已有数据",
|
||||
content: "已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
|
||||
content:
|
||||
plan.effective_target === "pro"
|
||||
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
|
||||
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
|
||||
});
|
||||
if (plan.effects.includes("existing_memories_added_to_graph"))
|
||||
rows.push({
|
||||
label: "已有数据",
|
||||
label: "知识图谱",
|
||||
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
|
||||
});
|
||||
if (plan.effects.includes("evolve_stopped"))
|
||||
rows.push({
|
||||
label: "已有数据",
|
||||
content:
|
||||
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
|
||||
label: "每天自动整合",
|
||||
content: "每天自动整合会停止,已经生成的数据不会删除。",
|
||||
});
|
||||
if (plan.capabilities.evolve_available)
|
||||
rows.push({
|
||||
|
||||
@@ -72,7 +72,6 @@ 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;
|
||||
@@ -225,7 +224,6 @@ vi.mock("./use-model-profiles", () => ({
|
||||
activate,
|
||||
remove,
|
||||
update,
|
||||
refreshProfiles,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./use-embedding-switch", () => ({
|
||||
@@ -281,7 +279,6 @@ 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",
|
||||
@@ -579,8 +576,7 @@ describe("ModelSettings", () => {
|
||||
name: "高级:自行配置向量模型",
|
||||
});
|
||||
await user.click(disclosure);
|
||||
await user.click(screen.getByLabelText("向量配置模式"));
|
||||
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
|
||||
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
|
||||
await user.type(screen.getByLabelText("向量模型名"), "custom-vector");
|
||||
await user.type(
|
||||
screen.getByLabelText("向量模型 Base URL"),
|
||||
@@ -670,9 +666,9 @@ describe("ModelSettings", () => {
|
||||
];
|
||||
render(<ModelSettings />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
|
||||
await userEvent.click(screen.getByLabelText("向量配置模式"));
|
||||
await userEvent.click(
|
||||
screen.getByRole("option", { name: "本地内置(推荐)" }),
|
||||
await userEvent.selectOptions(
|
||||
screen.getByLabelText("向量配置模式"),
|
||||
"local",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
|
||||
expect(update).toHaveBeenCalledWith(profile.profile_id, {
|
||||
@@ -691,9 +687,9 @@ describe("ModelSettings", () => {
|
||||
name: "高级:自行配置向量模型",
|
||||
});
|
||||
await userEvent.click(disclosure);
|
||||
await userEvent.click(screen.getByLabelText("向量配置模式"));
|
||||
await userEvent.click(
|
||||
screen.getByRole("option", { name: "自定义搜索模型" }),
|
||||
await userEvent.selectOptions(
|
||||
screen.getByLabelText("向量配置模式"),
|
||||
"custom",
|
||||
);
|
||||
await userEvent.click(disclosure);
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存配置" }));
|
||||
@@ -854,190 +850,6 @@ 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();
|
||||
@@ -1232,16 +1044,14 @@ describe("ModelSettings", () => {
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "高级:自行配置向量模型" }),
|
||||
);
|
||||
await user.click(screen.getByLabelText("向量配置模式"));
|
||||
await user.click(screen.getByRole("option", { name: "自定义搜索模型" }));
|
||||
await user.selectOptions(screen.getByLabelText("向量配置模式"), "custom");
|
||||
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.click(screen.getByLabelText("向量模型来源"));
|
||||
await user.click(screen.getByRole("option", { name: "本机 Ollama" }));
|
||||
await user.selectOptions(screen.getByLabelText("向量模型来源"), "ollama");
|
||||
await user.type(
|
||||
screen.getByLabelText("向量模型 Base URL"),
|
||||
"http://127.0.0.1:11434",
|
||||
@@ -1468,87 +1278,6 @@ 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("把一张预置卡设为当前模型", () => {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type FormEvent,
|
||||
type Ref,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
presetForGeneration,
|
||||
validateGenerationModel,
|
||||
} from "./GenerationModelPrimitives";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { useOnboardingTranslation } from "./onboarding-language";
|
||||
import { PROVIDER_PRESETS } from "./provider-presets";
|
||||
import { R6StateRecovery } from "./R6StateRecovery";
|
||||
@@ -240,7 +238,6 @@ 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");
|
||||
@@ -278,9 +275,6 @@ 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);
|
||||
@@ -314,11 +308,10 @@ export function ModelSettings({
|
||||
return;
|
||||
settledSwitchJob.current = finished.job_id;
|
||||
if (finished.state === "done") {
|
||||
setSetupPhase("activating");
|
||||
void models.refreshProfiles();
|
||||
setSetupError(null);
|
||||
void models.refreshProfiles().then(() => {
|
||||
if (alive.current) setSwitchRefreshCompleted(finished.job_id);
|
||||
});
|
||||
setSwitchTarget(null);
|
||||
setSwitchJobId(null);
|
||||
onProfilesChanged?.();
|
||||
return;
|
||||
}
|
||||
@@ -330,7 +323,6 @@ export function ModelSettings({
|
||||
);
|
||||
setSwitchTarget(null);
|
||||
setSwitchJobId(null);
|
||||
setSwitchRefreshCompleted(null);
|
||||
}
|
||||
}, [
|
||||
embeddingSwitch.current?.last_finished,
|
||||
@@ -339,45 +331,6 @@ 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]);
|
||||
@@ -428,7 +381,6 @@ export function ModelSettings({
|
||||
saved);
|
||||
const editorLocked =
|
||||
saving ||
|
||||
switchJobId !== null ||
|
||||
models.busy !== null ||
|
||||
!!models.pendingCreation ||
|
||||
!!models.pendingProbe ||
|
||||
@@ -912,9 +864,6 @@ 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.
|
||||
@@ -1390,37 +1339,20 @@ export function ModelSettings({
|
||||
)}
|
||||
{focusSection === "embedding" || advancedEmbedding ? (
|
||||
<>
|
||||
<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`}
|
||||
<label>
|
||||
{text(
|
||||
focusSection === "embedding"
|
||||
? "搜索模型"
|
||||
: "向量配置模式",
|
||||
)}
|
||||
<select
|
||||
name="embedding-mode"
|
||||
value={embeddingMode}
|
||||
disabled={formLocked}
|
||||
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");
|
||||
onChange={(event) => {
|
||||
setEmbeddingMode(
|
||||
event.target.value as "local" | "custom",
|
||||
);
|
||||
setEmbeddingModeChanged(true);
|
||||
setFields((previous) => ({
|
||||
...previous,
|
||||
@@ -1428,8 +1360,11 @@ export function ModelSettings({
|
||||
"embedding-base-url": undefined,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<option value="local">{text("本地内置(推荐)")}</option>
|
||||
<option value="custom">{text("自定义搜索模型")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<small>{text("技术类型:Embedding")}</small>
|
||||
{embeddingMode === "custom" ? (
|
||||
<>
|
||||
@@ -1462,38 +1397,25 @@ export function ModelSettings({
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
<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`}
|
||||
<label>
|
||||
{text("向量模型来源")}
|
||||
<select
|
||||
name="embedding-provider"
|
||||
value={embeddingProvider}
|
||||
disabled={formLocked}
|
||||
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
|
||||
onChange={(event) => {
|
||||
const next = event.target.value as
|
||||
| "openai_compatible"
|
||||
| "ollama";
|
||||
setEmbeddingProvider(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<option value="ollama">{text("本机 Ollama")}</option>
|
||||
<option value="openai_compatible">
|
||||
{text("同源 OpenAI-compatible(共用 API Key)")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{text("向量模型 Base URL")}
|
||||
<input
|
||||
@@ -1747,14 +1669,13 @@ 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?.();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -111,7 +111,7 @@ export function OptionMenu(props: {
|
||||
function choose(index: number) {
|
||||
const item = props.options[index];
|
||||
if (!item || item.disabled) return;
|
||||
if (item.value !== props.value) props.onChange(item.value);
|
||||
props.onChange(item.value);
|
||||
setOpen(false);
|
||||
button.current?.focus();
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@ 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");
|
||||
@@ -115,8 +112,8 @@ vi.mock("./ModelSettings", async () => {
|
||||
}) => {
|
||||
useImperativeHandle(controlsRef, () => ({
|
||||
edit: () => false,
|
||||
editProfile: editProfileAction,
|
||||
add: addProfileAction,
|
||||
editProfile: () => false,
|
||||
add: () => false,
|
||||
test: () => Promise.resolve(undefined),
|
||||
}));
|
||||
return createElement(
|
||||
@@ -307,7 +304,6 @@ 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: "模型配置" }));
|
||||
@@ -325,13 +321,10 @@ 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(
|
||||
within(generationLane).getByRole("button", { name: "核对测试结果" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.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()} />);
|
||||
@@ -375,46 +368,11 @@ 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(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");
|
||||
expect(screen.getByText("embedding")).toBeTruthy();
|
||||
await user.click(
|
||||
within(generationLane).getByRole("button", { name: "配置" }),
|
||||
);
|
||||
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();
|
||||
expect(screen.getByText("generation")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves the switch unusable when the state cannot be read", async () => {
|
||||
|
||||
@@ -78,7 +78,6 @@ 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,
|
||||
);
|
||||
@@ -102,10 +101,6 @@ 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;
|
||||
@@ -229,49 +224,18 @@ 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 stays open even when the editor refuses: its recovery entry is
|
||||
// the only way to reconcile the pending operation.
|
||||
// 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);
|
||||
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() {
|
||||
@@ -420,156 +384,67 @@ export function R6Settings({
|
||||
</div>
|
||||
)}
|
||||
<div className="r6-model-overview">
|
||||
<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
|
||||
: "本地内置(推荐)",
|
||||
<div className="r6-model-lane">
|
||||
<div className="r6-model-lane-copy">
|
||||
<h3>{text("记忆整理")}</h3>
|
||||
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
|
||||
<strong>
|
||||
{currentModel?.name ??
|
||||
text(
|
||||
profiles === null
|
||||
? "正在读取模型…"
|
||||
: modelError
|
||||
? "模型状态暂不可读"
|
||||
: "尚未配置模型",
|
||||
)}
|
||||
</strong>
|
||||
</strong>
|
||||
{currentModel && (
|
||||
<small>
|
||||
{text(
|
||||
currentModel?.embedding_provider_kind &&
|
||||
currentModel.embedding_provider_kind !== "local_onnx"
|
||||
? `Embedding · ${currentModel.embedding_provider_kind}`
|
||||
: profiles !== null && !currentModel
|
||||
? "Embedding · 配置记忆整理后可自定义"
|
||||
: "Embedding · 本机处理,无需额外配置",
|
||||
)}
|
||||
LLM · {currentModel.model} ·{" "}
|
||||
{text(safeEndpoint(currentModel.base_url))}
|
||||
</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>
|
||||
{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>
|
||||
<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
|
||||
: "本地内置(推荐)",
|
||||
)}
|
||||
<ModelSettings
|
||||
compactSingle
|
||||
focusSection={configurationFocus}
|
||||
controlsRef={modelActions}
|
||||
onProfilesChanged={() =>
|
||||
setModelVersion((value) => value + 1)
|
||||
}
|
||||
onSetupComplete={completeConfiguration}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
</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>
|
||||
</div>
|
||||
{modelError && (
|
||||
<R6StateRecovery
|
||||
@@ -581,6 +456,13 @@ 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>
|
||||
@@ -617,6 +499,19 @@ 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`}
|
||||
|
||||
@@ -132,17 +132,12 @@ button svg {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: 50%;
|
||||
top: 22px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 1.5px solid #4e6478;
|
||||
border-bottom: 1.5px solid #4e6478;
|
||||
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);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.om-option-menu-button:disabled {
|
||||
color: #66727b;
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button::after {
|
||||
top: 10px;
|
||||
right: 11px;
|
||||
}
|
||||
.onboarding-r6-titlebar
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.r6-settings .r6-language-option-menu .om-option-menu-button::after {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
.r6-settings
|
||||
@@ -595,12 +596,6 @@
|
||||
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;
|
||||
@@ -644,7 +639,7 @@
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.r6-settings .r6-model-lane-header > .r6-button {
|
||||
.r6-settings .r6-model-lane > .r6-button {
|
||||
min-width: 72px;
|
||||
}
|
||||
.r6-settings .r6-model-recipient {
|
||||
@@ -695,9 +690,8 @@
|
||||
line-height: 28px;
|
||||
}
|
||||
.r6-settings .r6-model-owner {
|
||||
padding: 24px 20px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #d8e2ea;
|
||||
background: #f8fafb;
|
||||
}
|
||||
.r6-settings .r6-model-owner[hidden] {
|
||||
display: none;
|
||||
@@ -853,6 +847,7 @@
|
||||
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"] {
|
||||
@@ -871,39 +866,6 @@
|
||||
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;
|
||||
}
|
||||
@@ -922,8 +884,7 @@
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
}
|
||||
.r6-settings .model-advanced label,
|
||||
.r6-settings .model-advanced .model-form-field {
|
||||
.r6-settings .model-advanced label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -1027,17 +988,14 @@
|
||||
padding: 24px 20px;
|
||||
border-radius: 0 0 11px 11px;
|
||||
}
|
||||
.r6-settings .r6-model-lane-header {
|
||||
.r6-settings .r6-model-lane {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.r6-settings .r6-model-lane-header > .r6-button {
|
||||
.r6-settings .r6-model-lane > .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;
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
border-color: transparent;
|
||||
}
|
||||
.selected-source-filter .om-option-menu-button::after {
|
||||
top: 18px;
|
||||
right: 4px;
|
||||
}
|
||||
.selected-source-filter details {
|
||||
|
||||
@@ -275,9 +275,10 @@ describe("Mode catalog", () => {
|
||||
"搜索沉淀记忆,并用知识图谱补充关联。",
|
||||
"搜索沉淀后的记忆。",
|
||||
"已经处理完的旧对话不会补做;原始对话全部保留。",
|
||||
"已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
|
||||
"知识图谱",
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。",
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用。",
|
||||
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。",
|
||||
"记忆维护正在进行,完成后才能切换模式。",
|
||||
"模式切换影响暂时无法读取,请重试。",
|
||||
] as const;
|
||||
|
||||
@@ -47,7 +47,7 @@ const modeChineseMessages = {
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"高级记忆会保留,但在 lite 下不显示、不参与召回。",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"自动整合会停止,已有数据保留。",
|
||||
"每天自动整合会停止;已生成的整合结果继续保留。",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"已有高级记忆将在后台加入知识图谱。",
|
||||
"知识图谱仍在补建,不会重复启动。": "知识图谱仍在补建,不会重复启动。",
|
||||
@@ -97,6 +97,7 @@ const modeChineseMessages = {
|
||||
模型调用与费用: "模型调用与费用",
|
||||
搜索范围: "搜索范围",
|
||||
已有数据: "已有数据",
|
||||
知识图谱: "知识图谱",
|
||||
切换后: "切换后",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"之后只保存原始对话,不再生成新记忆。",
|
||||
@@ -295,6 +296,7 @@ export const modeEnglishMessages: ModeMessages = {
|
||||
模型调用与费用: "Model calls and cost",
|
||||
搜索范围: "Search scope",
|
||||
已有数据: "Existing data",
|
||||
知识图谱: "Knowledge graph",
|
||||
切换后: "After switching",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"New conversations are saved as original text only; no new memories are created.",
|
||||
@@ -498,6 +500,7 @@ export const modeSpanishMessages: ModeMessages = {
|
||||
模型调用与费用: "Llamadas al modelo y coste",
|
||||
搜索范围: "Ámbito de búsqueda",
|
||||
已有数据: "Datos existentes",
|
||||
知识图谱: "Grafo de conocimiento",
|
||||
切换后: "Después del cambio",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Las conversaciones nuevas se guardan solo como texto original; no se crean memorias nuevas.",
|
||||
@@ -693,6 +696,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
|
||||
模型调用与费用: "模型呼叫與費用",
|
||||
搜索范围: "搜尋範圍",
|
||||
已有数据: "現有資料",
|
||||
知识图谱: "知識圖譜",
|
||||
切换后: "切換後",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"之後只儲存原始對話,不再產生新記憶。",
|
||||
@@ -843,6 +847,7 @@ export const modeJapaneseMessages: ModeMessages = {
|
||||
模型调用与费用: "モデル呼び出しと料金",
|
||||
搜索范围: "検索範囲",
|
||||
已有数据: "既存データ",
|
||||
知识图谱: "ナレッジグラフ",
|
||||
切换后: "切り替え後",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"以降は元の会話のみを保存し、新しい記憶は生成しません。",
|
||||
@@ -1043,6 +1048,7 @@ export const modeGermanMessages: ModeMessages = {
|
||||
模型调用与费用: "Modellaufrufe und Kosten",
|
||||
搜索范围: "Suchbereich",
|
||||
已有数据: "Vorhandene Daten",
|
||||
知识图谱: "Wissensgraph",
|
||||
切换后: "Nach dem Wechsel",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Neue Unterhaltungen werden nur noch im Original gespeichert; es werden keine neuen Erinnerungen erstellt.",
|
||||
@@ -1246,6 +1252,7 @@ export const modePortugueseMessages: ModeMessages = {
|
||||
模型调用与费用: "Chamadas ao modelo e custos",
|
||||
搜索范围: "Âmbito da pesquisa",
|
||||
已有数据: "Dados existentes",
|
||||
知识图谱: "Grafo de conhecimento",
|
||||
切换后: "Após a mudança",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"As novas conversas passam a ser guardadas apenas como texto original; não são criadas novas memórias.",
|
||||
@@ -1451,6 +1458,7 @@ export const modeFrenchMessages: ModeMessages = {
|
||||
模型调用与费用: "Appels au modèle et coût",
|
||||
搜索范围: "Périmètre de recherche",
|
||||
已有数据: "Données existantes",
|
||||
知识图谱: "Graphe de connaissances",
|
||||
切换后: "Après le changement",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Les nouvelles conversations sont uniquement enregistrées dans leur forme originale ; aucune nouvelle mémoire n’est créée.",
|
||||
@@ -1651,6 +1659,7 @@ export const modeKoreanMessages: ModeMessages = {
|
||||
模型调用与费用: "모델 호출 및 비용",
|
||||
搜索范围: "검색 범위",
|
||||
已有数据: "기존 데이터",
|
||||
知识图谱: "지식 그래프",
|
||||
切换后: "전환 후",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"이후 새 대화는 원본만 저장되며 새 메모리는 생성되지 않습니다.",
|
||||
@@ -1850,6 +1859,7 @@ export const modeRussianMessages: ModeMessages = {
|
||||
模型调用与费用: "Вызовы модели и расходы",
|
||||
搜索范围: "Область поиска",
|
||||
已有数据: "Существующие данные",
|
||||
知识图谱: "Граф знаний",
|
||||
切换后: "После переключения",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Новые диалоги будут сохраняться только в исходном виде; новые воспоминания создаваться не будут.",
|
||||
@@ -2052,6 +2062,7 @@ export const modeIndonesianMessages: ModeMessages = {
|
||||
模型调用与费用: "Panggilan model dan biaya",
|
||||
搜索范围: "Cakupan pencarian",
|
||||
已有数据: "Data yang ada",
|
||||
知识图谱: "Graf pengetahuan",
|
||||
切换后: "Setelah beralih",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Percakapan baru hanya disimpan sebagai teks asli; tidak ada memori baru yang dibuat.",
|
||||
|
||||
@@ -32,7 +32,31 @@ describe("memory mode plan", () => {
|
||||
expect(parseMemoryModePlan({ ...valid, blockers: ["unknown"] })).toBeNull();
|
||||
});
|
||||
|
||||
it("isolates the old-Core matrix in one helper", () => {
|
||||
it.each([
|
||||
["lite", "pro", ["earlier_raw_not_extracted", "lite_search_stops"]],
|
||||
[
|
||||
"lite",
|
||||
"ultra",
|
||||
[
|
||||
"earlier_raw_not_extracted",
|
||||
"lite_search_stops",
|
||||
"existing_memories_added_to_graph",
|
||||
],
|
||||
],
|
||||
["pro", "lite", ["higher_mode_memories_hidden"]],
|
||||
["pro", "ultra", ["existing_memories_added_to_graph"]],
|
||||
["ultra", "lite", ["higher_mode_memories_hidden", "evolve_stopped"]],
|
||||
["ultra", "pro", ["higher_mode_memories_hidden", "evolve_stopped"]],
|
||||
] as const)(
|
||||
"keeps the old-Core %s to %s effects aligned with the plan route",
|
||||
(current, target, effects) => {
|
||||
expect(
|
||||
legacyMemoryModePlan(current, target, { hasActiveModel: true }).effects,
|
||||
).toEqual(effects);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the old-Core model blocker and target capabilities", () => {
|
||||
expect(
|
||||
legacyMemoryModePlan("lite", "ultra", { hasActiveModel: false }),
|
||||
).toMatchObject({
|
||||
@@ -42,10 +66,6 @@ describe("memory mode plan", () => {
|
||||
graph_enabled: true,
|
||||
},
|
||||
blockers: ["active_model_required"],
|
||||
effects: expect.arrayContaining([
|
||||
"earlier_raw_not_extracted",
|
||||
"existing_memories_added_to_graph",
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -106,12 +106,15 @@ export function legacyMemoryModePlan(
|
||||
options: { hasActiveModel: boolean },
|
||||
): MemoryModePlan {
|
||||
const effects: MemoryModeChange["effects"] = [];
|
||||
if (target === "lite") {
|
||||
effects.push("higher_mode_memories_hidden");
|
||||
if (current === "ultra") effects.push("evolve_stopped");
|
||||
} else if (current === "lite") {
|
||||
const rank: Record<MemoryPipelineMode, number> = {
|
||||
lite: 0,
|
||||
pro: 1,
|
||||
ultra: 2,
|
||||
};
|
||||
if (current !== target && current === "lite")
|
||||
effects.push("earlier_raw_not_extracted", "lite_search_stops");
|
||||
}
|
||||
if (rank[target] < rank[current]) effects.push("higher_mode_memories_hidden");
|
||||
if (current !== target && current === "ultra") effects.push("evolve_stopped");
|
||||
if (target === "ultra" && current !== "ultra")
|
||||
effects.push("existing_memories_added_to_graph");
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user