AI GO Custom App — 開發者指南
版本: 1.3 | 更新日期: 2026-04-07 | API 版本: v1
本文檔說明如何透過 API 開發 AI GO Custom App,適用於 AI Agent 自動化開發和人類開發者手動整合。
目錄
- 什麼是 Custom App
- 認證與連線
- 首次連入:理解 App 架構
- VFS 檔案結構
- 程式碼注入 API
- 編譯與偵錯
- 內建 SDK
- Server-Side Actions
- 驗證與發布
- 常見問題
- Shadow DOM 與 CSS 樣式規範
- VFS 注入腳本開發規範
- 共享資料表隔離策略 (Data Domain Separation)
- 檔案上傳與儲存 (Storage API)
- 開發與部署最佳實踐 (Best Practices)
1. 什麼是 Custom App
Custom App 是 AI GO 平台內的可程式化微應用,讓您以 React + TypeScript 建立自訂的業務工具。
核心概念
- VFS(Virtual File System):以 JSON 物件
{"檔案路徑": "檔案內容"}儲存所有原始碼 - esbuild 編譯器:將 React TSX 編譯為瀏覽器可執行的 JS bundle
- Runtime 沙箱:在 Shadow DOM 隔離環境中安全執行已編譯的 App
- Server-Side Actions:Python 後端腳本,在安全沙箱中執行
Internal vs External
| 特性 | Internal(內部應用) | External(外部應用) |
|---|---|---|
| 使用場景 | 組織內部管理工具 | 對外客戶/供應商應用 |
| 認證方式 | 主站帳號登入 | 獨立帳號系統 |
| API 操作 | 完全相同(透過 Builder API) | 完全相同(透過 Builder API) |
2. 認證與連線
取得 JWT Token
所有 API 操作需要具備 builder.access 權限的帳號。平台管理員會提供可用的帳號與密碼。
POST https://ai-go.app/api/v1/auth/login
Content-Type: application/json
{
"email": "developer@example.com",
"password": "your_password"
}
回應:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "...",
"expires_in": 3600,
"token_type": "bearer"
}
使用 JWT
GET /api/v1/builder/apps/{slug}
Authorization: Bearer {access_token}
3. 首次連入:理解 App 架構
⚠️ 重要:開始修改前,必須先讀取並理解當前 App 的 VFS 結構,避免使用不相容的架構。
標準流程
1. GET /api/v1/builder/apps/{slug}
→ 取得 vfs_state, vfs_version, access_mode
2. 分析 VFS 結構:
- 讀取 src/App.tsx → 理解路由結構
- 讀取 src/routes.ts → 理解導航配置
- 讀取 src/pages/_manifest.json → 頁面清單
3. 確認 SDK:
- src/api.ts → Custom Data CRUD
- src/db.ts → DB Proxy
- src/action.ts → Server-Side Action
核心規則
- 使用 React 18 + TypeScript + HashRouter(若 App 只有單一頁面,可直接渲染元件,不需 Router)
- React / ReactDOM / lucide-react / react-router-dom 由 Runtime 提供,不可自行安裝
- CSS 使用全域
App.css,不支援 CSS Modules 或 Tailwind - 入口點必須是
src/main.tsx - Server-Side Action 用 Python 撰寫,放在
actions/目錄 - ⚠️ Runtime 在 Shadow DOM 中執行 — CSS 變數必須用
:host, :root雙選擇器,不可只用:root(詳見第 11 章) - ⚠️ Shadow DOM 容器需設定
overflow-y: auto— 否則內容過長時無法捲動(詳見第 11 章)
4. VFS 檔案結構
標準檔案樹
├── package.json # 依賴宣告
├── src/
│ ├── main.tsx # ★ 入口點(必須存在)
│ ├── App.tsx # 路由 + Layout
│ ├── App.css # 全域樣式
│ ├── routes.ts # 導航配置
│ ├── api.ts # SDK:Custom Data CRUD
│ ├── db.ts # SDK:DB Proxy
│ ├── action.ts # SDK:Server-Side Action
│ ├── data.json # Custom Table 定義(自動注入)
│ ├── db.json # Data Reference 定義(自動注入)
│ ├── pages/
│ │ ├── _manifest.json # 頁面清單
│ │ ├── DashboardPage.tsx # 頁面元件
│ │ └── NotFoundPage.tsx # 404 頁面
│ └── components/
│ ├── AppLayout.tsx # 主 Layout
│ ├── AppSidebar.tsx # 側邊欄
│ └── AppHeader.tsx # 頂部欄
└── actions/
├── manifest.json # Action 註冊清單
└── example_action.py # Action 實作
不可修改的 SDK 檔案
| 檔案 | 說明 |
|---|---|
src/api.ts | Custom Data CRUD SDK |
src/db.ts | DB Proxy SDK |
src/action.ts | Server Action SDK |
src/data.json | Runtime 自動注入 |
src/db.json | Runtime 自動注入 |
5. 程式碼注入 API
API 端點一覽
| 操作 | HTTP 方法 | 端點 |
|---|---|---|
| 取得 App(含 VFS) | GET | /api/v1/builder/apps/{slug} |
| 全量覆寫 VFS | PUT | /api/v1/builder/apps/{id}/source |
| 局部更新檔案 | PATCH | /api/v1/builder/apps/{id}/source/files |
| 刪除檔案 | DELETE | /api/v1/builder/apps/{id}/source/files |
局部更新(推薦)
PATCH /api/v1/builder/apps/{app_id}/source/files
Authorization: Bearer {JWT}
Content-Type: application/json
{
"files": {
"src/pages/NewPage.tsx": "import React from 'react';\n\nexport default function NewPage() {\n return <div>新頁面</div>;\n}",
"src/App.tsx": "...更新後的完整內容..."
},
"expected_version": 5
}
刪除檔案
DELETE /api/v1/builder/apps/{app_id}/source/files
Authorization: Bearer {JWT}
Content-Type: application/json
{
"paths": ["src/pages/OldPage.tsx"],
"expected_version": 6
}
樂觀鎖
所有修改端點支援 expected_version 參數:
- 取得 App 時記錄
vfs_version - 修改時帶入
expected_version - 若版本不匹配 → 回傳 409 Conflict
6. 編譯與偵錯
編譯 API
POST /api/v1/compile/compile/{slug}?dev=true
Authorization: Bearer {JWT}
成功回應:
{
"success": true,
"html": "<!DOCTYPE html>...",
"bundle_js": "...",
"css": "..."
}
失敗回應:
{
"success": false,
"error": "✘ [ERROR] Could not resolve \"./pages/MissingPage\"..."
}
編譯限制
| 限制 | 值 |
|---|---|
| 最大檔案數 | 200 |
| 單檔大小 | 1 MB |
| 編譯超時 | 30 秒 |
External 模組(由 Runtime 提供)
以下模組不需安裝,直接 import 即可:
react, react-dom, lucide-react, react-router-dom, react-hot-toast
7. 內建 SDK
Custom Data(src/api.ts)
操作 App 自建的動態資料表:
import { listRecords, submitRecord, updateRecord, deleteRecord } from "../api";
const records = await listRecords("my_table");
await submitRecord("my_table", { name: "新記錄" });
await updateRecord("my_table", recordId, { name: "更新" });
await deleteRecord("my_table", recordId);
DB Proxy(src/db.ts)
操作已授權的系統資料表:
import { query, queryAdvanced, insert, update, remove } from "../db";
const customers = await query("customers", { limit: 50 });
const result = await queryAdvanced("customers", {
filters: [{ column: "status", op: "eq", value: "active" }],
order_by: [{ column: "name", direction: "asc" }],
});
⚠️ db.update() PATCH 格式注意事項
後端 Proxy API 的 PATCH 端點要求 payload 必須以 {"data": {...}} 包裝,但目前 db.ts SDK 的 update() 函式直接發送欄位物件,會觸發 「無有效欄位資料」 錯誤。
臨時解法:在需要更新資料的場景中,改用直接 fetch 呼叫:
// ❌ 目前 db.update() 會發送 {"state": "sent"} → 後端回傳 400
await db.update("sale_orders", orderId, { state: "sent" });
// ✅ 正確做法:直接 fetch 並以 {"data": {...}} 包裝
const apiBase = (window as any).__API_BASE__ || '/api/v1';
const appId = (window as any).__APP_ID__ || '';
const token = (window as any).__APP_TOKEN__ || '';
const resp = await fetch(`${apiBase}/proxy/${appId}/sale_orders/${orderId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
credentials: 'include',
body: JSON.stringify({ data: { state: "sent" } }),
});
備註:
db.insert()同樣存在此格式不一致的問題。若insert()呼叫失敗,請套用相同的{"data": {...}}包裝模式。此問題預計在 SDK 下一版修正。
Server Action(src/action.ts)
import { runAction, downloadFile } from "../action";
const result = await runAction("my_action", { key: "value" });
if (result.file) downloadFile(result.file);
8. Server-Side Actions
程式碼格式
def execute(ctx):
"""必須定義 execute(ctx) 函式"""
data = ctx.params.get("key", "default")
customers = ctx.db.query("customers", limit=10)
ctx.response.json({"result": customers})
ctx 物件
| 方法 | 說明 |
|---|---|
ctx.params | 前端傳入的參數 |
ctx.db.query(table) | 查詢資料 |
ctx.db.insert(table, data) | 新增記錄 |
ctx.http.call(service, endpoint) | 呼叫外部 API |
ctx.crypto.hash(alg, data) | 雜湊計算 |
ctx.secrets.get(key) | 取得金鑰 |
ctx.response.json(data) | JSON 回應 |
ctx.csv.export(rows) | CSV 匯出 |
安全限制
- 僅允許白名單模組(json、math、re、datetime、httpx 等)
- 禁止 exec/eval/open 等危險操作
- 執行超時 30 秒、記憶體上限 256 MB
9. 驗證與發布
標準開發迴圈
1. PATCH 修改檔案
2. POST 編譯(dev=true)
3. 編譯失敗 → 修改 → 回到 1
4. 編譯成功 → 預覽驗證
5. POST 發布
發布 API
POST /api/v1/builder/apps/{app_id}/publish
Authorization: Bearer {JWT}
Content-Type: application/json
{ "published_assets": {} }
10. 常見問題
| 問題 | 解法 |
|---|---|
| 白屏 | 確認 src/main.tsx 正確掛載 React |
| 路由不動 | 使用 HashRouter,不可用 BrowserRouter |
| 頁面無法捲動 | Shadow DOM 容器需設定 height: 100vh; overflow-y: auto(詳見第 11 章) |
| CSS 不生效 | 在 main.tsx 中 import "./App.css" |
| CSS 變數全部遺失(部署後) | 使用 :root 定義變數,Shadow DOM 無法穿透。改用 :host, :root(詳見第 11 章) |
db.update() 回傳「無有效欄位資料」 | SDK 的 update() 未以 {"data": {...}} 包裝 payload(詳見第 7 章 DB Proxy 注意事項) |
db.ts 呼叫回傳 500 | 此為平台後端問題而非前端 Bug。請確認:1) Data Reference 是否已建立並發布 2) 引用的表名是否正確 3) 回報平台管理員檢查後端日誌 |
db.ts / api.ts 回傳 401 | Token 可能過期或 SDK 變數未正確注入。確認 SDK 未被手動修改,且 Runtime 已正確啟動 |
| 409 Conflict | VFS 被同時修改,重新 GET 後合併重試 |
| 423 Locked | 有待審核的發布申請,等待或取消 |
| Action 超時 | 優化邏輯,控制在 30 秒內 |
| 禁止匯入模組 | 使用白名單模組或 ctx.http.call() |
11. Shadow DOM 與 CSS 樣式規範
⚠️ 重要:Custom App 在 AI GO Runtime 中是以 Shadow DOM 封裝執行的,與獨立 HTML 頁面有本質性差異。未遵守此規範將導致「本地開發正常,部署後樣式全部消失」的問題。
根本原因
CSS :root 選擇器匹配的是文件樹的根元素 <html>。當 App 在 Shadow DOM 內執行時,:root 無法穿透 Shadow 邊界,所有透過 :root { --color: blue; } 定義的 CSS 變數在 App 內完全讀取不到。
主站 HTML(<html> = :root 生效範圍)
└── <custom-app-runtime> ← Web Component
└── #shadow-root (closed) ← Shadow DOM 邊界
└── <div id="root"> ← :root 不可觸及此區域
強制規則
所有 CSS 變數必須使用 :host, :root 雙選擇器:
/* ✅ 正確:Shadow DOM 和獨立頁面皆可運作 */
:host, :root {
--primary: #2563eb;
--background: #fafbfc;
}
/* ❌ 錯誤:部署後變數全部遺失 */
:root {
--primary: #2563eb;
}
同樣適用於 HTML 元素重設:
/* ✅ 正確 */
html, :host {
line-height: 1.5;
font-family: 'Inter', system-ui, sans-serif;
}
/* ❌ 錯誤 */
html {
line-height: 1.5;
}
選擇器對照表
| 選擇器 | 獨立 HTML 頁面 | Shadow DOM(AI GO Runtime) |
|---|---|---|
:root | ✅ | ❌ 無法穿透 |
:host | ❌ 無意義 | ✅ 命中 Shadow Host |
:host, :root | ✅ fallback | ✅ 命中 |
自查 Checklist
- 全文搜尋
:root {(不含:host)— 改為:host, :root { - 全文搜尋
html {(不含:host)— 改為html, :host { - Dark Mode 的
@media區塊同樣使用:host, :root
JavaScript API 限制
部分瀏覽器原生 API 在 Shadow DOM 中會被靜默阻擋(不拋錯、不顯示),導致「preview 正常、部署後無反應」:
| API | Shadow DOM 行為 | 替代方案 |
|---|---|---|
confirm() | 靜默回傳 false | React useState 二階段確認 |
alert() | 不顯示 | react-hot-toast 或自訂 Toast |
prompt() | 回傳 null | React 自訂 input modal |
// ✅ 正確:React state 確認
const [showConfirm, setShowConfirm] = useState(false);
// ❌ 錯誤:confirm() 在 Runtime 中永遠回傳 false
if (!confirm("確定嗎?")) return;
可正常使用的 API:localStorage、fetch、window.location.reload()。
容器滾動限制
Shadow DOM 的根容器預設不具備捲動能力。當 App 內容超出視窗高度時,使用者無法向下滾動。
必須在最外層 Layout 元件設定明確的高度與溢出行為:
// ✅ 正確:明確設定高度與滾動
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div style={{
height: "100vh",
overflowY: "auto",
backgroundColor: "var(--color-gray-50)",
}}>
{children}
</div>
);
}
// ❌ 錯誤:minHeight 不會觸發 overflow
<div style={{ minHeight: "100vh" }}>
單頁 App 路由簡化
若 Custom App 只有一個主頁面(例如訂單看板、儀表板),不需要使用 React Router。直接在 App.tsx 渲染主元件即可,避免 Router 上下文缺失導致白屏:
// ✅ 單頁 App — 直接渲染,無需 Router
import OrderBoardPage from "./pages/OrderBoardPage";
export default function App() {
return (
<AppLayout>
<Toaster position="top-center" />
<OrderBoardPage />
</AppLayout>
);
}
// ❌ 單頁 App 卻使用 BrowserRouter — 在 Shadow DOM 中會白屏
import { BrowserRouter, Routes, Route } from "react-router-dom";
// BrowserRouter 無法控制 Runtime 的 URL,路由永遠匹配不到
何時需要 Router:只有在 App 有多個頁面(搭配 Sidebar 導航切換)時才需要
HashRouter。
12. VFS 注入腳本開發規範
⚠️ 使用 Python 腳本直接組合 React JSX 原始碼時,極易因字串操作引入語法錯誤,導致 esbuild 編譯失敗。
字串操作風險
# ❌ 危險:用 str.replace() 修改 JSX
text = text.replace(
"return (\n <main>",
"return (\n return (\n <main>" # 意外重複 return
)
# esbuild 報錯:Unexpected "return"
推薦做法
將每個 VFS 檔案以完整 raw string 定義,不做字串拼接或取代:
# ✅ 正確:完整定義,不做字串操作
files["src/pages/CartPage.tsx"] = r'''import React from "react";
export default function CartPage() {
return (
<main className="container">
<h1>購物車</h1>
</main>
);
}
'''
編譯防禦
部署腳本在呼叫 Compile API 後,必須檢查 success 欄位:
result = r.json()
if not result.get("success"):
print(f"❌ 編譯失敗:\n{result.get('error')}")
sys.exit(1) # 絕對不允許帶著編譯錯誤發布
VFS 版本鎖
讀取 App 詳情時必須透過 GET /builder/apps/{id}(單一物件端點)取得精確的 vfs_version,而非使用列表端點。
13. 共享資料表隔離策略 (Data Domain Separation)
💡 適用場景:當多個 Custom App 共用相同的 SaaS 標準表(如
product_templates、sale_orders、customers),如何防止資料互相污染。
在發展微服務架構的 Custom App 時,我們常會讓多個 App 共用核心表以方便未來建立統一的營收或顧客報表。但同時,不同 App 的前端應只看到屬於自己的資料。為達成此目的,必須引入 Data Domain 隔離策略。
核心策略:app_domain 標籤
利用資料表內的 JSON 欄位(通常為 custom_data),在所有相關記錄中注入 app_domain 屬性。每個 Custom App 都有專屬的 Domain 標識符(例:餐飲 = "food",空間租借 = "space")。
實作步驟
-
注入標籤(Insert): 在 SDK
db.ts或 Server-Side Action 執行insert時,強制將custom_data.app_domain寫入。await insert("sale_orders", { data: { name: `ORDER-${Date.now()}`, amount_total: 1000, custom_data: { app_domain: "space", // ← 宣告資料所有權 booking_date: "2024-05-01" } } }); -
強制過濾(Query): 在所有
query動作中,無論是列表查詢或關聯查詢,都必須顯式加入 JSON 欄位的過濾條件。const spaces = await query("product_templates", { filters: [{ column: "custom_data", op: "ilike", value: "%space%" // ← 過濾只屬於 app_domain="space" 的資料 }], limit: 100 });注意:使用
ilike或透過進階 JSONB 操作符是常見解法,未來平台將提供原生 JSON 結構過濾支援。 -
白名單隔離(AppDataReference): 在建立 App 的 DB Proxy 授權 (
app_data_references表) 時,這無法限制列級別 (Row-Level) 的存取。因此前端程式碼層級的防護是必須的。只有正確實作過濾的前端,配合正確的 AppDataReference 欄位白名單,才能達成完整的資料隔離。
分表 vs 共表 的選擇
- 使用自訂表 (Custom Data):適用於該業務用戶專屬、與核心金流/產品無交集的高度客製化欄位(如:客戶滿意度問卷、排班表)。
- 共用標準核心表 (Shared standard tables) +
app_domain:適用於可以共用底層基礎建設的資料,如商品(product_templates)、訂單(sale_orders)、顧客(customers)。這有助於後續在管理員後台建立跨部門統一財報。
14. 檔案上傳與儲存 (Storage API)
💡 適用場景:Custom App 需要讓使用者上傳圖片、文件、或其他檔案時,使用平台提供的 Storage API 進行統一管理。
概述
Custom App 可透過 /api/v1/ext/storage/* 端點進行檔案的上傳、下載、列出與刪除。所有檔案會自動存放在租戶與 App 的隔離路徑下,確保資料安全。
認證方式
所有 Storage API 都需要 Custom App Token(與 ext/proxy 相同的認證機制)。Token 在 Runtime 中可透過 window.__APP_TOKEN__ 取得。
API 端點
| 操作 | HTTP 方法 | 端點 |
|---|---|---|
| 上傳檔案 | POST | /api/v1/ext/storage/upload |
| 取得檔案 URL | GET | /api/v1/ext/storage/url?path={path} |
| 刪除檔案 | DELETE | /api/v1/ext/storage/file?path={path} |
| 列出檔案 | GET | /api/v1/ext/storage/list?folder={folder} |
上傳檔案
POST /api/v1/ext/storage/upload
Authorization: Bearer {custom_app_token}
Content-Type: multipart/form-data
file: (binary)
folder: "receipts" # 可選,子資料夾名稱
回應:
{
"path": "tenant-id/app-id/receipts/invoice.pdf",
"bucket": "files",
"size": 102400,
"mime_type": "application/pdf"
}
取得 Signed URL
GET /api/v1/ext/storage/url?path=tenant-id/app-id/receipts/invoice.pdf
Authorization: Bearer {custom_app_token}
回應:
{
"url": "https://xxx.supabase.co/storage/v1/object/sign/files/...",
"expires_in": 3600
}
列出檔案
GET /api/v1/ext/storage/list?folder=receipts&limit=50&offset=0
Authorization: Bearer {custom_app_token}
回應:
{
"files": [
{ "name": "invoice.pdf", "size": 102400, "updated_at": "2026-04-07T12:00:00Z" }
],
"count": 1
}
刪除檔案
DELETE /api/v1/ext/storage/file?path=tenant-id/app-id/receipts/invoice.pdf
Authorization: Bearer {custom_app_token}
限制與安全
| 限制 | 值 |
|---|---|
| 單檔大小上限 | 100 MB |
| 儲存 Bucket | files(共用,路徑隔離) |
| 路徑格式 | {tenant_id}/{app_id}/{folder}/{filename} |
| 跨 App 存取 | ❌ 403 Forbidden |
在前端使用
// 上傳檔案
const apiBase = (window as any).__API_BASE__ || '/api/v1';
const token = (window as any).__APP_TOKEN__ || '';
async function uploadFile(file: File, folder?: string) {
const formData = new FormData();
formData.append('file', file);
if (folder) formData.append('folder', folder);
const resp = await fetch(`${apiBase.replace('/api/v1', '')}/api/v1/ext/storage/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
return resp.json();
}
// 取得 Signed URL
async function getFileUrl(path: string) {
const resp = await fetch(
`${apiBase.replace('/api/v1', '')}/api/v1/ext/storage/url?path=${encodeURIComponent(path)}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const data = await resp.json();
return data.url;
}
注意:上傳的檔案會計入租戶的儲存空間用量統計,管理員可在 Dashboard 的「用量管理 > 儲存空間」查看。
15. 開發與部署最佳實踐 (Best Practices)
為了確保 Custom App 的維護性與跨環境同步的穩定度,請在開發與持續整合 (CI/CD) 過程中遵循以下防呆與最佳實踐:
15.1 VFS 同步與完整性驗證 (Environment Sync)
當您建立腳手架腳本 (Scaffolding Scripts) 將本機原始碼推送到雲端 Custom App 端點時,極易發生「本機檔案沒跟上雲端」的脫鉤狀況:
- 正確區分環境變數:若您利用 API 撰寫快速部署腳本,請確保 CLI 工具嚴格校驗或區隔
--env local與--env cloud。將程式推到錯誤的環境金鑰,將導致您在雲端 Dashboard 發生500 - 無法從 Storage 載入模板的中斷錯誤(因為雲端只讀到版號卻無實際檔案)。 - 原子性操作:強烈建議使用
PATCH /api/v1/builder/apps/{id}/source/files一次性更新所有相依的 VFS 檔案,且在指令結束前加入一次GET確認VFS 檔案數量是否一致。
15.2 防禦性編碼與禁用佔位符 (Prevent Lazy Code Replacement)
在維護龐大的 React 元件或複雜邏輯時,人類或 AI Agent 輔助編碼經常會使用 # ... 或 // ... 原有程式碼 等懶惰佔位符 (Lazy Loading)。在傳統專案中這可能無害,但在 VFS 動態編譯架構 中是致命的:
- 破壞編譯器上下文:esbuild 編譯器在雲端處理您的 TSX 時,任何省略或殘缺的程式片段將直接導致 AST 解析失敗,進而阻斷整個 App 的渲染。
- 嚴格規範:
- 無論更新多小的改動,每次覆寫單一 VFS 檔案都必須提供 100% 完整的檔案原始碼。
- 嚴禁在原始碼中殘留
// 這裡省略前面的程式碼或// ...。 - 利用 TypeScript 嚴格定義型別。一旦發生型別隱含錯誤,Runtime 沙箱的除錯成本將高於本地開發。
延伸閱讀
- AI GO 系統串接指南 — 第三方自建應用 API Key 整合
- Custom App 支援 Internal(內部)和 External(外部)兩種模式