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
用來整組取消,這是切換工具集的機制。
// 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'; }
execute 回傳的字串就是 agent 看到的結果——
要寫成「畫面現在變成什麼樣」,不是回傳原始資料
interface ToolDef { name: string; description: string; // agent 靠這句決定要不要呼叫,寫清楚 inputSchema?: JSONSchema; // 標準 JSON Schema annotations?: { readOnlyHint?: boolean; // 不改變狀態 → agent 可放心多呼叫 untrustedContentHint?: boolean; }; execute: (input: any) => Promise<string>; }
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; }
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_item | module, days_min, days_max, optional? | 報價單長出一列(stagger 淡入),總價滾動到新數字 | 前端 |
| remove_scope_item | item_id | 該列淡出移除,總價回落 | 前端 |
| toggle_scope_item | item_id, included | 勾選/取消,該列刪除線,全表重算 | 前端 |
| mark_optional | item_id, optional | 項目在必要區與選配區之間移動 | 前端 |
| set_complexity_factor | factor_key, on | 係數 chip 亮起,總價與歸因圖同步更新 | 前端 |
| set_budget | amount | 滑桿移動,超支項目轉紅並標出差額 | 前端 |
| fork_scenario | from_id, new_name | 複製目前方案成 B 案,可獨立修改 | 前端 |
| compare_scenarios | a_id, b_id | 畫面裂成兩欄,差異列高亮 | 前端 |
| highlight_cost_driver | item_id | 指定模組高亮,其餘淡出 | 前端 |
| explain_breakdown | — | 展開成本瀑布圖,逐段歸因 | 前端 |
| estimate_unknown_module | description | 該列顯示估算中 → 回填人天與金額 | 後端 |
| finalize_quote | focus? | 右側滑出正式報價與待提供資料清單 | 後端 |
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())}。` : ' 已在預算內。') ); }, };
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 下一輪的已知條件。
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 } })); }
// 使用者點擊報價單上的勾選框 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 說一句話、畫面要轉圈兩秒,demo 的魔力就沒了。 所以分界線不是照功能切,是照「需不需要思考」切。
後端那兩個直接接已經做好的 quote-agent: 它是一個 Claude Agent SDK 跑的完整 agent,讀價目表 Markdown、套用複雜度係數, 需要時用 WebSearch 查第三方費率並附來源。 AI 的智慧在後端,AI 的手在前端,兩邊不要混。
後端 API 契約
兩支端點就夠。共用一個 conversation_id, 讓後端 agent 記得整段脈絡。
// request { "description": "要能讓美容師用手機看今天的預約並改時段", "conversation_id": "b22ad48e-…" // 可省略,省略則開新對話 } // response { "conversation_id": "b22ad48e-…", "module": "美容師行動端排班檢視", "days_min": 4, "days_max": 7, "amount": 66000, "note": "含推播;不含原生 App,為 RWD 網頁", "sources": [{ "title": "…", "url": "…" }] // 有查證時附上 }
// 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 產品最大的不同, 也讓畫面能把全部空間給內容。
AI 依對話推斷「需求輪廓清楚但細節待定」,套用 ×1.2。點任一係數可手動覆寫。
你把預算拉到 25 萬 → AI 偵測超支,正在準備兩個替代方案。
符合預算。代價:預約資料在第三方,日後客製排班會被綁手。
你勾掉排班 → 31 萬降到 27 萬。AI:「這項之後補回來會貴 1.5 倍,因為得改資料結構。」
功能清單
- 範圍表:模組、人天區間、金額、必要/選配分區
- 即時計價:任何改動毫秒內重算
- 複雜度係數:chip 開關,相乘且上限 2.2
- 來源標記:每列標示 AI 或人
- 信心水準:粗估/初估/正式,隨資訊量自動升級
- 方案並排:兩案對照,差異列高亮
- 方案分支:從現況複製一案來改
- 預算滑桿:超支即時標示與差額
- 買得回什麼:預算放寬時按 CP 值排序建議
- 成本歸因:瀑布圖逐段解釋
- 需求轉範圍:一段話變成勾好的報價單
- 未知模組估算:價目表沒有的也能估
- 費率查證:上網查第三方定價並附來源
- 取捨評估:人改動後主動說出後果
- 風險標示:預算與需求不匹配時直說
- 正式報價單:範圍、金額、工期、假設
- 待客戶提供資料:逐項標註狀態與影響
- 風險與不包含:明確列出邊界
- 時程換算:人天 × 1.4 轉日曆天
- 匯出:列印樣式或 PDF
Demo 分鏡
兩分半。第 5 拍是全場最強的一秒,前面四拍都在為它鋪路。
畫面只有一句「描述你的專案」,角落顯示「WebMCP: 12 tools ready」。 刻意留白,讓後面的生長有對比。
報價單一行一行長出來,總價從 0 滾到 55.6 萬。五列各帶 AI 標記。
瀑布圖展開,×1.2 那段高亮,AI 同步說明係數理由。畫面與語音同步,不是各講各的。
畫面裂成兩欄:A 案 21.5 萬(SaaS 混搭)對 B 案 31 萬(全自建),差異列高亮。
價格即時掉到 27 萬。AI 沒被問就接話:
「這樣進得了預算,但這項之後補回來會貴 1.5 倍,因為得改資料結構。」
這一秒證明的不是 AI 會生成畫面,而是AI 和人在同一張報價單上協作。
單向 chatbot 做不到,一般 MCP 也做不到——它看不見瀏覽器裡發生了什麼。
畫面自動亮出「這多出來的 3 萬可以買回哪些」,被砍的項目按 CP 值排序浮回來。
右側滑出正式報價:範圍、金額、工期、假設與風險,以及 需要客戶提供的資料清單——這節最展現專業,也最容易被其他隊伍忽略。
實作順序
依「沒做完也還能上台」排序。每階段結束都是一個可 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 鎖兩案。