5f5c0a89e2
問題:舊 recipe schema 只有 {canonical_id, endpoint, method, auth_service}(body 有但淺)
⇒ ①帶 body 的 API 只能繞過 recipe 把整包寫進 workflow code
②回應解析綁死單一供應商(rag_chat 的 finalize 2786 字元全在對付 Gemini 形狀)
③Cloudflare binding(env.AI/VECTORIZE/BROWSER/QUEUE)整類被「只認 HTTP+金鑰」的抽象排除
⇒ 換 LLM 供應商=改 workflow,而非換 recipe,違背「外部 API 只有一條一致的路」。
新增 lib/recipe-payload.ts(純函式,好測):
- renderBodyTemplate:遞迴插值,單一 {{x}} 保留原型別、混合文字拼字串、
支援 dot path、取不到保留原樣(不靜默吞掉,看得見才好 debug)。
語義刻意與 graph-executor 的 interpolateData 一致,不新造第二種插值行為。
- applyResponseMap:text_path 取值/thinking_model 剔除 thought=true 取最後一個非 thought/
answer_marker 用 lastIndexOf(自檢清單內文也會提到標記)/strip_prefixes 循環剝殼
(實撞三型「Draft: 【答】」「* 【答】」「Answer: * 【答】」,單趟剝不乾淨)。
RecipeDefinition 加四個**全選填**欄位:body_template/response_map/auth/binding_name。
- component-loader:body_template 優先於 body,兩者皆無才沿用 ctx 當 body(既有行為)
- response_map 有設才附 text 欄,未設原樣回傳 ⇒ 既有 recipe 行為完全不變
- 新增 makeBindingRecipeRunner+pickRecipeRunner:auth='binding' 走平台 binding(免金鑰、
開機即可用),其餘一律走既有 HTTP 路徑。binding 缺綁定/無 run() 時回可操作錯誤,不假綠。
這型不是為 Workers AI 開特例——一次打開 env.AI/VECTORIZE/BROWSER/QUEUE 整排。
payload 用法要「查得到」(同 branch_hint 動機,leo 08-01 的 n8n 式逐顆查):
buildPayloadHint() 讓 recipe 的查詢回應說得出「payload 怎麼填、回應怎麼取值、
認證誰負責」,wire 進 discover 混搜/legacy 逐顆/target=recipe 三條路徑。
金鑰鐵律 D36:hint 只說「走 auth recipe X,金鑰由系統注入、你不必也不該填」,不吐值。
測試:tests/recipe-payload-response.test.ts 14 項全綠(含三家形狀 Gemini/Claude/Workers AI
用不同 path 都取得出文字=換源=換 recipe 的實證;未設 response_map 原樣回傳的相容性)。
全套 209 passed(前 179 +30 新),失敗數維持既有 9 筆未變;tsc --noEmit 綠。
SDD: workflow-discovery task 3.12|CP: arcrun-usable 步驟 5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
114 lines
5.3 KiB
TypeScript
114 lines
5.3 KiB
TypeScript
/**
|
||
* target-search — POST /cypher/search 的「指定搜尋對象」名字搜尋(t159)
|
||
*
|
||
* leo 07-31:「search 節點名稱和 search 工作流名稱是同一個?一個死了另一個不能動?
|
||
* 難道我不能指定要搜尋工作流或節點或 recipe 嗎?」
|
||
*
|
||
* ⇒ discover 入口加 `target`(component/recipe/workflow)+`query`:
|
||
* - target=component → 轉發 registry GET /components/search(MCP arcrun_search_components 同一條路)
|
||
* - target=recipe → 掃私庫 RECIPES KV(與 discover 混搜的第二庫**同一份讀法** listAllRecipes);
|
||
* 公庫(多作者市場)另有 /public-recipes=MCP arcrun_recipe_search,回應註明
|
||
* - target=workflow → lib/workflow-search.ts(GET /workflows/search=MCP arcrun_search_workflows 同一條路)
|
||
*
|
||
* 「外部 API 只有一條一致的路」:三個 target 各自對應**既有**搜尋機制,本檔只做轉接,
|
||
* 不新造第二套搜尋。flag 安全:主動 pull,無輪詢。
|
||
*/
|
||
|
||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||
import { fetchTenantWorkflowSearch } from '../lib/workflow-search';
|
||
import { listAllRecipes, buildPayloadHint, type SearchNodesEnv } from './search-nodes';
|
||
import { branchHintFor } from '../lib/branch-hints';
|
||
|
||
export type TargetQueryEnv = SearchNodesEnv & {
|
||
KBDB_BASE_URL?: string;
|
||
KBDB_INTERNAL_TOKEN?: string;
|
||
};
|
||
|
||
export type TargetQueryResult =
|
||
| { ok: true; body: Record<string, unknown> }
|
||
| { ok: false; status: 400 | 401 | 502; error: string };
|
||
|
||
export async function searchByTarget(
|
||
target: 'component' | 'recipe' | 'workflow',
|
||
query: string,
|
||
env: TargetQueryEnv,
|
||
apiKey?: string,
|
||
): Promise<TargetQueryResult> {
|
||
if (target === 'component') {
|
||
const sub = env.WORKER_SUBDOMAIN;
|
||
const registryBase = env.REGISTRY_BASE_URL ?? (sub ? wasmWorkerUrl('registry', sub) : undefined);
|
||
if (!registryBase) return { ok: false, status: 502, error: 'registry 位置未設定(WORKER_SUBDOMAIN/REGISTRY_BASE_URL 皆缺)' };
|
||
try {
|
||
const res = await fetch(
|
||
`${registryBase}/components/search?q=${encodeURIComponent(query)}`,
|
||
{ signal: AbortSignal.timeout(10000) },
|
||
);
|
||
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,
|
||
count: body.data?.count ?? 0,
|
||
},
|
||
};
|
||
} catch (e) {
|
||
return { ok: false, status: 502, error: `registry 查不通:${e instanceof Error ? e.message : String(e)}` };
|
||
}
|
||
}
|
||
|
||
if (target === 'recipe') {
|
||
if (!env.RECIPES) return { ok: false, status: 502, error: 'RECIPES KV 未綁定' };
|
||
const all = await listAllRecipes(env.RECIPES);
|
||
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;
|
||
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();
|
||
if (!hay.includes(q)) continue;
|
||
seen.add(r.canonical_id);
|
||
results.push({
|
||
canonical_id: r.canonical_id,
|
||
display_name: r.display_name,
|
||
description: r.description,
|
||
endpoint: r.endpoint,
|
||
// 3.12:逐顆查 recipe 時也要說得出「payload 怎麼填、回應怎麼取值」
|
||
payload_hint: buildPayloadHint(r),
|
||
});
|
||
}
|
||
return {
|
||
ok: true,
|
||
body: {
|
||
target,
|
||
query,
|
||
results,
|
||
count: results.length,
|
||
note: '搜的是本部署私庫(workflow 可直接 component: <canonical_id> 引用)。公庫(多作者市場)走 MCP arcrun_recipe_search/GET /public-recipes。',
|
||
},
|
||
};
|
||
}
|
||
|
||
// target === 'workflow':租戶隔離,必帶 API key(同 GET /workflows/search 的既有契約)
|
||
if (!apiKey) return { ok: false, status: 401, error: 'target=workflow 需要 X-Arcrun-API-Key header(workflow 搜尋限本租戶)' };
|
||
const res = await fetchTenantWorkflowSearch(env, apiKey, query);
|
||
if (!res.ok) return { ok: false, status: 502, error: `workflow 搜尋失敗(KBDB HTTP ${res.status})` };
|
||
const body = (await res.json()) as Record<string, unknown>;
|
||
return { ok: true, body: { target, query, ...body } };
|
||
}
|