概念與技術規劃 · WebMCP

報價工作台

一張活的 B2B 報價單。AI 在上面建構範圍、切換方案、解釋成本;人隨時伸手改動, 而 AI 立刻知道改了什麼並接話。畫面不是 AI 生成的,是 AI 操作的。

題目:AI 如何用 JavaScript 客製化工具提升畫面 核心技術:WebMCP(document.modelContext) 示範資料:實測產出
概念

AI 生成畫面,還是 AI 操作畫面

題目是「AI 如何透過 JavaScript 客製化工具提升畫面」。這句話有兩種解法, 其中一種已經被做爛了。

常見解法

AI 生成 UI

模型吐出 HTML 或元件程式碼,畫面每次都不一樣。樣式不可控、細節粗糙,複雜互動幾乎做不出來。 評審一天會看到很多個這種。

我們的解法

AI 操作你設計好的 UI

WebMCP 的 tool 執行在頁面裡,AI 呼叫的是你預先寫好的 UI 函式。 畫面品質是設計師的水準、互動是你調過的,驅動它的是自然語言。 這是 WebMCP 唯一無可取代的能力——一般 MCP 跑在伺服器端, 看不見也碰不到瀏覽器。

概念

為什麼是 B2B 報價

常見的 WebMCP demo 是電商比價:把兩個靜態物件並排。報價比那更適合這個載體, 因為報價的本質是取捨連動——砍掉一項功能,價格、工期、風險同時變動。 這種多變數即時重算,正好是純對話介面最無力、而畫面最能發揮的地方。

報價天生有「看得見的結構」

範圍表、金額、係數、時程——本來就是表格與數字,不需要為了 demo 硬做視覺化。

頭號痛點正好能用畫面解

客戶問「為什麼這麼貴」,文字解釋很無力。一張成本瀑布圖當場說服。

決策需要並排

B2B 買方要在方案之間選,還要帶回去給老闆看。並排比較是真需求,不是炫技。

人一定會想自己動手

「這項我不要」是報價場景的必然動作。這讓人機協作的展示變得自然,不需要刻意安排。

技術

WebMCP 怎麼運作

網頁把自己的 JavaScript 函式註冊成 AI 可呼叫的 tool。瀏覽器裡的 agent 讀得到這些 tool 的名稱、說明與參數格式,然後像呼叫 API 一樣呼叫它們—— 差別是這些函式跑在頁面裡,碰得到 DOM。

入口物件

規格從 navigator.modelContext 搬到 document.modelContext (tool 屬於特定頁面,不屬於整個瀏覽器)。Chrome 150 起棄用舊位置但保留別名, 所以實務上兩個都要試。

三個核心方法

registerTool() 註冊一個 tool;provideContext() 把目前畫面狀態推給 agent;註冊時傳入的 AbortSignal 用來整組取消,這是切換工具集的機制。

取得入口物件——永遠用瀏覽器原生的,不要 polyfill
// webmcp/context.ts
export function getModelContext(): any | null {
  const fromDocument = (document as any).modelContext;
  if (fromDocument) return fromDocument;

  // Chrome 149 仍只有 navigator 版本
  return (navigator as any).modelContext ?? null;
}

export function hasModelContext(): boolean {
  return typeof getModelContext()?.registerTool === 'function';
}
一個 tool 的完整形狀。execute 回傳的字串就是 agent 看到的結果—— 要寫成「畫面現在變成什麼樣」,不是回傳原始資料
interface ToolDef {
  name: string;
  description: string;        // agent 靠這句決定要不要呼叫,寫清楚
  inputSchema?: JSONSchema;   // 標準 JSON Schema
  annotations?: {
    readOnlyHint?: boolean;      // 不改變狀態 → agent 可放心多呼叫
    untrustedContentHint?: boolean;
  };
  execute: (input: any) => Promise<string>;
}
註冊一整組 tool。用 AbortController 分組,切換模式時整組換掉—— 報價工作台只需要一組,但這個機制讓 tool 數量不會爆炸
let controller: AbortController | null = null;

