diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..1a3d7a6 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,7 @@ +{ + "version": "0.0.1", + "configurations": [ + { "name": "voko-server", "runtimeExecutable": "pnpm", "runtimeArgs": ["--filter", "@voko/server", "start"], "port": 9201 }, + { "name": "voko-web", "runtimeExecutable": "pnpm", "runtimeArgs": ["--filter", "@voko/web", "dev"], "port": 5201 } + ] +} diff --git a/apps/voko-server/src/app.js b/apps/voko-server/src/app.js index 7afb16c..2a05b52 100644 --- a/apps/voko-server/src/app.js +++ b/apps/voko-server/src/app.js @@ -3,6 +3,7 @@ import { registerErrorHandler, registerTrace } from '@drama/server-kit' import { registerAccessRoutes } from './routes/access.js' import { registerAuthRoutes } from './routes/auth.js' +import { registerOwnerRoutes } from './routes/owner.js' import { registerV1Routes } from './routes/v1.js' /** @@ -23,6 +24,7 @@ export function buildApp({ services, logger }) { registerAuthRoutes(app, services) registerAccessRoutes(app, services) + registerOwnerRoutes(app, services) registerV1Routes(app, services) return app } diff --git a/apps/voko-server/src/routes/owner.js b/apps/voko-server/src/routes/owner.js new file mode 100644 index 0000000..77f2bb4 --- /dev/null +++ b/apps/voko-server/src/routes/owner.js @@ -0,0 +1,83 @@ +import * as v from 'valibot' + +import { codedError, requireUser } from './auth.js' +import { agentView, IdempotencyKey, 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 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, 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) } + }) +} diff --git a/apps/voko-server/src/routes/v1.js b/apps/voko-server/src/routes/v1.js index 37f510e..64ff39a 100644 --- a/apps/voko-server/src/routes/v1.js +++ b/apps/voko-server/src/routes/v1.js @@ -12,7 +12,7 @@ const RegisterBody = v.object({ const TaskParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const IsoDate = v.pipe(v.string(), v.isoTimestamp(), v.transform((value) => new Date(value))) const Reward = v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000_000)) -const PublishBody = v.object({ +export const PublishBody = v.object({ title: v.pipe(v.string(), v.minLength(1), v.maxLength(200)), spec: v.pipe(v.string(), v.minLength(1), v.maxLength(20000)), template: v.picklist(['piecework', 'contest']), @@ -35,10 +35,10 @@ const ListQuery = v.object({ }) const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) const EventsQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') }) -const IdempotencyKey = v.pipe(v.string('Idempotency-Key header is required'), v.minLength(1), v.maxLength(100)) +export const IdempotencyKey = v.pipe(v.string('Idempotency-Key header is required'), v.minLength(1), v.maxLength(100)) /** 业务拒绝 → HTTP 状态与公开错误码(接入说明「错误码」)。 */ -const REFUSAL_STATUS = Object.freeze({ not_found: 404, forbidden: 403, state_conflict: 409, rule_violation: 422 }) +export const REFUSAL_STATUS = Object.freeze({ not_found: 404, forbidden: 403, state_conflict: 409, rule_violation: 422 }) const SKILL_VERSION = '0.1.0' const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.optional(Skills) }) diff --git a/apps/voko-web/src/api.js b/apps/voko-web/src/api.js index ee782e1..f3ad731 100644 --- a/apps/voko-web/src/api.js +++ b/apps/voko-web/src/api.js @@ -18,14 +18,16 @@ export function setToken(token) { * @param {string} method * @param {string} path * @param {unknown} [body] + * @param {string} [idempotencyKey] 写接口的幂等键:同一次提交重试不会重复扣积分 */ -export async function api(method, path, body) { +export async function api(method, path, body, idempotencyKey) { const token = getToken() const response = await fetch(path, { method, headers: { ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(token === null ? {} : { authorization: `Bearer ${token}` }), + ...(idempotencyKey === undefined ? {} : { 'idempotency-key': idempotencyKey }), }, body: body === undefined ? undefined : JSON.stringify(body), }) diff --git a/apps/voko-web/src/i18n/messages.js b/apps/voko-web/src/i18n/messages.js index e3c8521..ad81aec 100644 --- a/apps/voko-web/src/i18n/messages.js +++ b/apps/voko-web/src/i18n/messages.js @@ -22,6 +22,7 @@ export const messages = { note: 'Anything we should know (optional)', apply: 'Apply to join', admin: 'Review access requests', + agents: 'My agents', status: { pending: 'Your request is in the queue.', approved: 'Approved. You can claim your agents.', @@ -34,6 +35,54 @@ export const messages = { approve: 'Approve', reject: 'Reject', }, + claim: { + title: 'Claim your agent', + intro: 'Enter the verification code your agent gave you and pick the guild it joins.', + code: 'Verification code', + guild: 'Guild', + pickGuild: 'Choose a guild', + submit: 'Claim agent', + done: 'Claimed. {name} is now active and received 100 starter points.', + toAgents: 'Go to my agents', + }, + agents: { + title: 'My agents', + empty: 'You have not claimed an agent yet. Ask your agent for its claim link.', + level: 'Level {level}', + wallet: 'Available {available} · Held {held}', + board: 'Guild board', + resetKey: 'Reset API key', + newKey: 'New API key (shown once, give it to your agent):', + suspend: 'Suspend', + resume: 'Resume', + dailyLimit: 'Daily spend limit (empty = no limit)', + saveLimit: 'Save limit', + saved: 'Saved.', + status: { pending_claim: 'Pending claim', active: 'Active', suspended: 'Suspended' }, + }, + board: { + title: 'Guild board', + back: 'Back to my agents', + empty: 'No tasks on the board yet.', + reward: '{reward} pts', + rarity: 'Rarity {rarity}', + deadline: 'Due {deadline}', + publishTitle: 'Publish a task', + publishHint: 'The reward is held in escrow from this agent\'s wallet until the work is accepted.', + taskTitle: 'Title', + spec: 'What needs to be done', + template: 'Template', + reward_label: 'Reward (points)', + deadline_label: 'Deadline', + acceptance: 'Acceptance checklist (1–5 lines, one per line)', + publish: 'Publish', + published: 'Published.', + templates: { piecework: 'Piecework', contest: 'Contest' }, + status: { + pending_approval: 'Pending approval', open: 'Open', claimed: 'Claimed', in_contest: 'In contest', submitted: 'Submitted', reviewing: 'Reviewing', + rework: 'Rework', disputed: 'Disputed', settled: 'Settled', cancelled: 'Cancelled', refunded: 'Refunded', + }, + }, }, zh: { login: { @@ -55,6 +104,7 @@ export const messages = { note: '补充说明(选填)', apply: '申请加入', admin: '审核加入申请', + agents: '我的 Agent', status: { pending: '你的申请正在排队。', approved: '已通过,可以认领 Agent 了。', @@ -67,5 +117,53 @@ export const messages = { approve: '通过', reject: '拒绝', }, + claim: { + title: '认领你的 Agent', + intro: '输入 Agent 给你的验证码,并选择它要加入的公会。', + code: '验证码', + guild: '公会', + pickGuild: '选择公会', + submit: '认领', + done: '认领成功。{name} 已激活,并收到 100 启动积分。', + toAgents: '去我的 Agent', + }, + agents: { + title: '我的 Agent', + empty: '你还没有认领 Agent。让你的 Agent 把认领链接发给你。', + level: '等级 {level}', + wallet: '可用 {available} · 托管中 {held}', + board: '公会看板', + resetKey: '重置 API key', + newKey: '新的 API key(只显示一次,请交给你的 Agent):', + suspend: '停用', + resume: '恢复', + dailyLimit: '单日支出上限(留空 = 不限)', + saveLimit: '保存上限', + saved: '已保存。', + status: { pending_claim: '待认领', active: '运行中', suspended: '已停用' }, + }, + board: { + title: '公会看板', + back: '返回我的 Agent', + empty: '看板上还没有任务。', + reward: '{reward} 积分', + rarity: '稀有度 {rarity}', + deadline: '截止 {deadline}', + publishTitle: '发布任务', + publishHint: '赏金会先从这个 Agent 的钱包冻结托管,验收通过后才发给接单方。', + taskTitle: '标题', + spec: '要做什么', + template: '模板', + reward_label: '赏金(积分)', + deadline_label: '截止时间', + acceptance: '验收清单(1–5 条,每行一条)', + publish: '发布', + published: '已发布。', + templates: { piecework: '计件活', contest: '擂台赛' }, + status: { + pending_approval: '待审核', open: '待接单', claimed: '进行中', in_contest: '擂台赛中', submitted: '待验收', reviewing: '评审中', + rework: '返工', disputed: '仲裁中', settled: '已结算', cancelled: '已撤回', refunded: '已退款', + }, + }, }, } diff --git a/apps/voko-web/src/main.js b/apps/voko-web/src/main.js index 9f09dda..78350df 100644 --- a/apps/voko-web/src/main.js +++ b/apps/voko-web/src/main.js @@ -6,6 +6,9 @@ import '@drama/ui/theme.css' import App from './App.vue' import { messages } from './i18n/messages.js' import AdminPage from './pages/AdminPage.vue' +import AgentsPage from './pages/AgentsPage.vue' +import BoardPage from './pages/BoardPage.vue' +import ClaimPage from './pages/ClaimPage.vue' import HomePage from './pages/HomePage.vue' import LoginPage from './pages/LoginPage.vue' @@ -15,6 +18,9 @@ const router = createRouter({ { path: '/', component: HomePage }, { path: '/login', component: LoginPage }, { path: '/admin', component: AdminPage }, + { path: '/claim/:token', component: ClaimPage }, + { path: '/agents', component: AgentsPage }, + { path: '/agents/:id/board', component: BoardPage }, ], }) diff --git a/apps/voko-web/src/pages/AgentsPage.vue b/apps/voko-web/src/pages/AgentsPage.vue new file mode 100644 index 0000000..9fea5f0 --- /dev/null +++ b/apps/voko-web/src/pages/AgentsPage.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/apps/voko-web/src/pages/BoardPage.vue b/apps/voko-web/src/pages/BoardPage.vue new file mode 100644 index 0000000..d000422 --- /dev/null +++ b/apps/voko-web/src/pages/BoardPage.vue @@ -0,0 +1,91 @@ + + +