Clarify AI config dispatch failures
This commit is contained in:
@@ -174,6 +174,7 @@ export interface LlmSuggestionView {
|
|||||||
source: "api";
|
source: "api";
|
||||||
recommendation: string;
|
recommendation: string;
|
||||||
diffSummary?: string;
|
diffSummary?: string;
|
||||||
executionStatus?: string;
|
executionStatus: string;
|
||||||
job?: JobResponse;
|
job: JobResponse;
|
||||||
|
mocked: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
|
import { LlmSection } from "./ServerDetailPage";
|
||||||
|
|
||||||
|
const invokeAI = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("../api/client", () => ({ platformApiClient: { invokeAI } }));
|
||||||
|
|
||||||
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
const serverId = "server-scum-1785923898033";
|
||||||
|
const operations = {
|
||||||
|
operations: [],
|
||||||
|
begin: vi.fn(() => "operation-1"),
|
||||||
|
succeed: vi.fn()
|
||||||
|
} as unknown as PageComponentProps["operations"];
|
||||||
|
const onOpenTerminal = vi.fn();
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (root) await act(async () => root?.unmount());
|
||||||
|
container?.remove();
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitPrompt() {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => root?.render(<LlmSection serverId={serverId} session={{ displayName: "Operator" } as PageComponentProps["session"]} operations={operations} onOpenTerminal={onOpenTerminal} />));
|
||||||
|
const textarea = container.querySelector("textarea")!;
|
||||||
|
await act(async () => {
|
||||||
|
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, "Adjust max players");
|
||||||
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await act(async () => container!.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AI config assistance", () => {
|
||||||
|
it("reports an outdated Platform response without claiming a config write", async () => {
|
||||||
|
invokeAI.mockResolvedValue({
|
||||||
|
status: "ok",
|
||||||
|
recommendation: "Mock AI recommendation for config.suggest: review the proposed change before dispatch.",
|
||||||
|
configRecommendation: { key: "server.properties", suggestedConfig: "max-players=40\n", diffId: "old-review", diffSummary: "review required before config write dispatch" },
|
||||||
|
usage: { mocked: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
await submitPrompt();
|
||||||
|
|
||||||
|
expect(container!.textContent).toContain("平台未派发配置写入任务;请更新或重启 Platform 后端后重试。");
|
||||||
|
expect(container!.textContent).not.toContain("仅供参考");
|
||||||
|
expect(operations.begin).not.toHaveBeenCalled();
|
||||||
|
expect(onOpenTerminal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes an empty AI result from an outdated Platform response", async () => {
|
||||||
|
invokeAI.mockResolvedValue({ status: "ok", recommendation: "No config produced", usage: { mocked: false } });
|
||||||
|
|
||||||
|
await submitPrompt();
|
||||||
|
|
||||||
|
expect(container!.textContent).toContain("AI 未生成可写入的配置内容,请调整请求后重试。");
|
||||||
|
expect(container!.textContent).not.toContain("请更新或重启 Platform 后端");
|
||||||
|
expect(operations.begin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the dispatched job and labels a mock provider", async () => {
|
||||||
|
const job = { id: "job-config-1", serverInstanceId: serverId, capability: "config.write", targetKey: "server.properties", state: "queued" };
|
||||||
|
invokeAI.mockResolvedValue({
|
||||||
|
status: "ok",
|
||||||
|
recommendation: "Mock recommendation",
|
||||||
|
configRecommendation: { key: "server.properties", suggestedConfig: "max-players=40\n", diffSummary: "Job queued" },
|
||||||
|
configExecution: { status: "queued", job },
|
||||||
|
usage: { mocked: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
await submitPrompt();
|
||||||
|
|
||||||
|
expect(operations.succeed).toHaveBeenCalledWith("operation-1", expect.stringContaining(job.id), job);
|
||||||
|
expect(onOpenTerminal).toHaveBeenCalledWith(job.id);
|
||||||
|
expect(container!.textContent).toContain("模拟 AI Provider");
|
||||||
|
expect(container!.textContent).toContain("AI 配置写入已派发");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -947,7 +947,7 @@ interface LlmSectionProps {
|
|||||||
onOpenTerminal: (jobId?: string) => void;
|
onOpenTerminal: (jobId?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LlmSection({ serverId, session, operations, onOpenTerminal }: LlmSectionProps) {
|
export function LlmSection({ serverId, session, operations, onOpenTerminal }: LlmSectionProps) {
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
|
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -968,18 +968,23 @@ function LlmSection({ serverId, session, operations, onOpenTerminal }: LlmSectio
|
|||||||
}
|
}
|
||||||
const recommendation = response.configRecommendation;
|
const recommendation = response.configRecommendation;
|
||||||
const execution = response.configExecution;
|
const execution = response.configExecution;
|
||||||
if (execution?.job) {
|
if (!recommendation?.suggestedConfig) {
|
||||||
const operationId = operations.begin({ intent: "AI 直接写入配置", targetKind: "llm", targetId: serverId, requester: session.displayName });
|
throw new Error("AI 未生成可写入的配置内容,请调整请求后重试。");
|
||||||
operations.succeed(operationId, "AI 建议已直接派发,写入任务 " + execution.job.id + " 已进入队列", execution.job);
|
|
||||||
onOpenTerminal(execution.job.id);
|
|
||||||
}
|
}
|
||||||
|
if (!execution?.job || execution.job.capability !== "config.write" || execution.job.serverInstanceId !== serverId) {
|
||||||
|
throw new Error("平台未派发配置写入任务;请更新或重启 Platform 后端后重试。");
|
||||||
|
}
|
||||||
|
const operationId = operations.begin({ intent: "AI 直接写入配置", targetKind: "llm", targetId: serverId, requester: session.displayName });
|
||||||
|
operations.succeed(operationId, "AI 建议已直接派发,写入任务 " + execution.job.id + " 已进入队列", execution.job);
|
||||||
|
onOpenTerminal(execution.job.id);
|
||||||
setSuggestion({
|
setSuggestion({
|
||||||
serverInstanceId: serverId,
|
serverInstanceId: serverId,
|
||||||
source: "api",
|
source: "api",
|
||||||
recommendation: response.recommendation ?? "Platform 已返回配置建议。",
|
recommendation: response.recommendation ?? "Platform 已返回配置建议。",
|
||||||
diffSummary: recommendation?.diffSummary,
|
diffSummary: recommendation?.diffSummary,
|
||||||
executionStatus: execution?.status,
|
executionStatus: execution.status,
|
||||||
job: execution?.job
|
job: execution.job,
|
||||||
|
mocked: response.usage.mocked
|
||||||
});
|
});
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
|
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
|
||||||
@@ -1035,28 +1040,18 @@ function LlmSection({ serverId, session, operations, onOpenTerminal }: LlmSectio
|
|||||||
<div style={{ display: "grid", gap: 12, marginTop: 14 }}>
|
<div style={{ display: "grid", gap: 12, marginTop: 14 }}>
|
||||||
<div className="panel-header" style={{ marginBottom: 0 }}>
|
<div className="panel-header" style={{ marginBottom: 0 }}>
|
||||||
<h3>AI 建议</h3>
|
<h3>AI 建议</h3>
|
||||||
<span className="page-status">平台 AI Provider</span>
|
<span className="page-status">{suggestion.mocked ? "模拟 AI Provider" : "平台 AI Provider"}</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
|
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
|
||||||
{suggestion.job ? (
|
<div className="console-record">
|
||||||
<>
|
<div className="console-record-head"><strong>AI 配置写入已派发</strong><span className="status-pill status-active">{suggestion.executionStatus}</span></div>
|
||||||
<div className="console-record">
|
<div className="console-record-meta"><span>Job {suggestion.job.id}</span><span>{suggestion.job.capability}</span><span>{suggestion.job.targetKey}</span></div>
|
||||||
<div className="console-record-head"><strong>AI 配置写入已派发</strong><span className="status-pill status-active">{suggestion.executionStatus ?? "queued"}</span></div>
|
<span className="provider-id">{suggestion.diffSummary ?? "AI 建议已直接进入 config.write 队列,日志正在实时输出。"}</span>
|
||||||
<div className="console-record-meta"><span>Job {suggestion.job.id}</span><span>{suggestion.job.capability}</span><span>{suggestion.job.targetKey}</span></div>
|
</div>
|
||||||
<span className="provider-id">{suggestion.diffSummary ?? "AI 建议已直接进入 config.write 队列,日志正在实时输出。"}</span>
|
<div className="confirm-actions">
|
||||||
</div>
|
<button type="button" onClick={() => onOpenTerminal(suggestion.job.id)}>打开实时终端</button>
|
||||||
<div className="confirm-actions">
|
<button type="button" className="confirm-primary" onClick={() => setSuggestion(null)}>清除结果</button>
|
||||||
<button type="button" onClick={() => onOpenTerminal(suggestion.job?.id)}>
|
</div>
|
||||||
打开实时终端
|
|
||||||
</button>
|
|
||||||
<button type="button" className="confirm-primary" onClick={() => setSuggestion(null)}>
|
|
||||||
清除结果
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="provider-id">该响应没有生成可应用的配置任务,仅供参考。</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
Reference in New Issue
Block a user