feat(web): add task SSE client and live phase-event mapper
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildTaskRequest, buildResumeRequest } from './taskStream';
|
||||
|
||||
test('buildTaskRequest: maps five project fields + task_key + user identity', () => {
|
||||
const req = buildTaskRequest({
|
||||
taskKey: 'feasibility_reg',
|
||||
projectName: '超声主机系统',
|
||||
customerName: '北京水木东方',
|
||||
deviceType: '超声吸引刀',
|
||||
intendedUse: '外科手术导航',
|
||||
targetMarket: '国内',
|
||||
userId: 1,
|
||||
projectId: 7,
|
||||
});
|
||||
assert.equal(req.task_key, 'feasibility_reg');
|
||||
assert.equal(req.project_name, '超声主机系统');
|
||||
assert.equal(req.customer_name, '北京水木东方');
|
||||
assert.equal(req.device_type, '超声吸引刀');
|
||||
assert.equal(req.intended_use, '外科手术导航');
|
||||
assert.equal(req.target_market, '国内');
|
||||
assert.equal(req.user_id, 1);
|
||||
assert.equal(req.project_id, 7);
|
||||
assert.equal('work_dir' in req, false);
|
||||
});
|
||||
|
||||
test('buildTaskRequest: omits optional fields when absent', () => {
|
||||
const req = buildTaskRequest({
|
||||
taskKey: 'intake',
|
||||
projectName: 'P',
|
||||
customerName: 'C',
|
||||
deviceType: 'D',
|
||||
});
|
||||
assert.equal('intended_use' in req, false);
|
||||
assert.equal('target_market' in req, false);
|
||||
assert.equal('user_id' in req, false);
|
||||
assert.equal('project_id' in req, false);
|
||||
});
|
||||
|
||||
test('buildResumeRequest: adds work_dir on top of task request', () => {
|
||||
const req = buildResumeRequest(
|
||||
{ taskKey: 'feasibility_reg', projectName: 'P', customerName: 'C', deviceType: 'D', userId: 2 },
|
||||
'/tmp/ws-x'
|
||||
);
|
||||
assert.equal(req.work_dir, '/tmp/ws-x');
|
||||
assert.equal(req.task_key, 'feasibility_reg');
|
||||
assert.equal(req.user_id, 2);
|
||||
assert.equal('add_steps' in req, false);
|
||||
});
|
||||
|
||||
test('buildResumeRequest: forwards add_steps when provided', () => {
|
||||
const req = buildResumeRequest(
|
||||
{ taskKey: 'intake', projectName: 'P', customerName: 'C', deviceType: 'D' },
|
||||
'/tmp/ws-y',
|
||||
[{ id: 'step_dyn_1', role: 'regulatory', title: '补充条款' }]
|
||||
);
|
||||
assert.equal(req.work_dir, '/tmp/ws-y');
|
||||
assert.deepEqual(req.add_steps, [{ id: 'step_dyn_1', role: 'regulatory', title: '补充条款' }]);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { parseSSEChunk, type StreamEvent } from './chatStream';
|
||||
|
||||
/** 长任务运行上下文:触发 / 重试 / 重新开始时重发请求所需的最小参数集 */
|
||||
export type TaskRunContext = {
|
||||
taskKey: string;
|
||||
projectName: string;
|
||||
customerName: string;
|
||||
deviceType: string;
|
||||
intendedUse?: string;
|
||||
targetMarket?: string;
|
||||
userId?: number;
|
||||
projectId?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* streamTask 消费 POST /api/v1/tasks/run/stream 的 SSE 事件流。
|
||||
* 复用 chatStream 的 parseSSEChunk 解析与中止语义。
|
||||
*/
|
||||
export async function streamTask(
|
||||
body: Record<string, unknown>,
|
||||
handlers: {
|
||||
onEvent: (ev: StreamEvent) => void;
|
||||
onRequestId?: (id: string) => void;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const resp = await fetch('/api/v1/tasks/run/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
const rid = resp.headers.get('X-Request-Id') || resp.headers.get('x-request-id');
|
||||
if (rid) handlers.onRequestId?.(rid);
|
||||
if (!resp.ok || !resp.body) {
|
||||
throw Object.assign(new Error(`HTTP ${resp.status}`), {
|
||||
httpStatus: resp.status,
|
||||
requestId: rid,
|
||||
});
|
||||
}
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parsed = parseSSEChunk(buf);
|
||||
buf = parsed.rest;
|
||||
for (const ev of parsed.events) handlers.onEvent(ev);
|
||||
}
|
||||
if (buf) {
|
||||
const flushed = parseSSEChunk(buf, { flush: true });
|
||||
for (const ev of flushed.events) handlers.onEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
/** 构造新任务运行请求(五要素 + task_key + 用户/项目标识) */
|
||||
export function buildTaskRequest(task: TaskRunContext): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
task_key: task.taskKey,
|
||||
project_name: task.projectName,
|
||||
customer_name: task.customerName,
|
||||
device_type: task.deviceType,
|
||||
};
|
||||
if (task.intendedUse) body.intended_use = task.intendedUse;
|
||||
if (task.targetMarket) body.target_market = task.targetMarket;
|
||||
if (task.userId) body.user_id = task.userId;
|
||||
if (task.projectId) body.project_id = task.projectId;
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 构造 resume 请求:在任务请求基础上追加 work_dir(可选动态步骤),交由后端续跑既有工作区 */
|
||||
export function buildResumeRequest(
|
||||
task: TaskRunContext,
|
||||
workDir: string,
|
||||
addSteps?: Array<Record<string, unknown>>,
|
||||
): Record<string, unknown> {
|
||||
const body = buildTaskRequest(task);
|
||||
body.work_dir = workDir;
|
||||
if (addSteps && addSteps.length > 0) {
|
||||
body.add_steps = addSteps;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
Reference in New Issue
Block a user