Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import { jsonrepair } from 'jsonrepair';
|
||||
import { reconcileAgentSession } from './session-reconcile.mjs';
|
||||
|
||||
const insecureDispatcher = new Agent({
|
||||
connect: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
const DEFAULT_TEXT_BYTES = 48 * 1024;
|
||||
|
||||
function isHttpsTarget(target) {
|
||||
return String(target).startsWith('https://');
|
||||
}
|
||||
|
||||
function runnerError(message, code, details) {
|
||||
return Object.assign(new Error(message), { code, details });
|
||||
}
|
||||
|
||||
function createUserMessage(text) {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
content: [{ type: 'text', text }],
|
||||
metadata: { userVisible: true, agentVisible: true },
|
||||
};
|
||||
}
|
||||
|
||||
function messageVisibleText(message) {
|
||||
return (message?.content ?? [])
|
||||
.filter((item) => item.type === 'text')
|
||||
.map((item) => item.text)
|
||||
.join('');
|
||||
}
|
||||
|
||||
import { mergeMessageContent } from './message-stream.mjs';
|
||||
|
||||
function pushMessage(messages, incoming) {
|
||||
const last = messages[messages.length - 1];
|
||||
if (last?.id && incoming?.id && last.id === incoming.id) {
|
||||
return [
|
||||
...messages.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
content: mergeMessageContent(last.content, incoming.content),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [...messages, incoming];
|
||||
}
|
||||
|
||||
function extractBalancedJsonObject(text, startIndex = 0) {
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (let i = startIndex; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (inString) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') depth += 1;
|
||||
else if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return text.slice(startIndex, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectJsonCandidates(text) {
|
||||
const source = String(text ?? '').trim();
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const push = (value) => {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
candidates.push(trimmed);
|
||||
};
|
||||
|
||||
for (const match of source.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
|
||||
push(match[1]);
|
||||
}
|
||||
push(source);
|
||||
|
||||
for (const candidate of [...candidates]) {
|
||||
for (let i = 0; i < candidate.length; i += 1) {
|
||||
if (candidate[i] !== '{') continue;
|
||||
const balanced = extractBalancedJsonObject(candidate, i);
|
||||
if (balanced) push(balanced);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function parseJsonObject(text) {
|
||||
for (const candidate of collectJsonCandidates(text)) {
|
||||
for (const normalized of [candidate, jsonrepair(candidate)]) {
|
||||
try {
|
||||
const parsed = JSON.parse(normalized);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractJsonObject(text) {
|
||||
const source = String(text ?? '').trim();
|
||||
const parsed = parseJsonObject(source);
|
||||
if (parsed) return parsed;
|
||||
|
||||
const start = source.indexOf('{');
|
||||
const end = source.lastIndexOf('}');
|
||||
if (start < 0 || end <= start) {
|
||||
throw runnerError('Agent 输出缺少 JSON 结果', 'invalid_agent_job_output');
|
||||
}
|
||||
|
||||
const raw = extractBalancedJsonObject(source, start) ?? source.slice(start, end + 1);
|
||||
try {
|
||||
return JSON.parse(jsonrepair(raw));
|
||||
} catch (error) {
|
||||
throw runnerError('Agent 输出 JSON 解析失败', 'invalid_agent_job_output', {
|
||||
raw: raw.slice(0, 2000),
|
||||
cause: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStructuredResult(payload) {
|
||||
const title = String(payload?.title ?? '').trim();
|
||||
const summary = String(payload?.summary ?? '').trim();
|
||||
const content =
|
||||
String(payload?.content ?? payload?.markdown ?? payload?.body ?? '').trim();
|
||||
const contentFormat = String(payload?.content_format ?? payload?.contentFormat ?? 'markdown')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!title || !content) {
|
||||
throw runnerError('Agent 输出缺少标题或正文', 'invalid_agent_job_output');
|
||||
}
|
||||
return {
|
||||
title,
|
||||
summary,
|
||||
content,
|
||||
contentFormat: contentFormat === 'html' ? 'html' : 'markdown',
|
||||
pageType: contentFormat === 'html' ? 'html' : 'article',
|
||||
templateId: contentFormat === 'html' ? 'static-html' : 'report',
|
||||
};
|
||||
}
|
||||
|
||||
async function readAssetContext(asset, maxBytes = DEFAULT_TEXT_BYTES) {
|
||||
const mimeType = String(asset.mimeType ?? '');
|
||||
const textLike =
|
||||
mimeType.startsWith('text/') ||
|
||||
mimeType === 'application/json' ||
|
||||
mimeType.includes('xml') ||
|
||||
mimeType.includes('javascript');
|
||||
if (!textLike) {
|
||||
return {
|
||||
assetId: asset.assetId,
|
||||
displayName: asset.displayName,
|
||||
mimeType,
|
||||
excerpt: '',
|
||||
note: '该文件不是纯文本,Runner 当前不会直接内嵌二进制内容。',
|
||||
};
|
||||
}
|
||||
const buffer = await fs.readFile(asset.path);
|
||||
return {
|
||||
assetId: asset.assetId,
|
||||
displayName: asset.displayName,
|
||||
mimeType,
|
||||
excerpt: buffer.subarray(0, maxBytes).toString('utf8'),
|
||||
truncated: buffer.length > maxBytes,
|
||||
note: buffer.length > maxBytes ? `内容已截断到 ${maxBytes} 字节。` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentJobPrompt(job, assetContexts) {
|
||||
const assetSections = assetContexts
|
||||
.map((asset, index) => {
|
||||
const header = `资料 ${index + 1}: ${asset.displayName} (${asset.mimeType || 'unknown'})`;
|
||||
const note = asset.note ? `说明: ${asset.note}\n` : '';
|
||||
const body = asset.excerpt
|
||||
? `内容:\n<<<ASSET_${index + 1}>>>\n${asset.excerpt}\n<<<END_ASSET_${index + 1}>>>`
|
||||
: '内容: [未内嵌文本内容]';
|
||||
return `${header}\n${note}${body}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return [
|
||||
'你正在为 MindSpace 生成一个页面草稿。',
|
||||
'你只能基于给定资料和用户任务生成结果,不能假设额外事实。',
|
||||
'不要请求工具确认,不要输出解释,不要调用工具。',
|
||||
'最终回复必须是一个合法 JSON 对象,且至少包含 title、summary、content 三个字段。',
|
||||
'content 可以是 Markdown 或 HTML;如需 HTML,请把 content_format 设为 "html"。',
|
||||
'示例:',
|
||||
'{"title":"页面标题","summary":"一句话摘要","content":"# 标题\\n\\n正文段落","content_format":"markdown"}',
|
||||
`用户任务: ${job.instruction}`,
|
||||
'',
|
||||
assetSections,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function readJsonResponse(response) {
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `upstream ${response.status}`);
|
||||
}
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function defaultExecuteSessionReply(apiFetch, sessionId, requestId, prompt) {
|
||||
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
});
|
||||
if (!eventsResponse.ok || !eventsResponse.body) {
|
||||
const text = await eventsResponse.text().catch(() => '');
|
||||
throw runnerError(text || '无法建立任务事件流', 'worker_unavailable');
|
||||
}
|
||||
|
||||
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
request_id: requestId,
|
||||
user_message: createUserMessage(prompt),
|
||||
}),
|
||||
});
|
||||
if (!replyResponse.ok) {
|
||||
const text = await replyResponse.text().catch(() => '');
|
||||
throw runnerError(text || 'Agent reply 请求失败', 'worker_unavailable');
|
||||
}
|
||||
replyResponse.body?.cancel().catch?.(() => {});
|
||||
|
||||
const reader = Readable.fromWeb(eventsResponse.body);
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let messages = [];
|
||||
|
||||
for await (const chunk of reader) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const frames = buffer.split('\n\n');
|
||||
buffer = frames.pop() ?? '';
|
||||
for (const frame of frames) {
|
||||
let data = '';
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('data:')) data += `${line.slice(5).trim()}`;
|
||||
}
|
||||
if (!data) continue;
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const routingId = event.chat_request_id ?? event.request_id;
|
||||
if (routingId && routingId !== requestId) continue;
|
||||
|
||||
if (event.type === 'Message' && event.message?.metadata?.userVisible) {
|
||||
const hasActionRequired = event.message.content?.some((item) => item.type === 'actionRequired');
|
||||
if (hasActionRequired) {
|
||||
throw runnerError('任务执行需要人工确认,Runner 当前无法自动处理', 'worker_unavailable');
|
||||
}
|
||||
messages = pushMessage(messages, event.message);
|
||||
} else if (event.type === 'UpdateConversation') {
|
||||
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible);
|
||||
} else if (event.type === 'Error') {
|
||||
throw runnerError(event.error || '任务执行失败', 'worker_unavailable');
|
||||
} else if (event.type === 'Finish') {
|
||||
const assistant = [...messages].reverse().find((item) => item.role === 'assistant');
|
||||
return {
|
||||
text: messageVisibleText(assistant),
|
||||
tokenState: event.token_state ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw runnerError('任务事件流提前结束', 'worker_unavailable');
|
||||
}
|
||||
|
||||
export function createMindSpaceAgentRunner({
|
||||
apiTarget,
|
||||
apiSecret,
|
||||
userAuth,
|
||||
agentJobService,
|
||||
executeSessionReply = defaultExecuteSessionReply,
|
||||
apiFetchImpl = null,
|
||||
}) {
|
||||
const apiFetch = apiFetchImpl ?? (async (pathname, init = {}) => {
|
||||
const url = new URL(pathname, apiTarget);
|
||||
const headers = {
|
||||
...(init.headers ?? {}),
|
||||
'X-Secret-Key': apiSecret,
|
||||
};
|
||||
if (init.body && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
return undiciFetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
dispatcher: isHttpsTarget(apiTarget) ? insecureDispatcher : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const runJob = async (jobId) => {
|
||||
let claim = null;
|
||||
let sessionId = null;
|
||||
try {
|
||||
claim = await agentJobService.claimJob(jobId);
|
||||
const gate = await userAuth.canUseChat(claim.userId);
|
||||
if (!gate.ok) {
|
||||
throw runnerError(gate.message || '当前用户无法执行 Agent 任务', 'worker_unavailable');
|
||||
}
|
||||
|
||||
const workingDir = await userAuth.resolveWorkingDir(claim.userId);
|
||||
const sessionPolicy = await userAuth.getAgentSessionPolicy(claim.userId);
|
||||
const startSession = await readJsonResponse(
|
||||
await apiFetch('/agent/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
working_dir: workingDir,
|
||||
enable_context_memory: sessionPolicy.enableContextMemory,
|
||||
...(sessionPolicy.extensionOverrides
|
||||
? { extension_overrides: sessionPolicy.extensionOverrides }
|
||||
: {}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
sessionId = startSession?.id;
|
||||
if (!sessionId) {
|
||||
throw runnerError('Agent 会话启动失败', 'worker_unavailable');
|
||||
}
|
||||
await userAuth.registerAgentSession(claim.userId, sessionId);
|
||||
await reconcileAgentSession(
|
||||
(pathname, init) => apiFetch(pathname, init),
|
||||
sessionId,
|
||||
{
|
||||
workingDir,
|
||||
sessionPolicy,
|
||||
sandboxConstraints: null,
|
||||
},
|
||||
);
|
||||
|
||||
const assetContexts = [];
|
||||
for (const asset of claim.allowedAssets) {
|
||||
const localAsset = await agentJobService.getAssetForJob(jobId, claim.jobToken, asset.assetId);
|
||||
assetContexts.push(await readAssetContext(localAsset));
|
||||
}
|
||||
const prompt = buildAgentJobPrompt(claim, assetContexts);
|
||||
const requestId = crypto.randomUUID();
|
||||
const reply = await executeSessionReply(apiFetch, sessionId, requestId, prompt);
|
||||
const parsed = normalizeStructuredResult(extractJsonObject(reply.text));
|
||||
|
||||
if (reply.tokenState) {
|
||||
await userAuth.billSessionUsage(claim.userId, sessionId, reply.tokenState, requestId);
|
||||
}
|
||||
|
||||
return agentJobService.completeJob(jobId, claim.jobToken, {
|
||||
title: parsed.title,
|
||||
summary: parsed.summary,
|
||||
content: parsed.content,
|
||||
contentFormat: parsed.contentFormat,
|
||||
pageType: parsed.pageType,
|
||||
templateId: parsed.templateId,
|
||||
sourceAssetIds: claim.allowedAssets.map((asset) => asset.assetId),
|
||||
});
|
||||
} catch (error) {
|
||||
if (claim?.jobToken) {
|
||||
await agentJobService
|
||||
.completeJob(jobId, claim.jobToken, {
|
||||
status: 'failed',
|
||||
errorCode: error?.code ?? 'worker_unavailable',
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return { runJob };
|
||||
}
|
||||
|
||||
export const agentRunnerInternals = {
|
||||
extractJsonObject,
|
||||
extractBalancedJsonObject,
|
||||
collectJsonCandidates,
|
||||
normalizeStructuredResult,
|
||||
buildAgentJobPrompt,
|
||||
readAssetContext,
|
||||
};
|
||||
Reference in New Issue
Block a user