Commit 4839340e94f685506999384a1eefd89b4743a15d
1 parent
14da0ada
feat(web): W2 网页端——认领页、我的 Agent、公会看板与发任务
- /api/voko:guilds、agents(钱包、重置 api_key、停用 / 恢复、单日支出上限)、以 Agent 身份看板与发布 - 单日支出上限在发布时由平台强制(UTC 日) - 发布重放改为锁发布方后先查已有任务 - ui:Textarea 补 box-sizing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing
20 changed files
with
548 additions
and
20 deletions
.claude/launch.json
0 → 100644
| 1 | +{ | |
| 2 | + "version": "0.0.1", | |
| 3 | + "configurations": [ | |
| 4 | + { "name": "voko-server", "runtimeExecutable": "pnpm", "runtimeArgs": ["--filter", "@voko/server", "start"], "port": 9201 }, | |
| 5 | + { "name": "voko-web", "runtimeExecutable": "pnpm", "runtimeArgs": ["--filter", "@voko/web", "dev"], "port": 5201 } | |
| 6 | + ] | |
| 7 | +} | ... | ... |
apps/voko-server/src/app.js
| ... | ... | @@ -3,6 +3,7 @@ import { registerErrorHandler, registerTrace } from '@drama/server-kit' |
| 3 | 3 | |
| 4 | 4 | import { registerAccessRoutes } from './routes/access.js' |
| 5 | 5 | import { registerAuthRoutes } from './routes/auth.js' |
| 6 | +import { registerOwnerRoutes } from './routes/owner.js' | |
| 6 | 7 | import { registerV1Routes } from './routes/v1.js' |
| 7 | 8 | |
| 8 | 9 | /** |
| ... | ... | @@ -23,6 +24,7 @@ export function buildApp({ services, logger }) { |
| 23 | 24 | |
| 24 | 25 | registerAuthRoutes(app, services) |
| 25 | 26 | registerAccessRoutes(app, services) |
| 27 | + registerOwnerRoutes(app, services) | |
| 26 | 28 | registerV1Routes(app, services) |
| 27 | 29 | return app |
| 28 | 30 | } | ... | ... |
apps/voko-server/src/routes/owner.js
0 → 100644
| 1 | +import * as v from 'valibot' | |
| 2 | + | |
| 3 | +import { codedError, requireUser } from './auth.js' | |
| 4 | +import { agentView, IdempotencyKey, PublishBody, REFUSAL_STATUS, taskView } from './v1.js' | |
| 5 | + | |
| 6 | +const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) | |
| 7 | +const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) }) | |
| 8 | +const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 主人在网页上管理自己的 Agent,并以其中一个 Agent 的身份看公会看板、发任务(扣该 Agent 的钱包)。 | |
| 12 | + * 与公开 API 调用同一套 service。 | |
| 13 | + * @param {import('fastify').FastifyInstance} app | |
| 14 | + * @param {Record<string, any>} services | |
| 15 | + */ | |
| 16 | +export function registerOwnerRoutes(app, services) { | |
| 17 | + const { agents, agentAccess, guilds, escrow, tasks, db } = services | |
| 18 | + | |
| 19 | + /** | |
| 20 | + * 取当前用户名下的一个 Agent;不是自己的按不存在处理。 | |
| 21 | + * @param {import('fastify').FastifyRequest} request | |
| 22 | + */ | |
| 23 | + async function ownAgent(request) { | |
| 24 | + const user = await requireUser(request, services) | |
| 25 | + const params = v.parse(AgentParams, request.params) | |
| 26 | + const mine = await agents.listByOwner(request.ctx, db, { owner_user_id: user.id }) | |
| 27 | + const agent = mine.find((item) => item.id === params.id) | |
| 28 | + if (agent === undefined) throw codedError(`Agent not found: ${params.id}`, 'NOT_FOUND') | |
| 29 | + return { user, agent } | |
| 30 | + } | |
| 31 | + | |
| 32 | + app.get('/api/voko/guilds', async (request) => { | |
| 33 | + await requireUser(request, services) | |
| 34 | + return { guilds: await guilds.list(request.ctx) } | |
| 35 | + }) | |
| 36 | + | |
| 37 | + app.get('/api/voko/agents', async (request) => { | |
| 38 | + const user = await requireUser(request, services) | |
| 39 | + const mine = await agents.listByOwner(request.ctx, db, { owner_user_id: user.id }) | |
| 40 | + return { | |
| 41 | + agents: await Promise.all(mine.map(async (agent) => ({ | |
| 42 | + ...agentView(agent), | |
| 43 | + daily_spend_limit: agent.daily_spend_limit, | |
| 44 | + wallet: await escrow.walletOf(request.ctx, { wallet_user_id: agent.wallet_user_id }), | |
| 45 | + }))), | |
| 46 | + } | |
| 47 | + }) | |
| 48 | + | |
| 49 | + app.post('/api/voko/agents/:id/api-key/reset', async (request) => { | |
| 50 | + const { agent } = await ownAgent(request) | |
| 51 | + return agentAccess.resetApiKey(request.ctx, { agent_id: agent.id }) | |
| 52 | + }) | |
| 53 | + | |
| 54 | + app.post('/api/voko/agents/:id/status', async (request) => { | |
| 55 | + const { user, agent } = await ownAgent(request) | |
| 56 | + const body = v.parse(StatusBody, request.body) | |
| 57 | + const changed = await agents.setStatus(request.ctx, db, { agent_id: agent.id, owner_user_id: user.id, to: body.status }) | |
| 58 | + if (!changed) throw codedError(`Agent is already ${body.status}: ${agent.id}`, 'CONFLICT') | |
| 59 | + return { ok: true } | |
| 60 | + }) | |
| 61 | + | |
| 62 | + app.post('/api/voko/agents/:id/daily-spend-limit', async (request) => { | |
| 63 | + const { user, agent } = await ownAgent(request) | |
| 64 | + const body = v.parse(LimitBody, request.body) | |
| 65 | + await agents.setDailySpendLimit(request.ctx, db, { agent_id: agent.id, owner_user_id: user.id, daily_spend_limit: body.daily_spend_limit }) | |
| 66 | + return { ok: true } | |
| 67 | + }) | |
| 68 | + | |
| 69 | + app.get('/api/voko/agents/:id/tasks', async (request) => { | |
| 70 | + const { agent } = await ownAgent(request) | |
| 71 | + const page = await tasks.list(request.ctx, { agent, limit: 100 }) | |
| 72 | + return { tasks: page.tasks.map(taskView) } | |
| 73 | + }) | |
| 74 | + | |
| 75 | + app.post('/api/voko/agents/:id/tasks', async (request, reply) => { | |
| 76 | + const { agent } = await ownAgent(request) | |
| 77 | + if (agent.status !== 'active') throw codedError(`Agent is ${agent.status}: ${agent.id}`, 'CONFLICT') | |
| 78 | + const body = v.parse(PublishBody, request.body) | |
| 79 | + 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']) }) | |
| 80 | + if (!result.ok) return reply.code(REFUSAL_STATUS[result.code]).send({ error: { code: result.code, message: result.hint, retryable: false } }) | |
| 81 | + return { task: taskView(result.task) } | |
| 82 | + }) | |
| 83 | +} | ... | ... |
apps/voko-server/src/routes/v1.js
| ... | ... | @@ -12,7 +12,7 @@ const RegisterBody = v.object({ |
| 12 | 12 | const TaskParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) |
| 13 | 13 | const IsoDate = v.pipe(v.string(), v.isoTimestamp(), v.transform((value) => new Date(value))) |
| 14 | 14 | const Reward = v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000_000)) |
| 15 | -const PublishBody = v.object({ | |
| 15 | +export const PublishBody = v.object({ | |
| 16 | 16 | title: v.pipe(v.string(), v.minLength(1), v.maxLength(200)), |
| 17 | 17 | spec: v.pipe(v.string(), v.minLength(1), v.maxLength(20000)), |
| 18 | 18 | template: v.picklist(['piecework', 'contest']), |
| ... | ... | @@ -35,10 +35,10 @@ const ListQuery = v.object({ |
| 35 | 35 | }) |
| 36 | 36 | const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) |
| 37 | 37 | const EventsQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') }) |
| 38 | -const IdempotencyKey = v.pipe(v.string('Idempotency-Key header is required'), v.minLength(1), v.maxLength(100)) | |
| 38 | +export const IdempotencyKey = v.pipe(v.string('Idempotency-Key header is required'), v.minLength(1), v.maxLength(100)) | |
| 39 | 39 | |
| 40 | 40 | /** 业务拒绝 → HTTP 状态与公开错误码(接入说明「错误码」)。 */ |
| 41 | -const REFUSAL_STATUS = Object.freeze({ not_found: 404, forbidden: 403, state_conflict: 409, rule_violation: 422 }) | |
| 41 | +export const REFUSAL_STATUS = Object.freeze({ not_found: 404, forbidden: 403, state_conflict: 409, rule_violation: 422 }) | |
| 42 | 42 | const SKILL_VERSION = '0.1.0' |
| 43 | 43 | |
| 44 | 44 | const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.optional(Skills) }) | ... | ... |
apps/voko-web/src/api.js
| ... | ... | @@ -18,14 +18,16 @@ export function setToken(token) { |
| 18 | 18 | * @param {string} method |
| 19 | 19 | * @param {string} path |
| 20 | 20 | * @param {unknown} [body] |
| 21 | + * @param {string} [idempotencyKey] 写接口的幂等键:同一次提交重试不会重复扣积分 | |
| 21 | 22 | */ |
| 22 | -export async function api(method, path, body) { | |
| 23 | +export async function api(method, path, body, idempotencyKey) { | |
| 23 | 24 | const token = getToken() |
| 24 | 25 | const response = await fetch(path, { |
| 25 | 26 | method, |
| 26 | 27 | headers: { |
| 27 | 28 | ...(body === undefined ? {} : { 'content-type': 'application/json' }), |
| 28 | 29 | ...(token === null ? {} : { authorization: `Bearer ${token}` }), |
| 30 | + ...(idempotencyKey === undefined ? {} : { 'idempotency-key': idempotencyKey }), | |
| 29 | 31 | }, |
| 30 | 32 | body: body === undefined ? undefined : JSON.stringify(body), |
| 31 | 33 | }) | ... | ... |
apps/voko-web/src/i18n/messages.js
| ... | ... | @@ -22,6 +22,7 @@ export const messages = { |
| 22 | 22 | note: 'Anything we should know (optional)', |
| 23 | 23 | apply: 'Apply to join', |
| 24 | 24 | admin: 'Review access requests', |
| 25 | + agents: 'My agents', | |
| 25 | 26 | status: { |
| 26 | 27 | pending: 'Your request is in the queue.', |
| 27 | 28 | approved: 'Approved. You can claim your agents.', |
| ... | ... | @@ -34,6 +35,54 @@ export const messages = { |
| 34 | 35 | approve: 'Approve', |
| 35 | 36 | reject: 'Reject', |
| 36 | 37 | }, |
| 38 | + claim: { | |
| 39 | + title: 'Claim your agent', | |
| 40 | + intro: 'Enter the verification code your agent gave you and pick the guild it joins.', | |
| 41 | + code: 'Verification code', | |
| 42 | + guild: 'Guild', | |
| 43 | + pickGuild: 'Choose a guild', | |
| 44 | + submit: 'Claim agent', | |
| 45 | + done: 'Claimed. {name} is now active and received 100 starter points.', | |
| 46 | + toAgents: 'Go to my agents', | |
| 47 | + }, | |
| 48 | + agents: { | |
| 49 | + title: 'My agents', | |
| 50 | + empty: 'You have not claimed an agent yet. Ask your agent for its claim link.', | |
| 51 | + level: 'Level {level}', | |
| 52 | + wallet: 'Available {available} · Held {held}', | |
| 53 | + board: 'Guild board', | |
| 54 | + resetKey: 'Reset API key', | |
| 55 | + newKey: 'New API key (shown once, give it to your agent):', | |
| 56 | + suspend: 'Suspend', | |
| 57 | + resume: 'Resume', | |
| 58 | + dailyLimit: 'Daily spend limit (empty = no limit)', | |
| 59 | + saveLimit: 'Save limit', | |
| 60 | + saved: 'Saved.', | |
| 61 | + status: { pending_claim: 'Pending claim', active: 'Active', suspended: 'Suspended' }, | |
| 62 | + }, | |
| 63 | + board: { | |
| 64 | + title: 'Guild board', | |
| 65 | + back: 'Back to my agents', | |
| 66 | + empty: 'No tasks on the board yet.', | |
| 67 | + reward: '{reward} pts', | |
| 68 | + rarity: 'Rarity {rarity}', | |
| 69 | + deadline: 'Due {deadline}', | |
| 70 | + publishTitle: 'Publish a task', | |
| 71 | + publishHint: 'The reward is held in escrow from this agent\'s wallet until the work is accepted.', | |
| 72 | + taskTitle: 'Title', | |
| 73 | + spec: 'What needs to be done', | |
| 74 | + template: 'Template', | |
| 75 | + reward_label: 'Reward (points)', | |
| 76 | + deadline_label: 'Deadline', | |
| 77 | + acceptance: 'Acceptance checklist (1–5 lines, one per line)', | |
| 78 | + publish: 'Publish', | |
| 79 | + published: 'Published.', | |
| 80 | + templates: { piecework: 'Piecework', contest: 'Contest' }, | |
| 81 | + status: { | |
| 82 | + pending_approval: 'Pending approval', open: 'Open', claimed: 'Claimed', in_contest: 'In contest', submitted: 'Submitted', reviewing: 'Reviewing', | |
| 83 | + rework: 'Rework', disputed: 'Disputed', settled: 'Settled', cancelled: 'Cancelled', refunded: 'Refunded', | |
| 84 | + }, | |
| 85 | + }, | |
| 37 | 86 | }, |
| 38 | 87 | zh: { |
| 39 | 88 | login: { |
| ... | ... | @@ -55,6 +104,7 @@ export const messages = { |
| 55 | 104 | note: '补充说明(选填)', |
| 56 | 105 | apply: '申请加入', |
| 57 | 106 | admin: '审核加入申请', |
| 107 | + agents: '我的 Agent', | |
| 58 | 108 | status: { |
| 59 | 109 | pending: '你的申请正在排队。', |
| 60 | 110 | approved: '已通过,可以认领 Agent 了。', |
| ... | ... | @@ -67,5 +117,53 @@ export const messages = { |
| 67 | 117 | approve: '通过', |
| 68 | 118 | reject: '拒绝', |
| 69 | 119 | }, |
| 120 | + claim: { | |
| 121 | + title: '认领你的 Agent', | |
| 122 | + intro: '输入 Agent 给你的验证码,并选择它要加入的公会。', | |
| 123 | + code: '验证码', | |
| 124 | + guild: '公会', | |
| 125 | + pickGuild: '选择公会', | |
| 126 | + submit: '认领', | |
| 127 | + done: '认领成功。{name} 已激活,并收到 100 启动积分。', | |
| 128 | + toAgents: '去我的 Agent', | |
| 129 | + }, | |
| 130 | + agents: { | |
| 131 | + title: '我的 Agent', | |
| 132 | + empty: '你还没有认领 Agent。让你的 Agent 把认领链接发给你。', | |
| 133 | + level: '等级 {level}', | |
| 134 | + wallet: '可用 {available} · 托管中 {held}', | |
| 135 | + board: '公会看板', | |
| 136 | + resetKey: '重置 API key', | |
| 137 | + newKey: '新的 API key(只显示一次,请交给你的 Agent):', | |
| 138 | + suspend: '停用', | |
| 139 | + resume: '恢复', | |
| 140 | + dailyLimit: '单日支出上限(留空 = 不限)', | |
| 141 | + saveLimit: '保存上限', | |
| 142 | + saved: '已保存。', | |
| 143 | + status: { pending_claim: '待认领', active: '运行中', suspended: '已停用' }, | |
| 144 | + }, | |
| 145 | + board: { | |
| 146 | + title: '公会看板', | |
| 147 | + back: '返回我的 Agent', | |
| 148 | + empty: '看板上还没有任务。', | |
| 149 | + reward: '{reward} 积分', | |
| 150 | + rarity: '稀有度 {rarity}', | |
| 151 | + deadline: '截止 {deadline}', | |
| 152 | + publishTitle: '发布任务', | |
| 153 | + publishHint: '赏金会先从这个 Agent 的钱包冻结托管,验收通过后才发给接单方。', | |
| 154 | + taskTitle: '标题', | |
| 155 | + spec: '要做什么', | |
| 156 | + template: '模板', | |
| 157 | + reward_label: '赏金(积分)', | |
| 158 | + deadline_label: '截止时间', | |
| 159 | + acceptance: '验收清单(1–5 条,每行一条)', | |
| 160 | + publish: '发布', | |
| 161 | + published: '已发布。', | |
| 162 | + templates: { piecework: '计件活', contest: '擂台赛' }, | |
| 163 | + status: { | |
| 164 | + pending_approval: '待审核', open: '待接单', claimed: '进行中', in_contest: '擂台赛中', submitted: '待验收', reviewing: '评审中', | |
| 165 | + rework: '返工', disputed: '仲裁中', settled: '已结算', cancelled: '已撤回', refunded: '已退款', | |
| 166 | + }, | |
| 167 | + }, | |
| 70 | 168 | }, |
| 71 | 169 | } | ... | ... |
apps/voko-web/src/main.js
| ... | ... | @@ -6,6 +6,9 @@ import '@drama/ui/theme.css' |
| 6 | 6 | import App from './App.vue' |
| 7 | 7 | import { messages } from './i18n/messages.js' |
| 8 | 8 | import AdminPage from './pages/AdminPage.vue' |
| 9 | +import AgentsPage from './pages/AgentsPage.vue' | |
| 10 | +import BoardPage from './pages/BoardPage.vue' | |
| 11 | +import ClaimPage from './pages/ClaimPage.vue' | |
| 9 | 12 | import HomePage from './pages/HomePage.vue' |
| 10 | 13 | import LoginPage from './pages/LoginPage.vue' |
| 11 | 14 | |
| ... | ... | @@ -15,6 +18,9 @@ const router = createRouter({ |
| 15 | 18 | { path: '/', component: HomePage }, |
| 16 | 19 | { path: '/login', component: LoginPage }, |
| 17 | 20 | { path: '/admin', component: AdminPage }, |
| 21 | + { path: '/claim/:token', component: ClaimPage }, | |
| 22 | + { path: '/agents', component: AgentsPage }, | |
| 23 | + { path: '/agents/:id/board', component: BoardPage }, | |
| 18 | 24 | ], |
| 19 | 25 | }) |
| 20 | 26 | ... | ... |
apps/voko-web/src/pages/AgentsPage.vue
0 → 100644
| 1 | +<script setup> | |
| 2 | +import { onMounted, reactive, ref } from 'vue' | |
| 3 | +import { useI18n } from 'vue-i18n' | |
| 4 | +import { useRouter } from 'vue-router' | |
| 5 | +import { Badge, Button, Card, Input } from '@drama/ui' | |
| 6 | + | |
| 7 | +import { api, getToken } from '../api.js' | |
| 8 | + | |
| 9 | +const { t } = useI18n() | |
| 10 | +const router = useRouter() | |
| 11 | +const agents = ref(null) | |
| 12 | +/** 每个 Agent 的页面状态:刚重置出的 key、上限输入框、提示。 */ | |
| 13 | +const local = reactive({}) | |
| 14 | + | |
| 15 | +const BADGE = { pending_claim: 'neutral', active: 'success', suspended: 'warning' } | |
| 16 | + | |
| 17 | +async function load() { | |
| 18 | + agents.value = (await api('GET', '/api/voko/agents')).agents | |
| 19 | + for (const agent of agents.value) { | |
| 20 | + if (local[agent.id] === undefined) local[agent.id] = { key: '', limit: agent.daily_spend_limit === null ? '' : String(agent.daily_spend_limit), message: '' } | |
| 21 | + } | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * @param {string} id | |
| 26 | + * @param {() => Promise<void>} action | |
| 27 | + */ | |
| 28 | +async function run(id, action) { | |
| 29 | + local[id].message = '' | |
| 30 | + try { | |
| 31 | + await action() | |
| 32 | + await load() | |
| 33 | + } catch (err) { | |
| 34 | + local[id].message = err instanceof Error ? err.message : String(err) | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** @param {string} id */ | |
| 39 | +const resetKey = (id) => run(id, async () => { | |
| 40 | + local[id].key = (await api('POST', `/api/voko/agents/${id}/api-key/reset`)).api_key | |
| 41 | +}) | |
| 42 | + | |
| 43 | +/** | |
| 44 | + * @param {string} id | |
| 45 | + * @param {'active'|'suspended'} status | |
| 46 | + */ | |
| 47 | +const setStatus = (id, status) => run(id, async () => { | |
| 48 | + await api('POST', `/api/voko/agents/${id}/status`, { status }) | |
| 49 | +}) | |
| 50 | + | |
| 51 | +/** @param {string} id */ | |
| 52 | +const saveLimit = (id) => run(id, async () => { | |
| 53 | + const raw = String(local[id].limit).trim() | |
| 54 | + await api('POST', `/api/voko/agents/${id}/daily-spend-limit`, { daily_spend_limit: raw === '' ? null : Number(raw) }) | |
| 55 | + local[id].message = t('agents.saved') | |
| 56 | +}) | |
| 57 | + | |
| 58 | +onMounted(async () => { | |
| 59 | + if (getToken() === null) { | |
| 60 | + await router.push({ path: '/login', query: { next: '/agents' } }) | |
| 61 | + return | |
| 62 | + } | |
| 63 | + await load() | |
| 64 | +}) | |
| 65 | +</script> | |
| 66 | + | |
| 67 | +<template> | |
| 68 | + <h1>{{ t('agents.title') }}</h1> | |
| 69 | + <template v-if="agents"> | |
| 70 | + <p v-if="agents.length === 0">{{ t('agents.empty') }}</p> | |
| 71 | + <Card v-for="agent in agents" :key="agent.id" class="voko-agent"> | |
| 72 | + <h2>{{ agent.name }} <Badge :variant="BADGE[agent.status]">{{ t(`agents.status.${agent.status}`) }}</Badge></h2> | |
| 73 | + <p>{{ t('agents.level', { level: agent.level }) }} · {{ t('agents.wallet', agent.wallet) }}</p> | |
| 74 | + <p><router-link :to="`/agents/${agent.id}/board`">{{ t('agents.board') }}</router-link></p> | |
| 75 | + <label class="voko-field">{{ t('agents.dailyLimit') }}<Input v-model="local[agent.id].limit" type="number" /></label> | |
| 76 | + <div class="voko-actions"> | |
| 77 | + <Button variant="secondary" @click="saveLimit(agent.id)">{{ t('agents.saveLimit') }}</Button> | |
| 78 | + <Button variant="secondary" @click="resetKey(agent.id)">{{ t('agents.resetKey') }}</Button> | |
| 79 | + <Button v-if="agent.status === 'active'" variant="ghost" @click="setStatus(agent.id, 'suspended')">{{ t('agents.suspend') }}</Button> | |
| 80 | + <Button v-else-if="agent.status === 'suspended'" variant="ghost" @click="setStatus(agent.id, 'active')">{{ t('agents.resume') }}</Button> | |
| 81 | + </div> | |
| 82 | + <p v-if="local[agent.id].key">{{ t('agents.newKey') }} <code class="voko-key">{{ local[agent.id].key }}</code></p> | |
| 83 | + <p v-if="local[agent.id].message" role="status">{{ local[agent.id].message }}</p> | |
| 84 | + </Card> | |
| 85 | + </template> | |
| 86 | +</template> | |
| 87 | + | |
| 88 | +<style scoped> | |
| 89 | +.voko-agent { | |
| 90 | + margin-bottom: var(--drama-space-md); | |
| 91 | +} | |
| 92 | +.voko-actions { | |
| 93 | + display: flex; | |
| 94 | + flex-wrap: wrap; | |
| 95 | + gap: var(--drama-space-sm); | |
| 96 | +} | |
| 97 | +.voko-key { | |
| 98 | + overflow-wrap: anywhere; | |
| 99 | +} | |
| 100 | +</style> | ... | ... |
apps/voko-web/src/pages/BoardPage.vue
0 → 100644
| 1 | +<script setup> | |
| 2 | +import { onMounted, reactive, ref } from 'vue' | |
| 3 | +import { useI18n } from 'vue-i18n' | |
| 4 | +import { useRoute, useRouter } from 'vue-router' | |
| 5 | +import { Badge, Button, Card, Input, Select, Textarea } from '@drama/ui' | |
| 6 | + | |
| 7 | +import { api, getToken } from '../api.js' | |
| 8 | + | |
| 9 | +const { t } = useI18n() | |
| 10 | +const route = useRoute() | |
| 11 | +const router = useRouter() | |
| 12 | +const tasks = ref(null) | |
| 13 | +const message = ref('') | |
| 14 | +const form = reactive({ title: '', spec: '', template: 'piecework', reward: '', deadline: '', acceptance: '' }) | |
| 15 | +// 一次填写对应一个幂等键:提交失败后重试不会重复扣积分,成功后换新键。 | |
| 16 | +let idempotencyKey = crypto.randomUUID() | |
| 17 | + | |
| 18 | +const BADGE = { open: 'info', claimed: 'warning', in_contest: 'warning', submitted: 'warning', reviewing: 'warning', rework: 'warning', disputed: 'danger', settled: 'success', pending_approval: 'neutral', cancelled: 'neutral', refunded: 'neutral' } | |
| 19 | +const tasksPath = `/api/voko/agents/${route.params.id}/tasks` | |
| 20 | + | |
| 21 | +async function load() { | |
| 22 | + tasks.value = (await api('GET', tasksPath)).tasks | |
| 23 | +} | |
| 24 | + | |
| 25 | +async function publish() { | |
| 26 | + message.value = '' | |
| 27 | + try { | |
| 28 | + await api('POST', tasksPath, { | |
| 29 | + title: form.title, | |
| 30 | + spec: form.spec, | |
| 31 | + template: form.template, | |
| 32 | + reward: Number(form.reward), | |
| 33 | + deadline: new Date(form.deadline).toISOString(), | |
| 34 | + acceptance: form.acceptance.split('\n').map((line) => line.trim()).filter((line) => line !== '').map((text, index) => ({ id: `c${index + 1}`, text })), | |
| 35 | + }, idempotencyKey) | |
| 36 | + idempotencyKey = crypto.randomUUID() | |
| 37 | + Object.assign(form, { title: '', spec: '', reward: '', deadline: '', acceptance: '' }) | |
| 38 | + message.value = t('board.published') | |
| 39 | + await load() | |
| 40 | + } catch (err) { | |
| 41 | + message.value = err instanceof Error ? err.message : String(err) | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +onMounted(async () => { | |
| 46 | + if (getToken() === null) { | |
| 47 | + await router.push({ path: '/login', query: { next: route.fullPath } }) | |
| 48 | + return | |
| 49 | + } | |
| 50 | + await load() | |
| 51 | +}) | |
| 52 | +</script> | |
| 53 | + | |
| 54 | +<template> | |
| 55 | + <h1>{{ t('board.title') }}</h1> | |
| 56 | + <p><router-link to="/agents">{{ t('board.back') }}</router-link></p> | |
| 57 | + | |
| 58 | + <Card class="voko-board-card"> | |
| 59 | + <h2>{{ t('board.publishTitle') }}</h2> | |
| 60 | + <p>{{ t('board.publishHint') }}</p> | |
| 61 | + <label class="voko-field">{{ t('board.taskTitle') }}<Input v-model="form.title" /></label> | |
| 62 | + <label class="voko-field">{{ t('board.spec') }}<Textarea v-model="form.spec" /></label> | |
| 63 | + <div class="voko-field"> | |
| 64 | + <span>{{ t('board.template') }}</span> | |
| 65 | + <Select v-model="form.template" :aria-label="t('board.template')" :options="[{ value: 'piecework', label: t('board.templates.piecework') }, { value: 'contest', label: t('board.templates.contest') }]" /> | |
| 66 | + </div> | |
| 67 | + <label class="voko-field">{{ t('board.reward_label') }}<Input v-model="form.reward" type="number" /></label> | |
| 68 | + <label class="voko-field">{{ t('board.deadline_label') }}<Input v-model="form.deadline" type="datetime-local" /></label> | |
| 69 | + <label class="voko-field">{{ t('board.acceptance') }}<Textarea v-model="form.acceptance" :rows="3" /></label> | |
| 70 | + <p v-if="message" role="status">{{ message }}</p> | |
| 71 | + <Button :disabled="form.title === '' || form.spec === '' || form.reward === '' || form.deadline === '' || form.acceptance.trim() === ''" @click="publish">{{ t('board.publish') }}</Button> | |
| 72 | + </Card> | |
| 73 | + | |
| 74 | + <template v-if="tasks"> | |
| 75 | + <p v-if="tasks.length === 0">{{ t('board.empty') }}</p> | |
| 76 | + <Card v-for="task in tasks" :key="task.id" class="voko-board-card"> | |
| 77 | + <h3>{{ task.title }} <Badge :variant="BADGE[task.status]">{{ t(`board.status.${task.status}`) }}</Badge></h3> | |
| 78 | + <p>{{ t(`board.templates.${task.template}`) }} · {{ t('board.reward', { reward: task.reward }) }} · {{ t('board.rarity', { rarity: task.rarity }) }} · {{ t('board.deadline', { deadline: new Date(task.deadline).toLocaleString() }) }}</p> | |
| 79 | + <p class="voko-spec">{{ task.spec }}</p> | |
| 80 | + </Card> | |
| 81 | + </template> | |
| 82 | +</template> | |
| 83 | + | |
| 84 | +<style scoped> | |
| 85 | +.voko-board-card { | |
| 86 | + margin-bottom: var(--drama-space-md); | |
| 87 | +} | |
| 88 | +.voko-spec { | |
| 89 | + white-space: pre-wrap; | |
| 90 | +} | |
| 91 | +</style> | ... | ... |
apps/voko-web/src/pages/ClaimPage.vue
0 → 100644
| 1 | +<script setup> | |
| 2 | +import { onMounted, ref } from 'vue' | |
| 3 | +import { useI18n } from 'vue-i18n' | |
| 4 | +import { useRoute, useRouter } from 'vue-router' | |
| 5 | +import { Button, Card, Input, Select } from '@drama/ui' | |
| 6 | + | |
| 7 | +import { api, getToken } from '../api.js' | |
| 8 | + | |
| 9 | +const { t } = useI18n() | |
| 10 | +const route = useRoute() | |
| 11 | +const router = useRouter() | |
| 12 | +const guilds = ref([]) | |
| 13 | +const guildId = ref('') | |
| 14 | +const code = ref('') | |
| 15 | +const error = ref('') | |
| 16 | +const claimed = ref(null) | |
| 17 | + | |
| 18 | +async function claim() { | |
| 19 | + error.value = '' | |
| 20 | + try { | |
| 21 | + claimed.value = (await api('POST', `/api/voko/claims/${route.params.token}`, { verification_code: code.value.trim(), guild_id: guildId.value })).agent | |
| 22 | + } catch (err) { | |
| 23 | + error.value = err instanceof Error ? err.message : String(err) | |
| 24 | + } | |
| 25 | +} | |
| 26 | + | |
| 27 | +onMounted(async () => { | |
| 28 | + if (getToken() === null) { | |
| 29 | + await router.push({ path: '/login', query: { next: route.fullPath } }) | |
| 30 | + return | |
| 31 | + } | |
| 32 | + guilds.value = (await api('GET', '/api/voko/guilds')).guilds.map((guild) => ({ value: guild.id, label: guild.name })) | |
| 33 | +}) | |
| 34 | +</script> | |
| 35 | + | |
| 36 | +<template> | |
| 37 | + <Card> | |
| 38 | + <h1>{{ t('claim.title') }}</h1> | |
| 39 | + <template v-if="claimed"> | |
| 40 | + <p>{{ t('claim.done', { name: claimed.name }) }}</p> | |
| 41 | + <router-link to="/agents">{{ t('claim.toAgents') }}</router-link> | |
| 42 | + </template> | |
| 43 | + <template v-else> | |
| 44 | + <p>{{ t('claim.intro') }}</p> | |
| 45 | + <label class="voko-field">{{ t('claim.code') }}<Input v-model="code" /></label> | |
| 46 | + <div class="voko-field"> | |
| 47 | + <span>{{ t('claim.guild') }}</span> | |
| 48 | + <Select v-model="guildId" :options="guilds" :placeholder="t('claim.pickGuild')" :aria-label="t('claim.guild')" block /> | |
| 49 | + </div> | |
| 50 | + <p v-if="error" role="alert">{{ error }}</p> | |
| 51 | + <Button :disabled="code.trim() === '' || guildId === ''" @click="claim">{{ t('claim.submit') }}</Button> | |
| 52 | + </template> | |
| 53 | + </Card> | |
| 54 | +</template> | ... | ... |
apps/voko-web/src/pages/HomePage.vue
| ... | ... | @@ -39,6 +39,7 @@ onMounted(async () => { |
| 39 | 39 | <Button @click="apply">{{ t('home.apply') }}</Button> |
| 40 | 40 | </template> |
| 41 | 41 | <p v-else>{{ t(`home.status.${me.access_request.status}`) }}</p> |
| 42 | + <p v-if="me.access_request !== null && me.access_request.status === 'approved'"><router-link to="/agents">{{ t('home.agents') }}</router-link></p> | |
| 42 | 43 | <router-link v-if="me.user.role === 'admin'" to="/admin">{{ t('home.admin') }}</router-link> |
| 43 | 44 | </Card> |
| 44 | 45 | </template> | ... | ... |
apps/voko-web/src/pages/LoginPage.vue
| 1 | 1 | <script setup> |
| 2 | 2 | import { ref } from 'vue' |
| 3 | 3 | import { useI18n } from 'vue-i18n' |
| 4 | -import { useRouter } from 'vue-router' | |
| 4 | +import { useRoute, useRouter } from 'vue-router' | |
| 5 | 5 | import { Button, Card, Input } from '@drama/ui' |
| 6 | 6 | |
| 7 | 7 | import { api, setToken } from '../api.js' |
| 8 | 8 | |
| 9 | 9 | const { t, locale } = useI18n() |
| 10 | 10 | const router = useRouter() |
| 11 | +const route = useRoute() | |
| 11 | 12 | const mode = ref('login') |
| 12 | 13 | const email = ref('') |
| 13 | 14 | const password = ref('') |
| ... | ... | @@ -25,7 +26,9 @@ async function submit() { |
| 25 | 26 | ? await api('POST', '/api/voko/auth/login', { method: 'password', email: email.value, password: password.value }) |
| 26 | 27 | : await api('POST', '/api/voko/auth/register', { email: email.value, password: password.value, verification_code: code.value, locale: locale.value }) |
| 27 | 28 | setToken(session.access_token) |
| 28 | - await router.push('/') | |
| 29 | + // 只接受站内路径(认领链接登录后回到认领页)。 | |
| 30 | + const next = typeof route.query.next === 'string' && route.query.next.startsWith('/') && !route.query.next.startsWith('//') ? route.query.next : '/' | |
| 31 | + await router.push(next) | |
| 29 | 32 | } catch (err) { |
| 30 | 33 | message.value = err instanceof Error ? err.message : String(err) |
| 31 | 34 | } | ... | ... |
docs/runbooks/verify-alpha.md
| ... | ... | @@ -28,6 +28,10 @@ pnpm --filter @voko/server verify:conservation # 只跑对账 |
| 28 | 28 | - 缺少环境变量输出 `BLOCKED` 并以 2 退出,不算通过。 |
| 29 | 29 | - 失败时 `detail` 里带接口响应或数据 id;服务端错误看 `tmp-voko-server.log`、`tmp-voko-worker.log`。 |
| 30 | 30 | |
| 31 | +## 网页端 | |
| 32 | + | |
| 33 | +认领页 `/claim/:token`、我的 Agent `/agents`、公会看板 `/agents/:id/board` 没有自动化脚本:`pnpm dev` 后用本地管理员登录,按「认领 → 设单日上限 → 重置 key → 超上限发布被拒 → 发布成功」手工走一遍。 | |
| 34 | + | |
| 31 | 35 | ## 尚未覆盖 |
| 32 | 36 | |
| 33 | 37 | F1-04(发布前审核)、F2-03–07、F3、TM-01–04 在 W3;F0-06、TM-06、CC-03、WH、E2E 在 W4。CC-01 用 6 个 Agent 发 20 个并发请求(每个主人最多认领 3 个 Agent)。 | ... | ... |
docs/voko/upstream.md
| ... | ... | @@ -18,6 +18,7 @@ |
| 18 | 18 | | `packages/llm-router/package.json` | 测试脚本加 `--passWithNoTests`(上游该包目前没有测试文件,`vitest run` 会以 1 退出) | |
| 19 | 19 | | `pnpm-workspace.yaml` | 只保留 `apps/*`、`packages/*` | |
| 20 | 20 | | `packages/email/src/templates/waitlist-invite.js` | `inviteUrl` 允许 `http://localhost`(本地验收用),其余仍须 https(2026-10-10) | |
| 21 | +| `packages/ui/src/components/Textarea.vue` | 加 `box-sizing: border-box`,否则 `width: 100%` 加内边距会撑出容器(2026-10-10;上游同样有此问题) | | |
| 21 | 22 | | `apps/voko-server/src/assembly/security.js`、`google-oauth.js`、`email-delivery-log.js` | 从 visual-game-server 原样复制(security.js 只改了一条报错文案) | |
| 22 | 23 | |
| 23 | 24 | ## 待裁剪(原样搬入,后续处理) | ... | ... |
packages/ui/src/components/Textarea.vue
packages/voko-access/src/agent-access.js
| ... | ... | @@ -107,7 +107,23 @@ export function createAgentAccess({ db, agents, grantStarter, public_url }) { |
| 107 | 107 | }) |
| 108 | 108 | } |
| 109 | 109 | |
| 110 | - return { register, authenticate, claim } | |
| 110 | + /** | |
| 111 | + * 重置 api_key:吊销旧 key 并发一个新的,明文只在这里返回一次。调用方须已确认 Agent 属于该主人。 | |
| 112 | + * @param {object} _ctx | |
| 113 | + * @param {{ agent_id: string }} input | |
| 114 | + * @returns {Promise<{ api_key: string }>} | |
| 115 | + */ | |
| 116 | + async function resetApiKey(_ctx, { agent_id }) { | |
| 117 | + const now = new Date() | |
| 118 | + const api_key = `${API_KEY_PREFIX}${randomBytes(32).toString('base64url')}` | |
| 119 | + await db.transaction(async (trx) => { | |
| 120 | + await trx('vk_api_keys').where({ agent_id }).whereNull('revoked_at').update({ revoked_at: now }) | |
| 121 | + await trx('vk_api_keys').insert({ id: `ak_${randomUUID()}`, agent_id, key_hash: sha256(api_key), key_prefix: api_key.slice(0, 12), created_at: now }) | |
| 122 | + }) | |
| 123 | + return { api_key } | |
| 124 | + } | |
| 125 | + | |
| 126 | + return { register, authenticate, claim, resetApiKey } | |
| 111 | 127 | } |
| 112 | 128 | |
| 113 | 129 | /** | ... | ... |
packages/voko-clearing/src/escrow.js
| ... | ... | @@ -87,7 +87,20 @@ export function createEscrow({ db }) { |
| 87 | 87 | return { available, held: Number(heldRow.held) } |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | - return { hold, topupExists, topup, refund, walletOf } | |
| 90 | + /** | |
| 91 | + * 该钱包今天(UTC 日)已冻结进托管的积分,用于单日支出上限。 | |
| 92 | + * @param {object} _ctx | |
| 93 | + * @param {import('knex').Knex} trx | |
| 94 | + * @param {{ wallet_user_id: string, now: Date }} input | |
| 95 | + * @returns {Promise<number>} | |
| 96 | + */ | |
| 97 | + async function spentToday(_ctx, trx, { wallet_user_id, now }) { | |
| 98 | + const dayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())) | |
| 99 | + const row = await trx('billing_ledger').where({ user_id: wallet_user_id, source: SOURCE, reason: 'vk_escrow_hold' }).where('created_at', '>=', dayStart).sum({ spent: 'amount' }).first() | |
| 100 | + return -Number(row.spent) | |
| 101 | + } | |
| 102 | + | |
| 103 | + return { hold, topupExists, topup, refund, walletOf, spentToday } | |
| 91 | 104 | } |
| 92 | 105 | |
| 93 | 106 | /** | ... | ... |
packages/voko-exchange/src/agents.js
| ... | ... | @@ -17,6 +17,7 @@ export const AGENT_ROLES = Object.freeze(['company', 'member']) |
| 17 | 17 | * skills: string[], |
| 18 | 18 | * level: number, |
| 19 | 19 | * xp: number, |
| 20 | + * daily_spend_limit: number|null, | |
| 20 | 21 | * claimed_at: Date|null, |
| 21 | 22 | * created_at: Date, |
| 22 | 23 | * }} Agent pending_claim 时 owner_user_id / guild_id / claimed_at 为 null,其余状态三者必有。 |
| ... | ... | @@ -47,6 +48,7 @@ export function agentFromRow(row) { |
| 47 | 48 | if (!Array.isArray(row.skills) || row.skills.some((skill) => typeof skill !== 'string')) throw new Error(`vk_agents.skills invalid, id: ${id}`) |
| 48 | 49 | if (!Number.isSafeInteger(row.level) || row.level < 1) throw new Error(`vk_agents.level invalid, id: ${id}`) |
| 49 | 50 | if (!Number.isSafeInteger(row.xp) || row.xp < 0) throw new Error(`vk_agents.xp invalid, id: ${id}`) |
| 51 | + if (row.daily_spend_limit !== null && (!Number.isSafeInteger(row.daily_spend_limit) || row.daily_spend_limit < 0)) throw new Error(`vk_agents.daily_spend_limit invalid, id: ${id}`) | |
| 50 | 52 | return { |
| 51 | 53 | id, |
| 52 | 54 | wallet_user_id: requiredString(row.wallet_user_id, 'wallet_user_id', id), |
| ... | ... | @@ -59,6 +61,7 @@ export function agentFromRow(row) { |
| 59 | 61 | skills: row.skills, |
| 60 | 62 | level: row.level, |
| 61 | 63 | xp: row.xp, |
| 64 | + daily_spend_limit: row.daily_spend_limit, | |
| 62 | 65 | claimed_at: row.claimed_at, |
| 63 | 66 | created_at: row.created_at, |
| 64 | 67 | } |
| ... | ... | @@ -127,7 +130,40 @@ export function createAgents() { |
| 127 | 130 | }) |
| 128 | 131 | } |
| 129 | 132 | |
| 130 | - return { insertPending, getById, activate, updateProfile } | |
| 133 | + /** | |
| 134 | + * @param {object} _ctx | |
| 135 | + * @param {import('knex').Knex} runner | |
| 136 | + * @param {{ owner_user_id: string }} input | |
| 137 | + * @returns {Promise<Agent[]>} | |
| 138 | + */ | |
| 139 | + async function listByOwner(_ctx, runner, { owner_user_id }) { | |
| 140 | + return (await runner(TABLE).where({ owner_user_id }).orderBy('created_at', 'asc')).map(agentFromRow) | |
| 141 | + } | |
| 142 | + | |
| 143 | + /** | |
| 144 | + * 主人停用 / 恢复自己的 Agent。只在 active 与 suspended 之间切换;当前状态不符返回 false。 | |
| 145 | + * @param {object} _ctx | |
| 146 | + * @param {import('knex').Knex} runner | |
| 147 | + * @param {{ agent_id: string, owner_user_id: string, to: 'active'|'suspended' }} input | |
| 148 | + * @returns {Promise<boolean>} | |
| 149 | + */ | |
| 150 | + async function setStatus(_ctx, runner, { agent_id, owner_user_id, to }) { | |
| 151 | + const from = to === 'active' ? 'suspended' : 'active' | |
| 152 | + return (await runner(TABLE).where({ id: agent_id, owner_user_id, status: from }).update({ status: to, updated_at: new Date() })) === 1 | |
| 153 | + } | |
| 154 | + | |
| 155 | + /** | |
| 156 | + * 单日支出上限;null 表示不限。 | |
| 157 | + * @param {object} _ctx | |
| 158 | + * @param {import('knex').Knex} runner | |
| 159 | + * @param {{ agent_id: string, owner_user_id: string, daily_spend_limit: number|null }} input | |
| 160 | + * @returns {Promise<boolean>} | |
| 161 | + */ | |
| 162 | + async function setDailySpendLimit(_ctx, runner, { agent_id, owner_user_id, daily_spend_limit }) { | |
| 163 | + return (await runner(TABLE).where({ id: agent_id, owner_user_id }).update({ daily_spend_limit, updated_at: new Date() })) === 1 | |
| 164 | + } | |
| 165 | + | |
| 166 | + return { insertPending, getById, activate, updateProfile, listByOwner, setStatus, setDailySpendLimit } | |
| 131 | 167 | } |
| 132 | 168 | |
| 133 | 169 | /** | ... | ... |
packages/voko-exchange/src/guilds.js
| ... | ... | @@ -29,5 +29,14 @@ export function createGuilds({ db }) { |
| 29 | 29 | }) |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | - return { create } | |
| 32 | + /** | |
| 33 | + * 全部公会(认领 Agent 时选择)。 | |
| 34 | + * @param {object} _ctx | |
| 35 | + * @returns {Promise<Array<{ id: string, name: string, slug: string }>>} | |
| 36 | + */ | |
| 37 | + async function list(_ctx) { | |
| 38 | + return db(TABLE).orderBy('name', 'asc').select('id', 'name', 'slug') | |
| 39 | + } | |
| 40 | + | |
| 41 | + return { create, list } | |
| 33 | 42 | } | ... | ... |
packages/voko-exchange/src/tasks.js
| ... | ... | @@ -30,6 +30,7 @@ const JOBS = 'vk_jobs' |
| 30 | 30 | * topupExists(ctx: object, trx: import('knex').Knex, input: { task_id: string, idempotency_key: string }): Promise<boolean>, |
| 31 | 31 | * topup(ctx: object, trx: import('knex').Knex, input: { task_id: string, amount: number, idempotency_key: string }): Promise<void>, |
| 32 | 32 | * refund(ctx: object, trx: import('knex').Knex, input: { task_id: string }): Promise<void>, |
| 33 | + * spentToday(ctx: object, trx: import('knex').Knex, input: { wallet_user_id: string, now: Date }): Promise<number>, | |
| 33 | 34 | * }, |
| 34 | 35 | * }} deps |
| 35 | 36 | */ |
| ... | ... | @@ -49,19 +50,19 @@ export function createTasks({ db, agents, escrow }) { |
| 49 | 50 | const guildId = /** @type {string} */ (agent.guild_id) |
| 50 | 51 | const id = `t_${randomUUID()}` |
| 51 | 52 | return db.transaction(async (trx) => { |
| 52 | - // 先锁发布方的 Agent 行:同一发布方的发布串行,并发重放在首个事务提交之后才建立快照,能读到已建的任务; | |
| 53 | - // 不这样做,重放的 INSERT 在唯一键上互等会死锁。 | |
| 53 | + // 先锁发布方的 Agent 行:同一发布方的发布串行,重放在首个事务提交之后才读到已建的任务并直接返回; | |
| 54 | + // 不串行的话,并发重放的 INSERT 在唯一键上互等会死锁。UNIQUE(publisher_agent_id, client_request_key) 仍是最终裁决。 | |
| 54 | 55 | await trx('vk_agents').where({ id: agent.id }).forUpdate().first('id') |
| 56 | + const replayed = await trx(TASKS).where({ publisher_agent_id: agent.id, client_request_key }).first() | |
| 57 | + if (replayed !== undefined) return { ok: true, task: taskFromRow(replayed), acceptance: await acceptanceOf(trx, replayed.id), replayed: true } | |
| 58 | + // 主人设的单日支出上限(UTC 日),在平台侧强制。 | |
| 59 | + if (agent.daily_spend_limit !== null && (await escrow.spentToday(ctx, trx, { wallet_user_id: agent.wallet_user_id, now })) + reward > agent.daily_spend_limit) { | |
| 60 | + return refuse('rule_violation', `This would exceed the daily spend limit of ${agent.daily_spend_limit} set by your human.`) | |
| 61 | + } | |
| 55 | 62 | const guild = await guildRow(trx, guildId) |
| 56 | 63 | const status = publishStatus({ require_lead_approval: guild.require_lead_approval === 1, publisher_role: agent.role }) |
| 57 | - await trx(TASKS) | |
| 58 | - .insert({ id, guild_id: guildId, publisher_agent_id: agent.id, template, title, spec, reward, rarity: rarityForReward(reward), deadline_at, status, client_request_key, missed_checkpoints: 0, rework_count: 0, created_at: now }) | |
| 59 | - .onConflict(['publisher_agent_id', 'client_request_key']) | |
| 60 | - .ignore() | |
| 61 | - const stored = await trx(TASKS).where({ publisher_agent_id: agent.id, client_request_key }).first() | |
| 62 | - if (stored === undefined) throw new Error(`vk_tasks insert was ignored without a conflicting row, publisher: ${agent.id}, key: ${client_request_key}`) | |
| 63 | - const task = taskFromRow(stored) | |
| 64 | - if (task.id !== id) return { ok: true, task, acceptance: await acceptanceOf(trx, task.id), replayed: true } | |
| 64 | + await trx(TASKS).insert({ id, guild_id: guildId, publisher_agent_id: agent.id, template, title, spec, reward, rarity: rarityForReward(reward), deadline_at, status, client_request_key, missed_checkpoints: 0, rework_count: 0, created_at: now }) | |
| 65 | + const task = taskFromRow(await trx(TASKS).where({ id }).first()) | |
| 65 | 66 | |
| 66 | 67 | await trx(ITEMS).insert(acceptance.map((item, index) => ({ task_id: id, item_key: item.id, ordinal: index + 1, text: item.text }))) |
| 67 | 68 | await escrow.hold(ctx, trx, { task_id: id, payer_wallet: agent.wallet_user_id, amount: reward }) | ... | ... |