825 lines
29 KiB
JavaScript
825 lines
29 KiB
JavaScript
import { randomBytes } from 'node:crypto';
|
||
|
||
const DEFAULT_BASE_URL = 'https://superagent.nianxx.cn';
|
||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||
const DEFAULT_TOTAL_TIMEOUT_MS = 180_000;
|
||
|
||
export class OpenAgentAPIError extends Error {
|
||
constructor(message, { statusCode = 0, detail = null, responseText = '' } = {}) {
|
||
super(message);
|
||
this.name = 'OpenAgentAPIError';
|
||
this.statusCode = statusCode;
|
||
this.detail = detail;
|
||
this.responseText = responseText;
|
||
}
|
||
}
|
||
|
||
export class ExternalAgentParser {
|
||
constructor({
|
||
baseUrl = DEFAULT_BASE_URL,
|
||
apiKey,
|
||
fetchImpl = globalThis.fetch,
|
||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||
totalTimeoutMs = DEFAULT_TOTAL_TIMEOUT_MS,
|
||
now = () => new Date(),
|
||
csrfToken = randomBytes(32).toString('base64url')
|
||
} = {}) {
|
||
this.baseUrl = normalizeBaseUrl(baseUrl);
|
||
this.apiKey = normalizeApiKey(apiKey);
|
||
this.fetchImpl = fetchImpl;
|
||
this.timeoutMs = timeoutMs;
|
||
this.totalTimeoutMs = totalTimeoutMs;
|
||
this.now = now;
|
||
this.csrfToken = csrfToken;
|
||
if (typeof this.fetchImpl !== 'function') {
|
||
throw new Error('当前 Node.js 运行时没有可用的 fetch。');
|
||
}
|
||
}
|
||
|
||
isConfigured() {
|
||
return Boolean(this.baseUrl && this.apiKey);
|
||
}
|
||
|
||
async checkConnection({ timeoutMs = Math.min(this.timeoutMs, 8_000) } = {}) {
|
||
if (!this.isConfigured()) {
|
||
return {
|
||
ok: false,
|
||
configured: false,
|
||
reachable: false,
|
||
error_code: 'external_service_not_configured'
|
||
};
|
||
}
|
||
|
||
try {
|
||
// The open session endpoint is state-changing on some deployments even
|
||
// when the payload is empty. Use the provider health endpoint here so a
|
||
// browser status refresh cannot create orphaned Agent sessions.
|
||
const response = await this.requestRaw('GET', '/health', {
|
||
accept: 'application/json',
|
||
timeoutMs
|
||
});
|
||
const httpStatus = response.status;
|
||
await response.text().catch(() => '');
|
||
|
||
if (response.ok) {
|
||
return {
|
||
ok: true,
|
||
configured: true,
|
||
reachable: true,
|
||
authenticated: null,
|
||
authentication_checked: false,
|
||
http_status: httpStatus,
|
||
probe: 'health_endpoint'
|
||
};
|
||
}
|
||
return {
|
||
ok: false,
|
||
configured: true,
|
||
reachable: true,
|
||
authenticated: null,
|
||
authentication_checked: false,
|
||
http_status: httpStatus,
|
||
error_code: 'external_health_unhealthy'
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
configured: true,
|
||
reachable: false,
|
||
error_code: errorCodeFor(error)
|
||
};
|
||
}
|
||
}
|
||
|
||
async parse({ rawText, taskId, receivedAt = this.now().toISOString(), signal: parentSignal } = {}) {
|
||
let sessionId = '';
|
||
let requestTrace = makeRequestTrace(this.now());
|
||
const normalizedRawText = String(rawText || '').trim();
|
||
const normalizedTaskId = String(taskId || '').trim();
|
||
if (!normalizedRawText) return blockedResult(['原始指令不能为空。']);
|
||
if (!normalizedTaskId) return blockedResult(['缺少任务 ID,无法建立独立解析会话。']);
|
||
if (!this.isConfigured()) {
|
||
return blockedResult([
|
||
'解析服务未配置:请在服务端设置 DEERFLOW_BASE_URL 和 DEERFLOW_OPEN_API_KEY。'
|
||
], {
|
||
error_code: 'external_service_not_configured',
|
||
external_request: updateRequestTrace(requestTrace, 'not_configured', this.now())
|
||
});
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
const unlinkParentSignal = linkAbortSignal(parentSignal, controller);
|
||
const deadlineTimer = setTimeout(() => controller.abort(), this.totalTimeoutMs);
|
||
const deadline = Date.now() + this.totalTimeoutMs;
|
||
const remainingTimeout = () => {
|
||
if (controller.signal.aborted) {
|
||
throw new OpenAgentAPIError('外部解析服务已取消。', { detail: 'parse_cancelled' });
|
||
}
|
||
const remaining = deadline - Date.now();
|
||
if (remaining <= 0) {
|
||
throw new OpenAgentAPIError(`外部解析服务总超时(>${this.totalTimeoutMs}ms)。`, {
|
||
detail: 'parse_timeout'
|
||
});
|
||
}
|
||
return Math.min(this.timeoutMs, remaining);
|
||
};
|
||
|
||
const sessionKey = `business-parser-session:${normalizedTaskId}`;
|
||
const messageKey = `business-parser-message:${normalizedTaskId}`;
|
||
const metadata = {
|
||
source: 'business_system',
|
||
module: 'input_parser',
|
||
task_id: normalizedTaskId
|
||
};
|
||
|
||
try {
|
||
requestTrace = updateRequestTrace(requestTrace, 'creating_session', this.now());
|
||
const session = await this.createSession({
|
||
externalSubjectId: `business-task:${normalizedTaskId}`,
|
||
idempotencyKey: sessionKey,
|
||
metadata,
|
||
timeoutMs: remainingTimeout(),
|
||
signal: controller.signal
|
||
});
|
||
sessionId = String(session?.session_id || '').trim();
|
||
if (!sessionId) {
|
||
throw new OpenAgentAPIError('外部服务创建会话成功但未返回 session_id。', {
|
||
detail: session
|
||
});
|
||
}
|
||
requestTrace = updateRequestTrace(requestTrace, 'session_created', this.now(), {
|
||
session_id_present: true
|
||
});
|
||
|
||
const message = buildParserMessage({
|
||
rawText: normalizedRawText,
|
||
taskId: normalizedTaskId,
|
||
receivedAt
|
||
});
|
||
const stream = await this.streamMessage(sessionId, message, {
|
||
idempotencyKey: messageKey,
|
||
metadata: {
|
||
...metadata,
|
||
session_id: sessionId
|
||
},
|
||
timeoutMs: remainingTimeout(),
|
||
signal: controller.signal
|
||
});
|
||
requestTrace = updateRequestTrace(requestTrace, 'stream_connecting', this.now());
|
||
const streamed = await collectSseOutputWithTimeout(
|
||
stream.body,
|
||
remainingTimeout(),
|
||
() => controller.abort(),
|
||
controller.signal
|
||
);
|
||
requestTrace = updateRequestTrace(requestTrace, 'stream_completed', this.now(), {
|
||
event_count: streamed.eventCount
|
||
});
|
||
const parsed = streamed.result || parseJsonObject(streamed.text);
|
||
if (!parsed) {
|
||
return blockedResult(['外部解析服务未返回可识别的 JSON 结果。'], {
|
||
error_code: 'external_result_not_json',
|
||
session_id: sessionId,
|
||
event_count: streamed.eventCount,
|
||
external_request: requestTrace
|
||
});
|
||
}
|
||
return normalizeParseResult(parsed, {
|
||
taskId: normalizedTaskId,
|
||
receivedAt,
|
||
sessionId,
|
||
requestTrace
|
||
});
|
||
} catch (error) {
|
||
requestTrace = updateRequestTrace(requestTrace, 'failed', this.now(), {
|
||
session_id_present: Boolean(sessionId),
|
||
error_code: errorCodeFor(error)
|
||
});
|
||
return blockedResult([formatExternalError(error)], {
|
||
error_code: errorCodeFor(error),
|
||
external_status: error?.statusCode || undefined,
|
||
external_request: requestTrace
|
||
});
|
||
} finally {
|
||
clearTimeout(deadlineTimer);
|
||
unlinkParentSignal();
|
||
}
|
||
}
|
||
|
||
async createSession({ externalSubjectId, idempotencyKey, metadata, timeoutMs = this.timeoutMs, signal } = {}) {
|
||
return this.requestJson('POST', '/api/open/agent-sessions', {
|
||
body: compactPayload({
|
||
external_subject_id: externalSubjectId,
|
||
idempotency_key: idempotencyKey,
|
||
metadata
|
||
}),
|
||
timeoutMs,
|
||
signal
|
||
});
|
||
}
|
||
|
||
async streamMessage(sessionId, message, { idempotencyKey, metadata, timeoutMs = this.timeoutMs, signal } = {}) {
|
||
return this.request('POST', `/api/open/agent-sessions/${encodeURIComponent(sessionId)}/messages/stream`, {
|
||
accept: 'text/event-stream',
|
||
body: compactPayload({
|
||
message,
|
||
idempotency_key: idempotencyKey,
|
||
metadata
|
||
}),
|
||
timeoutMs,
|
||
signal
|
||
});
|
||
}
|
||
|
||
async requestJson(method, path, { timeoutMs = this.timeoutMs, ...options } = {}) {
|
||
const response = await this.request(method, path, {
|
||
...options,
|
||
accept: 'application/json',
|
||
timeoutMs
|
||
});
|
||
const text = await response.text();
|
||
if (!text.trim()) return {};
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch (error) {
|
||
throw new OpenAgentAPIError('外部服务返回了无效 JSON。', {
|
||
statusCode: response.status,
|
||
responseText: text
|
||
});
|
||
}
|
||
}
|
||
|
||
async request(method, path, { body, accept = 'application/json', timeoutMs = this.timeoutMs, signal } = {}) {
|
||
const response = await this.requestRaw(method, path, { body, accept, timeoutMs, signal });
|
||
if (response.status >= 400) {
|
||
const responseText = await response.text();
|
||
throw new OpenAgentAPIError(formatHttpError(response.status), {
|
||
statusCode: response.status,
|
||
detail: parseErrorDetail(responseText),
|
||
responseText
|
||
});
|
||
}
|
||
return response;
|
||
}
|
||
|
||
async requestRaw(method, path, {
|
||
body,
|
||
accept = 'application/json',
|
||
timeoutMs = this.timeoutMs,
|
||
signal
|
||
} = {}) {
|
||
const requestController = new AbortController();
|
||
const requestSignal = signal
|
||
? AbortSignal.any([signal, requestController.signal])
|
||
: requestController.signal;
|
||
const timeout = setTimeout(() => requestController.abort(), timeoutMs);
|
||
try {
|
||
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
||
method,
|
||
headers: {
|
||
Accept: accept,
|
||
Authorization: `Bearer ${this.apiKey}`,
|
||
...(isStateChangingMethod(method)
|
||
? {
|
||
'X-CSRF-Token': this.csrfToken,
|
||
Cookie: `csrf_token=${this.csrfToken}`
|
||
}
|
||
: {}),
|
||
...(body === undefined ? {} : { 'Content-Type': 'application/json' })
|
||
},
|
||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||
signal: requestSignal
|
||
});
|
||
return response;
|
||
} catch (error) {
|
||
if (error?.name === 'AbortError') {
|
||
throw new OpenAgentAPIError(
|
||
signal?.aborted ? '外部解析服务请求已取消。' : `外部解析服务超时(>${timeoutMs}ms)。`,
|
||
{
|
||
detail: signal?.aborted ? 'parse_cancelled' : 'request_timeout'
|
||
}
|
||
);
|
||
}
|
||
throw error;
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|
||
}
|
||
|
||
export function normalizeParseResult(result, { taskId, receivedAt, sessionId, requestTrace } = {}) {
|
||
const normalized = normalizeResultShape(result);
|
||
if (!normalized || typeof normalized !== 'object') {
|
||
return blockedResult(['外部解析服务返回的内容不是 JSON 对象。'], {
|
||
error_code: 'external_result_not_object',
|
||
session_id: sessionId,
|
||
external_request: requestTrace
|
||
});
|
||
}
|
||
|
||
const status = normalized.status;
|
||
if (!status && !normalized.operation && !normalized.blockers) {
|
||
return blockedResult([
|
||
'外部 Profile 返回的 JSON 不符合标准 operation 契约,请检查已发布 Profile 的输出配置。'
|
||
], {
|
||
error_code: 'external_result_contract_mismatch',
|
||
response_keys: Object.keys(normalized).slice(0, 30),
|
||
session_id: sessionId,
|
||
external_request: requestTrace
|
||
});
|
||
}
|
||
if (status !== 'agent_parse_passed') {
|
||
return {
|
||
...normalized,
|
||
status: status || 'agent_parse_blocked',
|
||
blockers: Array.isArray(normalized.blockers) ? normalized.blockers : ['外部解析服务未通过输入校验。'],
|
||
operation: normalized.operation && typeof normalized.operation === 'object'
|
||
? normalized.operation
|
||
: null,
|
||
session_id: sessionId,
|
||
external_request: requestTrace
|
||
};
|
||
}
|
||
|
||
const operation = normalized.operation;
|
||
if (!operation || typeof operation !== 'object') {
|
||
return blockedResult(['外部解析服务返回通过状态,但缺少 operation。'], {
|
||
error_code: 'external_operation_missing',
|
||
session_id: sessionId,
|
||
external_request: requestTrace
|
||
});
|
||
}
|
||
|
||
operation.action = operation.action || 'team_order_create';
|
||
operation.submit_mode = 'dry_run';
|
||
operation.source = {
|
||
...(operation.source || {}),
|
||
task_id: taskId,
|
||
instruction_id: operation.source?.instruction_id || taskId,
|
||
received_at: operation.source?.received_at || receivedAt,
|
||
operator: operation.source?.operator || 'business-system-parser',
|
||
parser_prompt_version: operation.source?.parser_prompt_version || 'external-profile'
|
||
};
|
||
operation.data = operation.data || {};
|
||
operation.data.passenger_list = operation.data.passenger_list || {
|
||
operation: 'none',
|
||
passenger_count: 0,
|
||
file_count: 0
|
||
};
|
||
operation.data.attachments = Array.isArray(operation.data.attachments)
|
||
? operation.data.attachments
|
||
: [];
|
||
operation.data.system_defaults = {
|
||
fabudanwei: '老挝联泰',
|
||
business_status: '预订',
|
||
filing_required: true,
|
||
...(operation.data.system_defaults || {})
|
||
};
|
||
|
||
return {
|
||
...normalized,
|
||
status: 'agent_parse_passed',
|
||
blockers: Array.isArray(normalized.blockers) ? normalized.blockers : [],
|
||
operation,
|
||
session_id: sessionId,
|
||
external_request: requestTrace
|
||
};
|
||
}
|
||
|
||
export async function collectSseOutput(body) {
|
||
if (!body) return { text: '', result: null, eventCount: 0 };
|
||
let text = '';
|
||
let finalText = '';
|
||
let result = null;
|
||
let eventCount = 0;
|
||
for await (const event of iterateSseEvents(body)) {
|
||
eventCount += 1;
|
||
const structured = findStructuredResult(event.data);
|
||
if (structured) result = structured;
|
||
|
||
const fragment = event.event === 'messages'
|
||
? extractAssistantMessageText(event.data)
|
||
: extractEventText(event.data);
|
||
if (!fragment) continue;
|
||
if (isFinalEvent(event.event)) finalText = fragment;
|
||
else text += fragment;
|
||
}
|
||
return {
|
||
text: finalText || text,
|
||
result,
|
||
eventCount
|
||
};
|
||
}
|
||
|
||
async function collectSseOutputWithTimeout(body, timeoutMs, onTimeout, signal) {
|
||
let timer;
|
||
let abortHandler;
|
||
const cancellation = signal
|
||
? new Promise((resolve, reject) => {
|
||
abortHandler = () => reject(new OpenAgentAPIError('外部解析服务请求已取消。', {
|
||
detail: 'parse_cancelled'
|
||
}));
|
||
if (signal.aborted) abortHandler();
|
||
else signal.addEventListener('abort', abortHandler, { once: true });
|
||
})
|
||
: null;
|
||
try {
|
||
const races = [
|
||
collectSseOutput(body),
|
||
new Promise((resolve, reject) => {
|
||
timer = setTimeout(() => {
|
||
onTimeout?.();
|
||
reject(new OpenAgentAPIError(`外部解析服务超时(>${timeoutMs}ms)。`, {
|
||
detail: 'request_timeout'
|
||
}));
|
||
}, timeoutMs);
|
||
})
|
||
];
|
||
if (cancellation) races.push(cancellation);
|
||
return await Promise.race(races);
|
||
} catch (error) {
|
||
await cancelBodyWithBoundedWait(body);
|
||
throw error;
|
||
} finally {
|
||
clearTimeout(timer);
|
||
if (abortHandler) signal.removeEventListener('abort', abortHandler);
|
||
}
|
||
}
|
||
|
||
export async function* iterateSseEvents(body) {
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
for await (const chunk of body) {
|
||
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
||
while (true) {
|
||
const separator = findEventSeparator(buffer);
|
||
if (separator < 0) break;
|
||
const raw = buffer.slice(0, separator);
|
||
buffer = buffer.slice(separator + eventSeparatorLength(buffer, separator));
|
||
const event = parseSseBlock(raw);
|
||
if (!event || event.done) return;
|
||
yield event;
|
||
if (isFinalEvent(event.event)) return;
|
||
}
|
||
}
|
||
buffer += decoder.decode();
|
||
if (buffer.trim()) {
|
||
const event = parseSseBlock(buffer);
|
||
if (event && !event.done) yield event;
|
||
}
|
||
}
|
||
|
||
export function parseSseBlock(raw) {
|
||
let event;
|
||
const dataLines = [];
|
||
for (const line of String(raw).split(/\r?\n/)) {
|
||
if (!line || line.startsWith(':')) continue;
|
||
if (line.startsWith('event:')) {
|
||
event = line.slice('event:'.length).trim();
|
||
continue;
|
||
}
|
||
if (line.startsWith('data:')) dataLines.push(line.slice('data:'.length).replace(/^ /, ''));
|
||
}
|
||
if (event === undefined && dataLines.length === 0) return null;
|
||
const dataText = dataLines.join('\n');
|
||
if (!dataText) return { event: event || null, data: null };
|
||
if (dataText.trim() === '[DONE]') return { event: event || null, data: null, done: true };
|
||
try {
|
||
return { event: event || null, data: JSON.parse(dataText) };
|
||
} catch (error) {
|
||
return { event: event || null, data: dataText };
|
||
}
|
||
}
|
||
|
||
export function parseJsonObject(text) {
|
||
const raw = stripThinkBlocks(text);
|
||
if (!raw) return null;
|
||
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||
const candidate = fenced ? fenced[1].trim() : raw;
|
||
try {
|
||
return JSON.parse(candidate);
|
||
} catch (error) {
|
||
const first = candidate.indexOf('{');
|
||
const last = candidate.lastIndexOf('}');
|
||
if (first < 0 || last <= first) return null;
|
||
try {
|
||
return JSON.parse(candidate.slice(first, last + 1));
|
||
} catch (nestedError) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
export function buildParserMessage({ rawText, taskId, receivedAt }) {
|
||
return [
|
||
`任务 ID:${taskId}`,
|
||
`当前时间:${receivedAt}`,
|
||
'',
|
||
'请将以下原始业务指令解析为既定标准 operation JSON,只输出 JSON:',
|
||
rawText
|
||
].join('\n');
|
||
}
|
||
|
||
export function blockedResult(blockers, extra = {}) {
|
||
return {
|
||
status: 'agent_parse_blocked',
|
||
blockers: Array.isArray(blockers) ? blockers : [String(blockers)],
|
||
operation: null,
|
||
...extra
|
||
};
|
||
}
|
||
|
||
export function normalizeBaseUrl(value) {
|
||
const raw = String(value || DEFAULT_BASE_URL).trim().replace(/\/+$/, '');
|
||
try {
|
||
const url = new URL(raw);
|
||
if (!['http:', 'https:'].includes(url.protocol)) return '';
|
||
return url.toString().replace(/\/+$/, '');
|
||
} catch (error) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
export function normalizeApiKey(value) {
|
||
return String(value || '')
|
||
.trim()
|
||
.replace(/^['"]|['"]$/g, '')
|
||
.replace(/^Bearer\s+/i, '')
|
||
.trim();
|
||
}
|
||
|
||
function normalizeResultShape(result) {
|
||
if (!result || typeof result !== 'object') return null;
|
||
if (result.result && typeof result.result === 'object') return normalizeResultShape(result.result);
|
||
if (result.output && typeof result.output === 'object') return normalizeResultShape(result.output);
|
||
if (result.data && typeof result.data === 'object') return normalizeResultShape(result.data);
|
||
if (result.status || result.operation || result.blockers) return result;
|
||
if (result.action && result.data) return { status: 'agent_parse_passed', blockers: [], operation: result };
|
||
if (looksLikeFlatParserResult(result)) {
|
||
return {
|
||
status: 'agent_parse_passed',
|
||
blockers: [],
|
||
operation: flatParserResultToOperation(result)
|
||
};
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function findStructuredResult(value) {
|
||
if (!value || typeof value !== 'object') return null;
|
||
if (value.status === 'agent_parse_passed' || value.status === 'agent_parse_blocked') return value;
|
||
if (value.operation && typeof value.operation === 'object') {
|
||
return {
|
||
status: value.status || 'agent_parse_passed',
|
||
blockers: value.blockers || [],
|
||
operation: value.operation
|
||
};
|
||
}
|
||
if (value.result && typeof value.result === 'object') return findStructuredResult(value.result);
|
||
if (value.output && typeof value.output === 'object') return findStructuredResult(value.output);
|
||
if (value.data && typeof value.data === 'object') return findStructuredResult(value.data);
|
||
if (value.payload && typeof value.payload === 'object') return findStructuredResult(value.payload);
|
||
if (value.action && value.data) return { status: 'agent_parse_passed', blockers: [], operation: value };
|
||
return null;
|
||
}
|
||
|
||
function extractEventText(value) {
|
||
if (typeof value === 'string') return value;
|
||
if (!value || typeof value !== 'object') return '';
|
||
if (findStructuredResult(value)) return '';
|
||
for (const key of ['content', 'text', 'delta', 'output_text', 'final_output', 'answer', 'data', 'payload']) {
|
||
if (value[key] === undefined || value[key] === null) continue;
|
||
const nested = extractEventText(value[key]);
|
||
if (nested) return nested;
|
||
}
|
||
if (value.message !== undefined) return extractEventText(value.message);
|
||
return '';
|
||
}
|
||
|
||
function extractAssistantMessageText(value) {
|
||
if (!Array.isArray(value)) return '';
|
||
return value
|
||
.filter((item) => isAssistantMessage(item))
|
||
.map((item) => extractEventText(item))
|
||
.join('');
|
||
}
|
||
|
||
function isAssistantMessage(value) {
|
||
const type = String(value?.type || '').toLowerCase();
|
||
return type.includes('ai') || type.includes('assistant');
|
||
}
|
||
|
||
function isFinalEvent(eventName) {
|
||
return /(^|[._-])(completed|complete|final|done|finished)([._-]|$)/i.test(String(eventName || ''));
|
||
}
|
||
|
||
function compactPayload(payload) {
|
||
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== undefined && value !== null));
|
||
}
|
||
|
||
function makeRequestTrace(now) {
|
||
return {
|
||
service: 'external_parse_api',
|
||
transport: 'sse',
|
||
stage: 'not_started',
|
||
session_id_present: false,
|
||
event_count: 0,
|
||
updated_at: now.toISOString()
|
||
};
|
||
}
|
||
|
||
function updateRequestTrace(trace, stage, now, patch = {}) {
|
||
return {
|
||
...trace,
|
||
...patch,
|
||
stage,
|
||
updated_at: now.toISOString()
|
||
};
|
||
}
|
||
|
||
function linkAbortSignal(parentSignal, controller) {
|
||
if (!parentSignal) return () => {};
|
||
const abort = () => controller.abort();
|
||
if (parentSignal.aborted) abort();
|
||
else parentSignal.addEventListener('abort', abort, { once: true });
|
||
return () => parentSignal.removeEventListener('abort', abort);
|
||
}
|
||
|
||
async function cancelBodyWithBoundedWait(body) {
|
||
if (typeof body?.cancel !== 'function') return;
|
||
try {
|
||
const cancellation = Promise.resolve(body.cancel()).catch(() => undefined);
|
||
await Promise.race([
|
||
cancellation,
|
||
new Promise((resolve) => setTimeout(resolve, 1_000))
|
||
]);
|
||
} catch {
|
||
// The request has already been aborted. A provider that does not resolve
|
||
// cancel() must not hold the business task open indefinitely.
|
||
}
|
||
}
|
||
|
||
function isStateChangingMethod(method) {
|
||
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(String(method || '').toUpperCase());
|
||
}
|
||
|
||
function stripThinkBlocks(text) {
|
||
return String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
||
}
|
||
|
||
function findEventSeparator(buffer) {
|
||
const lf = buffer.indexOf('\n\n');
|
||
const crlf = buffer.indexOf('\r\n\r\n');
|
||
if (lf < 0) return crlf;
|
||
if (crlf < 0) return lf;
|
||
return Math.min(lf, crlf);
|
||
}
|
||
|
||
function eventSeparatorLength(buffer, index) {
|
||
return buffer.startsWith('\r\n\r\n', index) ? 4 : 2;
|
||
}
|
||
|
||
function parseErrorDetail(responseText) {
|
||
try {
|
||
const body = JSON.parse(responseText);
|
||
if (typeof body === 'string') return body;
|
||
if (body && typeof body === 'object') return body.detail || body.message || body.error || body;
|
||
} catch (error) {
|
||
// Keep the text path below intentionally short and redacted.
|
||
}
|
||
return redactSensitiveText(String(responseText || '').slice(0, 500));
|
||
}
|
||
|
||
function formatHttpError(statusCode) {
|
||
if (statusCode === 401) return '外部解析服务鉴权失败(401)。';
|
||
if (statusCode === 403) return '外部解析服务权限不足(403),请检查外部应用 scope。';
|
||
if (statusCode === 404) return '外部解析服务未找到可用的已发布 Profile(404)。';
|
||
if (statusCode === 409) return '外部解析服务报告当前 session 有运行中的任务(409)。';
|
||
return `外部解析服务请求失败(HTTP ${statusCode})。`;
|
||
}
|
||
|
||
function formatExternalError(error) {
|
||
if (error instanceof OpenAgentAPIError) {
|
||
const detail = error.detail && typeof error.detail === 'string'
|
||
? redactSensitiveText(error.detail).slice(0, 300)
|
||
: '';
|
||
return detail ? `${error.message} ${detail}` : error.message;
|
||
}
|
||
if (error?.code === 'ENOTFOUND' || error?.code === 'ECONNREFUSED') {
|
||
return '无法连接外部解析服务,请检查服务地址和网络。';
|
||
}
|
||
return `外部解析服务调用异常:${redactSensitiveText(error?.message || String(error)).slice(0, 300)}`;
|
||
}
|
||
|
||
function errorCodeFor(error) {
|
||
if (error?.statusCode === 401) return 'external_unauthorized';
|
||
if (error?.statusCode === 403) return 'external_forbidden';
|
||
if (error?.statusCode === 404) return 'external_profile_not_published';
|
||
if (error?.statusCode === 409) return 'external_active_run';
|
||
if (error?.detail === 'parse_cancelled') return 'external_cancelled';
|
||
if (error?.detail === 'request_timeout') return 'external_timeout';
|
||
return 'external_request_failed';
|
||
}
|
||
|
||
function redactSensitiveText(value) {
|
||
return String(value || '').replace(/df_open_[A-Za-z0-9_-]+/g, '[redacted-open-api-key]');
|
||
}
|
||
|
||
function looksLikeFlatParserResult(result) {
|
||
return Boolean(
|
||
result.order_mode
|
||
|| result.orderMode
|
||
|| result.product_name
|
||
|| result.productName
|
||
|| result.departure_date
|
||
|| result.departureDate
|
||
|| result.op
|
||
|| result.operator
|
||
|| result.sales
|
||
|| result.salesperson
|
||
);
|
||
}
|
||
|
||
function flatParserResultToOperation(result) {
|
||
const counts = result.passenger_counts || result.passengers || {};
|
||
const rooms = result.room_counts || result.rooms || {};
|
||
const prices = result.prices || result.unit_prices || {};
|
||
const passengerCounts = {
|
||
adult: numberValue(pick(result, ['adult', 'adults'], counts.adult)),
|
||
child_bed: numberValue(pick(result, ['child_with_bed', 'child_bed', 'childrenWithBed'], pick(counts, ['child_with_bed', 'child_bed', 'childrenWithBed']))),
|
||
child_no_bed: numberValue(pick(result, ['child_without_bed', 'child_no_bed', 'childrenWithoutBed'], pick(counts, ['child_without_bed', 'child_no_bed', 'childrenWithoutBed']))),
|
||
infant: numberValue(pick(result, ['infant', 'infants'], counts.infant)),
|
||
leader: numberValue(pick(result, ['tour_leader', 'leader', 'tourLeader'], pick(counts, ['tour_leader', 'leader', 'tourLeader'])))
|
||
};
|
||
passengerCounts.expected_total = numberValue(
|
||
pick(result, ['expected_total', 'passenger_total'], pick(counts, ['expected_total', 'passenger_total']))
|
||
) || Object.values(passengerCounts).reduce((sum, value) => sum + value, 0);
|
||
|
||
const productName = pick(result, ['product_name', 'productName'], result.product?.name || '');
|
||
const departureDate = pick(result, ['departure_date', 'departureDate'], result.departure_dates?.[0] || '');
|
||
const source = result.source && typeof result.source === 'object' ? { ...result.source } : {};
|
||
if (result.notes && !source.notes) source.notes = result.notes;
|
||
|
||
return {
|
||
action: result.action || 'team_order_create',
|
||
order_nature: normalizeOrderNature(pick(result, ['order_nature', 'orderNature'])),
|
||
submit_mode: 'dry_run',
|
||
source,
|
||
data: {
|
||
order_mode: pick(result, ['order_mode', 'orderMode'], '团队-单个下单'),
|
||
test_marker: pick(result, ['test_marker', 'testMarker', 'marker'], ''),
|
||
product: { name: productName },
|
||
departure_dates: departureDate ? [String(departureDate)] : [],
|
||
passenger_counts: passengerCounts,
|
||
room_counts: {
|
||
SGL: numberValue(pick(result, ['SGL', 'sgl', 'single_room'], pick(rooms, ['SGL', 'sgl', 'single_room']))),
|
||
TWN: numberValue(pick(result, ['TWN', 'twn', 'twin_room'], pick(rooms, ['TWN', 'twn', 'twin_room']))),
|
||
TRP: numberValue(pick(result, ['TRP', 'trp', 'triple_room'], pick(rooms, ['TRP', 'trp', 'triple_room']))),
|
||
DBL: numberValue(pick(result, ['DBL', 'dbl', 'double_room'], pick(rooms, ['DBL', 'dbl', 'double_room']))),
|
||
HNM: numberValue(pick(result, ['HNM', 'hnm', 'honeymoon_room'], pick(rooms, ['HNM', 'hnm', 'honeymoon_room']))),
|
||
TL: numberValue(pick(result, ['TL', 'tl', 'leader_room'], pick(rooms, ['TL', 'tl', 'leader_room'])))
|
||
},
|
||
prices: {
|
||
adult: numberValue(pick(result, ['adult_price', 'price_adult'], pick(prices, ['adult', 'adult_price']))),
|
||
child_bed: numberValue(pick(result, ['child_with_bed_price', 'child_bed_price'], pick(prices, ['child_with_bed', 'child_bed']))),
|
||
child_no_bed: numberValue(pick(result, ['child_without_bed_price', 'child_no_bed_price'], pick(prices, ['child_without_bed', 'child_no_bed']))),
|
||
infant: numberValue(pick(result, ['infant_price'], prices.infant)),
|
||
leader: numberValue(pick(result, ['tour_leader_price', 'leader_price'], pick(prices, ['tour_leader', 'leader'])))
|
||
},
|
||
op_user: { name: pick(result, ['op', 'operator'], result.op_user?.name || '') },
|
||
sales_user: { name: pick(result, ['sales', 'salesperson'], result.sales_user?.name || '') },
|
||
special_requests: pick(result, ['remarks', 'special_requests', 'specialRequests'], ''),
|
||
passenger_list: result.passenger_list || result.passengerList || {
|
||
operation: 'none',
|
||
passenger_count: 0,
|
||
file_count: 0
|
||
},
|
||
attachments: Array.isArray(result.attachments) ? result.attachments : [],
|
||
system_defaults: result.system_defaults || result.systemDefaults || {}
|
||
}
|
||
};
|
||
}
|
||
|
||
function pick(object, keys, fallback = '') {
|
||
if (!object || typeof object !== 'object') return fallback;
|
||
for (const key of keys) {
|
||
const value = object[key];
|
||
if (value !== undefined && value !== null && String(value).trim() !== '') return value;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function numberValue(value) {
|
||
const number = Number(String(value ?? '').replace(/[,,\s]/g, ''));
|
||
return Number.isFinite(number) ? number : 0;
|
||
}
|
||
|
||
function normalizeOrderNature(value) {
|
||
const text = String(value || '').trim().toLowerCase();
|
||
if (/test|测试/.test(text)) return 'test';
|
||
if (/formal|正式/.test(text)) return 'formal';
|
||
return value || 'test';
|
||
}
|
||
|
||
export { DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_TOTAL_TIMEOUT_MS };
|