/** * 工作流 API —— 外部 /api/v1 适配版。 * * 与平台内部版(同目录 workflow.internal.js)保持相同的函数签名与返回形态, * 因此 composables/useWorkflowRunner.js 可以零改动对接外部 API。 * * 与内部版的差异(详见 ../README.md「内外 API 差异」): * 1. 基础路径 /api/v1(内部版 /api),每个请求携带 X-API-Key 请求头。 * 2. 创建运行为两阶段:POST /runs(async:true)→ POST /runs/{runId}/start * → GET /runs/{runId}/stream。对上层仍表现为"一次调用返回 SSE Response", * 与内部版行为等价。 * 3. SSE 续传用 Last-Event-ID 请求头(内部版是 ?lastEventId= query 参数, * 外部端点不支持 query 参数)。 * 4. 外部 API 的 stream 路径含 {workflowName} 段:模块内记录最近一次运行的 * 工作流标识;页面刷新后内存丢失,重连前需调用 setWorkflowName() 恢复。 * 5. /state 与 /resume 走外部端点 /api/v1/runs/{runId}/...(v1.2 已开放, * 路径不带 workflowName 段,页面刷新后仍可直接调用)。 * submitClarifyAnswer 把外部响应的 accepted 映射为内部版语义的 resolved, * 并对 409 CLARIFY_ALREADY_ANSWERED 做幂等兼容(视为成功,spec §14.6)。 * * 配置:BASE_URL 与 API_KEY 从构建期环境变量注入(此处以 Vite 约定为例), * 严禁硬编码进源码;生产环境推荐业务方后端反代并注入密钥,前端走同源。 */ const BASE_URL = (import.meta.env.VITE_AMP_BASE_URL || '').replace(/\/+$/, '') const API_KEY = import.meta.env.VITE_AMP_API_KEY || '' const API = `${BASE_URL}/api/v1` /** 最近一次运行的工作流标识(name 或数字 id),供 reconnectRunStream 拼路径 */ let lastWorkflowNameOrId = null function requireApiKey() { if (!API_KEY) { throw new Error('缺少 X-API-Key:请配置 VITE_AMP_API_KEY(或改用后端反代注入)') } } /** 统一 JSON 请求:解包 {code:'OK', message, data},非 OK 抛错(code 挂在 err.code) */ async function apiJson(path, options = {}) { const headers = { 'X-API-Key': API_KEY } if (options.body !== undefined) headers['Content-Type'] = 'application/json' const resp = await fetch(API + path, { method: options.method || 'GET', headers, body: options.body !== undefined ? JSON.stringify(options.body) : undefined }) let json = null try { json = await resp.json() } catch (_) { /* 非 JSON 响应 */ } if (!resp.ok || !json || json.code !== 'OK') { const err = new Error((json && json.message) || `HTTP ${resp.status}`) err.code = json && json.code err.httpStatus = resp.status throw err } return json.data } /** 打开 SSE 订阅,返回原生 fetch Response(供 consumeSSEStream 消费) */ function openStream(nameOrId, runId, lastEventId) { const headers = { 'X-API-Key': API_KEY, Accept: 'text/event-stream' } if (lastEventId != null) headers['Last-Event-ID'] = String(lastEventId) return fetch(`${API}/workflows/${nameOrId}/runs/${runId}/stream`, { headers }) } /** 页面刷新后恢复工作流标识(reconnectRunStream 的路径需要它) */ export function setWorkflowName(nameOrId) { lastWorkflowNameOrId = nameOrId } /** * 运行工作流:两阶段创建 + 启动 + 订阅,返回原生 fetch Response(SSE 流)。 * * @param {string|number} nameOrId 工作流 name(kebab-case,推荐)或数字 id * @param {object} inputs 输入变量(对应外部 API 的 variables) * @param {File[]} files 可选上传文件 * @param {string[]} paths 每个文件在工作目录中的相对路径(目录上传场景) * * 目录结构说明:浏览器 FormData 无法为单个 part 设置 X-Relative-Path 请求头, * 此处以"含路径的 filename"尽力透传;严格目录结构需求请由业务方后端代理 * (反代层为 part 补 X-Relative-Path 头)。 */ export async function runWorkflow(nameOrId, inputs, files = [], paths = []) { requireApiKey() lastWorkflowNameOrId = nameOrId // 1) 创建运行(async:true 仅创建,不启动) const formData = new FormData() formData.append( 'payload', new Blob([JSON.stringify({ variables: inputs || {}, async: true })], { type: 'application/json' }) ) files.forEach((file, i) => formData.append('files', file, paths[i] || file.name)) // multipart 不走 apiJson:不能预设 Content-Type(需保留 FormData 边界) const createResp = await fetch(`${API}/workflows/${nameOrId}/runs`, { method: 'POST', headers: { 'X-API-Key': API_KEY }, body: formData }) let created = null try { created = await createResp.json() } catch (_) { /* 非 JSON 响应 */ } if (!createResp.ok || !created || created.code !== 'OK') { const err = new Error((created && created.message) || `HTTP ${createResp.status}`) err.code = created && created.code err.httpStatus = createResp.status throw err } const runId = created.data.runId // 2) 启动执行(运行启动信号由本接口的 HTTP 响应承担,SSE 无 run_started 事件) await apiJson(`/workflows/${nameOrId}/runs/${runId}/start`, { method: 'POST' }) // 3) 订阅 SSE 流 return openStream(nameOrId, runId, null) } /** * 断线重连:按 runId 重新订阅 SSE,携带 Last-Event-ID 请求头续传。 * 返回原生 fetch Response(与 runWorkflow 一致)。 * * @param {string} runId 工作流运行 ID * @param {number} lastEventId 上次收到的最大事件 id(来自 SSE 帧 id: 字段) */ export function reconnectRunStream(runId, lastEventId) { requireApiKey() if (!lastWorkflowNameOrId) { throw new Error('缺少工作流标识:页面刷新后请先调用 setWorkflowName()') } return openStream(lastWorkflowNameOrId, runId, lastEventId) } /** * 查询运行状态 + 当前未答暂停点(AskFor 恢复用)。 * * 走外部端点 GET /runs/{runId}/state(不带 workflowName 段)。 * 返回解包后的 data({runId, status, currentPause});上层 `res?.data || res` 容错兼容。 */ export async function getRunState(runId) { return apiJson(`/runs/${runId}/state`) } /** * 提交用户对 clarify_request 的回答,唤醒平台后端阻塞的执行线程。 * * 走外部端点 POST /runs/{runId}/resume(不带 workflowName 段)。 * 外部响应 data 为 {accepted, runId, clarifyId},此处映射为内部版语义的 * resolved 供 useWorkflowRunner 消费;重复提交返回 409 时按幂等约定视为成功。 */ export async function submitClarifyAnswer(runId, clarifyId, answer) { try { const data = await apiJson(`/runs/${runId}/resume`, { method: 'POST', body: { clarifyId, answer } }) return { resolved: !!data.accepted, runId: data.runId, clarifyId: data.clarifyId } } catch (e) { if (e.code === 'CLARIFY_ALREADY_ANSWERED') { return { resolved: true, idempotent: true } } throw e } } /** * 下载工作空间 zip(二进制,不走 JSON 解析;平台内部版无此独立函数, * RunHistory.vue 通过 window.location.href 直链下载,见其 downloadWorkspace)。 */ export function workspaceDownloadUrl(nameOrId, runId) { return `${API}/workflows/${nameOrId}/runs/${runId}/workspace` }