export async function enterQuoteMode(): Promise<void> {
  controller?.abort();              // 取消上一組註冊
  controller = new AbortController();

  const ctx = getModelContext();
  if (!ctx) return;                 // 沒有 WebMCP → 走 fallback 面板

  let registered = 0;

  for (const tool of quoteTools) {
    try {
      await ctx.registerTool(tool, { signal: controller.signal });
      registered += 1;
    } catch (error) {
      // 註冊失敗絕不能靜默:「物件存在」和「agent 看得到 12 個 tool」
      // 是兩件事,只有後者算數。把數字顯示在畫面角落。
      console.warn(`tool "${tool.name}" 註冊失敗`, error);
    }
  }

  showBadge(`WebMCP: ${registered}/${quoteTools.length} tools`);
}

實作陷阱:切換工具集會殺掉執行中的 tool

Chrome 152 以前,abort 掉某個 signal 會連帶取消「用那個 signal 註冊、當下正在執行」的 tool。 所以如果某個 tool 自己會觸發模式切換,必須讓它的 execute 先回傳完, 再用 setTimeout(..., 0) 換註冊組。這在既有專案裡已經踩過一次。

技術

狀態模型

整個工作台是一份可序列化的狀態。所有 tool 都只是在改它,畫面只是它的投影。 這讓 AI 的操作和人的操作走同一條路徑——這是雙向協作能成立的前提。

核心資料結構
interface QuoteState {
  project: { name: string; industry: string | null };

  scenarios: Scenario[];          // A 案、B 案
  activeScenarioId: string;
  compareWith: string | null;      // 非 null 時畫面進入並排模式

  budget: number | null;          // 客戶預算上限,用來算超支
  confidence: 'rough' | 'initial' | 'formal';

  // 給 agent 看的:人最近動了什麼
  lastHumanEdit: HumanEdit | null;
}

interface Scenario {
  id: string;
  name: string;                  // 「SaaS 混搭」
  items: ScopeItem[];
  factors: ComplexityFactor[];
}

interface ScopeItem {
  id: string;
  module: string;                // 「預約排程系統」
  daysMin: number; daysMax: number;
  rate: number;                  // 人天單價
  included: boolean;             // 勾選狀態
  optional: boolean;             // 選配區
  source: 'ai' | 'human';        // ← 畫面上的來源標記
  note: string | null;           // 查證到的費率等附註
}

interface HumanEdit {
  action: 'toggle' | 'remove' | 'budget' | 'factor';
  target: string;
  before: unknown; after: unknown;
  totalBefore: number; totalAfter: number;
  at: number;
}
計價函式是純函式——同一份 state 永遠算出同一個數字, AI 和人改出來的結果不會有差異
export function priceOf(scenario: Scenario) {
  const active = scenario.items.filter((item) => item.included);

  const devMin = sum(active, (i) => i.daysMin * i.rate);
  const devMax = sum(active, (i) => i.daysMax * i.rate);

  // 係數相乘,但總係數上限 2.2(價目表規則)
  const factor = Math.min(
    scenario.factors.filter((f) => f.on).reduce((acc, f) => acc * f.value, 1),
    2.2,
  );

  const pm = 0.12;
  return {
    devMin, devMax, factor,
    total: Math.round(devMax * factor * (1 + pm)),
  };
}
技術

Tool 完整規格

十二個 tool。分界線是「這個動作需不需要思考」—— 畫面操作走前端毫秒完成,只有真正要估算或查證的才付出網路往返。

