iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0

https://ithelp.ithome.com.tw/upload/images/20260829/20161290yMHIUz0WR2.png

把半截 spec 安全變成可看的 dashboard

在過去兩天中,我們完成了後端 GOAP Agent 的雙 Goal 建模(Day 27),並搭建了強大的 SseProgressBroker 串流管線(Day 28)。

今天,我們將把戰場正式拉回前端 React + TypeScript 應用層

我們的任務是:打造一個現代、優雅、具備即時思維鏈動態進度反饋的 Generative UI 儀表板頁面

前端必須在接收到 SSE 串流時,無縫完成三件事:

  1. 即時思維鏈可視化:將後端發送的 statusplan 即時轉化為清晰的步驟勾選進度條。
  2. 無損漸進渲染(Progressive Rendering):利用 Day 25 的 lenientParsesanitizeSpec,在 JSON 串流傳輸過程中,讓儀表板從骨架屏(Skeleton)到指標卡、再到表格逐一長出來,徹底告別生硬的閃爍與整頁重繪。
  3. 動態互動與狀態管理(ActionProvider & StateProvider):為生成的 UI 注入生命力,支援使用者點擊表格列進行互動或篩選。

1. 今天要解決的痛點與核心觀念

痛點背景:Generative UI 前端整合的三大體驗死穴

  1. 白屏等待與閃爍(Layout Shift & Flickering):每次收到新的 Token 就暴力摧毀當前 DOM 重新渲染,導致文字跳動、滾動條跳回頂部。
  2. 無效節點引發 React 紅字崩潰(Uncaught Error in Render):在串流剛開始時,父節點的 children 包含尚未抵達的子節點 Key,直接傳給 React 導致 Cannot read properties of undefined
  3. 死板的靜態展示(Non-interactive Static View):AI 生成的 Dashboard 只能看不能按,缺乏點擊篩選或觸發後續動作的互動能力。

觀念圖解:前端漸進渲染與狀態架構

https://ithelp.ithome.com.tw/upload/images/20260829/20161290u6x0tjoTJG.png

  • 輸入區:接收自然語言需求(例如「查詢客戶 1001 的年度消費」)。
  • 進度面板:展示 A* 演算法即時規劃與 Action 執行打勾進度。
  • 漸進渲染區:依據 Flat Tree Spec 動態掛載標題、預警、指標卡與資料表格。
  • 維運資訊:呈現端到端耗時、Token 消耗與精確維運成本。

2. 官方核心技術依據與架構深度

1. useDashboardStream 狀態機綁定

前端的核心狀態機由以下變數驅動:

  • spec: 當前經過淨化後最完整的 DashboardSpec(可直接傳給 FlatTreeRenderer)。
  • progress: 包含當前 Agent 階段(ANALYZINGRENDERINGCOMPLETED)與步驟勾選清單。
  • isStreaming: 布林值,用於控制按鈕 Disable 與微光動畫(Shimmer Effect)。

2. 宣告式互動(ActionProvider Pattern)

json-render 中,我們不讓 LLM 寫 onClick={() => {...}},而是讓元件 Props 攜帶宣告式的事件描述:

{
  "type": "Button",
  "props": { "label": "重新整理", "action": { "type": "RELOAD_METRICS", "payload": { "id": 1001 } } }
}

前端透過 Context 提供 ActionProvider,當使用者點擊按鈕時,統一由 React 調度派發,實現 100% 安全的動態互動。

3. 微動畫與 Skeleton 佔位設計

透過 Tailwind CSS 的 animate-pulse 與深色模式(Dark Mode)調色盤,在資料尚未抵達前優雅展示骨架屏,達到 Apple / Vercel 等級的精緻體驗。


3. 完整程式碼實戰(Production-Ready Code)

以下實作完整的 React 主應用程式:DashboardApp.tsx

// src/components/DashboardApp.tsx
import React, { useState } from 'react';
import { useDashboardStream } from '../hooks/useDashboardStream';
import { FlatTreeRenderer } from './renderer/FlatTreeRenderer';
import { useCatalogReconciliation } from './renderer/CatalogReconciler';
import { dashboardRegistry } from './dashboard/dashboardCatalog';

