mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): the unparseable-config banner is the recovery board's card at the top of the settings page, with the offending line, the repair and a backed-up repair action
This commit is contained in:
1 parent
f05b2392c3
commit
0e04e0d753
6 files changed
+142
-31
No files matched your search
@@ -4,6 +4,7 @@
|
||||
@import "./rows.css" layer(design);
|
||||
@import "./queue.css" layer(design);
|
||||
@import "./notices.css" layer(design);
|
||||
@import "./recovery.css" layer(design);
|
||||
@import "./shell.css" layer(design);
|
||||
@import "./composer.css" layer(design);
|
||||
@import "./turn.css" layer(design);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
.cfgbad { margin: 10px 0 14px; font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); }
|
||||
.cfgbad .cfg-busy { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 10px 12px; border-radius: var(--r-md); background: var(--page); color: var(--muted); font-size: var(--fs-meta); }
|
||||
.cfgbad .cfg-busy .studio-icon { width: 14px; height: 14px; }
|
||||
.cfgbad .cfg-card { overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--raised); }
|
||||
.cfgbad .cfg-hd { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--hair); }
|
||||
.cfgbad .cfg-card[data-lvl="err"] .cfg-hd { background: color-mix(in srgb, var(--err) 8%, transparent); }
|
||||
.cfgbad .cfg-card[data-lvl="warn"] .cfg-hd { background: color-mix(in srgb, var(--warn) 12%, transparent); }
|
||||
.cfgbad .cfg-hd > .studio-icon { flex: none; width: 16px; height: 16px; margin-top: 1px; }
|
||||
.cfgbad .cfg-card[data-lvl="err"] .cfg-hd > .studio-icon { color: var(--err-ink); }
|
||||
.cfgbad .cfg-card[data-lvl="warn"] .cfg-hd > .studio-icon { color: var(--warn-ink); }
|
||||
.cfgbad .cfg-hd > div { flex: 1; min-width: 0; }
|
||||
.cfgbad .cfg-hd b { font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); }
|
||||
.cfgbad .cfg-hd .sub { margin-top: 2px; color: var(--muted); font-size: var(--fs-meta); }
|
||||
.cfgbad .cfg-bd { padding: 10px 14px; }
|
||||
.cfgbad .cfg-path { display: flex; align-items: center; gap: 8px; color: var(--muted); font: var(--w-reg) var(--fs-meta) var(--mono); }
|
||||
.cfgbad .cfg-path > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.cfgbad .cfg-diff { margin-top: 8px; overflow: hidden; border: 1px solid var(--hair); border-radius: var(--r-sm); font: var(--w-reg) var(--fs-meta)/1.8 var(--mono); }
|
||||
.cfgbad .cfg-diff > div { display: flex; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.cfgbad .cfg-diff > div > span { flex: none; width: 30px; padding-right: 8px; text-align: right; color: var(--faint); }
|
||||
.cfgbad .cfg-diff .er { background: color-mix(in srgb, var(--err) 10%, transparent); }
|
||||
.cfgbad .cfg-diff .ok { background: color-mix(in srgb, var(--ok) 10%, transparent); }
|
||||
.cfgbad .cfg-ft { display: flex; justify-content: flex-end; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--hair); }
|
||||
.cfgbad .cfg-ft .btn { height: 32px; padding: 0 14px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1 var(--ui); cursor: pointer; }
|
||||
.cfgbad .cfg-ft .btn[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
||||
.cfgbad .cfg-ft .btn:disabled { opacity: .55; cursor: default; }
|
||||
.cfgbad .cfg-fail { padding: 8px 14px; border-top: 1px solid var(--hair); color: var(--err-ink); font-size: var(--fs-meta); }
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import css from "./recovery.css?raw";
|
||||
|
||||
const rule = (selector: string) => {
|
||||
const at = css.indexOf(`${selector} {`);
|
||||
return at < 0 ? "" : css.slice(css.indexOf("{", at) + 1, css.indexOf("}", at));
|
||||
};
|
||||
|
||||
describe("the config banner follows the board", () => {
|
||||
it("is a hairline card with a tinted header and a footer rule", () => {
|
||||
expect(rule(".cfgbad .cfg-card")).toMatch(/border-radius:\s*var\(--r-md\)/);
|
||||
expect(rule(".cfgbad .cfg-hd")).toMatch(/padding:\s*12px 14px/);
|
||||
expect(rule(".cfgbad .cfg-card[data-lvl=\"err\"] .cfg-hd")).toMatch(/var\(--err\) 8%/);
|
||||
expect(rule(".cfgbad .cfg-card[data-lvl=\"warn\"] .cfg-hd")).toMatch(/var\(--warn\) 12%/);
|
||||
expect(rule(".cfgbad .cfg-ft")).toMatch(/border-top:\s*1px solid var\(--hair\)/);
|
||||
});
|
||||
|
||||
it("sets the diff in the mono face with a 30px line gutter and error / ok tints", () => {
|
||||
expect(rule(".cfgbad .cfg-diff")).toMatch(/var\(--mono\)/);
|
||||
expect(rule(".cfgbad .cfg-diff > div > span")).toMatch(/width:\s*30px/);
|
||||
expect(rule(".cfgbad .cfg-diff .er")).toMatch(/var\(--err\) 10%/);
|
||||
expect(rule(".cfgbad .cfg-diff .ok")).toMatch(/var\(--ok\) 10%/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
import "./testkit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConfigTrouble } from "./ConfigTrouble";
|
||||
import type { AgentPort, ConfigProblem } from "../port/port";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const problem = (over: Partial<ConfigProblem> = {}): ConfigProblem => ({
|
||||
path: "~/.reasonix/config.toml", line: 12, excerpt: "model = deepseek-v4-pro", recovered: "last-known-good", ...over,
|
||||
}) as ConfigProblem;
|
||||
|
||||
function draw(p: ConfigProblem | null, repair = vi.fn(async () => ({ problem: null }))) {
|
||||
const port = { configProblem: async () => p, repairConfig: repair } as unknown as AgentPort;
|
||||
const onRepaired = vi.fn();
|
||||
const view = render(<ConfigTrouble port={port} onRepaired={onRepaired} />);
|
||||
return { view, repair, onRepaired };
|
||||
}
|
||||
|
||||
describe("the config banner at the top of settings", () => {
|
||||
it("says nothing when the file parses", async () => {
|
||||
const { view } = draw(null);
|
||||
await Promise.resolve();
|
||||
expect(view.container.querySelector(".cfgbad")).toBeNull();
|
||||
});
|
||||
|
||||
it("names the line, what is shown instead, the path and the offending text", async () => {
|
||||
draw(problem());
|
||||
const bar = await waitFor(() => { const e = document.querySelector(".cfgbad"); expect(e).toBeTruthy(); return e!; });
|
||||
expect(bar.querySelector(".cfg-hd b")?.textContent).toBe("配置文件第 12 行无法解析");
|
||||
expect(bar.querySelector(".cfg-hd .sub")?.textContent).toContain("上一次成功解析的配置");
|
||||
expect(bar.querySelector(".cfg-path span")?.textContent).toBe("~/.reasonix/config.toml");
|
||||
expect(bar.querySelector(".cfg-diff .er")?.textContent).toBe("12model = deepseek-v4-pro");
|
||||
expect(bar.querySelector(".cfg-card")?.getAttribute("data-lvl")).toBe("warn");
|
||||
expect(bar.querySelector(".cfg-ft")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the repair as the main action only when the file parses afterwards", async () => {
|
||||
const { repair, onRepaired } = draw(problem({ repair: 'model = "deepseek-v4-pro"' }));
|
||||
const button = await screen.findByRole("button", { name: "备份原文件并修复" });
|
||||
expect(button.hasAttribute("data-primary")).toBe(true);
|
||||
expect(document.querySelector(".cfg-card")?.getAttribute("data-lvl")).toBe("err");
|
||||
expect(document.querySelector(".cfg-diff .ok")?.textContent).toBe('改成model = "deepseek-v4-pro"');
|
||||
await userEvent.click(button);
|
||||
expect(repair).toHaveBeenCalledOnce();
|
||||
await waitFor(() => expect(onRepaired).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("shows the refusal when the repair fails", async () => {
|
||||
const repair = vi.fn(async () => { throw new Error("disk is read-only"); });
|
||||
draw(problem({ repair: 'model = "x"' }), repair as never);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "备份原文件并修复" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("disk is read-only");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { AgentPort, ConfigProblem } from "../port/port";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
// The file every panel here writes to, when it is the file that is wrong.
|
||||
//
|
||||
@@ -43,41 +44,43 @@ export function ConfigTrouble({ port, onRepaired }: { port: AgentPort; onRepaire
|
||||
}
|
||||
};
|
||||
|
||||
const repairable = !!problem.repair;
|
||||
return (
|
||||
<div className="find cfgbad" data-lvl="warn" role="status">
|
||||
<span className="t">
|
||||
{problem.line ? t("app.line_line_of_the_config", { line: problem.line }) : t("app.the_config_file_does_not")}
|
||||
</span>
|
||||
<span className="why">
|
||||
{problem.recovered === "last-known-good"
|
||||
? t("app.what_is_shown_below_is")
|
||||
: t("app.what_is_shown_below_is_2")}
|
||||
</span>
|
||||
<div className="term">
|
||||
<div className="term-l dim">{problem.path}</div>
|
||||
{problem.excerpt && (
|
||||
<div className="term-l er">
|
||||
{problem.line} | {problem.excerpt}
|
||||
<div className="cfgbad" role="status">
|
||||
{busy && <div className="cfg-busy"><StudioIcon name="refresh" />{t("app.repairing")}</div>}
|
||||
<div className="cfg-card" data-lvl={repairable ? "err" : "warn"}>
|
||||
<div className="cfg-hd">
|
||||
<StudioIcon name={repairable ? "fail" : "warning"} />
|
||||
<div>
|
||||
<b>{problem.line ? t("app.line_line_of_the_config", { line: problem.line }) : t("app.the_config_file_does_not")}</b>
|
||||
<div className="sub">
|
||||
{problem.recovered === "last-known-good"
|
||||
? t("app.what_is_shown_below_is")
|
||||
: t("app.what_is_shown_below_is_2")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* The repair is the same bytes said in a way TOML accepts, not a
|
||||
guess at what was meant — it is only offered when the file parses
|
||||
afterwards. */}
|
||||
{problem.repair && (
|
||||
<div className="term-l ok">
|
||||
{t("app.becomes")} | {problem.repair}
|
||||
</div>
|
||||
<div className="cfg-bd">
|
||||
<div className="cfg-path"><span>{problem.path}</span><CopyButton text={problem.path} label={t("common.copy_path")} /></div>
|
||||
{problem.excerpt && (
|
||||
<div className="cfg-diff">
|
||||
<div className="er"><span>{problem.line}</span>{problem.excerpt}</div>
|
||||
{/* The repair is the same bytes said in a way TOML accepts, not a
|
||||
guess at what was meant: it is only offered when the file parses
|
||||
afterwards. */}
|
||||
{problem.repair && <div className="ok"><span>{t("app.becomes")}</span>{problem.repair}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{repairable && (
|
||||
<div className="cfg-ft">
|
||||
<button className="btn" data-primary="" data-action="config.repair" disabled={busy} onClick={() => void repair()}>
|
||||
{t("app.keep_a_copy_and_repair")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{failed && <div className="cfg-fail" role="alert">{failed}</div>}
|
||||
</div>
|
||||
<div className="acts">
|
||||
{problem.repair && (
|
||||
<button className="act" data-action="config.repair" disabled={busy} onClick={() => void repair()}>
|
||||
{busy ? t("app.repairing") : t("app.keep_a_copy_and_repair")}
|
||||
</button>
|
||||
)}
|
||||
<CopyButton text={problem.path} label={t("common.copy_path")} />
|
||||
</div>
|
||||
{failed && <span className="why nwhy">{failed}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -408,11 +408,11 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfigTrouble port={port} onRepaired={onChanged} />
|
||||
<div className="prefs-body">
|
||||
{!solo && <SettingsNav at={at} values={nav} shown={shown} danger={danger} disabled={updatingPkg.applying} onSelect={(section) => { if (section !== at || deepAt) navigation.leave(() => { setLanded(""); setDeep(null); setAt(section); }); }} page={pageNow} onPage={(section, page) => navigation.leave(() => { setLanded(""); setPages((p) => ({ ...p, [section]: page })); })} />}
|
||||
|
||||
<div className="prefs-main" key={at} role="tabpanel" aria-labelledby={pageNow ? `prefs-${at}-${pageNow}` : `prefs-${at}`} data-sec={at}>
|
||||
<ConfigTrouble port={port} onRepaired={onChanged} />
|
||||
{!solo && <SettingsTitle at={at} />}
|
||||
<Boundary
|
||||
fallback={
|
||||
|
||||
Reference in new issue
Block a user