Tool參數畫面反應位置
add_scope_itemmodule, days_min, days_max, optional?報價單長出一列(stagger 淡入),總價滾動到新數字前端
remove_scope_itemitem_id該列淡出移除,總價回落前端
toggle_scope_itemitem_id, included勾選/取消,該列刪除線,全表重算前端
mark_optionalitem_id, optional項目在必要區與選配區之間移動前端
set_complexity_factorfactor_key, on係數 chip 亮起,總價與歸因圖同步更新前端
set_budgetamount滑桿移動,超支項目轉紅並標出差額前端
fork_scenariofrom_id, new_name複製目前方案成 B 案,可獨立修改前端
compare_scenariosa_id, b_id畫面裂成兩欄,差異列高亮前端
highlight_cost_driveritem_id指定模組高亮,其餘淡出前端
explain_breakdown展開成本瀑布圖,逐段歸因前端
estimate_unknown_moduledescription該列顯示估算中 → 回填人天與金額後端
finalize_quotefocus?右側滑出正式報價與待提供資料清單後端
一個前端 tool 的完整實作。注意回傳字串——它描述的是畫面現在的樣子下一步可以做什麼,不是回傳資料結構
const toggleScopeItem: ToolDef = {
  name: 'toggle_scope_item',
  description:
    '勾選或取消報價單上的一個項目。取消後該項不計入總價,' +
    '畫面即時重算。用於回應「這項不要」「把金流加回來」。',
  inputSchema: {
    type: 'object',
    properties: {
      item_id: { type: 'string', description: '項目 id,可從 get_quote_state 取得' },
      included: { type: 'boolean', description: 'true 計入、false 排除' },
    },
    required: ['item_id', 'included'],
  },
  annotations: { readOnlyHint: false },

  async execute(input) {
    const item = findItem(input.item_id);
    if (!item) {
      return `找不到項目 ${input.item_id}。先呼叫 get_quote_state 取得目前清單。`;
    }

    const before = currentTotal();
    patchState({ /* 改 included,並標記 source: 'ai' */ });
    const after = currentTotal();

    // 回傳「畫面變成什麼樣」——agent 據此決定要不要接話
    return (
      `已${input.included ? '加入' : '排除'}「${item.module}」。` +
      `總價 ${fmt(before)} → ${fmt(after)}。` +
      (after > budget() ? ` 仍超出預算 ${fmt(after - budget())}。` : ' 已在預算內。')
    );
  },
};
後端 tool:唯一會等網路的一類。畫面必須立刻進入「估算中」狀態, 不能讓使用者看著沒反應的介面
const estimateUnknownModule: ToolDef = {
  name: 'estimate_unknown_module',
  description:
    '當客戶提到價目表沒有的功能時,請報價 agent 估算人天與金額。' +
    'agent 會在需要時上網查證(第三方服務費率、平台能力)。',
  inputSchema: {
    type: 'object',
    properties: { description: { type: 'string', description: '功能的白話描述' } },
    required: ['description'],
  },

  async execute(input) {
    const pending = addPendingRow(input.description);   // 畫面先長出骨架列

    try {
      const res = await fetch('/api/quote/estimate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          description: input.description,
          conversation_id: getState().conversationId,   // 續談
        }),
      });

      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      const data = await res.json();
      fillRow(pending.id, data);                        // 回填真實數字

      return `已加入「${data.module}」:${data.days_min}–${data.days_max} 人天,` +
             `${fmt(data.amount)}。${data.note ?? ''}`;
    } catch (error) {
      markRowFailed(pending.id);                        // 畫面不能卡在轉圈
      return `估算失敗:${String(error)}。可以手動用 add_scope_item 填入。`;
    }
  },
};
技術

雙向協作機制

這是整個 demo 的技術核心,也是最容易被忽略的一半。 大多數人只做「AI 改畫面」,但真正讓人驚訝的是反方向:人改了畫面,AI 立刻知道。

WebMCP 提供 provideContext()——頁面主動把目前狀態推給 agent, agent 不需要呼叫任何 tool 就看得到。每次狀態變動就同步一次, 於是人在畫面上的動作變成 agent 下一輪的已知條件。

狀態同步:所有改動都經過這裡,人和 AI 的路徑完全相同
export function patchState(partial: Partial<QuoteState>): void {
  Object.assign(state, partial);
  sync();
}

