Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
413b9033f9 |
No files matched your search
@@ -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("把一张预置卡设为当前模型", () => {
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -97,7 +97,6 @@
|
||||
border-color: transparent;
|
||||
}
|
||||
.selected-source-filter .om-option-menu-button::after {
|
||||
top: 18px;
|
||||
right: 4px;
|
||||
}
|
||||
.selected-source-filter details {
|
||||
|
||||
Reference in new issue
Block a user