重點摘要(TL;DR)
- 銷售/業務人員在 Telegram 一句話「幫我畫上週每個客戶訂單金額長條圖」 → LLM 30 秒產出 self-contained HTML 檔案 → 員工瀏覽器點開 → 看到即時圖表。
- HTML 是 standalone 純靜態,從 CDN 載 Chart.js + Tailwind,內含 JS 透過公司 Gateway 對接 iDempiere REST(不直連 ERP)。
- 四層架構:Generation(LLM 產)/ Execution(瀏覽器跑)/ Data(Gateway proxy)/ Rendering(Chart.js 渲染)。
- 混合模式:LLM 產 HTML 時 inline 一份預載資料(打開立即顯示),HTML 內按「重新整理」可主動 fetch 最新值。速度和即時性兼得。
- 安全設計:HTML 不含 secrets、Gateway 認 SSO、LLM 產的 HTML 用 textContent 防 XSS、Gateway 校驗 OData filter 防 injection、員工只看到 AD_Role 允許的資料。
- 本文是腦子系統的第六篇,前五篇:Why / How / Scale / Tools / ERP。
一、使用情境
銷售 Tom 在通勤路上滑手機,腦子裡想到「**等等開會要秀上週業績**」。
傳統流程(沒有這個工具)
- 到公司打開電腦
- 登入 ERP
- 找到訂單視窗
- 篩選日期
- 匯出 Excel
- 用 PowerPoint / Excel 畫圖
- 合計花 30-60 分鐘
新流程(本文設計)
- Telegram 對 bot 說:「上週每個客戶訂單金額長條圖」
- 30 秒後 bot 回 HTML 檔(或連結)
- 點開,圖表立刻渲染
- 開會時直接全螢幕秀
- 合計花 30 秒
關鍵:銷售不需要學任何工具、不需要安裝 app、不需要 IT 部署任何東西。產出的 HTML 還可以分享給同事、存證、離線重看。
二、四層架構
銷售 Tom (Telegram chat)
↓
[Generation 層] LLM 看公司腦 + 員工 prompt
↓ 產 self-contained HTML
HTML 檔案 (Chart.js + Tailwind 從 CDN 載)
↓ Tom 在瀏覽器打開
[Execution 層] 瀏覽器跑 HTML 內 JS
↓ JS 對 https://gateway.example.com/erp/query 發 fetch
[Data 層] 公司 Gateway (LiteLLM + Portkey + 自製 ERP proxy)
↓ Gateway 帶 Tom 的 SSO 身份
↓ 呼叫 iDempiere MCP server / REST API
↓ iDempiere AD_Role 自動過濾資料
↓ 回 JSON
HTML 內 JS 接到 JSON
↓
[Rendering 層] Chart.js 渲染圖表 / 動態表格 / 資訊圖
四層各自的職責清晰、可獨立替換:
- Generation:換 LLM(Claude/GPT/本地 Qwen)不影響其他層
- Execution:瀏覽器標準環境,任何裝置都能跑(Mac / Windows / iPhone Safari)
- Data:Gateway 換成內部 service mesh、ERP 換成另一套都不影響 HTML
- Rendering:換 Chart.js 為 ECharts / Plotly 只改前端,後端不動
三、混合模式:預載 + 即時刷新
LLM 產生 HTML 時面對一個取捨:
| 模式 | 優點 | 缺點 |
|---|---|---|
| A 純 inline:LLM 把資料寫死進 HTML | 簡單、離線可看、無 CORS | 資料是快照,要新查就要重新產 |
| B 純 fetch:HTML 啟動才查 | 每次最新 | 打開時白屏 1-2 秒、需連線 |
| C 混合(推薦):預載 + 重新整理按鈕 | 立即顯示 + 隨時更新 + 離線可看快照 | HTML 較大(包含初始資料) |
實務上 C 混合模式最佳。實作:LLM 在產 HTML 時順便呼叫一次 Gateway 拿初始資料,把 JSON 寫進 HTML 的 const initialData = [...],同時保留 refresh() 函數讓員工按按鈕主動更新。
四、LLM 怎麼產 HTML — Prompt 設計
給 LLM 的 system prompt 要包含五件事:
- HTML 模板骨架:固定的 head / body 結構,用哪個 CDN 圖表庫
- Gateway URL 與 API schema:fetch 要打哪、payload 格式
- 可用 ERP table 與欄位:從公司腦讀(C_Order / C_BPartner / M_Product 等的可查欄位)
- OData filter 語法:eq/neq/gt/contains 等(注意 iDempiere 用 neq 不是 ne)
- 安全規範:用 textContent 不用 innerHTML、不要 hardcode token、不要 eval()
4.1 System Prompt 範例(精簡版)
You are a HTML dashboard generator for sales staff.
CONTEXT (from company brain):
- Available ERP tables: C_Order, C_BPartner, M_Product, M_InOut
- Common columns for C_Order: GrandTotal, DateOrdered, C_BPartner_ID, IsSOTrx
- Filter syntax: OData (use 'neq' not 'ne')
- Gateway endpoint: https://gateway.example.com/erp/query
- Gateway auth: SSO cookies (credentials: 'include')
OUTPUT REQUIREMENTS:
1. Generate ONE complete self-contained HTML file
2. Use Chart.js via CDN (https://cdn.jsdelivr.net/npm/chart.js)
3. Use Tailwind via CDN (https://cdn.tailwindcss.com)
4. Include initial data inline (call Gateway once and embed JSON)
5. Provide a refresh() function for live update
6. Use textContent (NEVER innerHTML) when displaying data
7. Add a loading spinner during fetch
8. Style: clean, presentation-ready (用得上開會秀客戶)
USER QUERY: {{user_message}}
五、產出 HTML 範例(完整可執行)
下面是 LLM 看到「上週每個客戶訂單金額長條圖」這個 query 後產出的範例 HTML。這是真實可執行的 self-contained 檔案:
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<title>上週訂單金額(by 客戶)</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-50 p-6 font-sans">
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">上週訂單金額(by 客戶)</h1>
<button id="refreshBtn"
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
🔄 重新整理
</button>
</div>
<p id="meta" class="text-sm text-slate-500 mb-4"></p>
<canvas id="chart" height="120"></canvas>
<table class="mt-6 w-full text-sm">
<thead class="bg-slate-200">
<tr><th class="text-left p-2">客戶</th><th class="text-right p-2">訂單數</th><th class="text-right p-2">金額</th></tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
</div>
<script>
// === 預載資料(LLM 產生時 inline 進來) ===
const initialData = [
{customer: "客戶 A", orderCount: 5, amount: 1280000},
{customer: "客戶 B", orderCount: 3, amount: 850000},
{customer: "客戶 C", orderCount: 7, amount: 2100000},
{customer: "客戶 D", orderCount: 2, amount: 420000}
];
const generatedAt = "2026-05-02 09:30";
// === Gateway 設定 ===
const GATEWAY_URL = "https://gateway.example.com/erp/query";
const QUERY = {
table: "C_Order",
filter: "DateOrdered ge '2026-04-25' and DateOrdered le '2026-05-01'",
groupBy: "C_BPartner_ID",
aggregate: ["count", "sum(GrandTotal)"]
};
// === 渲染函數 ===
let chart;
function render(data, ts) {
// 注意:用 textContent 不用 innerHTML 防 XSS
document.getElementById('meta').textContent = `資料時間:${ts}`;
const tbody = document.getElementById('tableBody');
tbody.textContent = '';
data.forEach(row => {
const tr = document.createElement('tr');
tr.className = 'border-b';
[row.customer, row.orderCount, row.amount.toLocaleString()].forEach((v, i) => {
const td = document.createElement('td');
td.className = i === 0 ? 'p-2' : 'p-2 text-right';
td.textContent = v;
tr.appendChild(td);
});
tbody.appendChild(tr);
});
if (chart) chart.destroy();
chart = new Chart(document.getElementById('chart'), {
type: 'bar',
data: {
labels: data.map(d => d.customer),
datasets: [{
label: '訂單金額(NTD)',
data: data.map(d => d.amount),
backgroundColor: 'rgba(59, 130, 246, 0.6)'
}]
},
options: {
responsive: true,
plugins: {legend: {display: false}}
}
});
}
// === 即時刷新 ===
async function refresh() {
const btn = document.getElementById('refreshBtn');
btn.disabled = true;
btn.textContent = '⏳ 載入中...';
try {
const r = await fetch(GATEWAY_URL, {
method: 'POST',
credentials: 'include', // 帶 SSO cookies
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(QUERY)
});
if (!r.ok) throw new Error(`Gateway error ${r.status}`);
const data = await r.json();
render(data.rows, new Date().toLocaleString('zh-TW'));
} catch (e) {
alert('刷新失敗:' + e.message);
} finally {
btn.disabled = false;
btn.textContent = '🔄 重新整理';
}
}
// === 初始化 ===
document.getElementById('refreshBtn').addEventListener('click', refresh);
render(initialData, generatedAt);
</script>
</body>
</html>
這個檔案存成 orders.html,雙擊即可在瀏覽器打開。打開時看到預載資料(已渲染圖表 + 表格);按「重新整理」就 fetch 最新資料。整個檔案約 80 行,包含全部 logic。
六、安全設計(必看)
6.1 HTML 端
- ❌ 絕不在 HTML 寫 token / API key:HTML 是員工拿到的檔案,寫 token 等於洩漏。所有認證在 Gateway server side
- ✅ 用
fetch(..., {credentials: 'include'})帶員工 SSO cookies - ✅ 渲染用 textContent,不用 innerHTML(防 LLM 產的 XSS)
- ✅ 不用 eval()、Function() 等動態 code 執行
- ✅ Chart.js / Tailwind 從固定 CDN 載(版本鎖定),不從不可信來源載
6.2 Gateway 端
- SSO 認證:員工已登入公司,cookies 自動帶,Gateway 認 user identity
- OData filter 校驗:LLM 產生的 filter 要過 Gateway 校驗(白名單欄位、operator 限制),防 SQL injection / 越權查詢
- Rate limit:單一員工每分鐘最多 X 個 query,防 LLM 產的迴圈失控
- Audit log:每個 query 記錄(誰、何時、查什麼、回傳幾筆),進 SIEM
- CORS 白名單:Gateway 只允許指定 origin(若 HTML 託管在內網檔案分享伺服器,設定該 origin)
6.3 ERP 端
- iDempiere AD_Role 自動套:Gateway 帶員工 token 進 iDempiere,業務 Tom 看不到 CFO 才看的到的資料
- 不直連 ERP:HTML 的 fetch 不直接打 iDempiere,一律走 Gateway proxy。理由:ERP 不該暴露在 internet,Gateway 才是受控邊界
- Process call 限制:銷售工具預設 read-only,要寫資料(下單、修改)需要更高層審核或專用工具
七、CORS / 認證的具體做法
三條路徑分析:
| 路徑 | CORS | 認證 | 推薦 |
|---|---|---|---|
| HTML → 直連 iDempiere REST | 需開 iDempiere CORS 設定 | JWT token 存 HTML(危險) | ❌ 不要 |
| HTML → 公司 Gateway → iDempiere | Gateway 設 CORS 白名單 | SSO cookies 自動帶 | ✅ 推薦 |
| HTML → MCP server → iDempiere | MCP server 設 CORS | MCP OAuth 2.1 | ⚠️ 進階(複雜但可) |