function sync(): void {
  // 只推「摘要」,不是整份 state。agent 的 context 很貴,
  // 而且它需要的是「現在長什麼樣」,不是每個欄位。
  const summary = {
    project: state.project.name,
    activeScenario: activeScenario().name,
    comparing: state.compareWith ? bothScenarioNames() : null,
    total: priceOf(activeScenario()).total,
    budget: state.budget,
    overBudget: overBudgetAmount(),
    included: activeScenario().items.filter((i) => i.included).map((i) => i.module),
    excluded: activeScenario().items.filter((i) => !i.included).map((i) => i.module),
    lastHumanEdit: state.lastHumanEdit,     // ← 關鍵欄位
  };

  try {
    getModelContext()?.provideContext?.(summary);
  } catch { /* 沒有 WebMCP 時靜默略過 */ }

  // 同一份狀態也驅動畫面重繪與 fallback 面板
  document.dispatchEvent(new CustomEvent('quote:state', { detail: { ...state } }));
}
人動手時,除了推狀態,還主動送一句話給 agent—— 這就是 demo 第 4 拍「AI 沒被問就接話」的實作
// 使用者點擊報價單上的勾選框
function onHumanToggle(itemId: string, included: boolean): void {
  const item = findItem(itemId);
  const totalBefore = currentTotal();

  applyToggle(itemId, included, { source: 'human' });   // 標記為人改的

  patchState({
    lastHumanEdit: {
      action: 'toggle',
      target: item.module,
      before: !included, after: included,
      totalBefore, totalAfter: currentTotal(),
      at: Date.now(),
    },
  });

  // 有些 WebMCP 實作允許頁面主動發訊息給 agent。
  // 沒有這個能力時,agent 也會在下一輪從 provideContext 讀到 lastHumanEdit。
  notifyAgent(
    `使用者手動${included ? '加回' : '移除'}了「${item.module}」,` +
    `總價變成 ${fmt(currentTotal())}。請評估這個取捨的後果。`
  );
}
AI agent ChatGPT 側邊欄 QuoteState 單一真相來源 純函式計價 報價工作台 畫面 點、勾、拉 tool 呼叫 重繪 <16ms 寫回同一份 state provideContext() lastHumanEdit 青 = AI 發起 琥珀 = 人發起 兩條路徑改的是同一份狀態
關鍵在中間那個方框:AI 和人改的是同一份狀態,用的是同一組計價函式。 沒有「AI 的版本」和「人的版本」需要同步,所以不會有兩邊算出不同數字的經典 bug。
技術

前後端分界

只要 AI 說一句話、畫面要轉圈兩秒,demo 的魔力就沒了。 所以分界線不是照功能切,是照「需不需要思考」切。

ChatGPT 桌面版 內建瀏覽器 tool call 頁面內的 WebMCP tools document.modelContext.registerTool execute() 跑在瀏覽器,碰得到 DOM 改 state < 16ms fetch 2–40s 純前端 · 十個畫面操作 add / toggle / remove / optional factor / budget / fork / compare highlight / explain_breakdown 要思考 · 兩個打後端 estimate_unknown_module finalize_quote 畫面先進入估算中狀態 QuoteState → 畫面 即時重繪 quote-agent Agent SDK · 價目表 · WebSearch 估算結果回填 青 = 即時路徑(demo 的節奏靠它) 虛線 = 非同步
十個 tool 完全不碰網路,所以 AI 說「把金流拿掉、跟另一案並排」時, 畫面在同一個影格內完成。只有兩個真正需要估算與撰寫的才走後端。

後端那兩個直接接已經做好的 quote-agent: 它是一個 Claude Agent SDK 跑的完整 agent,讀價目表 Markdown、套用複雜度係數, 需要時用 WebSearch 查第三方費率並附來源。 AI 的智慧在後端,AI 的手在前端,兩邊不要混。

技術

後端 API 契約

兩支端點就夠。共用一個 conversation_id, 讓後端 agent 記得整段脈絡。

POST /api/quote/estimate — 估算價目表沒有的模組
// request
{
  "description": "要能讓美容師用手機看今天的預約並改時段",
  "conversation_id": "b22ad48e-…"      // 可省略,省略則開新對話
}

// response
{
  "conversation_id": "b22ad48e-…",
  "module": "美容師行動端排班檢視",
  "days_min": 4, "days_max": 7,
  "amount": 66000,
  "note": "含推播;不含原生 App,為 RWD 網頁",
  "sources": [{ "title": "…", "url": "…" }]   // 有查證時附上
}
POST /api/quote/finalize — 產出正式報價文字
// request:把整份畫面狀態送過去,讓 agent 依真實範圍寫
{
  "conversation_id": "b22ad48e-…",
  "scenario": { "name": "SaaS 混搭", "items": [ … ], "factors": [ … ] },
  "budget": 250000,
  "focus": "只報必要功能,選配另列"          // 可省略
}