/**
 * 智慧生成式儀表板全端主應用
 */
export const DashboardApp: React.FC = () => {
  const [queryInput, setQueryInput] = useState("查詢客戶 1001 的年度消費與旅遊偏好");
  const { spec, progress, isLoading, startStream } = useDashboardStream();

  // 1. 前端啟動期 Catalog 契約自動對帳
  const { isAligned, missingInFrontend } = useCatalogReconciliation(Object.keys(dashboardRegistry));

  /**
   * 發起生成請求
   */
  const handleGenerate = (e: React.FormEvent) => {
    e.preventDefault();
    if (!queryInput.trim() || isLoading) return;
    startStream(queryInput.trim());
  };

  return (
    <div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col items-center p-6 font-sans">
      {/* 頂部 Header */}
      <header className="w-full max-w-5xl mb-8 flex justify-between items-center border-b border-slate-800 pb-4">
        <div>
          <h1 className="text-2xl font-bold bg-gradient-to-r from-teal-400 to-blue-500 bg-clip-text text-transparent">
            Antechinus Travel Generative Dashboard
          </h1>
          <p className="text-xs text-slate-400 mt-1">
            Embabel GOAP Backend × json-render Streaming Frontend
          </p>
        </div>
        {!isAligned && (
          <span className="text-xs px-2.5 py-1 rounded bg-amber-500/20 text-amber-300 border border-amber-500/40">
            ⚠ Catalog 漂移: 缺少 [{missingInFrontend.join(', ')}]
          </span>
        )}
      </header>

      {/* 自然語言輸入區 */}
      <section className="w-full max-w-5xl mb-8">
        <form onSubmit={handleGenerate} className="flex gap-3">
          <input
            type="text"
            value={queryInput}
            onChange={(e) => setQueryInput(e.target.value)}
            placeholder="輸入自然語言查詢需求,例如:列出近一年消費超過 10 萬的高價值常客..."
            className="flex-1 bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-teal-500 transition-colors shadow-inner"
            disabled={isLoading}
          />
          <button
            type="submit"
            disabled={isLoading || !queryInput.trim()}
            className="bg-gradient-to-r from-teal-500 to-blue-600 hover:from-teal-400 hover:to-blue-500 text-white font-medium px-6 py-3 rounded-xl text-sm transition-all shadow-lg shadow-teal-500/20 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
          >
            {isLoading ? (
              <>
                <span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
                <span>規劃中...</span>
              </>
            ) : (
              <span>🚀 智慧生成</span>
            )}
          </button>
        </form>
      </section>

      {/* Agent 即時思維鏈與進度面板 */}
      {progress.phase !== 'IDLE' && (
        <section className="w-full max-w-5xl mb-6 p-4 bg-slate-900/60 border border-slate-800 rounded-xl">
          <div className="flex items-center justify-between text-xs font-semibold text-slate-300 mb-2">
            <span className="flex items-center gap-2">
              <span className={`w-2 h-2 rounded-full ${isLoading ? 'bg-teal-400 animate-ping' : 'bg-emerald-400'}`}></span>
              <span>Agent 階段: {progress.phase}</span>
            </span>
            {progress.costUsd > 0 && (
              <span className="text-slate-400">
                預估費用: ${progress.costUsd.toFixed(5)} USD
              </span>
            )}
          </div>

          {/* 思維步驟清單 */}
          {progress.steps.length > 0 && (
            <div className="flex flex-wrap gap-2 mt-2">
              {progress.st![https://ithelp.ithome.com.tw/upload/images/20260829/20161290WO0FxBgu5s.png](https://ithelp.ithome.com.tw/upload/images/20260829/20161290WO0FxBgu5s.png)eps.map((step, idx) => (
                <span
                  key={idx}
                  className={`text-xs px-3 py-1 rounded-full flex items-center gap-1.5 ${
                    step.status === 'DONE'
                      ? 'bg-emerald-950/60 border border-emerald-800 text-emerald-300'
                      : step.status === 'RUNNING'
                      ? 'bg-teal-950/60 border border-teal-700 text-teal-300 animate-pulse'
                      : 'bg-slate-800 text-slate-400'
                  }`}
                >
                  {step.status === 'DONE' && '✔'}
                  {step.status === 'RUNNING' && '⏳'}
                  <span>{step.name}</span>
                </span>
              ))}
            </div>
          )}
        </section>
      )}

      {/* 儀表板漸進渲染主視窗 */}
      <main className="w-full max-w-5xl bg-slate-900/40 border border-slate-800/80 rounded-2xl p-6 min-h-[400px] shadow-2xl flex flex-col justify-center">
        {spec ? (
          <FlatTreeRenderer spec={spec} />
        ) : isLoading ? (
          <div className="flex flex-col items-center justify-center gap-3 text-slate-500 py-16">
            <div className="w-8 h-8 border-2 border-teal-500 border-t-transparent rounded-full animate-spin"></div>
            <span className="text-sm">正在依據意圖建構儀表板規格 (Flat Spec)...</span>
          </div>
        ) : (
          <div className="text-center text-slate-500 py-16 text-sm">
            請於上方輸入自然語言需求,系統將透過 Embabel + json-render 生成動態儀表板。
          </div>
        )}
      </main>
    </div>
  );
};

