feat(agent): 聊天提交统一走 POST /agent/runs 异步网关
引入 Agent Run 网关替代直连 /sessions/:id/reply,并在 api_lockdown 白名单中放行新入口,避免策略拦截导致聊天不可用。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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\/[^/]+$/ },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user