// response
{
  "markdown": "### 專案理解\n…",          // 完整報價單
  "required_materials": [                      // 拆成結構化,畫面要單獨呈現
    { "item": "LINE 官方帳號與 Messaging API 開通",
      "status": "pending",
      "impact": "未開通則無法發預約通知,影響上線" }
  ],
  "risks": [ … ],
  "confidence": "initial"
}

安全:這兩支端點不能公開

後端 agent 的 system prompt 就是完整價目表——人天單價、係數表、原始碼買斷倍率。 端點必須綁 session 或 token,並加 rate limit。Demo 場景風險低, 但如果之後真的掛上官網,這是第一件要處理的事。

產品

畫面設計

整個視窗就是報價單本身。對話發生在 ChatGPT 的側邊欄, 所以頁面不需要自己做聊天框——這是 WebMCP 情境和一般 AI 產品最大的不同, 也讓畫面能把全部空間給內容。

寵物美容預約網站 方案 A AI 剛新增 5 項 28–45 人天
範圍
形象官網(5 頁、RWD、CMS)AI8–12 天96,000
預約排程系統AI10–16 天156,000
會員系統AI6–9 天90,000
LINE LoginAI1–2 天18,000
LINE 通知串接AI3–6 天54,000
金流串接(選配)4–7 天66,000
複雜度係數
需求細節待定 ×1.2 舊系統串接 ×1.4 法遵 ×1.4 時程壓縮 ×1.35

AI 依對話推斷「需求輪廓清楚但細節待定」,套用 ×1.2。點任一係數可手動覆寫。

總計(未稅)
NT$ 556,416 信心水準:初估 ±25%
開發小計414,000
複雜度 ×1.2496,800
專案管理 12%59,616
合計556,416
預算上限
客戶預算250,000
超支+306,416

你把預算拉到 25 萬 → AI 偵測超支,正在準備兩個替代方案。

1來源標記是設計核心。 每一列標明是 AI 加的還是你改的。協作在畫面上看得見,這是視覺主張,不是裝飾。
2數字會動。 總價滾動、新列 stagger 淡入。「報價單在成形」這件事靜態截圖傳達不了,現場演示才有殺傷力。
3超支用形狀也用顏色。 紅字之外加左側色條,不依賴色覺分辨。
4WebMCP 狀態要看得見。 角落顯示「已註冊 12 個 tool」。註冊失敗必須顯示出來—— 「物件存在」和「agent 真的看得到 tool」是兩件事。
產品

功能清單

報價單本體
  • 範圍表:模組、人天區間、金額、必要/選配分區
  • 即時計價:任何改動毫秒內重算
  • 複雜度係數:chip 開關,相乘且上限 2.2
  • 來源標記:每列標示 AI 或人
  • 信心水準:粗估/初估/正式,隨資訊量自動升級
決策輔助
  • 方案並排:兩案對照,差異列高亮
  • 方案分支:從現況複製一案來改
  • 預算滑桿:超支即時標示與差額
  • 買得回什麼:預算放寬時按 CP 值排序建議
  • 成本歸因:瀑布圖逐段解釋
AI 能力
  • 需求轉範圍:一段話變成勾好的報價單
  • 未知模組估算:價目表沒有的也能估
  • 費率查證:上網查第三方定價並附來源
  • 取捨評估:人改動後主動說出後果
  • 風險標示:預算與需求不匹配時直說
交付物
  • 正式報價單:範圍、金額、工期、假設
  • 待客戶提供資料:逐項標註狀態與影響
  • 風險與不包含:明確列出邊界
  • 時程換算:人天 × 1.4 轉日曆天
  • 匯出:列印樣式或 PDF
執行

Demo 分鏡

兩分半。第 5 拍是全場最強的一秒,前面四拍都在為它鋪路。

0:00
空白工作台

畫面只有一句「描述你的專案」,角落顯示「WebMCP: 12 tools ready」。 刻意留白,讓後面的生長有對比。

0:15
「客戶要做寵物美容預約網站,要線上預約、會員、LINE 通知」

報價單一行一行長出來,總價從 0 滾到 55.6 萬。五列各帶 AI 標記。

