Przeglądaj źródła

可视化:组装 Journey 应用外壳与数据状态

新增 JourneyApp,连接 Build 选择、轮询、健康状态、画布和证据面板;替换页面入口与元信息,并覆盖基础页面渲染。
SamLee 1 dzień temu
rodzic
commit
673eb72529

+ 2 - 2
visualization/frontend/app/layout.tsx

@@ -2,8 +2,8 @@ import type { Metadata } from "next";
 import "./globals.css";
 
 export const metadata: Metadata = {
-  title: "智能创作系统 · 真实执行观察器",
-  description: "全局 Planner、动态 Task 树、Task 生命周期与 Worker 工具循环",
+  title: "智能创作系统 · 创作旅程",
+  description: "展示智能创作系统的规划、执行、验收和返工过程",
 };
 
 export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {

+ 2 - 2
visualization/frontend/app/page.tsx

@@ -1,5 +1,5 @@
-import { MissionControl } from "@/components/MissionControl";
+import { JourneyApp } from "@/components/JourneyApp";
 
 export default function Page() {
-  return <MissionControl />;
+  return <JourneyApp />;
 }

+ 215 - 0
visualization/frontend/components/JourneyApp.tsx

@@ -0,0 +1,215 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { AlertTriangle, RefreshCw } from "lucide-react";
+
+import { fetchJourney, fetchRuns } from "@/lib/api";
+import { statusLabel } from "@/lib/journey";
+import type { JourneyStep, JourneyView, RunSummary, ZoomLevel } from "@/lib/types";
+
+import { EvidencePanel } from "@/components/EvidencePanel";
+import { JourneyCanvas } from "@/components/JourneyCanvas";
+
+const ZOOM_OPTIONS: Array<{ value: ZoomLevel; label: string; hint: string }> = [
+  { value: "journey", label: "全局", hint: "看创作回合" },
+  { value: "steps", label: "步骤", hint: "看规划与返工" },
+  { value: "evidence", label: "证据", hint: "看原始记录" },
+];
+const TERMINAL_STATUSES = new Set(["completed", "stopped", "failed", "published"]);
+
+export function JourneyApp() {
+  const [runs, setRuns] = useState<RunSummary[]>([]);
+  const [selectedRunId, setSelectedRunId] = useState<number | null>(null);
+  const [journey, setJourney] = useState<JourneyView | null>(null);
+  const [zoom, setZoom] = useState<ZoomLevel>("journey");
+  const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+  const [refreshTick, setRefreshTick] = useState(0);
+
+  useEffect(() => {
+    const controller = new AbortController();
+    fetchRuns(controller.signal)
+      .then((values) => {
+        setRuns(values);
+        setSelectedRunId((current) => current ?? values[0]?.script_build_id ?? null);
+      })
+      .catch((reason: Error) => setError(reason.message))
+      .finally(() => setLoading(false));
+    return () => controller.abort();
+  }, []);
+
+  const loadJourney = useCallback(
+    async (signal?: AbortSignal) => {
+      if (selectedRunId === null) return null;
+      setError(null);
+      try {
+        const value = await fetchJourney(selectedRunId, signal);
+        setJourney(value);
+        setSelectedStepId((current) =>
+          current && value.steps.some((step) => step.step_id === current)
+            ? current
+            : value.steps[0]?.step_id ?? null,
+        );
+        return value;
+      } catch (reason) {
+        if (!(reason instanceof DOMException && reason.name === "AbortError")) {
+          setError(reason instanceof Error ? reason.message : "无法读取创作旅程");
+        }
+        return null;
+      }
+    },
+    [selectedRunId],
+  );
+
+  useEffect(() => {
+    const controller = new AbortController();
+    let timer: number | null = null;
+
+    async function refresh() {
+      const value = await loadJourney(controller.signal);
+      if (
+        !controller.signal.aborted &&
+        value &&
+        !TERMINAL_STATUSES.has(value.status.toLowerCase())
+      ) {
+        timer = window.setTimeout(refresh, 12_000);
+      }
+    }
+
+    setLoading(true);
+    refresh().finally(() => setLoading(false));
+    return () => {
+      controller.abort();
+      if (timer !== null) window.clearTimeout(timer);
+    };
+  }, [loadJourney, refreshTick]);
+
+  const selectedStep = useMemo<JourneyStep | null>(
+    () => journey?.steps.find((step) => step.step_id === selectedStepId) ?? null,
+    [journey, selectedStepId],
+  );
+  const selectedRun = useMemo(
+    () => runs.find((run) => run.script_build_id === selectedRunId) ?? null,
+    [runs, selectedRunId],
+  );
+
+  if (!loading && runs.length === 0 && !error) {
+    return (
+      <main className="empty-state">
+        <h1>还没有可展示的创作记录</h1>
+        <p>完成一次 Script Build 后,这里会按真实执行顺序展示它的规划、执行、验收和返工。</p>
+      </main>
+    );
+  }
+
+  return (
+    <main className={`journey-app zoom-${zoom}`}>
+      <header className="app-bar">
+        <div className="brand-lockup">
+          <span className="brand-mark" aria-hidden="true" />
+          <div>
+            <strong>智能创作旅程</strong>
+            <span>每一步都回到真实记录</span>
+          </div>
+        </div>
+
+        <label className="run-picker">
+          <span>本次创作</span>
+          <select
+            value={selectedRunId ?? ""}
+            onChange={(event) => setSelectedRunId(Number(event.target.value))}
+          >
+            {runs.map((run) => (
+              <option key={run.script_build_id} value={run.script_build_id}>
+                #{run.script_build_id} · {run.summary}
+              </option>
+            ))}
+          </select>
+        </label>
+
+        <div className="zoom-switch" aria-label="查看层级">
+          {ZOOM_OPTIONS.map((option) => (
+            <button
+              key={option.value}
+              type="button"
+              className={zoom === option.value ? "active" : ""}
+              aria-pressed={zoom === option.value}
+              onClick={() => setZoom(option.value)}
+              title={option.hint}
+            >
+              {option.label}
+            </button>
+          ))}
+        </div>
+
+        <button
+          className="icon-button"
+          type="button"
+          onClick={() => setRefreshTick((value) => value + 1)}
+          aria-label="刷新创作旅程"
+        >
+          <RefreshCw size={17} className={loading ? "is-spinning" : ""} />
+        </button>
+      </header>
+
+      {error ? (
+        <div className="error-banner" role="alert">
+          <AlertTriangle size={16} />
+          <span>{error}</span>
+          <button type="button" onClick={() => setRefreshTick((value) => value + 1)}>
+            重试
+          </button>
+        </div>
+      ) : null}
+
+      {journey ? (
+        <>
+          <section className="mission-strip">
+            <div className="mission-copy">
+              <span className={`run-status status-${journey.status}`}>{statusLabel(journey.status)}</span>
+              <h1>{journey.objective}</h1>
+            </div>
+            <div className="input-ribbon" aria-label="本次创作输入">
+              <span className="ribbon-label">DATA IN</span>
+              <strong>{journey.input_summary.topic}</strong>
+              <span>{journey.input_summary.account_name ?? "未指定账号"}</span>
+              <span>{journey.input_summary.persona_point_count} 条人设信息</span>
+              <span>
+                {journey.input_summary.strategy_names.length
+                  ? journey.input_summary.strategy_names.join("、")
+                  : "无额外策略"}
+              </span>
+            </div>
+          </section>
+
+          <div className="workspace">
+            <JourneyCanvas
+              journey={journey}
+              zoom={zoom}
+              selectedStepId={selectedStepId}
+              onSelectStep={(stepId) => {
+                setSelectedStepId(stepId);
+                if (zoom === "journey") setZoom("steps");
+              }}
+            />
+            {zoom === "evidence" ? (
+              <EvidencePanel
+                journey={journey}
+                run={selectedRun}
+                step={selectedStep}
+                onClose={() => setZoom("steps")}
+              />
+            ) : null}
+          </div>
+        </>
+      ) : (
+        <div className="journey-skeleton" aria-label="正在读取创作旅程">
+          <span />
+          <span />
+          <span />
+        </div>
+      )}
+    </main>
+  );
+}

+ 54 - 0
visualization/frontend/tests/shell.test.tsx

@@ -0,0 +1,54 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, expect, test, vi } from "vitest";
+
+import Page from "@/app/page";
+
+afterEach(() => {
+  cleanup();
+  vi.unstubAllGlobals();
+});
+
+test("展示来自 Host 投影的创作目标", async () => {
+  vi.stubGlobal(
+    "fetch",
+    vi.fn(async (input: RequestInfo | URL) => {
+      const url = String(input);
+      const payload = url.endsWith("/api/runs")
+        ? [
+            {
+              script_build_id: 42,
+              status: "running",
+              summary: "正在生成脚本",
+              started_at: null,
+              completed_at: null,
+            },
+          ]
+        : {
+            schema_version: "script-build-journey/v1",
+            generated_at: "2026-07-21T00:00:00Z",
+            script_build_id: 42,
+            root_trace_id: "root-42",
+            status: "running",
+            objective: "为选题生成一份可发布的脚本",
+            input_summary: {
+              topic: "一次反转如何改变信念",
+              account_name: "每天心理学",
+              persona_point_count: 3,
+              strategy_names: ["先冲突后解法"],
+              snapshot_id: "11",
+              digest: "sha256:input",
+            },
+            steps: [],
+            edges: [],
+            warnings: [],
+          };
+      return { ok: true, json: async () => payload } as Response;
+    }),
+  );
+
+  render(<Page />);
+
+  expect(
+    await screen.findByRole("heading", { name: "为选题生成一份可发布的脚本" }),
+  ).toBeTruthy();
+});