import * as v from 'valibot' import { codedError, requireUser } from './auth.js' import { agentView, IdempotencyKey, ledgerView, PublishBody, REFUSAL_STATUS, taskView } from './v1.js' const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) }) const TaskIdParams = v.object({ task_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const ApprovalBody = v.object({ decision: v.picklist(['approve', 'reject']) }) const ResolveBody = v.object({ outcome: v.picklist(['settled', 'refunded']), winner_agent_id: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))) }) const HelpParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const AnswerBody = v.object({ answer: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) const Hour = v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(23)) const SettingsBody = v.object({ timezone: v.pipe(v.string(), v.check((zone) => Intl.supportedValuesOf('timeZone').includes(zone) || zone === 'UTC', 'timezone must be an IANA time zone')), quiet_start_hour: Hour, quiet_end_hour: Hour, email_help: v.boolean(), email_digest: v.boolean(), }) const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) /** * 主人在网页上管理自己的 Agent,并以其中一个 Agent 的身份看公会看板、发任务(扣该 Agent 的钱包)。 * 与公开 API 调用同一套 service。 * @param {import('fastify').FastifyInstance} app * @param {Record} services */ export function registerOwnerRoutes(app, services) { const { agents, agentAccess, guilds, escrow, tasks, help, ownerSettings, db } = services /** * 取当前用户名下的一个 Agent;不是自己的按不存在处理。 * @param {import('fastify').FastifyRequest} request */ async function ownAgent(request) { const user = await requireUser(request, services) const params = v.parse(AgentParams, request.params) const mine = await agents.listByOwner(request.ctx, db, { owner_user_id: user.id }) const agent = mine.find((item) => item.id === params.id) if (agent === undefined) throw codedError(`Agent not found: ${params.id}`, 'NOT_FOUND') return { user, agent } } app.get('/api/voko/guilds', async (request) => { await requireUser(request, services) return { guilds: await guilds.list(request.ctx) } }) app.get('/api/voko/agents', async (request) => { const user = await requireUser(request, services) const mine = await agents.listByOwner(request.ctx, db, { owner_user_id: user.id }) return { agents: await Promise.all(mine.map(async (agent) => ({ ...agentView(agent), daily_spend_limit: agent.daily_spend_limit, wallet: await escrow.walletOf(request.ctx, { wallet_user_id: agent.wallet_user_id }), }))), } }) app.post('/api/voko/agents/:id/api-key/reset', async (request) => { const { agent } = await ownAgent(request) return agentAccess.resetApiKey(request.ctx, { agent_id: agent.id }) }) app.post('/api/voko/agents/:id/status', async (request) => { const { user, agent } = await ownAgent(request) const body = v.parse(StatusBody, request.body) const changed = await agents.setStatus(request.ctx, db, { agent_id: agent.id, owner_user_id: user.id, to: body.status }) if (!changed) throw codedError(`Agent is already ${body.status}: ${agent.id}`, 'CONFLICT') return { ok: true } }) app.post('/api/voko/agents/:id/daily-spend-limit', async (request) => { const { user, agent } = await ownAgent(request) const body = v.parse(LimitBody, request.body) await agents.setDailySpendLimit(request.ctx, db, { agent_id: agent.id, owner_user_id: user.id, daily_spend_limit: body.daily_spend_limit }) return { ok: true } }) app.get('/api/voko/agents/:id/tasks', async (request) => { const { agent } = await ownAgent(request) const page = await tasks.list(request.ctx, { agent, limit: 100 }) return { tasks: page.tasks.map(taskView) } }) app.post('/api/voko/agents/:id/tasks', async (request, reply) => { const { agent } = await ownAgent(request) if (agent.status !== 'active') throw codedError(`Agent is ${agent.status}: ${agent.id}`, 'CONFLICT') const body = v.parse(PublishBody, request.body) const result = await tasks.publish(request.ctx, { agent, title: body.title, spec: body.spec, template: body.template, reward: body.reward, deadline_at: body.deadline, acceptance: body.acceptance, client_request_key: v.parse(IdempotencyKey, request.headers['idempotency-key']) }) if (!result.ok) return reply.code(REFUSAL_STATUS[result.code]).send({ error: { code: result.code, message: result.hint, retryable: false } }) return { task: taskView(result.task) } }) /** * 业务拒绝 → 网页接口的错误形状。 * @param {import('fastify').FastifyReply} reply * @param {{ code: keyof typeof REFUSAL_STATUS, hint: string }} refusal */ const refused = (reply, refusal) => reply.code(REFUSAL_STATUS[refusal.code]).send({ error: { code: refusal.code, message: refusal.hint, retryable: false } }) app.get('/api/voko/agents/:id/ledger', async (request) => { const { agent } = await ownAgent(request) return { ledger: (await escrow.ledgerOf(request.ctx, { wallet_user_id: agent.wallet_user_id, limit: 100 })).map(ledgerView) } }) // 求助收件箱:主人回复自己 Agent 的求助。 app.get('/api/voko/help', async (request) => { const user = await requireUser(request, services) const rows = await help.listForOwner(request.ctx, { user_id: user.id }) return { help_requests: rows.map((row) => ({ ...row, created_at: row.created_at.toISOString(), answered_at: row.answered_at === null ? null : row.answered_at.toISOString() })) } }) app.post('/api/voko/help/:id/answer', async (request, reply) => { const user = await requireUser(request, services) const params = v.parse(HelpParams, request.params) const body = v.parse(AnswerBody, request.body) const result = await help.answer(request.ctx, { user_id: user.id, help_id: params.id, answer: body.answer }) return result.ok ? { ok: true } : refused(reply, result) }) // 设置:时区、安静时段、邮件开关。 app.get('/api/voko/me/settings', async (request) => { const user = await requireUser(request, services) return { settings: await ownerSettings.get(request.ctx, { user_id: user.id }) } }) app.put('/api/voko/me/settings', async (request) => { const user = await requireUser(request, services) const settings = v.parse(SettingsBody, request.body) await ownerSettings.save(request.ctx, { user_id: user.id, settings }) return { settings } }) // 负责人:待审核任务(公司 Agent 发布、公会开启发布前审核时)。 app.get('/api/voko/approvals', async (request) => { const user = await requireUser(request, services) return { tasks: (await tasks.listPendingApprovals(request.ctx, { user_id: user.id })).map(taskView) } }) app.post('/api/voko/approvals/:task_id', async (request, reply) => { const user = await requireUser(request, services) const params = v.parse(TaskIdParams, request.params) const body = v.parse(ApprovalBody, request.body) const result = await tasks.decideApproval(request.ctx, { user_id: user.id, task_id: params.task_id, decision: body.decision }) return result.ok ? { task: taskView(result.task) } : refused(reply, result) }) // 运营:仲裁队列与裁决。 app.get('/api/voko/admin/disputes', async (request) => { const user = await requireUser(request, services) if (user.role !== 'admin') throw codedError('admin permission required', 'FORBIDDEN') return { disputes: (await tasks.listOpenDisputes(request.ctx)).map((item) => ({ dispute_id: item.dispute_id, status: item.status, ai_proposal: item.ai_proposal, opened_by_agent_id: item.opened_by_agent_id, created_at: item.created_at.toISOString(), task: taskView(item.task) })) } }) app.post('/api/voko/admin/disputes/:task_id/resolve', async (request, reply) => { const user = await requireUser(request, services) if (user.role !== 'admin') throw codedError('admin permission required', 'FORBIDDEN') const params = v.parse(TaskIdParams, request.params) const body = v.parse(ResolveBody, request.body) const result = await tasks.resolveDispute(request.ctx, { user_id: user.id, task_id: params.task_id, outcome: body.outcome, winner_agent_id: body.winner_agent_id ?? null }) return result.ok ? { task: taskView(result.task) } : refused(reply, result) }) }