0:40
「為什麼這麼貴?」

瀑布圖展開,×1.2 那段高亮,AI 同步說明係數理由。畫面與語音同步,不是各講各的。

1:05
「客戶預算只有 25 萬」

畫面裂成兩欄:A 案 21.5 萬(SaaS 混搭)對 B 案 31 萬(全自建),差異列高亮。

1:30
人(動手)滑鼠勾掉 B 案的「多美容師排班」

價格即時掉到 27 萬。AI 沒被問就接話: 「這樣進得了預算,但這項之後補回來會貴 1.5 倍,因為得改資料結構。」
這一秒證明的不是 AI 會生成畫面,而是AI 和人在同一張報價單上協作。 單向 chatbot 做不到,一般 MCP 也做不到——它看不見瀏覽器裡發生了什麼。

1:55
把預算滑桿拉到 30 萬

畫面自動亮出「這多出來的 3 萬可以買回哪些」,被砍的項目按 CP 值排序浮回來。

2:20
「產出報價單」

右側滑出正式報價:範圍、金額、工期、假設與風險,以及 需要客戶提供的資料清單——這節最展現專業,也最容易被其他隊伍忽略。

執行

實作順序

依「沒做完也還能上台」排序。每階段結束都是一個可 demo 的狀態。

階段一 · 骨架
  • QuoteState 資料模型與純函式計價
  • 工作台版面、範圍表、總價卡
  • 先用一般按鈕驅動,完全不接 AI
  • 可 demo:即時重算
階段二 · 手
  • WebMCP 註冊流程與 tool 計數徽章
  • 十個前端 tool 接到既有狀態函式
  • 來源標記、數字滾動、stagger 動畫
  • 可 demo:AI 操作畫面
階段三 · 眼
  • provideContext() 推送狀態摘要
  • 人的操作寫入 lastHumanEdit
  • 動手後主動送變更摘要給 agent
  • 可 demo:第 5 拍成立
階段四 · 腦
  • 兩支 API 端點轉給 quote-agent
  • 未知模組估算、費率查證、正式報價
  • 逾時與錯誤要有畫面狀態,不能卡轉圈
  • 可 demo:完整流程
執行

風險與 fallback

WebMCP 不保證在評選現場的機器上能跑

它需要 ChatGPT 桌面版的內建瀏覽器(實驗性、依帳號 rollout、限特定模型、 Enterprise 與 Edu 不支援),或開了 flag 的 Chrome。現場設備你控制不了。

對策:頁面內建一個聊天面板,驅動完全同一組 tool。 因為所有 tool 都已經是獨立的函式、都只是在改同一份 state, 接一個內建面板的成本很低。WebMCP 可用時由 ChatGPT 驅動最有說服力; 不可用時切內建面板,demo 照跑,差別只在對話框長在誰身上。 沒有這個 fallback,等於把整場押在別人的 rollout 進度上。

tool 註冊靜默失敗

逐一 try/catch 並計數,把「已註冊 N 個」顯示在畫面角落。 沒有這個,你會在台上對著一個 agent 看不見的 tool 說話。

agent 呼叫錯 tool 或參數

每個 execute 的失敗回傳都要寫成「該怎麼修正」, 例如「找不到這個 id,先呼叫 get_quote_state」。agent 讀得懂就會自己重試。

後端估算太慢卡住節奏

實測一輪 agent 問答約 20–90 秒。Demo 時把需要估算的部分放在 講解段落,或預先暖機一次讓 session 已存在。

價目表在畫面上外流

簡報可能被錄影。準備一份結構相同、單價打散的 demo 價目表, 真實數字留在內部版本。

待拍板

要先決定的三件事

  • 前端從零寫還是移植。既有 CMS 專案的 WebMCP 架構 (context / state / modes / tools 四層)已經驗證過, 可以整套搬來當地基;也可以只參考模式、重寫一份更貼合報價的。 移植較快,重寫較乾淨。
  • 價目表要不要露真的。真實數字最有說服力,但簡報可能外流。 建議準備打散版。
  • 方案數上限。並排兩案版面剛好;三案以上要改成橫向捲動, 複雜度跳一階。建議 demo 鎖兩案。