diff --git a/platform_web/contracts/workspace.ts b/platform_web/contracts/workspace.ts index c3e4784..bc775c7 100644 --- a/platform_web/contracts/workspace.ts +++ b/platform_web/contracts/workspace.ts @@ -174,6 +174,7 @@ export interface LlmSuggestionView { source: "api"; recommendation: string; diffSummary?: string; - executionStatus?: string; - job?: JobResponse; + executionStatus: string; + job: JobResponse; + mocked: boolean; } diff --git a/platform_web/pages/LlmSection.test.tsx b/platform_web/pages/LlmSection.test.tsx new file mode 100644 index 0000000..3597d18 --- /dev/null +++ b/platform_web/pages/LlmSection.test.tsx @@ -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()); + 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 配置写入已派发"); + }); +}); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 831f6bb..b3b2e62 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -947,7 +947,7 @@ interface LlmSectionProps { onOpenTerminal: (jobId?: string) => void; } -function LlmSection({ serverId, session, operations, onOpenTerminal }: LlmSectionProps) { +export function LlmSection({ serverId, session, operations, onOpenTerminal }: LlmSectionProps) { const [prompt, setPrompt] = useState(""); const [suggestion, setSuggestion] = useState(null); const [busy, setBusy] = useState(false); @@ -968,18 +968,23 @@ function LlmSection({ serverId, session, operations, onOpenTerminal }: LlmSectio } const recommendation = response.configRecommendation; const execution = response.configExecution; - if (execution?.job) { - 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); + if (!recommendation?.suggestedConfig) { + throw new Error("AI 未生成可写入的配置内容,请调整请求后重试。"); } + 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({ serverInstanceId: serverId, source: "api", recommendation: response.recommendation ?? "Platform 已返回配置建议。", diffSummary: recommendation?.diffSummary, - executionStatus: execution?.status, - job: execution?.job + executionStatus: execution.status, + job: execution.job, + mocked: response.usage.mocked }); } catch (caught) { setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败"); @@ -1035,28 +1040,18 @@ function LlmSection({ serverId, session, operations, onOpenTerminal }: LlmSectio

AI 建议

- 平台 AI Provider + {suggestion.mocked ? "模拟 AI Provider" : "平台 AI Provider"}

{suggestion.recommendation}

- {suggestion.job ? ( - <> -
-
AI 配置写入已派发{suggestion.executionStatus ?? "queued"}
-
Job {suggestion.job.id}{suggestion.job.capability}{suggestion.job.targetKey}
- {suggestion.diffSummary ?? "AI 建议已直接进入 config.write 队列,日志正在实时输出。"} -
-
- - -
- - ) : ( - 该响应没有生成可应用的配置任务,仅供参考。 - )} +
+
AI 配置写入已派发{suggestion.executionStatus}
+
Job {suggestion.job.id}{suggestion.job.capability}{suggestion.job.targetKey}
+ {suggestion.diffSummary ?? "AI 建议已直接进入 config.write 队列,日志正在实时输出。"} +
+
+ + +
)}