6ee6fd64dd
Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev. Co-authored-by: Cursor <cursoragent@cursor.com>
270 lines
8.2 KiB
JavaScript
270 lines
8.2 KiB
JavaScript
import { Agent, fetch as undiciFetch } from 'undici';
|
||
import { buildPageEditAgentPolicy } from './capabilities.mjs';
|
||
import { buildPageEditSubAgentInstructions } from './mindspace-chat-context.mjs';
|
||
import { buildMindSpacePageSaveInstructions } from './mindspace-page-patch.mjs';
|
||
import { reconcileAgentSession } from './session-reconcile.mjs';
|
||
|
||
const insecureDispatcher = new Agent({
|
||
connect: { rejectUnauthorized: false },
|
||
});
|
||
|
||
function isHttpsTarget(target) {
|
||
return target.startsWith('https://');
|
||
}
|
||
|
||
function createApiFetch(apiTarget, apiSecret) {
|
||
return 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,
|
||
});
|
||
};
|
||
}
|
||
|
||
async function readJson(upstream) {
|
||
const text = await upstream.text();
|
||
if (!upstream.ok) {
|
||
throw Object.assign(new Error(text || `upstream ${upstream.status}`), {
|
||
code: 'worker_unavailable',
|
||
});
|
||
}
|
||
return text ? JSON.parse(text) : null;
|
||
}
|
||
|
||
function buildPageEditSeedContent({ page, parentSessionId, h5ApiBase, sessionId }) {
|
||
const context = {
|
||
spaceId: 'mindspace',
|
||
spaceName: 'MindSpace',
|
||
view: 'page',
|
||
route: `/mindspace/page/${page.id}/preview`,
|
||
pageEditMode: true,
|
||
parentAgentSessionId: parentSessionId,
|
||
agentSessionId: sessionId,
|
||
h5ApiBase,
|
||
page: {
|
||
id: page.id,
|
||
title: page.title,
|
||
summary: page.summary ?? '',
|
||
status: page.status,
|
||
versionNo: page.versionNo,
|
||
templateId: page.templateId,
|
||
contentFormat: page.contentFormat ?? 'html',
|
||
},
|
||
};
|
||
|
||
const lines = [
|
||
...buildPageEditSubAgentInstructions(context).split('\n'),
|
||
'[MindSpace 页面编辑子会话]',
|
||
`- 页面:${page.title}(id: ${page.id})`,
|
||
`- 内容格式:${page.contentFormat ?? 'html'}`,
|
||
`- 当前版本:v${page.versionNo ?? 1}`,
|
||
...buildMindSpacePageSaveInstructions(
|
||
{
|
||
id: page.id,
|
||
contentFormat: page.contentFormat ?? 'html',
|
||
},
|
||
{ sessionId, h5ApiBase },
|
||
),
|
||
'请用一句话确认你已准备好修改此页面,并等待用户指令。',
|
||
];
|
||
return lines.join('\n');
|
||
}
|
||
|
||
export function buildPageEditSessionConstraints(pageTitle) {
|
||
return [
|
||
'【页面编辑子 Agent 沙箱】',
|
||
`- 当前任务:仅修改 MindSpace 页面「${pageTitle}」`,
|
||
'- 禁止读写工作区其它文件;禁止 delegate / load_skill / 发布新页面',
|
||
'- 改页面优先 curl 调用 mindspace_page_patch;若无 shell 权限则在回复末尾输出 ```mindspace-page-update``` 块',
|
||
'- 每次改动后立即触发预览更新,不要只口头说「已修改」',
|
||
].join('\n');
|
||
}
|
||
|
||
export function createPageEditSessionService({
|
||
apiTarget,
|
||
apiSecret,
|
||
userAuth,
|
||
pageService,
|
||
pageLiveEdit,
|
||
llmProviderService,
|
||
}) {
|
||
const apiFetch = createApiFetch(apiTarget, apiSecret);
|
||
|
||
const applySessionLlmProvider = async (sessionId) => {
|
||
if (!llmProviderService || !sessionId) return null;
|
||
try {
|
||
return await llmProviderService.applyBestProviderForSession(sessionId);
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
return {
|
||
async forkSession({ userId, pageId, parentSessionId, h5ApiBase = null }) {
|
||
if (!userId || !pageId || !parentSessionId) {
|
||
throw Object.assign(new Error('缺少 fork 参数'), { code: 'invalid_request' });
|
||
}
|
||
|
||
const ownsParent = await userAuth.ownsSession(userId, parentSessionId);
|
||
if (!ownsParent) {
|
||
throw Object.assign(new Error('无权使用父会话'), { code: 'forbidden' });
|
||
}
|
||
|
||
const page = await pageService.getPage(userId, pageId);
|
||
if (page.contentFormat !== 'html') {
|
||
throw Object.assign(new Error('仅 HTML 页面支持编辑子会话'), { code: 'invalid_request' });
|
||
}
|
||
|
||
const gate = await userAuth.canUseChat(userId);
|
||
if (!gate.ok) {
|
||
throw Object.assign(new Error(gate.message || '当前用户无法使用 Agent'), { code: 'forbidden' });
|
||
}
|
||
|
||
const workingDir = await userAuth.resolveWorkingDir(userId);
|
||
const basePolicy = await userAuth.getAgentSessionPolicy(userId);
|
||
const sessionPolicy = buildPageEditAgentPolicy(basePolicy);
|
||
const publishLayout = await userAuth.getUserPublishLayout(userId);
|
||
|
||
const startSession = await readJson(
|
||
await apiFetch('/agent/start', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
working_dir: workingDir,
|
||
enable_context_memory: sessionPolicy.enableContextMemory,
|
||
...(sessionPolicy.extensionOverrides
|
||
? { extension_overrides: sessionPolicy.extensionOverrides }
|
||
: {}),
|
||
}),
|
||
}),
|
||
);
|
||
|
||
const sessionId = startSession?.id;
|
||
if (!sessionId) {
|
||
throw Object.assign(new Error('Agent 会话启动失败'), { code: 'worker_unavailable' });
|
||
}
|
||
|
||
await userAuth.registerAgentSession(userId, sessionId);
|
||
|
||
if (sessionPolicy.gooseMode) {
|
||
await readJson(
|
||
await apiFetch('/agent/update_session', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
session_id: sessionId,
|
||
goose_mode: sessionPolicy.gooseMode,
|
||
}),
|
||
}),
|
||
);
|
||
}
|
||
|
||
const sandboxConstraints = [
|
||
publishLayout?.constraints ?? '',
|
||
buildPageEditSessionConstraints(page.title),
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n\n');
|
||
|
||
await reconcileAgentSession(apiFetch, sessionId, {
|
||
workingDir,
|
||
sessionPolicy,
|
||
sandboxConstraints,
|
||
userContext: publishLayout
|
||
? {
|
||
userId,
|
||
displayName: publishLayout.displayName,
|
||
username: publishLayout.username,
|
||
slug: publishLayout.slug,
|
||
}
|
||
: null,
|
||
});
|
||
|
||
await applySessionLlmProvider(sessionId);
|
||
|
||
await pageLiveEdit.bindSession({
|
||
userId,
|
||
sessionId,
|
||
pageId,
|
||
parentSessionId,
|
||
});
|
||
|
||
const seed = buildPageEditSeedContent({
|
||
page,
|
||
parentSessionId,
|
||
h5ApiBase,
|
||
sessionId,
|
||
});
|
||
|
||
await apiFetch('/agent/harness_remember', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
sessionId,
|
||
title: `页面编辑 · ${page.title}`,
|
||
content: seed,
|
||
}),
|
||
});
|
||
|
||
await apiFetch('/agent/harness_bootstrap', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ sessionId, force: true }),
|
||
});
|
||
|
||
return {
|
||
sessionId,
|
||
pageId,
|
||
parentSessionId,
|
||
};
|
||
},
|
||
|
||
async closeSession({ userId, sessionId, pageId, parentSessionId, summary = '' }) {
|
||
if (!userId || !sessionId || !pageId) {
|
||
throw Object.assign(new Error('缺少 close 参数'), { code: 'invalid_request' });
|
||
}
|
||
|
||
const owns = await userAuth.ownsSession(userId, sessionId);
|
||
if (!owns) {
|
||
throw Object.assign(new Error('无权关闭该子会话'), { code: 'forbidden' });
|
||
}
|
||
|
||
const binding = pageLiveEdit.getBinding(sessionId);
|
||
if (binding && binding.pageId !== String(pageId)) {
|
||
throw Object.assign(new Error('子会话未绑定该页面'), { code: 'page_binding_mismatch' });
|
||
}
|
||
|
||
pageLiveEdit.unbindSession(sessionId);
|
||
|
||
const trimmedSummary = String(summary ?? '').trim();
|
||
const parentId = String(parentSessionId ?? binding?.parentSessionId ?? '').trim();
|
||
if (trimmedSummary && parentId) {
|
||
const ownsParent = await userAuth.ownsSession(userId, parentId);
|
||
if (ownsParent) {
|
||
const page = await pageService.getPage(userId, pageId).catch(() => null);
|
||
await apiFetch('/agent/harness_remember', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
sessionId: parentId,
|
||
title: `页面编辑摘要 · ${page?.title ?? pageId}`,
|
||
content: trimmedSummary,
|
||
}),
|
||
});
|
||
}
|
||
}
|
||
|
||
return { sessionId, pageId, merged: Boolean(trimmedSummary && parentId) };
|
||
},
|
||
};
|
||
}
|
||
|
||
export const pageEditSessionInternals = {
|
||
buildPageEditSeedContent,
|
||
buildPageEditSessionConstraints,
|
||
};
|