4. 生產環境避坑指南與對比分析

常見踩雷與除錯秘訣

  1. 雷區一:在 renderNode 中沒有使用穩定唯一 key
    • 現象:每次串流 Chunk 到達時,React 判定節點無 Key 或使用陣列 Index,導致整個元件樹被銷毀重建,輸入游標跳動。
    • 解法:一律強制使用 Flat Spec 的唯一節點 Key 作為 React Key(<Component key={nodeKey} />)。
  2. 雷區二:忽略深色模式(Dark Mode)色彩對比度
    • 現象:LLM 生成的文字在預設淺色背景下看不清,切換深色時又變成全黑。
    • 解法:在 Component Registry 內部統一使用 Tailwind 語意化配色(如 text-slate-100bg-slate-900),不要讓 LLM 生成隨機 CSS Hex 顏色。
  3. 雷區三:未防範網路超時導致的無限 Spinner
    • 現象:後端拋出 500 例外但未發送 error SSE 事件,前端一直停留在 isLoading = true
    • 解法:在前端 fetch 配置 30 秒超時 AbortController,並在 catch 區塊強制執行 setIsLoading(false)

前端體驗 Good vs Bad 對比表

評估維度 ❌ 傳統粗糙渲染 (Bad) ✅ json-render 漸進渲染 (Good)
等待反饋 只有單一 Loading 轉圈,不知道在幹嘛 即時呈現 Agent 思維步驟與進度標籤
生長體驗 8 秒後瞬間啪一聲彈出全部內容 隨著 Chunk 接收,卡片與表格漸進式滑入長出
容錯性 後端語法少一個括號直接全頁噴紅字 lenientParse 自動補齊引號括號,保留可用部分
契約安全 前端遇到沒看過的 Tag 直接 Crash 自動降級為 SmartFallbackComponent 標記

5. 實機畫面:半截 spec 長成儀表板的過程

與 Day 25 同一次查詢、僅隔數百毫秒:spec 仍在串流(進度 80%、狀態「串流渲染中…」),但左側已經從單一標題長出四張指標卡——每收到一個完整的 element chunk 就多渲染一塊,永遠不會因為 JSON 還沒收完而白屏。

https://ithelp.ithome.com.tw/upload/images/20260829/20161290Lf3q8KwwP2.png


6. 今日動手實作任務與發文備註

🛠️ 今日實作任務

  1. 實裝 DashboardApp 頁面:在 React 專案中完整建立 DashboardApp.tsx,結合 Tailwind CSS 打造現代深色風格介面。
  2. 串接真實後端串流:啟動 Spring Boot 後端(Day 28),在前端點擊「智慧生成」,驗證思維鏈進度條是否順利推進且儀表板如期長出。
  3. 思考題:如果在儀表板生成完成後,使用者點擊了 DataTable 的某一行,如何透過宣告式事件(Declarative Action)向後端發起二次對話或詳細數據鑽取(Drill-down)?

上一篇
Day 28:讓進度一路跑到前端
系列文
讓 AI Agent 真的做事:用 Embabel 打造可控、可測試的智慧 Dashboard29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言