Remove AI config approval flow
This commit is contained in:
@@ -165,7 +165,7 @@ async function main() {
|
||||
{
|
||||
name: "AI 提供商管理",
|
||||
hash: "#/aiProviders",
|
||||
markers: ["AI 提供商管理", "平台 API", aiProvider.name, "密钥状态", "已配置", "AI 配置审查", pluginSeed.diff.diffSummary, server.id]
|
||||
markers: ["AI 提供商管理", "平台 API", aiProvider.name, "密钥状态", "已配置"]
|
||||
},
|
||||
{
|
||||
name: "系统维护",
|
||||
@@ -204,7 +204,7 @@ async function main() {
|
||||
|
||||
evidence.pluginInteractions = {
|
||||
pluginLifecycle: await verifyPluginLifecycleInteraction(chrome, authHeaders, plugin, server),
|
||||
aiDiffApproval: await verifyAIConfigDiffInteraction(chrome, authHeaders, pluginSeed.diff)
|
||||
aiDirectExecution: await verifyAIDirectExecution(chrome, authHeaders, server)
|
||||
};
|
||||
|
||||
evidence.walkthroughs = await verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server);
|
||||
@@ -579,29 +579,29 @@ async function preparePluginOperations(headers, server, plugin) {
|
||||
requestId: `browser-acceptance-ai-config-${stamp}`,
|
||||
serverInstanceId: server.id,
|
||||
purpose: "config.suggest",
|
||||
prompt: "Keep existing settings and add a reviewed max players recommendation."
|
||||
prompt: "Keep existing settings and add a max players recommendation."
|
||||
},
|
||||
headers
|
||||
);
|
||||
if (aiInvocation.status !== "ok" || !aiInvocation.configRecommendation?.diffId) {
|
||||
throw new Error(`AI invocation did not persist a reviewable diff: ${JSON.stringify(aiInvocation)}`);
|
||||
if (aiInvocation.status !== "ok" || !aiInvocation.configRecommendation?.key || !aiInvocation.configExecution?.job?.id || aiInvocation.configExecution.job.capability !== "config.write") {
|
||||
throw new Error(`AI invocation did not dispatch a config.write job: ${JSON.stringify(aiInvocation)}`);
|
||||
}
|
||||
|
||||
const [lifecycles, diffs] = await Promise.all([
|
||||
getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers),
|
||||
getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(server.id)}`, headers)
|
||||
]);
|
||||
const jobs = await getJson(`/jobs?serverInstanceId=${encodeURIComponent(server.id)}`, headers);
|
||||
const lifecycles = await getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers);
|
||||
const installation = findRequired(lifecycles.items, (item) => item.id === lifecycle.installation.id && item.jobId === lifecycle.job.id, "durable plugin lifecycle installation");
|
||||
const diff = findRequired(diffs.items, (item) => item.id === aiInvocation.configRecommendation.diffId && item.state === "pending", "pending AI config diff");
|
||||
const configJob = findRequired(jobs.items, (item) => item.id === aiInvocation.configExecution.job.id && item.capability === "config.write", "AI config write job");
|
||||
|
||||
for (const [label, value] of Object.entries({ installation, diff, aiInvocation })) {
|
||||
for (const [label, value] of Object.entries({ installation, configJob, aiInvocation })) {
|
||||
assertNoForbiddenProjection(value, `plugin operations seed ${label}`);
|
||||
}
|
||||
return {
|
||||
diff,
|
||||
apiProof: {
|
||||
pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId"]),
|
||||
aiConfigDiff: pick(diff, ["id", "requestId", "serverInstanceId", "pluginId", "providerId", "model", "key", "configVersion", "diffSummary", "state", "expiresAt"])
|
||||
aiConfigExecution: {
|
||||
recommendation: pick(aiInvocation.configRecommendation, ["key", "diffSummary"]),
|
||||
job: pick(configJob, ["id", "serverInstanceId", "runEndpointId", "capability", "targetKey", "state", "resultRef"])
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -649,46 +649,35 @@ async function verifyPluginLifecycleInteraction(chrome, headers, plugin, server)
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyAIConfigDiffInteraction(chrome, headers, seededDiff) {
|
||||
await chrome.navigate(`${webUrl}/#/aiProviders`);
|
||||
await chrome.waitForText(["AI 配置审查", seededDiff.serverInstanceId, seededDiff.diffSummary, "审查并批准"], "AI config diff review");
|
||||
async function verifyAIDirectExecution(chrome, headers, server) {
|
||||
await chrome.navigate(`${webUrl}/#/servers/${encodeURIComponent(server.id)}`);
|
||||
await chrome.waitForText([server.name, "AI 助手"], "server AI assistant");
|
||||
await chrome.evaluate(() => {
|
||||
const button = Array.from(document.querySelectorAll(".ai-diff-review-panel button")).find((item) => item.textContent?.includes("审查并批准"));
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error("AI diff review button not found");
|
||||
const tab = Array.from(document.querySelectorAll(".section-tab")).find((item) => item.textContent?.trim() === "AI 助手");
|
||||
if (!(tab instanceof HTMLButtonElement)) throw new Error("AI assistant tab not found");
|
||||
tab.click();
|
||||
});
|
||||
await chrome.waitForText(["AI 配置助手", "生成建议"], "AI direct execution panel");
|
||||
await chrome.evaluate(() => {
|
||||
const textarea = document.querySelector("textarea");
|
||||
if (!(textarea instanceof HTMLTextAreaElement)) throw new Error("AI prompt textarea not found");
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
||||
setter.call(textarea, "Keep existing settings and adjust max players.");
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await chrome.evaluate(() => {
|
||||
const button = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.trim() === "生成建议");
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error("AI suggestion button not found");
|
||||
button.click();
|
||||
});
|
||||
await chrome.waitForText(["批准 AI 配置差异", seededDiff.id, "取消"], "AI diff approval confirmation");
|
||||
await chrome.evaluate(() => {
|
||||
const cancel = document.querySelector(".confirm-panel .confirm-actions button");
|
||||
if (!(cancel instanceof HTMLButtonElement)) throw new Error("AI diff approval cancel button not found");
|
||||
cancel.click();
|
||||
});
|
||||
const pendingResponse = await getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(seededDiff.serverInstanceId)}`, headers);
|
||||
const pending = findRequired(pendingResponse.items, (item) => item.id === seededDiff.id, "AI diff after approval cancel");
|
||||
assertEqual(pending.state, "pending", "cancel keeps AI diff pending");
|
||||
|
||||
await chrome.evaluate(() => {
|
||||
const button = Array.from(document.querySelectorAll(".ai-diff-review-panel button")).find((item) => item.textContent?.includes("审查并批准"));
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error("AI diff review button not found after cancel");
|
||||
button.click();
|
||||
});
|
||||
await chrome.waitForText(["批准 AI 配置差异", seededDiff.id], "AI diff approval confirmation reopen");
|
||||
await chrome.evaluate(() => {
|
||||
const confirm = document.querySelector(".confirm-panel .confirm-primary");
|
||||
if (!(confirm instanceof HTMLButtonElement)) throw new Error("AI diff approval submit button not found");
|
||||
confirm.click();
|
||||
});
|
||||
await chrome.waitForText(["已审批", "写入任务"], "AI diff durable approval");
|
||||
const approvedResponse = await getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(seededDiff.serverInstanceId)}`, headers);
|
||||
const approved = findRequired(approvedResponse.items, (item) => item.id === seededDiff.id, "approved AI config diff");
|
||||
assertEqual(approved.state, "approved", "browser AI diff approval persisted");
|
||||
if (!approved.jobId || !approved.approvedBy || !approved.approvedAt) {
|
||||
throw new Error(`approved AI diff missed durable approval linkage: ${JSON.stringify(approved)}`);
|
||||
}
|
||||
assertNoForbiddenProjection(approved, "approved AI diff response");
|
||||
await chrome.waitForText(["AI 配置写入已派发", "config.write", "AI 写入任务"], "AI direct config job");
|
||||
const jobs = await getJson(`/jobs?serverInstanceId=${encodeURIComponent(server.id)}`, headers);
|
||||
const configJob = findRequired(jobs.items, (item) => item.capability === "config.write", "browser AI config write job");
|
||||
const terminalSSEPath = `/api/v1/server-instances/${encodeURIComponent(server.id)}/logs/events?jobId=${encodeURIComponent(configJob.id)}`;
|
||||
return {
|
||||
cancelPreservedState: pending.state,
|
||||
persisted: pick(approved, ["id", "serverInstanceId", "state", "approvedBy", "approvedAt", "jobId", "configVersion", "currentConfigChecksum"]),
|
||||
persisted: pick(configJob, ["id", "serverInstanceId", "runEndpointId", "capability", "targetKey", "state", "resultRef"]),
|
||||
terminalSSEPath,
|
||||
forbiddenFragmentScan: "passed",
|
||||
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||
};
|
||||
|
||||
@@ -565,8 +565,9 @@ describe("PlatformApiClient AI providers", () => {
|
||||
providerId: "ai.openai",
|
||||
model: "gpt-4.1",
|
||||
status: "ok",
|
||||
recommendation: "Review before applying.",
|
||||
configRecommendation: { key: "server.properties", suggestedConfig: "server.name=Example Survival #1\npvp=false\n", diffSummary: "review required" },
|
||||
recommendation: "Configuration changes are dispatched automatically.",
|
||||
configRecommendation: { key: "server.properties", suggestedConfig: "server.name=Example Survival #1\npvp=false\n", diffSummary: "AI config write queued" },
|
||||
configExecution: { status: "queued", job: { ...job, id: "job-ai-config-1", capability: "config.write", targetKey: "server.properties" } },
|
||||
usage: { providerId: "ai.openai", model: "gpt-4.1", inputTokens: 20, outputTokens: 12, mocked: true }
|
||||
});
|
||||
}
|
||||
@@ -645,7 +646,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
});
|
||||
await expect(
|
||||
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
|
||||
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
|
||||
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "AI config write queued" }, configExecution: { status: "queued", job: { id: "job-ai-config-1", capability: "config.write" } } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(51);
|
||||
});
|
||||
@@ -799,6 +800,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
const client = new PlatformApiClient("/api/v1");
|
||||
|
||||
expect(client.serverLogEventsUrl("server/scum 1")).toBe("/api/v1/server-instances/server%2Fscum%201/logs/events");
|
||||
expect(client.serverLogEventsUrl("server-1", { jobId: "job/config write" })).toBe("/api/v1/server-instances/server-1/logs/events?jobId=job%2Fconfig%20write");
|
||||
expect(client.serverLogEventsUrl("server-1")).toBe("/api/v1/server-instances/server-1/logs/events");
|
||||
});
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import type {
|
||||
AiProviderUpdateRequest,
|
||||
AIInvocationRequest,
|
||||
AIInvocationResponse,
|
||||
AIConfigDiffApprovalResponse,
|
||||
AIConfigDiffListResponse,
|
||||
ApiErrorResponse,
|
||||
ArtifactContentChunk,
|
||||
ArtifactDownloadReferenceResponse,
|
||||
@@ -500,17 +498,6 @@ export class PlatformApiClient {
|
||||
return this.request<PluginLifecycleActionResponse>(`/plugin-lifecycles/${encodeURIComponent(pluginId)}/actions`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async listAIConfigDiffs(filter: { serverInstanceId?: string; pluginId?: string; state?: string } = {}): Promise<AIConfigDiffListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
|
||||
const query = params.toString();
|
||||
return this.request<AIConfigDiffListResponse>(`/ai/config-diffs${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
async approveAIConfigDiff(id: string, idempotencyKey: string): Promise<AIConfigDiffApprovalResponse> {
|
||||
return this.request<AIConfigDiffApprovalResponse>(`/ai/config-diffs/${encodeURIComponent(id)}/approve`, { method: "POST", body: { idempotencyKey } });
|
||||
}
|
||||
|
||||
async listServerMetrics(): Promise<ServerMetricsListResponse> {
|
||||
return this.request<ServerMetricsListResponse>("/metrics/server-instances");
|
||||
}
|
||||
@@ -641,8 +628,8 @@ export class PlatformApiClient {
|
||||
return this.request<LogStreamListResponse>(`/log-streams${query}`);
|
||||
}
|
||||
|
||||
openServerLogEvents(id: string): PlatformEventStream {
|
||||
const url = this.serverLogEventsUrl(id);
|
||||
openServerLogEvents(id: string, options: { jobId?: string } = {}): PlatformEventStream {
|
||||
const url = this.serverLogEventsUrl(id, options);
|
||||
const sessionToken = this.sessionTokenProvider();
|
||||
if (!sessionToken) {
|
||||
return new EventSource(url, { withCredentials: true });
|
||||
@@ -650,8 +637,9 @@ export class PlatformApiClient {
|
||||
return new FetchServerSentEventStream(url, sessionToken);
|
||||
}
|
||||
|
||||
serverLogEventsUrl(id: string): string {
|
||||
return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events`;
|
||||
serverLogEventsUrl(id: string, options: { jobId?: string } = {}): string {
|
||||
const query = options.jobId ? `?jobId=${encodeURIComponent(options.jobId)}` : "";
|
||||
return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events${query}`;
|
||||
}
|
||||
|
||||
async queryLogStream(request: LogStreamCursorRequest): Promise<LogStreamCursorResponse> {
|
||||
|
||||
@@ -32,7 +32,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
|
||||
- `listArtifacts`, `openArtifactDownload`, `downloadArtifactContent`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser downloads stream the full body through `/artifacts/{id}/content`; explicit range reads may still use bounded `offset`/`limit` chunks and must render only safe filenames, checksums, progress, and platform storage behavior.
|
||||
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
|
||||
- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential.
|
||||
- `invokeAI` posts `AIInvocationRequest` to `/ai/invocations` for platform-mediated AI assistance. Responses carry redacted recommendations, usage metadata, optional reviewable config suggestions, and safe errors; they must not include provider base URLs, key refs, raw keys, or direct provider transport details.
|
||||
- `invokeAI` posts `AIInvocationRequest` to `/ai/invocations` for platform-mediated AI assistance. Config suggestions return redacted recommendation text, the proposed config, and `configExecution.job` metadata for the job dispatched immediately after Platform validation; responses must not include provider base URLs, key refs, raw keys, or direct provider transport details.
|
||||
- `listRunEndpoints` and `listJobs` provide refresh data for endpoint availability, capacity, and durable lifecycle status. Job projections include `retrying`, attempt/max-attempt counts, next retry timing, safe ack/lease deadlines, cancellation timestamps/reason, terminal time, and reconciliation outcome/count.
|
||||
- `getDependencyCatalog` reads `GET /server-instances/{id}/dependencies` and returns only target-matched probe state/evidence, typed plan step summaries, approved download hosts, and immutable SHA-256 `planDigest` values. Install requests must submit the selected digest; the browser never receives bindings, commands, paths, credentials, tokens, or private download refs.
|
||||
- `listRunUpdates` reads `GET /server-instances/{id}/run/update` and returns only target, artifact checksum, release identity, phase, bounded status message, rollback flag, and timestamps. The UI treats `restart-requested`/`activating` as non-terminal until a later safe projection confirms health.
|
||||
@@ -53,9 +53,9 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
|
||||
- `PUT /api/v1/users/current/theme` (`UserThemePreferenceRequest`/`UserThemePreferenceResponse`): implemented per-user theme preferences, including selected palette IDs such as `mecha-black` or `magical-girl`, uploaded background reference or safe persisted data URL metadata, and readable overlay preference.
|
||||
- `GET /api/v1/metrics/platform` (`PlatformResourceUsageResponse`): implemented platform-level CPU/memory/disk usage and LLM connectivity summary for the overview first screen.
|
||||
- `GET /api/v1/metrics/server-instances` (`ServerMetricsListResponse`): implemented per-server online state, player count, TPS, latency, CPU/memory/disk for server cards on the server list.
|
||||
- Server-scoped raw config routes (`GET /api/v1/server-instances/{id}/config`, `POST .../config/diff`, `POST .../config/approve`) are removed from the product API. AI configuration assistance uses `/api/v1/ai/invocations` plus reviewable AI config-diff approval APIs; plugin pages do not receive raw config text.
|
||||
- Server-scoped raw config routes (`GET /api/v1/server-instances/{id}/config`, `POST .../config/diff`, `POST .../config/approve`) are removed from the product API. AI configuration assistance uses `/api/v1/ai/invocations` and direct typed `configExecution` job metadata; plugin pages do not receive provider credentials.
|
||||
- `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only.
|
||||
- `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets.
|
||||
- `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation scoped to one server. Config suggestions dispatch a bounded `config.write` job immediately after validation and return its job metadata; provider keys stay in `platform/` and never reach the browser or plugin pages.
|
||||
- Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals.
|
||||
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, and `POST /api/v1/jobs/{id}/cancel`; the frontend wraps these in one visible operation lifecycle per user intent.
|
||||
|
||||
|
||||
@@ -15,14 +15,10 @@ describe("PlatformApiClient plugin operations", () => {
|
||||
|
||||
await client.listPluginLifecycles({ pluginId: "game.scum" });
|
||||
await client.runPluginLifecycle("game.scum", { serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1" });
|
||||
await client.listAIConfigDiffs({ state: "pending" });
|
||||
await client.approveAIConfigDiff("diff-1", "approve-1");
|
||||
|
||||
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
||||
"GET /api/v1/plugin-lifecycles?pluginId=game.scum",
|
||||
"POST /api/v1/plugin-lifecycles/game.scum/actions",
|
||||
"GET /api/v1/ai/config-diffs?state=pending",
|
||||
"POST /api/v1/ai/config-diffs/diff-1/approve"
|
||||
"POST /api/v1/plugin-lifecycles/game.scum/actions"
|
||||
]);
|
||||
const serialized = JSON.stringify(calls);
|
||||
expect(serialized).not.toMatch(/apiKey|token|secret|providerBaseUrl|runSocket|runEndpointUrl|hostPath|credential|dsn|rcon/i);
|
||||
|
||||
@@ -1575,6 +1575,7 @@ export interface LlmConfigSuggestionResponse {
|
||||
serverInstanceId: string;
|
||||
recommendation: string;
|
||||
suggestedConfig?: string;
|
||||
configExecution?: AIConfigExecutionResponse;
|
||||
}
|
||||
|
||||
export interface AIInvocationRequest {
|
||||
@@ -1602,8 +1603,11 @@ export interface AIConfigRecommendationResponse {
|
||||
key: string;
|
||||
suggestedConfig?: string;
|
||||
diffSummary: string;
|
||||
diffId: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface AIConfigExecutionResponse {
|
||||
status: string;
|
||||
job: JobResponse;
|
||||
}
|
||||
|
||||
export interface PluginProductionLifecycleDeclaration {
|
||||
@@ -1633,30 +1637,6 @@ export interface PluginLifecycleListResponse { items: PluginLifecycleInstallatio
|
||||
export interface PluginLifecycleActionRequest { serverInstanceId: string; operation: PluginLifecycleOperation; targetVersion?: string; idempotencyKey: string; }
|
||||
export interface PluginLifecycleActionResponse { status: string; installation: PluginLifecycleInstallationResponse; job: JobResponse; }
|
||||
|
||||
export interface AIConfigDiffPreviewResponse {
|
||||
id: string;
|
||||
requestId: string;
|
||||
createdBy: string;
|
||||
serverInstanceId: string;
|
||||
pluginId?: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
key: string;
|
||||
configVersion: number;
|
||||
currentConfigChecksum?: string;
|
||||
proposedConfig?: string;
|
||||
diffSummary: string;
|
||||
state: "pending" | "approved" | "cancelled" | "expired";
|
||||
expiresAt: string;
|
||||
approvedBy?: string;
|
||||
approvedAt?: string;
|
||||
jobId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface AIConfigDiffListResponse { items: AIConfigDiffPreviewResponse[]; count: number; }
|
||||
export interface AIConfigDiffApprovalResponse { preview: AIConfigDiffPreviewResponse; dispatch: ServerConfigWriteDispatchResponse; }
|
||||
|
||||
export interface AIInvocationSafeErrorResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
@@ -1671,6 +1651,7 @@ export interface AIInvocationResponse {
|
||||
status: "ok" | "denied" | "error" | string;
|
||||
recommendation?: string;
|
||||
configRecommendation?: AIConfigRecommendationResponse;
|
||||
configExecution?: AIConfigExecutionResponse;
|
||||
usage: AIInvocationUsageResponse;
|
||||
error?: AIInvocationSafeErrorResponse;
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { FileCheck2, RotateCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { AIConfigDiffPreviewResponse } from "../api/types";
|
||||
import { ConfirmDialog } from "./OperationControls";
|
||||
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
||||
|
||||
export function AIConfigDiffReviewPanel() {
|
||||
const [items, setItems] = useState<AIConfigDiffPreviewResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<AIConfigDiffPreviewResponse | null>(null);
|
||||
const [busyId, setBusyId] = useState("");
|
||||
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await platformApiClient.listAIConfigDiffs();
|
||||
setItems(response.items);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "AI 配置审查队列加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function approve() {
|
||||
if (!selected || busyId) return;
|
||||
setBusyId(selected.id);
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await platformApiClient.approveAIConfigDiff(selected.id, `web:ai.config.approve:${selected.id}`);
|
||||
setResult({ status: "succeeded", label: `已审批 ${response.preview.id} · 写入任务 ${response.dispatch.job.id}` });
|
||||
setSelected(null);
|
||||
await refresh();
|
||||
} catch (caught) {
|
||||
setResult({ status: "failed", label: caught instanceof Error ? caught.message : "AI 配置审批失败" });
|
||||
setSelected(null);
|
||||
} finally {
|
||||
setBusyId("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="console-panel console-module ai-diff-review-panel" aria-label="AI config diff review">
|
||||
<div className="panel-header">
|
||||
<h2><FileCheck2 size={16} /> AI 配置审查</h2>
|
||||
<button type="button" className="icon-command" disabled={loading || Boolean(busyId)} onClick={() => void refresh()}><RotateCw size={14} /><span>刷新</span></button>
|
||||
</div>
|
||||
{result && <ResultBadge status={result.status} label={result.label} />}
|
||||
{loading && <LoadingState label="正在同步 AI 配置差异…" compact />}
|
||||
{!loading && error && <ErrorState title="AI 配置审查不可用" reason={error} diagnosticId="ai-config-diffs" onRetry={() => void refresh()} compact />}
|
||||
{!loading && !error && (
|
||||
<div className="console-record-list">
|
||||
{items.length === 0 && <p className="console-empty-note">当前没有 AI 配置差异。</p>}
|
||||
{items.slice(0, 12).map((item) => (
|
||||
<div key={item.id} className="console-record">
|
||||
<div className="console-record-head"><strong>{item.serverInstanceId} · {item.key}</strong><span className={`status-pill status-${item.state === "approved" ? "succeeded" : item.state === "pending" ? "warning" : "disabled"}`}>{item.state}</span></div>
|
||||
<div className="console-record-meta"><span>请求 {item.requestId}</span><span>版本 {item.configVersion}</span><span>{item.model || "Platform model"}</span><span>到期 {new Date(item.expiresAt).toLocaleString()}</span>{item.jobId && <span>任务 {item.jobId}</span>}</div>
|
||||
<p>{item.diffSummary}</p>
|
||||
{item.proposedConfig && <pre className="log-view ai-config-proposal">{item.proposedConfig}</pre>}
|
||||
{item.state === "pending" && <div className="row-actions console-row-actions"><button type="button" disabled={Boolean(busyId)} onClick={() => setSelected(item)}><FileCheck2 size={14} /><span>审查并批准</span></button></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog open={selected !== null} title="批准 AI 配置差异" description={selected ? `服务器 ${selected.serverInstanceId},配置版本 ${selected.configVersion},差异 ${selected.id}。` : "确认 AI 配置差异。"} confirmLabel="批准并派发" busy={Boolean(busyId)} onCancel={() => { if (!busyId) setSelected(null); }} onConfirm={() => void approve()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,15 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AIConfigDiffReviewPanel } from "./AIConfigDiffReviewPanel";
|
||||
import { PluginLifecycleWorkbench } from "./PluginLifecycleWorkbench";
|
||||
import lifecycleSource from "./PluginLifecycleWorkbench.tsx?raw";
|
||||
import diffSource from "./AIConfigDiffReviewPanel.tsx?raw";
|
||||
|
||||
describe("plugin operations components", () => {
|
||||
it("renders persisted loading states without optimistic terminal success", () => {
|
||||
expect(renderToStaticMarkup(<PluginLifecycleWorkbench pluginId="game.example" pluginName="Example" />)).toContain("正在同步插件生命周期");
|
||||
expect(renderToStaticMarkup(<AIConfigDiffReviewPanel />)).toContain("正在同步 AI 配置差异");
|
||||
for (const source of [lifecycleSource, diffSource]) {
|
||||
expect(source).not.toContain("setTimeout");
|
||||
expect(source).not.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i);
|
||||
expect(source).toContain("disabled=");
|
||||
}
|
||||
expect(lifecycleSource).not.toContain("setTimeout");
|
||||
expect(lifecycleSource).not.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i);
|
||||
expect(lifecycleSource).toContain("disabled=");
|
||||
expect(lifecycleSource).toContain("if (!selectedServerId || busy) return");
|
||||
expect(diffSource).toContain("if (!selected || busyId) return");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,7 +228,7 @@ async function renderDrawer() {
|
||||
await act(async () => {
|
||||
root?.render(<ServerManagementTerminalDrawer open serverId="server-1" serverName="SCUM Alpha" onClose={() => undefined} />);
|
||||
});
|
||||
expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1");
|
||||
expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1", { jobId: undefined });
|
||||
}
|
||||
|
||||
async function emitSession(logSessionId?: string, serverTime = "2026-08-14T00:00:00Z") {
|
||||
|
||||
@@ -75,10 +75,11 @@ interface ServerManagementTerminalDrawerProps {
|
||||
open: boolean;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
jobId?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ServerManagementTerminalDrawer({ open, serverId, serverName, onClose }: ServerManagementTerminalDrawerProps) {
|
||||
export function ServerManagementTerminalDrawer({ open, serverId, serverName, jobId, onClose }: ServerManagementTerminalDrawerProps) {
|
||||
const [command, setCommand] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||
@@ -140,7 +141,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
||||
followLatestRef.current = true;
|
||||
setFollowLatest(true);
|
||||
setLines([terminalSystemLine("info", "正在连接当前受管进程输出。", "SYSTEM", undefined, serverTimeRef.current)]);
|
||||
}, [open]);
|
||||
}, [jobId, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined;
|
||||
@@ -153,7 +154,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
||||
|
||||
const hydrateCurrentSessionHistory = useCallback((sessionId: string | null | undefined) => {
|
||||
if (!sessionId) return;
|
||||
const streamsToHydrate = liveStreamsRef.current.filter((stream) => eventBelongsToLiveSession(stream.logSessionId, sessionId) && stream.latestSeq > 0 && !hydratedLiveStreamKeysRef.current.has(liveHistoryStreamKey(sessionId, stream)));
|
||||
const streamsToHydrate = liveStreamsRef.current.filter((stream) => (jobId ? stream.id.startsWith(`job.${jobId}.`) : eventBelongsToLiveSession(stream.logSessionId, sessionId)) && stream.latestSeq > 0 && !hydratedLiveStreamKeysRef.current.has(liveHistoryStreamKey(sessionId, stream)));
|
||||
if (streamsToHydrate.length === 0) return;
|
||||
const requestId = liveHistoryRequestRef.current + 1;
|
||||
liveHistoryRequestRef.current = requestId;
|
||||
@@ -172,12 +173,12 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
||||
appendLines([terminalSystemLine("warn", "当前会话历史读取失败,继续等待实时输出。", "SYSTEM", `session-history-failed-${sessionId}`, serverTimeRef.current)]);
|
||||
lockTerminalFollow();
|
||||
});
|
||||
}, [appendLines, lockTerminalFollow]);
|
||||
}, [appendLines, jobId, lockTerminalFollow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
let ready = false;
|
||||
const events = platformApiClient.openServerLogEvents(serverId);
|
||||
const events = platformApiClient.openServerLogEvents(serverId, { jobId });
|
||||
events.addEventListener("session", (event) => {
|
||||
const session = parseLogSessionEvent(event);
|
||||
if (!session) return;
|
||||
@@ -199,7 +200,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
||||
});
|
||||
events.addEventListener("stream", (event) => {
|
||||
const stream = parseLogStreamEvent(event);
|
||||
if (!stream || !eventBelongsToLiveSession(stream.logSessionId, liveSessionRef.current)) return;
|
||||
if (!stream || (jobId ? !stream.id.startsWith(`job.${jobId}.`) : !eventBelongsToLiveSession(stream.logSessionId, liveSessionRef.current))) return;
|
||||
ready = true;
|
||||
liveStreamsRef.current = mergeLogStreams(liveStreamsRef.current, stream);
|
||||
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
||||
@@ -212,7 +213,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
||||
});
|
||||
events.addEventListener("log", (event) => {
|
||||
const payload = parseServerLogEvent(event);
|
||||
if (!payload || !eventBelongsToLiveSession(payload.logSessionId, liveSessionRef.current)) return;
|
||||
if (!payload || (jobId ? !payload.streamId.startsWith(`job.${jobId}.`) : !eventBelongsToLiveSession(payload.logSessionId, liveSessionRef.current))) return;
|
||||
ready = true;
|
||||
const stream = streamFromServerLogEvent(payload);
|
||||
liveStreamsRef.current = mergeLogStreams(liveStreamsRef.current, stream);
|
||||
@@ -223,19 +224,20 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
||||
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
||||
};
|
||||
return () => events.close();
|
||||
}, [appendLines, hydrateCurrentSessionHistory, lockTerminalFollow, open, serverId]);
|
||||
}, [appendLines, hydrateCurrentSessionHistory, jobId, lockTerminalFollow, open, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !historyOpen) return;
|
||||
let cancelled = false;
|
||||
setHistoryStreams({ status: "loading" });
|
||||
void platformApiClient.listLogStreams(serverId).then((response) => {
|
||||
if (!cancelled) setHistoryStreams({ status: "ready", data: [...response.items].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) });
|
||||
const items = jobId ? response.items.filter((stream) => stream.id.startsWith(`job.${jobId}.`)) : response.items;
|
||||
if (!cancelled) setHistoryStreams({ status: "ready", data: [...items].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) });
|
||||
}).catch((error) => {
|
||||
if (!cancelled) setHistoryStreams({ status: "error", reason: error instanceof Error ? error.message : "历史日志列表加载失败" });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [historyOpen, open, serverId]);
|
||||
}, [historyOpen, jobId, open, serverId]);
|
||||
|
||||
async function selectHistoryStream(streamId: string) {
|
||||
const stream = historyStreams.status === "ready" ? historyStreams.data.find((item) => item.id === streamId) : undefined;
|
||||
@@ -312,7 +314,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
||||
<div className="terminal-output-topbar">
|
||||
<div>
|
||||
<strong>{serverName}</strong>
|
||||
<span>{historyOpen ? "历史日志(独立于实时终端)" : `当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {streams.status === "ready" ? "已连接" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
|
||||
<span>{historyOpen ? "历史日志(独立于实时终端)" : jobId ? `AI 写入任务 ${jobId} · ${liveSessionId ? "SSE 实时推送" : "等待 Run 输出"}` : `当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {streams.status === "ready" ? "已连接" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span>清屏</span></button>
|
||||
|
||||
@@ -8,7 +8,7 @@ All first-party pages inherit the platform_web game-operations style with black-
|
||||
- Built-in magical desktops and user-uploaded backgrounds render behind readable contrast surfaces.
|
||||
- Global theme ultimate motion is supplied by the shell-level background layer and lightweight global particle DOM layer, not by page-local fixed decoration elements. It must remain theme-specific and low-cost rather than a dense field of tiny rotating particles.
|
||||
- Page-specific work must not introduce opaque card islands, unrelated dark/light themes, marketing-style hero layouts, or one-off decorative systems.
|
||||
- Status, errors, warnings, destructive operations, LLM diff review, and operation/job feedback remain text/icon-visible and traceable.
|
||||
- Status, errors, warnings, destructive operations, LLM recommendations, and operation/job feedback remain text/icon-visible and traceable.
|
||||
|
||||
## 平台概览(原首页)
|
||||
|
||||
@@ -23,7 +23,7 @@ Default landing page for server owners and server administrators. Shows searchab
|
||||
|
||||
## 服务器详情
|
||||
|
||||
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start, stop, restart, and plugin-declared graceful-update lifecycle actions, plus a game-version module that checks the plugin-declared Steam build probe and only lights up the update action when a newer public build is reported. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM user and vehicle pages read platform-maintained SCUM tables while plugin-owned squads, map settings, gifts, and workflows stay in scoped plugin records. Built-in sections are 管理 (deployment status, metadata, administrators) and AI 助手 (LLM suggestions produce reviewable config diffs or typed workflow drafts; no raw AI keys reach the frontend). Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail.
|
||||
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start, stop, restart, and plugin-declared graceful-update lifecycle actions, plus a game-version module that checks the plugin-declared Steam build probe and only lights up the update action when a newer public build is reported. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM user and vehicle pages read platform-maintained SCUM tables while plugin-owned squads, map settings, gifts, and workflows stay in scoped plugin records. Built-in sections are 管理 (deployment status, metadata, administrators) and AI 助手 (LLM suggestions dispatch a bounded config.write job directly and stream its job logs in the terminal; no raw AI keys reach the frontend). Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail.
|
||||
|
||||
## 插件市场
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ export interface LlmSuggestionView {
|
||||
serverInstanceId: string;
|
||||
source: "api";
|
||||
recommendation: string;
|
||||
diffId?: string;
|
||||
expiresAt?: string;
|
||||
diffSummary?: string;
|
||||
executionStatus?: string;
|
||||
job?: JobResponse;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useS
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { AiProviderKind, AiProviderResponse, AiProviderStatus } from "../api/types";
|
||||
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
|
||||
import { AIConfigDiffReviewPanel } from "../components/AIConfigDiffReviewPanel";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { isPlatformAdmin } from "../contracts/workspace";
|
||||
@@ -504,8 +503,6 @@ export function AiProvidersPage({ initialState, session, operations }: AiProvide
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AIConfigDiffReviewPanel />
|
||||
|
||||
<ManagementDialog open={formMode !== null} title={formMode === "edit" ? "编辑提供商" : "新增提供商"} wide onClose={() => { if (viewState !== "saving") closeForm(); }}>
|
||||
<form className="provider-form dialog-form" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<ProviderSetupGuide />
|
||||
|
||||
@@ -23,7 +23,7 @@ const preview: ServerConfigDiffPreviewResponse = {
|
||||
reviewedAt: "2026-07-06T00:00:00Z"
|
||||
};
|
||||
|
||||
describe("ServerDetailPage config write approval", () => {
|
||||
describe("ServerDetailPage config write flow", () => {
|
||||
it("keeps server overview metrics on the server list instead of the detail header", () => {
|
||||
expect(serverDetailPageSource).not.toContain("listServerMetrics");
|
||||
expect(serverDetailPageSource).not.toContain("server-detail-stat-strip");
|
||||
@@ -52,7 +52,7 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).toContain('setSection(`plugin:${defaultPluginPage.key}`)');
|
||||
});
|
||||
|
||||
it("maps platform diff preview responses into the display diff without losing approval metadata", () => {
|
||||
it("maps platform diff preview responses into the display diff without losing config metadata", () => {
|
||||
const view = configDiffViewFromPreview(preview);
|
||||
|
||||
expect(view).toMatchObject({
|
||||
@@ -70,8 +70,10 @@ describe("ServerDetailPage config write approval", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses AI config diff approval without exposing raw config workbench APIs", () => {
|
||||
expect(serverDetailPageSource).toContain("approveAIConfigDiff");
|
||||
it("dispatches AI config writes directly and opens the streaming terminal", () => {
|
||||
expect(serverDetailPageSource).toContain("configExecution");
|
||||
expect(serverDetailPageSource).toContain("onOpenTerminal");
|
||||
expect(serverDetailPageSource).not.toContain(["config", "diffs"].join("-"));
|
||||
expect(serverDetailPageSource).toContain("AI 配置助手");
|
||||
expect(serverDetailPageSource).not.toContain("previewServerConfigDiff");
|
||||
expect(serverDetailPageSource).not.toContain("approveServerConfigWrite");
|
||||
@@ -88,7 +90,7 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain("fallbackConfig");
|
||||
});
|
||||
|
||||
it("does not locally mutate visible config after dispatching approval jobs", () => {
|
||||
it("does not locally mutate visible config after dispatching config jobs", () => {
|
||||
expect(serverDetailPageSource).not.toContain("setCurrentConfig(suggestion.diff.nextContent)");
|
||||
expect(serverDetailPageSource).not.toContain("content: diff.nextContent");
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalJobId, setTerminalJobId] = useState<string | null>(null);
|
||||
const [configEditorOpen, setConfigEditorOpen] = useState(false);
|
||||
const defaultSectionResolvedRef = useRef(false);
|
||||
|
||||
@@ -214,7 +215,7 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
className="icon-command"
|
||||
disabled={!canManageServers}
|
||||
title={canManageServers ? "打开终端" : "当前账号没有管理权限"}
|
||||
onClick={() => setTerminalOpen(true)}
|
||||
onClick={() => { setTerminalJobId(null); setTerminalOpen(true); }}
|
||||
>
|
||||
<Terminal size={15} />
|
||||
<span>打开终端</span>
|
||||
@@ -275,8 +276,8 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
/>
|
||||
)}
|
||||
{section === "files" && <ServerFilesSection instance={instance.data} session={session} operations={operations} />}
|
||||
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
|
||||
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} onClose={() => setTerminalOpen(false)} />
|
||||
{section === "llm" && <LlmSection serverId={serverId} session={session} operations={operations} onOpenTerminal={(jobId) => { setTerminalJobId(jobId ?? null); setTerminalOpen(true); }} />}
|
||||
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} jobId={terminalJobId ?? undefined} onClose={() => { setTerminalOpen(false); setTerminalJobId(null); }} />
|
||||
{configEditorOpen && <ServerConfigEditor instance={instance.data} operations={operations} requester={session.displayName} onClose={() => setConfigEditorOpen(false)} />}
|
||||
</>
|
||||
)}
|
||||
@@ -941,17 +942,15 @@ function formatDateTime(value?: string): string {
|
||||
|
||||
interface LlmSectionProps {
|
||||
serverId: string;
|
||||
instance: ServerInstanceResponse;
|
||||
session: PageComponentProps["session"];
|
||||
operations: PageComponentProps["operations"];
|
||||
onOpenTerminal: (jobId?: string) => void;
|
||||
}
|
||||
|
||||
function LlmSection({ serverId, instance, session, operations }: LlmSectionProps) {
|
||||
function LlmSection({ serverId, session, operations, onOpenTerminal }: LlmSectionProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [approvalBusy, setApprovalBusy] = useState(false);
|
||||
const [suggestionError, setSuggestionError] = useState("");
|
||||
|
||||
async function requestSuggestion(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -968,13 +967,19 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
throw new Error(response.error?.message ?? "AI 提供商未返回可用建议");
|
||||
}
|
||||
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);
|
||||
}
|
||||
setSuggestion({
|
||||
serverInstanceId: serverId,
|
||||
source: "api",
|
||||
recommendation: response.recommendation ?? "Platform 已返回配置建议。",
|
||||
diffId: recommendation?.diffId,
|
||||
expiresAt: recommendation?.expiresAt,
|
||||
diffSummary: recommendation?.diffSummary
|
||||
diffSummary: recommendation?.diffSummary,
|
||||
executionStatus: execution?.status,
|
||||
job: execution?.job
|
||||
});
|
||||
} catch (caught) {
|
||||
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
|
||||
@@ -983,27 +988,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
}
|
||||
}
|
||||
|
||||
async function applySuggestion() {
|
||||
if (!suggestion?.diffId || approvalBusy) {
|
||||
return;
|
||||
}
|
||||
const operationId = operations.begin({ intent: "应用 AI 配置建议", targetKind: "llm", targetId: serverId, requester: session.displayName });
|
||||
setApprovalBusy(true);
|
||||
try {
|
||||
const approved = await platformApiClient.approveAIConfigDiff(suggestion.diffId, `web:ai.config.approve:${suggestion.diffId}`);
|
||||
const job = approved.dispatch.job;
|
||||
operations.succeed(operationId, `AI 建议已确认,写入任务 ${job.id} 已派发`, job);
|
||||
setSuggestion(null);
|
||||
setConfirming(false);
|
||||
} catch (error) {
|
||||
operations.fail(operationId, error instanceof Error ? error.message : "写入任务派发失败", operationId);
|
||||
setConfirming(false);
|
||||
} finally {
|
||||
setApprovalBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const llmOperation = operations.operations.find((operation) => operation.intent === "应用 AI 配置建议" && operation.targetId === serverId);
|
||||
const llmOperation = operations.operations.find((operation) => operation.intent === "AI 直接写入配置" && operation.targetId === serverId);
|
||||
|
||||
return (
|
||||
<article className="console-panel" aria-label="llm configuration assistance">
|
||||
@@ -1014,7 +999,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
<span className="page-status">建议仅作用于 {serverId}</span>
|
||||
</div>
|
||||
<p style={{ margin: "0 0 12px", color: "var(--ink-soft)", fontSize: 13.5 }}>
|
||||
AI 建议会先生成推荐说明和配置差异,<strong>不会自动写入</strong>。只有你确认差异后,平台才会派发写入任务。前端不会接触任何 AI 提供商密钥。
|
||||
AI 会生成配置推荐并直接派发写入任务,执行过程会在下方实时终端中流式显示。前端不会接触任何 AI 提供商密钥。
|
||||
</p>
|
||||
{llmOperation && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
@@ -1053,37 +1038,27 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
<span className="page-status">平台 AI Provider</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
|
||||
{suggestion.diffId ? (
|
||||
{suggestion.job ? (
|
||||
<>
|
||||
<div className="console-record">
|
||||
<div className="console-record-head"><strong>Reviewable AI diff</strong><span className="status-pill status-active">pending</span></div>
|
||||
<div className="console-record-meta"><span>Diff {suggestion.diffId}</span>{suggestion.expiresAt && <span>到期 {new Date(suggestion.expiresAt).toLocaleString()}</span>}</div>
|
||||
<span className="provider-id">{suggestion.diffSummary ?? "平台已保存可审查配置差异;批准后才会派发写入任务。"}</span>
|
||||
<div className="console-record-head"><strong>AI 配置写入已派发</strong><span className="status-pill status-active">{suggestion.executionStatus ?? "queued"}</span></div>
|
||||
<div className="console-record-meta"><span>Job {suggestion.job.id}</span><span>{suggestion.job.capability}</span><span>{suggestion.job.targetKey}</span></div>
|
||||
<span className="provider-id">{suggestion.diffSummary ?? "AI 建议已直接进入 config.write 队列,日志正在实时输出。"}</span>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button type="button" onClick={() => setSuggestion(null)}>
|
||||
放弃建议
|
||||
<button type="button" onClick={() => onOpenTerminal(suggestion.job?.id)}>
|
||||
打开实时终端
|
||||
</button>
|
||||
<button type="button" className="confirm-primary" onClick={() => setConfirming(true)}>
|
||||
审批 AI 差异
|
||||
<button type="button" className="confirm-primary" onClick={() => setSuggestion(null)}>
|
||||
清除结果
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="provider-id">该建议没有生成可应用的配置差异,仅供参考。</span>
|
||||
<span className="provider-id">该响应没有生成可应用的配置任务,仅供参考。</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirming}
|
||||
title="确认应用 AI 配置建议"
|
||||
description={`即将向服务器 ${instance.name}(${serverId})派发配置写入任务。写入内容以上方差异为准。`}
|
||||
confirmLabel="确认写入"
|
||||
busy={approvalBusy || llmOperation?.status === "pending"}
|
||||
onCancel={() => setConfirming(false)}
|
||||
onConfirm={() => void applySuggestion()}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -619,7 +619,6 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
||||
.console-row-actions .theme-upload,.maintenance-actions .theme-upload,.user-actions .theme-upload{min-height:30px}
|
||||
.console-record-list,.operation-list{display:grid;gap:10px}
|
||||
.console-record,.operation-item{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim);position:relative;overflow:hidden;min-width:0}
|
||||
.ai-diff-review-panel{margin-block:14px}
|
||||
.console-stat-strip-spaced{margin-bottom:12px}
|
||||
.console-record-list-spaced{margin-top:12px}
|
||||
.plugin-lifecycle-controls{flex-wrap:wrap}
|
||||
|
||||
Reference in New Issue
Block a user