feat(agent): 聊天提交统一走 POST /agent/runs 异步网关

引入 Agent Run 网关替代直连 /sessions/:id/reply,并在 api_lockdown 白名单中放行新入口,避免策略拦截导致聊天不可用。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-29 22:50:00 +08:00
parent 16828a58f7
commit 8f620fa714
14 changed files with 784 additions and 89 deletions
+194
View File
@@ -0,0 +1,194 @@
import crypto from 'node:crypto';
const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000];
const TERMINAL_STATUSES = new Set(['succeeded', 'failed']);
function nowMs() {
return Date.now();
}
function safeJsonParse(value, fallback = null) {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function serializeMessage(message) {
return JSON.stringify(message ?? {});
}
function projectRun(row) {
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
sessionId: row.agent_session_id ?? null,
requestId: row.request_id,
status: row.status,
attempts: Number(row.attempts ?? 0),
error: row.error_message ?? null,
createdAt: Number(row.created_at ?? 0),
updatedAt: Number(row.updated_at ?? 0),
startedAt: row.started_at == null ? null : Number(row.started_at),
completedAt: row.completed_at == null ? null : Number(row.completed_at),
};
}
export function createAgentRunGateway({
pool,
userAuth,
tkmindProxy,
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
autoDispatch = true,
}) {
const inFlight = new Set();
async function appendEvent(runId, type, data = null) {
await pool.query(
`INSERT INTO h5_agent_run_events (id, run_id, event_type, data_json, created_at)
VALUES (?, ?, ?, ?, ?)`,
[
crypto.randomUUID(),
runId,
type,
data == null ? null : JSON.stringify(data),
nowMs(),
],
);
}
async function getRunById(runId) {
const [rows] = await pool.query(
`SELECT * FROM h5_agent_runs WHERE id = ? LIMIT 1`,
[runId],
);
return rows[0] ?? null;
}
async function getRunForUser(userId, runId) {
const [rows] = await pool.query(
`SELECT * FROM h5_agent_runs WHERE id = ? AND user_id = ? LIMIT 1`,
[runId, userId],
);
return projectRun(rows[0] ?? null);
}
async function getRunByRequest(userId, requestId) {
const [rows] = await pool.query(
`SELECT * FROM h5_agent_runs WHERE user_id = ? AND request_id = ? LIMIT 1`,
[userId, requestId],
);
return rows[0] ?? null;
}
async function createRun(userId, { sessionId = null, requestId, userMessage }) {
const normalizedRequestId = String(requestId ?? '').trim();
if (!normalizedRequestId) {
throw new Error('缺少 request_id');
}
const existing = await getRunByRequest(userId, normalizedRequestId);
if (existing) {
if (autoDispatch && !TERMINAL_STATUSES.has(existing.status)) dispatchRun(existing.id);
return projectRun(existing);
}
const runId = crypto.randomUUID();
const createdAt = nowMs();
await pool.query(
`INSERT INTO h5_agent_runs
(id, user_id, agent_session_id, request_id, status, attempts,
user_message_json, error_message, created_at, updated_at, started_at, completed_at)
VALUES (?, ?, ?, ?, 'queued', 0, ?, NULL, ?, ?, NULL, NULL)`,
[
runId,
userId,
sessionId || null,
normalizedRequestId,
serializeMessage(userMessage),
createdAt,
createdAt,
],
);
await appendEvent(runId, 'queued', { sessionId: sessionId || null });
if (autoDispatch) dispatchRun(runId);
return projectRun(await getRunById(runId));
}
async function markRun(runId, status, fields = {}) {
const updates = ['status = ?', 'updated_at = ?'];
const values = [status, nowMs()];
for (const [key, value] of Object.entries(fields)) {
updates.push(`${key} = ?`);
values.push(value);
}
values.push(runId);
await pool.query(
`UPDATE h5_agent_runs SET ${updates.join(', ')} WHERE id = ?`,
values,
);
await appendEvent(runId, status, fields);
}
async function processRun(runId) {
const row = await getRunById(runId);
if (!row || TERMINAL_STATUSES.has(row.status)) return;
const nextAttempt = Number(row.attempts ?? 0) + 1;
const [claim] = await pool.query(
`UPDATE h5_agent_runs
SET status = 'running', attempts = ?, started_at = COALESCE(started_at, ?), updated_at = ?, error_message = NULL
WHERE id = ? AND status IN ('queued', 'retryable')`,
[nextAttempt, nowMs(), nowMs(), runId],
);
if (Number(claim?.affectedRows ?? 0) === 0) return;
await appendEvent(runId, 'running', { attempt: nextAttempt });
try {
let sessionId = row.agent_session_id ?? null;
if (!sessionId) {
const session = await tkmindProxy.startSessionForUser(row.user_id);
sessionId = session.id;
await pool.query(
`UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`,
[sessionId, nowMs(), runId],
);
await appendEvent(runId, 'session_started', { sessionId });
}
const userMessage = safeJsonParse(row.user_message_json, {});
await tkmindProxy.submitSessionReplyForUser(
row.user_id,
sessionId,
row.request_id,
userMessage,
);
await markRun(runId, 'succeeded', {
agent_session_id: sessionId,
completed_at: nowMs(),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const retryable = nextAttempt < retryDelaysMs.length;
await markRun(runId, retryable ? 'retryable' : 'failed', {
error_message: message,
completed_at: retryable ? null : nowMs(),
});
if (retryable) {
setTimeout(() => dispatchRun(runId), retryDelaysMs[nextAttempt - 1]);
}
}
}
function dispatchRun(runId) {
if (!runId || inFlight.has(runId)) return;
inFlight.add(runId);
void processRun(runId).finally(() => inFlight.delete(runId));
}
return {
createRun,
getRunForUser,
dispatchRun,
};
}
+168
View File
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAgentRunGateway } from './agent-run-gateway.mjs';
function createFakePool() {
const runs = new Map();
const events = [];
return {
runs,
events,
async query(sql, params = []) {
if (sql.includes('SELECT * FROM h5_agent_runs WHERE id = ? AND user_id = ?')) {
const [id, userId] = params;
const row = runs.get(id);
return [[row && row.user_id === userId ? row : undefined].filter(Boolean)];
}
if (sql.includes('SELECT * FROM h5_agent_runs WHERE user_id = ? AND request_id = ?')) {
const [userId, requestId] = params;
return [[...runs.values()].filter((row) => row.user_id === userId && row.request_id === requestId)];
}
if (sql.includes('SELECT * FROM h5_agent_runs WHERE id = ? LIMIT 1')) {
return [[runs.get(params[0])].filter(Boolean)];
}
if (sql.includes('INSERT INTO h5_agent_runs')) {
const [
id,
userId,
sessionId,
requestId,
userMessageJson,
createdAt,
updatedAt,
] = params;
runs.set(id, {
id,
user_id: userId,
agent_session_id: sessionId,
request_id: requestId,
status: 'queued',
attempts: 0,
user_message_json: userMessageJson,
error_message: null,
created_at: createdAt,
updated_at: updatedAt,
started_at: null,
completed_at: null,
});
return [{ affectedRows: 1 }];
}
if (sql.includes('INSERT INTO h5_agent_run_events')) {
const [id, runId, eventType, dataJson, createdAt] = params;
events.push({ id, runId, eventType, dataJson, createdAt });
return [{ affectedRows: 1 }];
}
if (sql.includes("SET status = 'running'")) {
const [attempts, startedAt, updatedAt, id] = params;
const row = runs.get(id);
if (!row || !['queued', 'retryable'].includes(row.status)) {
return [{ affectedRows: 0 }];
}
Object.assign(row, {
status: 'running',
attempts,
started_at: row.started_at ?? startedAt,
updated_at: updatedAt,
error_message: null,
});
return [{ affectedRows: 1 }];
}
if (sql.includes('UPDATE h5_agent_runs SET')) {
const id = params.at(-1);
const row = runs.get(id);
if (!row) return [{ affectedRows: 0 }];
const columns = [...sql.matchAll(/([a-z_]+) = \?/g)].map((match) => match[1]);
for (let i = 0; i < columns.length; i += 1) {
row[columns[i]] = params[i];
}
return [{ affectedRows: 1 }];
}
throw new Error(`Unhandled SQL: ${sql}`);
},
};
}
async function waitFor(predicate) {
for (let i = 0; i < 50; i += 1) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.fail('condition was not met');
}
test('agent run creation is idempotent by user and request id', async () => {
const pool = createFakePool();
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {},
autoDispatch: false,
});
const first = await gateway.createRun('user-1', {
requestId: 'req-1',
userMessage: { role: 'user', content: [] },
});
const second = await gateway.createRun('user-1', {
requestId: 'req-1',
userMessage: { role: 'user', content: [] },
});
assert.equal(first.id, second.id);
assert.equal(pool.runs.size, 1);
assert.equal(second.status, 'queued');
});
test('agent run starts a session and marks submitted reply as succeeded', async () => {
const pool = createFakePool();
const submitted = [];
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {
async startSessionForUser() {
return { id: 'session-1' };
},
async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) {
submitted.push({ userId, sessionId, requestId, userMessage });
},
},
retryDelaysMs: [],
});
const run = await gateway.createRun('user-1', {
requestId: 'req-1',
userMessage: { role: 'user', content: [{ type: 'text', text: 'hi' }] },
});
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
assert.deepEqual(submitted.map((item) => item.sessionId), ['session-1']);
assert.equal(pool.runs.get(run.id).attempts, 1);
});
test('agent run retries transient failures and then becomes terminal', async () => {
const pool = createFakePool();
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {
async startSessionForUser() {
return { id: 'session-1' };
},
async submitSessionReplyForUser() {
throw new Error('upstream unavailable');
},
},
retryDelaysMs: [0, 0],
});
const run = await gateway.createRun('user-1', {
requestId: 'req-1',
userMessage: { role: 'user', content: [] },
});
await waitFor(() => pool.runs.get(run.id)?.status === 'failed');
assert.equal(pool.runs.get(run.id).attempts, 2);
assert.match(pool.runs.get(run.id).error_message, /upstream unavailable/);
});
+34
View File
@@ -255,6 +255,40 @@ export async function migrateSchema(pool) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_agent_runs (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
agent_session_id VARCHAR(128) NULL,
request_id VARCHAR(128) NOT NULL,
status ENUM('queued', 'running', 'retryable', 'succeeded', 'failed') NOT NULL DEFAULT 'queued',
attempts INT NOT NULL DEFAULT 0,
user_message_json LONGTEXT NOT NULL,
error_message TEXT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
started_at BIGINT NULL,
completed_at BIGINT NULL,
UNIQUE KEY uq_h5_agent_run_request (user_id, request_id),
KEY idx_h5_agent_run_user_status (user_id, status, updated_at),
KEY idx_h5_agent_run_session (agent_session_id, updated_at),
CONSTRAINT fk_h5_agent_run_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_agent_run_session FOREIGN KEY (agent_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_agent_run_events (
id CHAR(36) PRIMARY KEY,
run_id CHAR(36) NOT NULL,
event_type VARCHAR(64) NOT NULL,
data_json JSON NULL,
created_at BIGINT NOT NULL,
KEY idx_h5_agent_run_event_run (run_id, created_at),
CONSTRAINT fk_h5_agent_run_event_run FOREIGN KEY (run_id) REFERENCES h5_agent_runs(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_user_memory_items (
id CHAR(64) PRIMARY KEY,
+1 -1
View File
@@ -34,7 +34,7 @@
"enrich:plaza": "node scripts/enrich-plaza-posts.mjs",
"build": "vite build",
"build:portal-runtime": "node scripts/build-portal-runtime.mjs",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs",
"test:mindspace-e2e": "node scripts/mindspace-e2e.mjs",
"test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs",
"test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs",
+2
View File
@@ -205,6 +205,8 @@ const USER_API_ALLOWLIST = [
{ method: 'GET', pattern: /^\/status$/ },
{ method: 'POST', pattern: /^\/agent\/start$/ },
{ method: 'POST', pattern: /^\/agent\/resume$/ },
{ method: 'POST', pattern: /^\/agent\/runs$/ },
{ method: 'GET', pattern: /^\/agent\/runs\/[^/]+$/ },
{ method: 'GET', pattern: /^\/sessions$/ },
{ method: 'GET', pattern: /^\/sessions\/[^/]+$/ },
{ method: 'DELETE', pattern: /^\/sessions\/[^/]+$/ },
+10
View File
@@ -60,6 +60,16 @@ test('api lockdown blocks extension admin routes', () => {
});
assert.equal(allowed.allowed, true);
const agentRunCreate = evaluateProxyRequest('POST', '/agent/runs', {
api_lockdown: true,
});
assert.equal(agentRunCreate.allowed, true);
const agentRunGet = evaluateProxyRequest('GET', '/agent/runs/run-1', {
api_lockdown: true,
});
assert.equal(agentRunGet.allowed, true);
const deleteAllowed = evaluateProxyRequest('DELETE', '/sessions/abc', {
api_lockdown: true,
});
+30
View File
@@ -353,6 +353,36 @@ CREATE TABLE IF NOT EXISTS h5_user_sessions (
CONSTRAINT fk_h5_session_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_agent_runs (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
agent_session_id VARCHAR(128) NULL,
request_id VARCHAR(128) NOT NULL,
status ENUM('queued', 'running', 'retryable', 'succeeded', 'failed') NOT NULL DEFAULT 'queued',
attempts INT NOT NULL DEFAULT 0,
user_message_json LONGTEXT NOT NULL,
error_message TEXT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
started_at BIGINT NULL,
completed_at BIGINT NULL,
UNIQUE KEY uq_h5_agent_run_request (user_id, request_id),
KEY idx_h5_agent_run_user_status (user_id, status, updated_at),
KEY idx_h5_agent_run_session (agent_session_id, updated_at),
CONSTRAINT fk_h5_agent_run_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_agent_run_session FOREIGN KEY (agent_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_agent_run_events (
id CHAR(36) PRIMARY KEY,
run_id CHAR(36) NOT NULL,
event_type VARCHAR(64) NOT NULL,
data_json JSON NULL,
created_at BIGINT NOT NULL,
KEY idx_h5_agent_run_event_run (run_id, created_at),
CONSTRAINT fk_h5_agent_run_event_run FOREIGN KEY (run_id) REFERENCES h5_agent_runs(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_session_billing_state (
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
+92 -36
View File
@@ -13,6 +13,7 @@ import {
sessionCookie,
} from './auth.mjs';
import { createDbPool, initSchema, isDatabaseConfigured } from './db.mjs';
import { createAgentRunGateway } from './agent-run-gateway.mjs';
import { createTkmindProxy } from './tkmind-proxy.mjs';
import {
clearUserSessionCookie,
@@ -216,6 +217,7 @@ if (ACCESS_PASSWORD) {
let userAuth = null;
let tkmindProxy = null;
let agentRunGateway = null;
let sessionSnapshotService = null;
let conversationMemoryService = null;
let mindSpace = null;
@@ -540,6 +542,11 @@ async function bootstrapUserAuth() {
}
: null,
});
agentRunGateway = createAgentRunGateway({
pool,
userAuth,
tkmindProxy,
});
wechatMpService = createWechatMpService({
config: WECHAT_MP_CONFIG,
userAuth,
@@ -3668,6 +3675,85 @@ api.post('/agent/start', async (req, res, next) => {
return runHandlerChain(tkmindProxy.handlers['POST /agent/start'], req, res, next);
});
api.post('/agent/runs', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy || !agentRunGateway) return next();
return runHandlerChain(
[
tkmindProxy.requireUser,
tkmindProxy.ensureChatAllowed,
async (request, response) => {
try {
const sessionId = String(request.body?.session_id ?? '').trim() || null;
const requestId = String(request.body?.request_id ?? '').trim();
const userMessage = request.body?.user_message ?? null;
if (!requestId) {
response.status(400).json({ message: '缺少 request_id' });
return;
}
if (!userMessage) {
response.status(400).json({ message: '缺少 user_message' });
return;
}
if (sessionId) {
const owns = await userAuth.ownsSession(request.currentUser.id, sessionId);
if (!owns) {
response.status(403).json({ message: '无权访问该会话' });
return;
}
}
const run = await agentRunGateway.createRun(request.currentUser.id, {
sessionId,
requestId,
userMessage,
});
response.status(202).json({ run });
} catch (err) {
response.status(500).json({
message: err instanceof Error ? err.message : '创建任务失败',
});
}
},
],
req,
res,
next,
);
});
api.get('/agent/runs/:runId', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy || !agentRunGateway) return next();
return runHandlerChain(
[
tkmindProxy.requireUser,
async (request, response) => {
try {
const run = await agentRunGateway.getRunForUser(
request.currentUser.id,
request.params.runId,
);
if (!run) {
response.status(404).json({ message: '任务不存在' });
return;
}
if (run.status !== 'succeeded' && run.status !== 'failed') {
agentRunGateway.dispatchRun(run.id);
}
response.json({ run });
} catch (err) {
response.status(500).json({
message: err instanceof Error ? err.message : '读取任务失败',
});
}
},
],
req,
res,
next,
);
});
api.post('/agent/resume', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
@@ -3802,46 +3888,16 @@ api.use(async (req, res, next) => {
if (req.path.endsWith('/events') && req.method === 'GET') {
return next();
}
if (req.path.endsWith('/reply') && req.method === 'POST') {
return res.status(410).json({
message: '聊天提交入口已统一为 POST /agent/runs',
code: 'AGENT_RUNS_REQUIRED',
});
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
if (req.path.endsWith('/reply') && req.method === 'POST') {
const gate = await userAuth.canUseChat(req.currentUser.id);
if (!gate.ok) {
return res.status(402).json({
message: gate.message,
code: gate.code,
balanceCents: gate.balanceCents,
minRechargeCents: gate.minRechargeCents,
suggestedTiers: gate.suggestedTiers,
});
}
try {
await tkmindProxy.reconcileSessionPolicyForUser(req.currentUser.id, sessionId);
} catch (err) {
console.warn(
'Session policy sync before reply failed:',
err instanceof Error ? err.message : err,
);
return res.status(500).json({
message:
err instanceof Error
? `会话策略同步失败:${err.message}`
: '会话策略同步失败',
});
}
if (llmProviderService) {
try {
await tkmindProxy.applySessionLlmProvider(sessionId);
} catch (err) {
console.warn(
'LLM provider sync before reply skipped:',
err instanceof Error ? err.message : err,
);
}
}
}
}
if (req.method === 'POST' && req.path === '/agent/resume' && req.body?.session_id) {
+26 -4
View File
@@ -51,6 +51,21 @@ import { normalizeConversationMessages, normalizeUserMessageForApi } from '../ut
const API = '/api';
const DEFAULT_API_TIMEOUT_MS = 20_000;
const AGENT_RUNS_PATH = '/agent/runs';
export type AgentRun = {
id: string;
userId: string;
sessionId: string | null;
requestId: string;
status: 'queued' | 'running' | 'retryable' | 'succeeded' | 'failed';
attempts: number;
error: string | null;
createdAt: number;
updatedAt: number;
startedAt: number | null;
completedAt: number | null;
};
export class ApiError extends Error {
readonly status: number;
@@ -2038,18 +2053,25 @@ export async function loadSessionDetail(
return { session: detail, messages };
}
export async function sendReply(
sessionId: string,
export async function createAgentRun(
sessionId: string | null,
requestId: string,
userMessage: Message,
): Promise<void> {
await apiFetch(`${sessionPath(sessionId)}/reply`, {
): Promise<AgentRun> {
const result = await apiFetch<{ run: AgentRun }>(AGENT_RUNS_PATH, {
method: 'POST',
body: JSON.stringify({
session_id: sessionId,
request_id: requestId,
user_message: normalizeUserMessageForApi(userMessage),
}),
});
return result.run;
}
export async function getAgentRun(runId: string): Promise<AgentRun> {
const result = await apiFetch<{ run: AgentRun }>(`${AGENT_RUNS_PATH}/${encodeURIComponent(runId)}`);
return result.run;
}
export async function cancelRequest(sessionId: string, requestId: string): Promise<void> {
+7 -1
View File
@@ -134,7 +134,11 @@ export function ChatPanel({
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, chatState, pendingTool]);
const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting';
const busy =
chatState === 'streaming' ||
chatState === 'loading' ||
chatState === 'connecting' ||
chatState === 'waiting';
const offlineBlocked = !online;
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
@@ -147,6 +151,8 @@ export function ChatPanel({
? '网络断开,恢复连接后可继续输入'
: chatState === 'connecting'
? '正在连接会话…'
: chatState === 'waiting'
? '消息已收到,正在连接后台…'
: compact
? '在这里继续和 Agent 对话…'
: randomPrompt;
+5 -1
View File
@@ -71,7 +71,11 @@ export function ChatView({
}
}, [sidebarOpen, onSidebarOpen]);
const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting';
const busy =
chatState === 'streaming' ||
chatState === 'loading' ||
chatState === 'connecting' ||
chatState === 'waiting';
const showHomeWelcome = messages.length === 0;
const handleSelectSession = (sessionId: string) => {
+31 -4
View File
@@ -4,12 +4,13 @@ import {
cancelRequest,
closeMindSpacePageEditSession,
confirmTool,
createAgentRun,
forkMindSpacePageEditSession,
getAgentRun,
getMindSpace,
loadSessionDetail,
resumeSession,
uploadMindSpaceAsset,
sendReply,
subscribeSessionEvents,
} from '../api/client';
import type { ChatState, Message, MindSpaceChatContext, PortalUser, Session, SessionEvent } from '../types';
@@ -30,6 +31,21 @@ import {
pushMessage,
} from '../utils/message';
const AGENT_RUN_POLL_DELAY_MS = 1200;
const AGENT_RUN_MAX_POLLS = 100;
async function waitForAgentRun(runId: string) {
for (let attempt = 0; attempt < AGENT_RUN_MAX_POLLS; attempt += 1) {
const run = await getAgentRun(runId);
if (run.status === 'succeeded') return run;
if (run.status === 'failed') {
throw new Error(run.error || '后台任务失败,请稍后重试');
}
await new Promise((resolve) => window.setTimeout(resolve, AGENT_RUN_POLL_DELAY_MS));
}
throw new Error('后台任务仍在排队,请稍后刷新会话查看结果');
}
function buildPageEditSummary(messages: Message[], pageTitle: string): string {
const lines = messages
.map((message) => {
@@ -288,7 +304,12 @@ export function usePageEditSubChat({
(value) => typeof value === 'string' && value.trim(),
);
if (!currentSession || (!text.trim() && normalizedImageUrls.length === 0)) return;
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
if (
chatState === 'streaming' ||
chatState === 'loading' ||
chatState === 'connecting' ||
chatState === 'waiting'
) return;
const trimmed = text.trim();
const mindspacePrefix = buildContextPrefix(context);
@@ -304,12 +325,18 @@ export function usePageEditSubChat({
activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage];
setMessages(messagesRef.current);
setChatState('streaming');
setChatState('waiting');
setError(null);
setPendingTool(null);
try {
await sendReply(currentSession.id, requestId, userMessage);
const createdRun = await createAgentRun(currentSession.id, requestId, userMessage);
const finishedRun =
createdRun.status === 'succeeded' ? createdRun : await waitForAgentRun(createdRun.id);
if (!finishedRun.sessionId) {
throw new Error('后台任务已提交,但未返回会话');
}
setChatState('streaming');
} catch (err) {
if (err instanceof ApiError && err.status === 402) {
setNotice('余额不足,请充值后继续使用');
+73 -36
View File
@@ -5,7 +5,9 @@ import {
bootstrapProjectMemory,
cancelRequest,
confirmTool,
createAgentRun,
deleteChatSession,
getAgentRun,
getMindSpace,
getMe,
listNotifications,
@@ -17,8 +19,6 @@ import {
rememberProjectContext,
uploadMindSpaceAsset,
resumeSession,
sendReply,
startSession,
subscribeNotificationEvents,
subscribeSessionEvents,
updateProvider,
@@ -69,9 +69,23 @@ import {
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
const REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000];
const AGENT_RUN_POLL_DELAY_MS = 1200;
const AGENT_RUN_MAX_POLLS = 100;
export { INSUFFICIENT_BALANCE_NOTICE };
async function waitForAgentRun(runId: string) {
for (let attempt = 0; attempt < AGENT_RUN_MAX_POLLS; attempt += 1) {
const run = await getAgentRun(runId);
if (run.status === 'succeeded') return run;
if (run.status === 'failed') {
throw new Error(run.error || '后台任务失败,请稍后重试');
}
await new Promise((resolve) => window.setTimeout(resolve, AGENT_RUN_POLL_DELAY_MS));
}
throw new Error('后台任务仍在排队,请稍后刷新会话查看结果');
}
function isAmbiguousReplySubmitError(err: unknown) {
if (!(err instanceof ApiError)) return true;
return err.status === 0 || err.status === 409 || err.status >= 500;
@@ -444,9 +458,15 @@ export function useTKMindChat(
}
const retryRequestId = crypto.randomUUID();
activeRequestId.current = retryRequestId;
setChatState('streaming');
setChatState('waiting');
setError(null);
await sendReply(sessionRef.current!.id, retryRequestId, lastUser);
const retryRun = await createAgentRun(sessionRef.current!.id, retryRequestId, lastUser);
const finishedRetryRun =
retryRun.status === 'succeeded' ? retryRun : await waitForAgentRun(retryRun.id);
if (!finishedRetryRun.sessionId) {
throw new Error('后台任务已提交,但未返回会话');
}
setChatState('streaming');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
@@ -741,7 +761,7 @@ export function useTKMindChat(
const switchSession = useCallback(
(sessionId: string) => {
if (session?.id === sessionId || chatState === 'streaming') return;
if (session?.id === sessionId || chatState === 'streaming' || chatState === 'waiting') return;
resetSessionView();
const token = connectTokenRef.current;
@@ -776,7 +796,12 @@ export function useTKMindChat(
(value) => typeof value === 'string' && value.trim(),
);
if (!text.trim() && normalizedImageUrls.length === 0) return;
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
if (
chatState === 'streaming' ||
chatState === 'loading' ||
chatState === 'connecting' ||
chatState === 'waiting'
) return;
const trimmed = text.trim();
const mindspacePrefix = options?.mindspaceContext
@@ -795,41 +820,48 @@ export function useTKMindChat(
activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage];
setMessages(messagesRef.current);
setChatState('streaming');
setChatState('waiting');
setError(null);
setPendingTool(null);
let activeSessionId = session?.id ?? null;
if (!activeSessionId) {
try {
const created = await startSession();
activeSessionId = created.id;
writeStoredSessionId(userRef.current?.id, created.id);
setSession(created);
setSessions((prev) => prependUnique(prev, created));
subscribeToSession(created.id);
void ensureProvider(created.id);
void loadProjectMemory(created.id, false);
void refreshSessions();
} catch (err) {
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
} else {
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
activeRequestId.current = null;
return;
}
} else {
if (activeSessionId) {
setSessions((prev) => touchSession(prev, activeSessionId!, 1));
}
try {
await sendReply(activeSessionId, requestId, userMessage);
const createdRun = await createAgentRun(activeSessionId, requestId, userMessage);
const finishedRun =
createdRun.status === 'succeeded' ? createdRun : await waitForAgentRun(createdRun.id);
activeSessionId = finishedRun.sessionId;
if (!activeSessionId) {
throw new Error('后台任务已提交,但未返回会话');
}
if (!session?.id || session.id !== activeSessionId) {
const nextSession: Session = {
id: activeSessionId,
name: 'New Chat',
message_count: messagesRef.current.length,
working_dir: '',
};
writeStoredSessionId(userRef.current?.id, activeSessionId);
setSession(nextSession);
setSessions((prev) => prependUnique(prev, nextSession));
void loadSessionDetail(activeSessionId)
.then(({ session: loaded }) => {
setSession(loaded);
setSessions((prev) => prependUnique(prev, loaded));
})
.catch(() => {});
void ensureProvider(activeSessionId);
void loadProjectMemory(activeSessionId, false);
void refreshSessions();
}
subscribeToSession(activeSessionId);
setChatState('streaming');
} catch (err) {
if (isAmbiguousReplySubmitError(err)) {
if (activeSessionId && isAmbiguousReplySubmitError(err)) {
subscribeToSession(activeSessionId);
setChatState('streaming');
setError(null);
@@ -842,7 +874,7 @@ export function useTKMindChat(
}
return;
}
if (session) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
if (session && activeSessionId) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
} else {
@@ -886,7 +918,7 @@ export function useTKMindChat(
);
const newSession = useCallback(() => {
if (chatState === 'streaming') return;
if (chatState === 'streaming' || chatState === 'waiting') return;
connectTokenRef.current += 1;
unsubscribeRef.current?.();
@@ -902,12 +934,17 @@ export function useTKMindChat(
}, [chatState]);
const refreshProjectMemory = useCallback(async () => {
if (!session || chatState === 'streaming') return;
if (!session || chatState === 'streaming' || chatState === 'waiting') return;
await loadProjectMemory(session.id, true);
}, [chatState, loadProjectMemory, session]);
const rememberCurrentContext = useCallback(async () => {
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
if (
chatState === 'streaming' ||
chatState === 'loading' ||
chatState === 'connecting' ||
chatState === 'waiting'
) return;
await rememberRecentContext({ silent: false });
}, [chatState, rememberRecentContext]);
@@ -917,7 +954,7 @@ export function useTKMindChat(
const deleteSession = useCallback(
(sessionId: string) => {
if (chatState === 'streaming') return;
if (chatState === 'streaming' || chatState === 'waiting') return;
void (async () => {
try {
+111 -6
View File
@@ -363,14 +363,16 @@ export function createTkmindProxy({
recipe = null,
} = {},
) {
const resolvedWorkingDir = workingDir ?? await userAuth.resolveWorkingDir(userId);
const resolvedSessionPolicy = sessionPolicy ?? await userAuth.getAgentSessionPolicy(userId);
const startTarget = await pickTarget();
const upstream = await apiFetch(startTarget, apiSecret, '/agent/start', {
method: 'POST',
body: JSON.stringify({
...(workingDir ? { working_dir: workingDir } : {}),
enable_context_memory: sessionPolicy?.enableContextMemory,
...(sessionPolicy?.extensionOverrides
? { extension_overrides: sessionPolicy.extensionOverrides }
...(resolvedWorkingDir ? { working_dir: resolvedWorkingDir } : {}),
enable_context_memory: resolvedSessionPolicy?.enableContextMemory,
...(resolvedSessionPolicy?.extensionOverrides
? { extension_overrides: resolvedSessionPolicy.extensionOverrides }
: {}),
...(recipe ? { recipe } : {}),
}),
@@ -384,12 +386,12 @@ export function createTkmindProxy({
throw new Error('创建会话失败:缺少 session id');
}
await userAuth.registerAgentSession(userId, session.id, startTarget);
if (sessionPolicy?.gooseMode) {
if (resolvedSessionPolicy?.gooseMode) {
const modeRes = await apiFetch(startTarget, apiSecret, '/agent/update_session', {
method: 'POST',
body: JSON.stringify({
session_id: session.id,
goose_mode: sessionPolicy.gooseMode,
goose_mode: resolvedSessionPolicy.gooseMode,
}),
});
if (!modeRes.ok) {
@@ -397,6 +399,29 @@ export function createTkmindProxy({
throw new Error(modeText || `设置会话模式失败 (${modeRes.status})`);
}
}
const publishLayout = await userAuth.getUserPublishLayout(userId);
const userMemories = conversationMemoryService?.listMemories
? await conversationMemoryService.listMemories(userId, { limit: 40 }).catch(() => [])
: [];
await reconcileAgentSession(
(pathname, init) => apiFetch(startTarget, apiSecret, pathname, init),
session.id,
{
workingDir: resolvedWorkingDir,
sessionPolicy: resolvedSessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
userMemories,
userContext: publishLayout
? {
userId,
displayName: publishLayout.displayName,
username: publishLayout.username,
slug: publishLayout.slug,
}
: null,
},
);
await applySessionLlmProvider(session.id);
return session;
}
@@ -499,6 +524,68 @@ export function createTkmindProxy({
);
}
async function submitSessionReplyForUser(userId, sessionId, requestId, userMessage) {
if (!userId || !sessionId) throw new Error('缺少会话信息');
const owns = await userAuth.ownsSession(userId, sessionId);
if (!owns) throw new Error('无权访问该会话');
const gate = await userAuth.canUseChat(userId);
if (!gate.ok) {
const err = new Error(gate.message ?? '余额不足,请充值后继续使用');
err.code = gate.code;
err.status = 402;
throw err;
}
await reconcileSessionPolicyForUser(userId, sessionId);
await applySessionLlmProvider(sessionId);
const user = await userAuth.getUserById(userId);
if (!user) throw new Error('用户不存在');
let finalUserMessage = userMessage;
if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) {
const publishLayout = await userAuth.getUserPublishLayout(userId).catch(() => null);
const visionResult = await buildVisionBody(userMessage, userId, publishLayout).catch(() => null);
if (visionResult?.userMessage) {
finalUserMessage = visionResult.userMessage;
}
if (visionResult?.billableImageCount > 0 && subscriptionService) {
await subscriptionService.consumeImageQuota(userId, visionResult.billableImageCount).catch((err) => {
console.warn(
'Subscription image quota consume skipped:',
err instanceof Error ? err.message : err,
);
});
}
}
const policyState = await userAuth.resolveUserPolicies(user);
let body = {
request_id: requestId,
user_message: finalUserMessage,
};
if (!policyState.unrestricted) {
body = injectTaskRoutingHint(
body,
await userAuth.getAgentSessionPolicy(userId),
);
}
const target = await resolveTarget(sessionId);
const upstream = await apiFetch(
target,
apiSecret,
`/sessions/${encodeURIComponent(sessionId)}/reply`,
{
method: 'POST',
body: JSON.stringify(body),
},
);
if (!upstream.ok) {
const text = await upstream.text().catch(() => '');
throw new Error(text || `发送失败 (${upstream.status})`);
}
return { ok: true };
}
const requireUser = async (req, res, next) => {
try {
const session = req.userSession;
@@ -877,6 +964,16 @@ export function createTkmindProxy({
});
return;
}
const isAgentRunPath =
(req.method === 'POST' && pathname === '/agent/runs')
|| (req.method === 'GET' && /^\/agent\/runs\/[^/]+$/.test(pathname));
if (isAgentRunPath) {
res.status(503).json({
message: 'Agent Run 接口需在 Portal 本地处理,请确认 server.mjs 已更新并重启后端',
code: 'AGENT_RUNS_NATIVE_REQUIRED',
});
return;
}
const policyState = await userAuth.resolveUserPolicies(req.currentUser);
const capabilityState = await userAuth.resolveUserCapabilities(req.currentUser);
const gate = evaluateProxyRequest(req.method, pathname, policyState.policies, {
@@ -896,6 +993,13 @@ export function createTkmindProxy({
}
const isReplyPath = pathname.match(/^\/sessions\/[^/]+\/reply$/) && req.method === 'POST';
const sessionMatch = pathname.match(/^\/sessions\/([^/]+)/);
if (isReplyPath) {
res.status(410).json({
message: '聊天提交入口已统一为 POST /agent/runs',
code: 'AGENT_RUNS_REQUIRED',
});
return;
}
let baseBody = req.body;
if (isReplyPath && !policyState.unrestricted) {
@@ -983,6 +1087,7 @@ export function createTkmindProxy({
proxySessionEvents,
resolveTarget,
startSessionForUser,
submitSessionReplyForUser,
apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init),
apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init),
};