合流 batch:步驟 3/4/5/6 併成一版出貨(同一顆 cypher worker,bundle 是 worker 粒度)
鐵律(08-01 血淚):CP 可分步驗收,但 bundle 是 worker 粒度——凡改同一顆 worker 的多步, 出貨前必先合流成一版再重生 bundle,否則 stage 只拿到一步(三條分支各自建 bundle 的坑)。 base=feat/step3-missing-guidance(最前緣,已含 step4+step6+t158-t162 prod 修) merge=feat/step5-conditional-edges(引擎條件邊+recipe 三層+教材世代修)
This commit is contained in:
@@ -43,12 +43,28 @@ export function buildExecutionGraph(
|
||||
iterator = foreachMatch[1];
|
||||
label = '對每個'; // 改回標準 label 走 SEMANTIC_EDGE_MAP
|
||||
}
|
||||
const edge: { from: string; to: string; type: ReturnType<typeof toEdgeType>; iterator?: string } = {
|
||||
|
||||
// 「ON_BRANCH(標籤)」抽 branch:意圖語法表達具名分支(SDD workflow-discovery 3.11)
|
||||
// 例:'my_switch >> ON_BRANCH(branch_active) >> 處理啟用' → type=ON_BRANCH, branch='branch_active'
|
||||
// 沒有這段的話,帶括號的 label 會落到 toEdgeType 的預設值 PIPE ⇒ 分支靜默失效
|
||||
// (即「教了語法但引擎不收」——比沒做更糟,故與 skill 文件同批補上)
|
||||
let branch: string | undefined;
|
||||
const branchMatch = label.match(/^(?:ON_BRANCH|分支)\s*[((]\s*([\w-]+)\s*[))]$/i);
|
||||
if (branchMatch) {
|
||||
branch = branchMatch[1];
|
||||
label = 'ON_BRANCH';
|
||||
}
|
||||
|
||||
const edge: {
|
||||
from: string; to: string; type: ReturnType<typeof toEdgeType>;
|
||||
iterator?: string; branch?: string;
|
||||
} = {
|
||||
from: e.from.toLowerCase().replace(/\s+/g, '-'),
|
||||
to: e.to.toLowerCase().replace(/\s+/g, '-'),
|
||||
type: toEdgeType(label),
|
||||
};
|
||||
if (iterator) edge.iterator = iterator;
|
||||
if (branch) edge.branch = branch;
|
||||
return edge;
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { resolveNodeRole, isVirtualIoName } from './triplet-parser';
|
||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||
import { resolveRecipe } from '../routes/recipes';
|
||||
import type { RecipeDefinition } from '../routes/recipes';
|
||||
import { branchHintFor } from '../lib/branch-hints';
|
||||
import type { BranchHint } from '../lib/branch-hints';
|
||||
|
||||
/**
|
||||
* `not_found` 而非 `missing`:欄位契約以頂層機械考
|
||||
@@ -52,6 +54,18 @@ export type NodeInfo = {
|
||||
/** recipe found 時附上(AI 看得懂這個 recipe 在打哪個 API)。 */
|
||||
description?: string;
|
||||
endpoint?: string;
|
||||
/**
|
||||
* recipe 的 payload/回應用法自我說明(3.12,同 branch_hint 的動機):
|
||||
* 逐顆查 recipe 時光看 endpoint 不知道「payload 怎麼填、回應怎麼取值」⇒ 會退回寫 code。
|
||||
*/
|
||||
payload_hint?: {
|
||||
/** 這個 recipe 期望的 body 形狀(body_template 的欄位骨架,值是 {{var}} 佔位) */
|
||||
body_template?: unknown;
|
||||
/** 回應正規化規則存在時,說明取值路徑等 */
|
||||
response_map?: unknown;
|
||||
/** 一行說明:怎麼用這個 recipe */
|
||||
usage: string;
|
||||
};
|
||||
/**
|
||||
* not_found 時的分型指路(task 3.7):兩庫(零件 registry+recipe 庫)都查過才點名,
|
||||
* 並告訴 AI 該走哪條補件路+去哪裡看做法。欄位名 `suggestion`(單數字串)=verify.sh 03 組契約。
|
||||
@@ -63,6 +77,13 @@ export type NodeInfo = {
|
||||
similar_recipes?: string[];
|
||||
/** resolved 時的替換明細(步驟 4:意圖節點 → 真實零件/recipe)。 */
|
||||
substitution?: NodeSubstitution;
|
||||
/**
|
||||
* 分支用法自我說明(3.11):只有「本身會分岔」的零件才有
|
||||
* (if_control/switch/try_catch)。
|
||||
* 存在的理由=走 n8n 式「逐顆查、自己組圖」的 AI,光看 input_schema 不知道
|
||||
* 「判斷完之後兩條路怎麼接」⇒ 會回頭寫 code。判準:只看這一顆的回應就知道怎麼接下一步。
|
||||
*/
|
||||
branch_hint?: BranchHint;
|
||||
};
|
||||
|
||||
export type SearchResult = {
|
||||
@@ -216,6 +237,7 @@ export async function searchNodes(
|
||||
input_schema: hit.input_schema,
|
||||
success_rate: typeof hit.success_rate === 'number' ? hit.success_rate : undefined,
|
||||
stability: typeof hit.stability === 'string' ? hit.stability : undefined,
|
||||
branch_hint: branchHintFor(componentId),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
@@ -230,6 +252,7 @@ export async function searchNodes(
|
||||
source: 'recipe',
|
||||
description: recipe.description,
|
||||
endpoint: recipe.endpoint,
|
||||
payload_hint: buildPayloadHint(recipe),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
@@ -355,6 +378,7 @@ async function legacyPerNodeLookup(
|
||||
info: {
|
||||
status: 'found', componentId, type: role, source: 'component',
|
||||
input_schema: q.entry.input_schema, success_rate: q.entry.success_rate, stability: q.entry.stability,
|
||||
branch_hint: branchHintFor(componentId),
|
||||
},
|
||||
missing: false,
|
||||
};
|
||||
@@ -366,6 +390,7 @@ async function legacyPerNodeLookup(
|
||||
info: {
|
||||
status: 'found', componentId: recipe.canonical_id, type: role, source: 'recipe',
|
||||
description: recipe.description, endpoint: recipe.endpoint,
|
||||
payload_hint: buildPayloadHint(recipe),
|
||||
},
|
||||
missing: false,
|
||||
};
|
||||
@@ -407,7 +432,7 @@ async function legacyPerNodeLookup(
|
||||
type SubstitutionHit = Pick<
|
||||
NodeInfo,
|
||||
'status' | 'componentId' | 'source' | 'substitution' |
|
||||
'input_schema' | 'success_rate' | 'stability' | 'description' | 'endpoint'
|
||||
'input_schema' | 'success_rate' | 'stability' | 'description' | 'endpoint' | 'branch_hint'
|
||||
>;
|
||||
|
||||
function trySubstitution(
|
||||
@@ -472,6 +497,9 @@ function trySubstitution(
|
||||
input_schema: top.entry.input_schema,
|
||||
success_rate: typeof top.entry.success_rate === 'number' ? top.entry.success_rate : undefined,
|
||||
stability: typeof top.entry.stability === 'string' ? top.entry.stability : undefined,
|
||||
// 替換成分岔零件時(例「判斷有沒有新資料」→ if_control)一併附分支用法,
|
||||
// 否則 AI 換到零件卻不知道怎麼接兩條路,仍會退回寫 code。
|
||||
branch_hint: branchHintFor(top.entry.canonical_id),
|
||||
substitution: {
|
||||
from: nodeName,
|
||||
componentId: top.entry.canonical_id,
|
||||
@@ -539,6 +567,41 @@ function buildSuggestion(componentId: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* recipe 的 payload/回應用法自我說明(3.12)。
|
||||
* 動機同 branch_hint:逐顆查 recipe(n8n 式)時,光看 endpoint 不知道 payload 怎麼填、
|
||||
* 回應怎麼取值 ⇒ AI 會退回把整包寫進 workflow code。
|
||||
*/
|
||||
export function buildPayloadHint(recipe: RecipeDefinition): NodeInfo['payload_hint'] {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (recipe.body_template) {
|
||||
parts.push('payload 已收在 recipe 的 body_template 裡,你只要把 {{變數}} 對應的值放進節點 context');
|
||||
} else if (recipe.body) {
|
||||
parts.push('payload 形狀見 body 欄位({{變數}} 由節點 context 填)');
|
||||
} else {
|
||||
parts.push('未定義 body_template:節點 context 會整包當 body 送出(_ 開頭的內部欄位會被剔除)');
|
||||
}
|
||||
|
||||
if (recipe.response_map) {
|
||||
parts.push('回應已正規化:執行結果除了原始 data,另附 text(取值路徑等規則寫在 recipe 裡,換源不必改 workflow)');
|
||||
} else {
|
||||
parts.push('未定義 response_map:回應原樣放在 data,取值要自己指路徑');
|
||||
}
|
||||
|
||||
if (recipe.auth === 'binding') {
|
||||
parts.push(`認證=binding(免金鑰,用平台內建 ${recipe.binding_name ?? 'AI'})`);
|
||||
} else if (recipe.auth_service) {
|
||||
parts.push(`認證走 auth recipe「${recipe.auth_service}」(金鑰由系統在執行前注入,你不必也不該填)`);
|
||||
}
|
||||
|
||||
return {
|
||||
body_template: recipe.body_template,
|
||||
response_map: recipe.response_map,
|
||||
usage: parts.join(';') + '。',
|
||||
};
|
||||
}
|
||||
|
||||
// ── registry 查詢 ─────────────────────────────────────────────────────────────
|
||||
|
||||
type CatalogEntry = {
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||
import { fetchTenantWorkflowSearch } from '../lib/workflow-search';
|
||||
import { listAllRecipes, type SearchNodesEnv } from './search-nodes';
|
||||
import { listAllRecipes, buildPayloadHint, type SearchNodesEnv } from './search-nodes';
|
||||
import { branchHintFor } from '../lib/branch-hints';
|
||||
|
||||
export type TargetQueryEnv = SearchNodesEnv & {
|
||||
KBDB_BASE_URL?: string;
|
||||
@@ -44,12 +45,21 @@ export async function searchByTarget(
|
||||
);
|
||||
if (!res.ok) return { ok: false, status: 502, error: `registry 搜尋失敗(HTTP ${res.status})` };
|
||||
const body = (await res.json()) as { data?: { results?: unknown[]; count?: number } };
|
||||
// 3.11:逐顆查零件(n8n 式「自己一顆一顆填」)時,會分岔的零件要自我說明分支用法。
|
||||
// leo 08-01:「它可以一一查詢自己手工填寫每個零件,就像在 n8n 那樣」——
|
||||
// 這條路徑若只回 input_schema,AI 拿到 if_control/switch 仍不知道兩條路怎麼接 ⇒ 回頭寫 code。
|
||||
const results = (body.data?.results ?? []).map(r => {
|
||||
if (!r || typeof r !== 'object') return r;
|
||||
const rec = r as Record<string, unknown>;
|
||||
const hint = branchHintFor(typeof rec.canonical_id === 'string' ? rec.canonical_id : undefined);
|
||||
return hint ? { ...rec, branch_hint: hint } : rec;
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
body: {
|
||||
target,
|
||||
query,
|
||||
results: body.data?.results ?? [],
|
||||
results,
|
||||
count: body.data?.count ?? 0,
|
||||
},
|
||||
};
|
||||
@@ -64,7 +74,10 @@ export async function searchByTarget(
|
||||
const q = query.toLowerCase();
|
||||
// 與 discover 混搜同一份庫(私庫=workflow 實際引用得到的);子字串比對、canonical 去重
|
||||
const seen = new Set<string>();
|
||||
const results: Array<{ canonical_id: string; display_name?: string; description?: string; endpoint: string }> = [];
|
||||
const results: Array<{
|
||||
canonical_id: string; display_name?: string; description?: string; endpoint: string;
|
||||
payload_hint?: unknown;
|
||||
}> = [];
|
||||
for (const r of all) {
|
||||
if (seen.has(r.canonical_id)) continue;
|
||||
const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
|
||||
@@ -75,6 +88,8 @@ export async function searchByTarget(
|
||||
display_name: r.display_name,
|
||||
description: r.description,
|
||||
endpoint: r.endpoint,
|
||||
// 3.12:逐顆查 recipe 時也要說得出「payload 怎麼填、回應怎麼取值」
|
||||
payload_hint: buildPayloadHint(r),
|
||||
});
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -478,6 +478,37 @@ export class GraphExecutor {
|
||||
break;
|
||||
}
|
||||
|
||||
// ── 條件邊(SDD workflow-discovery 3.11 / CP arcrun-usable 步驟 5 缺口①)──
|
||||
// 為什麼要有:`if_control` 回 {result, branch} 卻沒有邊讀得懂它,
|
||||
// AI 照規矩用了零件仍得寫 code 判斷走哪條 ⇒「全變成 code」的根(Arcrun#5)。
|
||||
// 讀法對齊零件 output_schema:優先 data.branch(if_control/switch 的正式形狀),
|
||||
// 相容 top-level branch / result 布林。讀不出分支=不走(誠實,不亂挑一條)。
|
||||
case 'ON_TRUE': {
|
||||
if (readBranch(result) === 'true') {
|
||||
const mergedCtx = propagateCtx(context, result, node.id);
|
||||
result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ON_FALSE': {
|
||||
if (readBranch(result) === 'false') {
|
||||
const mergedCtx = propagateCtx(context, result, node.id);
|
||||
result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ON_BRANCH': {
|
||||
// switch 具名分支:邊上的 branch 要跟上游 output 的 branch 字面相等才走
|
||||
const actual = readBranch(result);
|
||||
if (edge.branch !== undefined && actual !== undefined && actual === edge.branch) {
|
||||
const mergedCtx = propagateCtx(context, result, node.id);
|
||||
result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'FOREACH': {
|
||||
const iteratorKey = edge.iterator ?? 'item';
|
||||
// 找 iterable 順序:先看上游 output (result),沒有再看完整 context (含上游 chain 累積的 fields)
|
||||
@@ -631,6 +662,30 @@ function getNestedValue(ctx: unknown, path: string): unknown {
|
||||
return cur;
|
||||
}
|
||||
|
||||
/**
|
||||
* 從節點 output 讀出「走哪條分支」(SDD workflow-discovery 3.11)
|
||||
*
|
||||
* 讀取順序(對齊零件 contract 的 output_schema,由正式到相容):
|
||||
* 1. `data.branch` —— if_control / switch 的正式輸出形狀 {success, data:{result, branch}}
|
||||
* 2. `branch` —— 已被 propagateCtx spread 到 top-level 的情況
|
||||
* 3. `data.result` —— 只有布林沒有 branch 的零件
|
||||
* 4. `result` —— top-level 布林
|
||||
* 讀不出來回 undefined ⇒ 呼叫端一律不走該邊(誠實:寧可不走,不亂挑一條)。
|
||||
*/
|
||||
function readBranch(result: unknown): string | undefined {
|
||||
if (!result || typeof result !== 'object') return undefined;
|
||||
const r = result as Record<string, unknown>;
|
||||
const data = (r.data && typeof r.data === 'object') ? r.data as Record<string, unknown> : undefined;
|
||||
|
||||
const named = data?.branch ?? r.branch;
|
||||
if (typeof named === 'string') return named;
|
||||
|
||||
const bool = data?.result ?? r.result;
|
||||
if (typeof bool === 'boolean') return bool ? 'true' : 'false';
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 判斷節點執行結果是否為失敗:success === false 或含有 error key */
|
||||
function isFailure(result: unknown): boolean {
|
||||
if (!result || typeof result !== 'object') return false;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 分支用法自我說明(SDD workflow-discovery 3.11 / CP arcrun-usable 步驟 5)
|
||||
*
|
||||
* 為什麼需要這一層(leo 08-01 逼出的洞,別刪):
|
||||
* leo:「它也可以不要送整個意圖工作流去查詢,它可以**一一查詢自己手工填寫每個零件,
|
||||
* 就像在 n8n 那樣**,這時它不會每個都寫 code?」
|
||||
* 取證:逐顆查 `if_control`,回應只有 {status, componentId, input_schema, success_rate…},
|
||||
* `input_schema` 只說得出 {condition, input}——**沒有任何欄位告訴 AI「判斷完之後兩條路怎麼分岔」**
|
||||
* ⇒ 走 n8n 式逐顆查、自己組圖的 AI 拿到 if_control 後必然卡在「然後呢」,回頭寫 code。
|
||||
*
|
||||
* 判準(leo 一貫要求:資訊出現在需要它的那一刻):
|
||||
* **AI 只看這一顆的查詢回應,就知道怎麼接下一步**,不必回頭讀 skill。
|
||||
*
|
||||
* 三顆流程控制零件的 output_schema 都收斂到同一個形狀 `data.branch: string`
|
||||
* ⇒ 引擎只有「依標籤選邊」一個機制(ON_BRANCH),ON_TRUE/ON_FALSE 是布林路的語法糖。
|
||||
*/
|
||||
|
||||
export type BranchHint = {
|
||||
/** 這顆零件會輸出哪個欄位當分支標籤 */
|
||||
branch_field: string;
|
||||
/** 可能的分支標籤(switch 是動態的,故標明由 cases 決定) */
|
||||
branches: string[] | string;
|
||||
/** 接下游要用哪些邊型 */
|
||||
edge_types: string[];
|
||||
/** 一行說明:這顆零件之後怎麼分岔 */
|
||||
usage: string;
|
||||
/** 可直接照抄的最小範例(意圖語法+對應的邊) */
|
||||
example: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 零件 → 分支用法。key = canonical_id。
|
||||
* 只收「本身會分岔」的零件;不分岔的零件不該有 branch_hint(避免噪音)。
|
||||
*/
|
||||
const BRANCH_HINTS: Record<string, BranchHint> = {
|
||||
if_control: {
|
||||
branch_field: 'data.branch',
|
||||
branches: ['true', 'false'],
|
||||
edge_types: ['ON_TRUE', 'ON_FALSE'],
|
||||
usage:
|
||||
'這顆算完會輸出 data.branch("true"/"false")。下游接兩條邊:ON_TRUE 接條件成立要做的事,' +
|
||||
'ON_FALSE 接不成立要做的事。**不需要自己寫 code 判斷走哪條**——引擎依 branch 自動選路。',
|
||||
example:
|
||||
'判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram\n' +
|
||||
'判斷有沒有新資料 >> ON_FALSE >> 結束\n' +
|
||||
'(中文語意詞亦可:「成立時」=ON_TRUE、「否則」=ON_FALSE)',
|
||||
},
|
||||
switch: {
|
||||
branch_field: 'data.branch',
|
||||
branches: '由 input_schema.cases[].branch 與 default_branch 決定(N 路,非固定清單)',
|
||||
edge_types: ['ON_BRANCH'],
|
||||
usage:
|
||||
'這顆依 value 比對 cases,輸出 data.branch=命中那個 case 的 branch 名(都沒中則是 default_branch)。' +
|
||||
'下游**每條路各接一條 ON_BRANCH 邊,並在邊上標 branch 等於你在 cases 裡取的名字**。' +
|
||||
'default_branch 不需要特別的邊型,照樣用 ON_BRANCH 標它的名字即可。',
|
||||
example:
|
||||
'{"cases":[{"match":"active","branch":"branch_active"}],"default_branch":"branch_default"}\n' +
|
||||
'edges: [\n' +
|
||||
' {"from":"my_switch","to":"處理啟用","type":"ON_BRANCH","branch":"branch_active"},\n' +
|
||||
' {"from":"my_switch","to":"處理其他","type":"ON_BRANCH","branch":"branch_default"}\n' +
|
||||
']',
|
||||
},
|
||||
try_catch: {
|
||||
branch_field: 'data.branch',
|
||||
branches: ['try', 'catch'],
|
||||
edge_types: ['ON_BRANCH'],
|
||||
usage:
|
||||
'這顆看上游 error 是否非空,輸出 data.branch("try"=沒錯/"catch"=有錯)。' +
|
||||
'下游接兩條 ON_BRANCH 邊,branch 分別標 "try" 與 "catch"。' +
|
||||
'**錯誤處理不需要寫 code**——把要補救的節點接在 catch 那條邊後面即可。',
|
||||
example:
|
||||
'edges: [\n' +
|
||||
' {"from":"my_try_catch","to":"正常流程","type":"ON_BRANCH","branch":"try"},\n' +
|
||||
' {"from":"my_try_catch","to":"補救流程","type":"ON_BRANCH","branch":"catch"}\n' +
|
||||
']',
|
||||
},
|
||||
};
|
||||
|
||||
/** 取某零件的分支用法說明;不分岔的零件回 undefined(回應不加噪音)。 */
|
||||
export function branchHintFor(componentId: string | undefined): BranchHint | undefined {
|
||||
if (!componentId) return undefined;
|
||||
return BRANCH_HINTS[componentId.toLowerCase()];
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { isComponentHash, isRecipeHash } from './hash';
|
||||
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
|
||||
import type { AuthRecipeDefinition } from '../routes/recipes';
|
||||
import type { Bindings, ComponentRunner, ServiceBinding } from '../types';
|
||||
import { renderBodyTemplate, applyResponseMap } from './recipe-payload';
|
||||
|
||||
/**
|
||||
* WASM HTTP runner:canonical_id → 對應獨立 Worker URL。
|
||||
@@ -120,7 +121,7 @@ export function createComponentLoader(env: Bindings) {
|
||||
// 4. rec_hash → 查 RECIPES KV idx → recipe 執行
|
||||
if (isRecipeHash(componentId)) {
|
||||
const recipe = await resolveRecipe(componentId, env.RECIPES);
|
||||
if (recipe) return makeRecipeRunner(recipe);
|
||||
if (recipe) return pickRecipeRunner(recipe, env);
|
||||
throw new Error(`找不到 recipe hash "${componentId}",請確認已透過 acr push 上傳`);
|
||||
}
|
||||
|
||||
@@ -134,7 +135,7 @@ export function createComponentLoader(env: Bindings) {
|
||||
|
||||
// 6. KV recipe(動態,用戶 push 的)
|
||||
const kvRecipe = await resolveRecipe(componentId, env.RECIPES);
|
||||
if (kvRecipe) return makeRecipeRunner(kvRecipe);
|
||||
if (kvRecipe) return pickRecipeRunner(kvRecipe, env);
|
||||
|
||||
// 7. WASM HTTP runner:auth primitive / API 零件 → 獨立 Worker URL
|
||||
// 白名單見 WASM_HTTP_RUNNER_IDS(http_request、5 個待降級 API 零件、4 個 auth primitive)。
|
||||
@@ -271,6 +272,73 @@ function makeLogicRunner(canonicalId: string, env: Bindings): ComponentRunner |
|
||||
return makeHttpRunner(wasmWorkerUrl(canonicalId, env.WORKER_SUBDOMAIN));
|
||||
}
|
||||
|
||||
/**
|
||||
* recipe → runner 的分派(3.12):auth='binding' 走平台 binding(免金鑰),
|
||||
* 其餘一律走既有 HTTP 路徑(沒宣告 auth 的舊 recipe 完全不受影響)。
|
||||
*/
|
||||
function pickRecipeRunner(
|
||||
recipe: import('../routes/recipes').RecipeDefinition,
|
||||
env: Bindings,
|
||||
): ComponentRunner {
|
||||
return recipe.auth === 'binding'
|
||||
? makeBindingRecipeRunner(recipe, env)
|
||||
: makeRecipeRunner(recipe);
|
||||
}
|
||||
|
||||
/**
|
||||
* auth='binding' 的 recipe runner(3.12 第四型認證):不打外部 HTTP、不需要任何金鑰,
|
||||
* 直接用平台 binding(env.AI/VECTORIZE/…)⇒ leo 要的「開機就可用」。
|
||||
*
|
||||
* 為什麼要開這型:recipe 的舊抽象=「打一個外部 HTTP API」(endpoint+method+auth_service),
|
||||
* 而 Cloudflare 的 binding 呼叫不是 HTTP ⇒ **整類能力被排除在 recipe 之外**。
|
||||
* 開這一型不是為 Workers AI 開特例,是一次打開 env.AI/VECTORIZE/BROWSER/QUEUE 整排。
|
||||
*/
|
||||
function makeBindingRecipeRunner(
|
||||
recipe: import('../routes/recipes').RecipeDefinition,
|
||||
env: Bindings,
|
||||
): ComponentRunner {
|
||||
return async (ctx: unknown) => {
|
||||
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
||||
const name = recipe.binding_name ?? 'AI';
|
||||
const binding = (env as unknown as Record<string, unknown>)[name];
|
||||
|
||||
if (!binding) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
`recipe "${recipe.canonical_id}" 宣告 auth: binding、binding_name: "${name}",` +
|
||||
`但這個部署沒有綁定 ${name}。請在 wrangler.toml 補上該 binding 後重新部署。`,
|
||||
};
|
||||
}
|
||||
|
||||
// endpoint 在 binding 型當作「要呼叫的資源名」(例 Workers AI 的模型 id)
|
||||
const target = recipe.endpoint;
|
||||
const payload = renderBodyTemplate(recipe.body_template ?? recipe.body, ctxObj)
|
||||
?? Object.fromEntries(Object.entries(ctxObj).filter(([k]) => !k.startsWith('_')));
|
||||
|
||||
try {
|
||||
const runner = binding as { run?: (model: string, input: unknown) => Promise<unknown> };
|
||||
if (typeof runner.run !== 'function') {
|
||||
return {
|
||||
success: false,
|
||||
error: `binding "${name}" 沒有 run() 方法,目前 binding 型只支援 run(model, input) 形狀(如 env.AI)。`,
|
||||
};
|
||||
}
|
||||
const data = await runner.run(target, payload);
|
||||
if (recipe.response_map) {
|
||||
const normalized = applyResponseMap(data, recipe.response_map);
|
||||
return { success: true, data, text: normalized.text };
|
||||
}
|
||||
return { success: true, data };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: `binding "${name}" 呼叫失敗(${target}):${e instanceof Error ? e.message : String(e)}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): ComponentRunner {
|
||||
return async (ctx: unknown) => {
|
||||
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
||||
@@ -293,9 +361,12 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
|
||||
headers[k] = interpolate(v);
|
||||
}
|
||||
|
||||
// body:把 recipe.body 裡的 {{key}} 都換掉
|
||||
// body:優先 body_template(③ payload 層,3.12——支援巢狀/dot path/保留型別),
|
||||
// 其次既有 recipe.body(淺層 {{key}},舊 recipe 照舊),最後才拿 ctx 當 body。
|
||||
let bodyStr: string | undefined;
|
||||
if (recipe.body) {
|
||||
if (recipe.body_template) {
|
||||
bodyStr = JSON.stringify(renderBodyTemplate(recipe.body_template, ctxObj));
|
||||
} else if (recipe.body) {
|
||||
bodyStr = interpolate(JSON.stringify(recipe.body));
|
||||
} else if (method !== 'GET') {
|
||||
// 沒指定 body template → 用 ctx 當 body,但剔除 _ 前綴的內部欄位
|
||||
@@ -313,6 +384,13 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
|
||||
});
|
||||
|
||||
const data = await readBodyOnce(res);
|
||||
|
||||
// ③ 回應正規化(3.12):未設 response_map ⇒ 原樣回傳(既有 recipe 零行為變化)。
|
||||
// 設了 ⇒ 額外附 `text`(各家形狀差異收在 recipe 裡,換源不必改 workflow)。
|
||||
if (recipe.response_map) {
|
||||
const normalized = applyResponseMap(data, recipe.response_map);
|
||||
return { success: res.ok, status: res.status, data, text: normalized.text };
|
||||
}
|
||||
return { success: res.ok, status: res.status, data };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export const VALID_EDGE_TYPES = new Set([
|
||||
'PIPE', 'IF', 'FOREACH', 'CONTINUE',
|
||||
// 新增:執行語意
|
||||
'IS_A', 'ON_SUCCESS', 'ON_FAIL',
|
||||
// 新增:條件語意(SDD workflow-discovery 3.11)—— 讀上游 if_control/switch 的 branch
|
||||
'ON_TRUE', 'ON_FALSE', 'ON_BRANCH',
|
||||
// 新增:觸發語意
|
||||
'ON_CLICK', 'CALLS_SUBFLOW',
|
||||
// 新增:結構語意(記錄圖結構,不執行)
|
||||
@@ -28,9 +30,19 @@ export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
|
||||
'失敗時': 'ON_FAIL',
|
||||
'對每個': 'FOREACH',
|
||||
'條件滿足時': 'IF',
|
||||
// 條件分支語意(SDD workflow-discovery 3.11):讓意圖工作流寫得出兩條路
|
||||
'成立時': 'ON_TRUE',
|
||||
'為真時': 'ON_TRUE',
|
||||
'不成立時': 'ON_FALSE',
|
||||
'為假時': 'ON_FALSE',
|
||||
'否則': 'ON_FALSE',
|
||||
// 英文別名
|
||||
'SUCCESS': 'ON_SUCCESS',
|
||||
'FAIL': 'ON_FAIL',
|
||||
'TRUE': 'ON_TRUE',
|
||||
'FALSE': 'ON_FALSE',
|
||||
'ELSE': 'ON_FALSE',
|
||||
'BRANCH': 'ON_BRANCH',
|
||||
'CLICK': 'ON_CLICK',
|
||||
'SUBFLOW': 'CALLS_SUBFLOW',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* recipe 的 payload 與回應處理層(SDD workflow-discovery 3.12 / CP arcrun-usable 步驟 5 缺口②)
|
||||
*
|
||||
* 為什麼存在(leo 的三層模型,第③層過去是空的):
|
||||
* ① 零件(http_request) ② auth recipe(auth_service) ③ **payload recipe** ← 這層
|
||||
* 舊 schema 存不住 body 與「回應怎麼取值」⇒ 帶 body 的 API 只能把整包寫進 workflow code,
|
||||
* 回應解析(rag_chat 的 finalize,2786 字元)綁死 Gemini 格式 ⇒ 換源必壞。
|
||||
* 有了這層:**換 LLM 供應商=換 recipe,不必動 workflow**。
|
||||
*
|
||||
* 相容鐵律:三個欄位全為選填。既有 recipe(沒有這些欄位)行為**完全不變**——
|
||||
* renderBodyTemplate(undefined,…) 回 undefined、applyResponseMap(body, undefined) 原樣回傳。
|
||||
*/
|
||||
|
||||
/** 回應正規化規則(隨 recipe 走,故換源=換 recipe) */
|
||||
export type ResponseMap = {
|
||||
/**
|
||||
* 取值路徑(dot path,支援陣列索引)。
|
||||
* 例:Gemini `candidates.0.content.parts.0.text`/Claude `content.0.text`/
|
||||
* Workers AI `response`。
|
||||
* 搭配 thinking_model 時可指向 parts 陣列本身。
|
||||
*/
|
||||
text_path?: string;
|
||||
/**
|
||||
* 思考型模型(如 gemma):parts 內會混入 `thought: true` 的思考過程,
|
||||
* 要剔除後取最後一個非 thought 的 part。
|
||||
*/
|
||||
thinking_model?: boolean;
|
||||
/** 淨化:要剝掉的前綴(實撞過「Draft:」「*」「Answer:」,且組合順序不定) */
|
||||
strip_prefixes?: string[];
|
||||
/** 答案標記:出現時只取其後的內容(實撞:模型會把草稿吐在標記前) */
|
||||
answer_marker?: string;
|
||||
};
|
||||
|
||||
/** 從物件用 dot path 取值:'a.0.b' → obj.a[0].b */
|
||||
function getPath(obj: unknown, path: string): unknown {
|
||||
let cur: unknown = obj;
|
||||
for (const part of path.split('.')) {
|
||||
if (cur === null || cur === undefined) return undefined;
|
||||
if (typeof cur !== 'object') return undefined;
|
||||
cur = (cur as Record<string, unknown>)[part];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
// ── ③-a body_template:payload 收回 recipe ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* 把 body_template 內所有 `{{var}}` 用 ctx 填掉(遞迴進巢狀 object / array)。
|
||||
*
|
||||
* 與 graph-executor 的 interpolateData 同一套語義(刻意一致,避免兩種插值行為):
|
||||
* - 整個字串就是單一 `{{x}}` → 回**原型別**(陣列/物件/數字不被 stringify)
|
||||
* - 混合文字 → 拼成字串
|
||||
* - 取不到 → **保留原樣** `{{x}}`(看得見才好 debug,不靜默吞掉)
|
||||
*/
|
||||
export function renderBodyTemplate(
|
||||
template: unknown,
|
||||
ctx: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (template === undefined || template === null) return undefined;
|
||||
return renderValue(template, ctx);
|
||||
}
|
||||
|
||||
function renderValue(v: unknown, ctx: Record<string, unknown>): unknown {
|
||||
if (typeof v === 'string') return renderString(v, ctx);
|
||||
if (Array.isArray(v)) return v.map(item => renderValue(item, ctx));
|
||||
if (v !== null && typeof v === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||
out[k] = renderValue(val, ctx);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function renderString(s: string, ctx: Record<string, unknown>): unknown {
|
||||
const single = s.match(/^\s*\{\{([\w.]+)\}\}\s*$/);
|
||||
if (single) {
|
||||
const val = getPath(ctx, single[1]);
|
||||
return val === undefined ? s : val;
|
||||
}
|
||||
return s.replace(/\{\{([\w.]+)\}\}/g, (_, key: string) => {
|
||||
const val = getPath(ctx, key);
|
||||
if (val === undefined) return `{{${key}}}`;
|
||||
return typeof val === 'string' ? val : JSON.stringify(val);
|
||||
});
|
||||
}
|
||||
|
||||
// ── ③-b response_map:回應正規化 ─────────────────────────────────────────────
|
||||
|
||||
export type NormalizedResponse = {
|
||||
/** 正規化後的純文字(沒有 response_map 或取不到時 undefined——誠實,不編造) */
|
||||
text?: string;
|
||||
/** 原始回應永遠保留(除錯與向後相容都靠它) */
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* 依 response_map 把各家 API 的回應正規化成 `{ text }`。
|
||||
* 沒給 map ⇒ 原樣回傳(既有 recipe 零行為變化)。
|
||||
*/
|
||||
export function applyResponseMap(body: unknown, map?: ResponseMap): NormalizedResponse {
|
||||
if (!map) return { raw: body };
|
||||
|
||||
let picked: unknown = map.text_path ? getPath(body, map.text_path) : body;
|
||||
|
||||
// 思考型模型:picked 是 parts 陣列 → 剔除 thought=true,取最後一個
|
||||
if (map.thinking_model && Array.isArray(picked)) {
|
||||
const real = picked.filter(
|
||||
p => !(p && typeof p === 'object' && (p as Record<string, unknown>).thought === true),
|
||||
);
|
||||
const last = real[real.length - 1];
|
||||
picked = (last && typeof last === 'object')
|
||||
? (last as Record<string, unknown>).text
|
||||
: last;
|
||||
}
|
||||
|
||||
if (typeof picked !== 'string') return { text: undefined, raw: body };
|
||||
|
||||
return { text: sanitize(picked, map), raw: body };
|
||||
}
|
||||
|
||||
/**
|
||||
* 淨化(知識是實撞出來的,非預想):
|
||||
* 1. 有 answer_marker → 只取標記**最後一次**出現之後的內容
|
||||
* (實撞:模型的自檢清單內文也會提到標記,用 lastIndexOf 才撈得到真的那個)
|
||||
* 2. 前綴組合順序不定(「* 【答】」「Draft: 【答】」「Answer: * 【答】」三型都撞過)
|
||||
* ⇒ **循環**剝殼,單趟剝不乾淨
|
||||
*/
|
||||
function sanitize(input: string, map: ResponseMap): string {
|
||||
let s = input.trim();
|
||||
|
||||
if (map.answer_marker) {
|
||||
const idx = s.lastIndexOf(map.answer_marker);
|
||||
if (idx >= 0) s = s.slice(idx + map.answer_marker.length);
|
||||
}
|
||||
|
||||
const prefixes = map.strip_prefixes ?? [];
|
||||
if (prefixes.length > 0) {
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
s = s.trimStart();
|
||||
for (const p of prefixes) {
|
||||
if (p && s.startsWith(p)) {
|
||||
s = s.slice(p.length);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s.trim();
|
||||
}
|
||||
@@ -14,9 +14,10 @@ export const graphSchema = z.object({
|
||||
edges: z.array(z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE', 'IS_A', 'ON_SUCCESS', 'ON_FAIL', 'ON_CLICK', 'CALLS_SUBFLOW', 'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR']),
|
||||
type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE', 'IS_A', 'ON_SUCCESS', 'ON_FAIL', 'ON_TRUE', 'ON_FALSE', 'ON_BRANCH', 'ON_CLICK', 'CALLS_SUBFLOW', 'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR']),
|
||||
condition: z.string().optional(),
|
||||
iterator: z.string().optional(),
|
||||
branch: z.string().optional(), // ON_BRANCH 的具名分支(SDD workflow-discovery 3.11)
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { deriveRecipeHash } from '../lib/hash';
|
||||
import type { ResponseMap } from '../lib/recipe-payload';
|
||||
|
||||
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -34,6 +35,26 @@ export interface RecipeDefinition {
|
||||
method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST
|
||||
headers?: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
/**
|
||||
* ③ payload 層(SDD workflow-discovery 3.12):帶 body 的 API 把 payload 收回 recipe,
|
||||
* 不必寫進 workflow code。與 `body` 的差別=支援巢狀 {{var}} 與 dot path、
|
||||
* 單一引用保留原型別。兩者並存時 body_template 優先(新欄位贏,舊 recipe 不受影響)。
|
||||
*/
|
||||
body_template?: Record<string, unknown>;
|
||||
/**
|
||||
* ③ 回應正規化層:各家 API 回應形狀不同(Gemini/Claude/Workers AI),
|
||||
* 取值路徑・思考型模型旗標・淨化規則**隨 recipe 走** ⇒ 換源=換 recipe,不必改 workflow。
|
||||
* 未設=原樣回傳(既有 recipe 行為零變化)。
|
||||
*/
|
||||
response_map?: ResponseMap;
|
||||
/**
|
||||
* 認證型別。未設=沿用既有 auth_service 判斷(向後相容)。
|
||||
* `binding`=**免金鑰**,用平台內建能力(env.AI/VECTORIZE/BROWSER/QUEUE),
|
||||
* 不是為 Workers AI 開特例——Cloudflare 這一整類都被舊抽象(只認 HTTP+金鑰)排除在外。
|
||||
*/
|
||||
auth?: 'static_key' | 'service_account' | 'oauth2' | 'binding';
|
||||
/** auth='binding' 時指定用哪個 binding(例 'AI'/'VECTORIZE')。 */
|
||||
binding_name?: string;
|
||||
/**
|
||||
* 此 recipe 要用哪個 auth recipe(auth_recipe:{auth_service})。
|
||||
* 讓多個 recipe 共用同一把 auth(例:kbdb_get / kbdb_create_block 都設 "kbdb")。
|
||||
@@ -116,6 +137,11 @@ recipesRouter.post('/recipes', async (c) => {
|
||||
method: (body.method ?? 'POST').toUpperCase(),
|
||||
headers: body.headers,
|
||||
body: body.body,
|
||||
// ③ payload/回應/binding 三層(3.12):全選填,沒給就是 undefined=既有行為
|
||||
body_template: body.body_template,
|
||||
response_map: body.response_map,
|
||||
auth: body.auth,
|
||||
binding_name: body.binding_name,
|
||||
auth_service: body.auth_service,
|
||||
credentials_required: body.credentials_required,
|
||||
created_at: existing?.created_at ?? now,
|
||||
|
||||
@@ -155,6 +155,7 @@ export type GraphNode = {
|
||||
export type EdgeType =
|
||||
| 'PIPE' | 'IF' | 'FOREACH' | 'CONTINUE' // 現有
|
||||
| 'IS_A' | 'ON_SUCCESS' | 'ON_FAIL' // 執行語意
|
||||
| 'ON_TRUE' | 'ON_FALSE' | 'ON_BRANCH' // 條件語意(SDD workflow-discovery 3.11)
|
||||
| 'ON_CLICK' | 'CALLS_SUBFLOW' // 觸發語意
|
||||
| 'CONTAINS' | 'HAS_STYLE' | 'HAS_BEHAVIOR'; // 結構語意(記錄圖結構,不執行)
|
||||
|
||||
@@ -164,6 +165,8 @@ export type GraphEdge = {
|
||||
type: EdgeType;
|
||||
condition?: string; // IF 的條件表達式
|
||||
iterator?: string; // FOREACH 的迭代變數名
|
||||
/** ON_BRANCH 的具名分支(對應 switch 零件 output 的 data.branch) */
|
||||
branch?: string;
|
||||
};
|
||||
|
||||
export type ExecutionGraph = {
|
||||
|
||||
Reference in New Issue
Block a user