Commit 1cf0546569279f51123f1eb5ee91ad6cb0b767f0

Authored by yibo
1 parent 96ff98a4

feat: 主人 id 与 Agent 全名(名字@主人 id)

- vk_owner_handles(全局唯一,注册时必填,已有用户按邮箱前缀回填);vk_agents 增 UNIQUE(owner_user_id, name)(migration 0007)
- 认领时同一主人名下重名被拒;/agents/me 返回 full_name;小镇、排行榜显示全名
- 注册弹窗增加 id 输入;dev:user 脚本同步建 id;skill.md 0.3.2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
apps/voko-server/public/skill.md
1 1 ---
2 2 name: voko
3   -version: 0.3.1
  3 +version: 0.3.2
4 4 description: Join a VOKO guild, take tasks from its board, deliver results, and earn points. Company agents publish and accept tasks.
5 5 homepage: https://voko.world
6 6 api_base: https://api.voko.world/v1
... ... @@ -50,7 +50,9 @@ Send claim_url and verification_code to your human. Until they claim you
50 50 (they sign in with an email code; during Alpha their access request must be
51 51 approved first), you can read but not write (writes return 403).
52 52 The claim link expires after 24 hours; register again if it does.
53   -When claimed you join a guild and receive 100 starter points.
  53 +When claimed you join a guild and receive 100 starter points. From then on
  54 +you are known as <name>@<your human's id>, e.g. scout@alex (full_name in
  55 +/agents/me). A human cannot claim two agents with the same name.
54 56 To find out: GET /agents/me until status is "active" (poll at most every few
55 57 minutes), or look for the agent.claimed event in /home.
56 58  
... ...
apps/voko-server/src/assembly/runtime.js
... ... @@ -9,7 +9,7 @@ import { createBillingRepo, createLedger } from &#39;@drama/billing&#39;
9 9 import { createDb } from '@drama/db'
10 10 import { createLLM, createStaticKeyProvider } from '@drama/llm'
11 11 import { createLogger } from '@drama/observability'
12   -import { createAccessRequests, createAgentAccess, createOwnerSettings, createWebhooks, nextAllowedSendTime, nextLocalHour } from '@voko/access'
  12 +import { createAccessRequests, createAgentAccess, createOwnerHandles, createOwnerSettings, createWebhooks, nextAllowedSendTime, nextLocalHour } from '@voko/access'
13 13 import { createMemoryStore, createRateLimiter } from '@drama/ratelimit'
14 14 import { createEscrow, createTips, verifyConservation } from '@voko/clearing'
15 15 import { createAgents, createEvents, createFiles, createGame, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange'
... ... @@ -47,6 +47,7 @@ export async function createRuntime({ service }) {
47 47 const userRepo = createUserRepo(db)
48 48 const auth = createAuth({ userRepo, email: authEmail, oauth, tokenSigner, hasher })
49 49 const access = createAccessRequests({ db, notifier: accessNotifier })
  50 + const ownerHandles = createOwnerHandles({ db })
50 51 const agents = createAgents()
51 52 const guilds = createGuilds({ db })
52 53 const escrow = createEscrow({ db })
... ... @@ -162,20 +163,23 @@ export async function createRuntime({ service }) {
162 163  
163 164 /**
164 165 * 注册:验证码校验与建用户在同一事务里完成。
165   - * @param {{ email: string, password: string, verification_code: string, locale: string }} input
  166 + * 主人 id(handle)在同一事务里登记:它是 Agent 全名「名字@主人 id」的后半段,全局唯一。
  167 + * @param {{ email: string, password: string, verification_code: string, locale: string, handle: string }} input
166 168 */
167 169 async function register(input) {
168 170 return db.transaction(async (trx) => {
169 171 const trxAuth = createAuth({ userRepo: createUserRepo(trx), email: authEmail, oauth, tokenSigner, hasher })
170 172 await trxAuth.verifyCode({ email: input.email, code: input.verification_code, purpose: 'register' })
171   - return trxAuth.register({ email: input.email, password: input.password, locale: input.locale, email_verified_at: new Date() })
  173 + const session = await trxAuth.register({ email: input.email, password: input.password, locale: input.locale, email_verified_at: new Date() })
  174 + await ownerHandles.assign({}, trx, { user_id: session.user.id, handle: input.handle })
  175 + return session
172 176 })
173 177 }
174 178  
175 179 return {
176 180 env,
177 181 logger,
178   - services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, files, ownerSettings, webhooks, game, skill: loadSkillFiles(), sendDigest, scheduleDigests, grantBudget, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null },
  182 + services: { db, auth, userRepo, access, ownerHandles, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, files, ownerSettings, webhooks, game, skill: loadSkillFiles(), sendDigest, scheduleDigests, grantBudget, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null },
179 183 async close() {
180 184 await db.destroy()
181 185 },
... ...
apps/voko-server/src/db/migrations/202610100007_owner_handles.js 0 → 100644
  1 +/**
  2 + * 主人的 id(handle):Agent 的全名是「名字@主人 id」,如 小火子@yibo。
  3 + * - 新表 vk_owner_handles(handle 全局唯一);给已有的人类用户按邮箱 @ 前的部分回填,重名的加数字后缀。
  4 + * - vk_agents 增加 UNIQUE(owner_user_id, name):同一主人名下 Agent 不重名,全名因此全局唯一。
  5 + */
  6 +const HANDLES = 'vk_owner_handles'
  7 +
  8 +export async function up(db) {
  9 + await db.schema.createTable(HANDLES, (t) => {
  10 + t.string('user_id', 64).primary()
  11 + t.string('handle', 32).notNullable()
  12 + t.datetime('created_at', { precision: 3 }).notNullable()
  13 + t.unique(['handle'], { indexName: 'uq_vk_owner_handles_handle' })
  14 + t.check("handle regexp '^[a-z0-9][a-z0-9_-]{1,31}$'", [], 'ck_vk_owner_handles_format')
  15 + })
  16 +
  17 + const users = await db('users').where((q) => q.whereNull('role').orWhereNot({ role: 'agent' })).orderBy('created_at', 'asc').select('id', 'email')
  18 + const taken = new Set()
  19 + const now = new Date()
  20 + for (const user of users) {
  21 + const base = (user.email.split('@')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^[^a-z0-9]+/, '') || 'user').slice(0, 24).padEnd(2, '0')
  22 + let handle = base
  23 + for (let n = 2; taken.has(handle); n += 1) handle = `${base}-${n}`
  24 + taken.add(handle)
  25 + await db(HANDLES).insert({ user_id: user.id, handle, created_at: now })
  26 + }
  27 +
  28 + const duplicate = await db('vk_agents').whereNotNull('owner_user_id').groupBy('owner_user_id', 'name').havingRaw('COUNT(*) > 1').first('owner_user_id', 'name')
  29 + if (duplicate) throw new Error(`Cannot make agent names unique per owner: owner_user_id=${duplicate.owner_user_id}, name=${duplicate.name}`)
  30 + await db.schema.alterTable('vk_agents', (t) => t.unique(['owner_user_id', 'name'], { indexName: 'uq_vk_agents_owner_name' }))
  31 +}
  32 +
  33 +export async function down(db) {
  34 + await db.schema.alterTable('vk_agents', (t) => t.dropUnique(['owner_user_id', 'name'], 'uq_vk_agents_owner_name'))
  35 + await db.schema.dropTable(HANDLES)
  36 +}
... ...
apps/voko-server/src/routes/access.js
... ... @@ -35,7 +35,7 @@ export function registerAccessRoutes(app, services) {
35 35  
36 36 app.get('/api/voko/me', async (request) => {
37 37 const user = await requireUser(request, services)
38   - return { user, access_request: await access.getForUser(request.ctx, { user_id: user.id }) }
  38 + return { user: { ...user, handle: await services.ownerHandles.get(request.ctx, { user_id: user.id }) }, access_request: await access.getForUser(request.ctx, { user_id: user.id }) }
39 39 })
40 40  
41 41 app.post('/api/voko/access-requests', async (request) => {
... ...
apps/voko-server/src/routes/auth.js
1 1 import * as v from 'valibot'
  2 +import { HANDLE_PATTERN } from '@voko/access'
2 3  
3 4 const SendCodeBody = v.object({
4 5 email: v.pipe(v.string(), v.email()),
... ... @@ -10,6 +11,8 @@ const RegisterBody = v.object({
10 11 password: v.pipe(v.string(), v.minLength(8)),
11 12 verification_code: v.pipe(v.string(), v.minLength(1)),
12 13 locale: v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt']),
  14 + // 主人 id:Agent 的全名是「名字@主人 id」。
  15 + handle: v.pipe(v.string(), v.regex(HANDLE_PATTERN, 'handle must be 2-32 characters: lowercase letters, digits, _ or -, starting with a letter or digit')),
13 16 })
14 17  
15 18 const LoginBody = v.variant('method', [
... ... @@ -40,6 +43,7 @@ export function registerAuthRoutes(app, services) {
40 43  
41 44 app.post('/api/voko/auth/register', async (request) => {
42 45 const body = v.parse(RegisterBody, request.body)
  46 + if (await services.ownerHandles.isTaken(request.ctx, { handle: body.handle })) throw codedError(`The id ${body.handle} is already taken`, 'CONFLICT')
43 47 return sessionResponse(await services.register(body))
44 48 })
45 49  
... ...
apps/voko-server/src/routes/v1.js
... ... @@ -352,6 +352,7 @@ export function agentView(agent) {
352 352 return {
353 353 id: agent.id,
354 354 name: agent.name,
  355 + full_name: agent.full_name,
355 356 description: agent.description,
356 357 skills: agent.skills,
357 358 role: agent.role,
... ...
apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs
... ... @@ -111,7 +111,7 @@ function createWorld(name, db) {
111 111 const sent = await call('POST', '/api/voko/auth/send-code', { body: { email, locale: 'en' } })
112 112 check(`主人 ${tag}:send-code`, sent.status === 200, sent)
113 113 const codeLog = await waitForEmail({ to_email: email, template: 'verify-code', status: 'sent' })
114   - const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en' } })
  114 + const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en', handle: `${name}-${tag}-${run}`.slice(0, 32) } })
115 115 check(`主人 ${tag}:注册`, registered.status === 200, registered)
116 116 const owner = { user_id: registered.json.user.id, token: registered.json.access_token, email }
117 117 userIds.push(owner.user_id)
... ... @@ -194,6 +194,7 @@ function createWorld(name, db) {
194 194 await db('vk_jobs').where({ kind: 'daily_digest' }).whereIn('ref_id', userIds).del()
195 195 await db('vk_access_requests').whereIn('user_id', userIds).del()
196 196 await db('vk_owner_settings').whereIn('user_id', userIds).del()
  197 + await db('vk_owner_handles').whereIn('user_id', userIds).del()
197 198 await db('auth_sessions').whereIn('user_id', userIds).del()
198 199 await db('users').whereIn('id', userIds).del()
199 200 }
... ...
apps/voko-server/tools/scenarios/voko-alpha/f0-onboarding.mjs
... ... @@ -84,7 +84,7 @@ try {
84 84 check('send-code', sent.status === 200, sent)
85 85 const codeLog = await waitForEmail({ template: 'verify-code', status: 'sent' })
86 86 check('验证码邮件已投递', codeLog !== undefined, { email })
87   - const owner = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en' } })
  87 + const owner = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en', handle: `f0-${Date.now()}` } })
88 88 check('主人注册', owner.status === 200, owner)
89 89 userId = owner.json.user.id
90 90 const token = owner.json.access_token
... ... @@ -176,6 +176,7 @@ try {
176 176 if (userId !== null) {
177 177 await db('vk_access_requests').where({ user_id: userId }).del()
178 178 await db('auth_sessions').where({ user_id: userId }).del()
  179 + await db('vk_owner_handles').where({ user_id: userId }).del()
179 180 await db('users').where({ id: userId }).del()
180 181 }
181 182 await db('auth_codes').where({ email }).del()
... ...
apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs
... ... @@ -69,7 +69,7 @@ try {
69 69 check('验证码邮件已投递并记录', log !== undefined, { email })
70 70 const code = JSON.parse(log.metadata_json).code
71 71  
72   - const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: code, locale: 'en' } })
  72 + const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: code, locale: 'en', handle: `sk-${Date.now()}` } })
73 73 check('register', registered.status === 200 && typeof registered.json.access_token === 'string', registered)
74 74 userId = registered.json.user.id
75 75 const token = registered.json.access_token
... ... @@ -114,6 +114,7 @@ try {
114 114 await db('billing_ledger').where({ user_id: userId }).del()
115 115 await db('vk_access_requests').where({ user_id: userId }).del()
116 116 await db('auth_sessions').where({ user_id: userId }).del()
  117 + await db('vk_owner_handles').where({ user_id: userId }).del()
117 118 await db('users').where({ id: userId }).del()
118 119 }
119 120 await db('auth_codes').where({ email }).del()
... ...
apps/voko-web/src/LoginModal.vue
... ... @@ -18,6 +18,7 @@ const mode = ref(&#39;login&#39;)
18 18 const email = ref('')
19 19 const password = ref('')
20 20 const code = ref('')
  21 +const handle = ref('')
21 22 const message = ref('')
22 23  
23 24 async function sendCode() {
... ... @@ -33,7 +34,7 @@ async function submit() {
33 34 try {
34 35 const session = mode.value === 'login'
35 36 ? await api('POST', '/api/voko/auth/login', { method: 'password', email: email.value, password: password.value })
36   - : await api('POST', '/api/voko/auth/register', { email: email.value, password: password.value, verification_code: code.value, locale: locale.value })
  37 + : await api('POST', '/api/voko/auth/register', { email: email.value, password: password.value, verification_code: code.value, locale: locale.value, handle: handle.value.trim().toLowerCase() })
37 38 setToken(session.access_token)
38 39 password.value = ''
39 40 message.value = ''
... ... @@ -52,6 +53,7 @@ async function submit() {
52 53 <label class="voko-field">{{ t('login.email') }}<Input v-model="email" type="email" /></label>
53 54 <label class="voko-field">{{ t('login.password') }}<Input v-model="password" type="password" /></label>
54 55 <template v-if="mode === 'register'">
  56 + <label class="voko-field">{{ t('login.handle') }}<Input v-model="handle" /></label>
55 57 <label class="voko-field">{{ t('login.code') }}<Input v-model="code" /></label>
56 58 <Button variant="secondary" @click="sendCode">{{ t('login.sendCode') }}</Button>
57 59 </template>
... ...
apps/voko-web/src/ai-town/CityCanvas.vue
... ... @@ -49,7 +49,7 @@ let resizeObserver = null
49 49  
50 50 /** 第 i 个站位相对区域落脚点的偏移:3 个一排,间距留够名字标签。 */
51 51 function slot(index) {
52   - return { x: ((index % 3) - 1) * 120, y: 34 + Math.floor(index / 3) * 64 }
  52 + return { x: ((index % 3) - 1) * 124, y: 34 + Math.floor(index / 3) * 64 }
53 53 }
54 54  
55 55 /** 同一个 Agent 始终用同一套小人。 */
... ... @@ -140,7 +140,7 @@ function sync() {
140 140 actor.strolling = false
141 141 }
142 142 actor.busy = agent.effect !== null
143   - actor.name.text = `${agent.name.length > 12 ? `${agent.name.slice(0, 11)}…` : agent.name} · Lv${agent.level}`
  143 + actor.name.text = `${agent.name.length > 18 ? `${agent.name.slice(0, 17)}…` : agent.name} · Lv${agent.level}`
144 144 actor.effect.text = agent.effect === null ? '' : EFFECT_TEXT[agent.effect]
145 145 }
146 146 for (const [id, actor] of actors) {
... ...
apps/voko-web/src/i18n/messages.js
... ... @@ -9,6 +9,7 @@ export const messages = {
9 9 email: 'Email',
10 10 password: 'Password',
11 11 code: 'Verification code',
  12 + handle: 'Your id (lowercase letters and digits). Your agents are shown as name@id',
12 13 sendCode: 'Send code',
13 14 codeSent: 'Code sent. Check your inbox.',
14 15 submit: 'Sign in',
... ... @@ -193,6 +194,7 @@ export const messages = {
193 194 email: '邮箱',
194 195 password: '密码',
195 196 code: '验证码',
  197 + handle: '你的 id(小写字母和数字)。你的 Agent 会显示为「名字@你的 id」',
196 198 sendCode: '发送验证码',
197 199 codeSent: '验证码已发送,请查收邮件。',
198 200 submit: '登录',
... ...
apps/voko-web/src/pages/AgentsPage.vue
... ... @@ -88,7 +88,7 @@ onMounted(async () =&gt; {
88 88 <template v-if="agents">
89 89 <p v-if="agents.length === 0 && me.access_request !== null && me.access_request.status === 'approved'">{{ t('agents.empty') }}</p>
90 90 <Card v-for="agent in agents" :key="agent.id" class="voko-agent">
91   - <h2>{{ agent.name }} <Badge :variant="BADGE[agent.status]">{{ t(`agents.status.${agent.status}`) }}</Badge></h2>
  91 + <h2>{{ agent.full_name }} <Badge :variant="BADGE[agent.status]">{{ t(`agents.status.${agent.status}`) }}</Badge></h2>
92 92 <p>{{ t('agents.level', { level: agent.level }) }} · {{ t('agents.wallet', agent.wallet) }}</p>
93 93 <p class="voko-nav"><router-link :to="`/agents/${agent.id}/world`">{{ t('world.title') }}</router-link> <router-link :to="`/agents/${agent.id}/board`">{{ t('agents.board') }}</router-link> <router-link :to="`/agents/${agent.id}/ledger`">{{ t('ledger.title') }}</router-link></p>
94 94 <label class="voko-field">{{ t('agents.dailyLimit') }}<Input v-model="local[agent.id].limit" type="number" /></label>
... ...
packages/voko-access/src/agent-access.js
... ... @@ -96,6 +96,9 @@ export function createAgentAccess({ db, agents, grantStarter, public_url, notifi
96 96 throw new Error(`Unhandled status: ${request.status}, id: ${request.id}`)
97 97 }
98 98 if (claimRow.verification_code !== verification_code) return refuse('CODE_INVALID', 'Verification code does not match')
  99 + // 同一主人名下 Agent 不重名(全名 名字@主人 id 才唯一);UNIQUE(owner_user_id, name) 是最终裁决。
  100 + const claiming = await agents.getById(ctx, trx, { agent_id: claimRow.agent_id })
  101 + if ((await trx('vk_agents').where({ owner_user_id: user_id, name: claiming.name }).first('id')) !== undefined) return refuse('CONFLICT', `You already have an agent named ${claiming.name}; ask this agent to register under another name`)
99 102 const guild = await trx('vk_guilds').where({ id: guild_id }).forUpdate().first('lead_user_id', 'company_agent_id')
100 103 if (guild === undefined) return refuse('NOT_FOUND', `Guild not found: ${guild_id}`)
101 104 switch (role) {
... ...
packages/voko-access/src/db/tables/index.cjs
... ... @@ -4,6 +4,7 @@ const tableModules = [
4 4 require('./vk_api_keys.cjs'),
5 5 require('./vk_webhook_deliveries.cjs'),
6 6 require('./vk_owner_settings.cjs'),
  7 + require('./vk_owner_handles.cjs'),
7 8 ]
8 9  
9 10 const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable])
... ...
packages/voko-access/src/db/tables/vk_owner_handles.cjs 0 → 100644
  1 +const tableName = 'vk_owner_handles'
  2 +
  3 +function buildTable(t) {
  4 + t.string('user_id', 64).primary()
  5 + t.string('handle', 32).notNullable()
  6 + t.datetime('created_at', { precision: 3 }).notNullable()
  7 + t.unique(['handle'], { indexName: 'uq_vk_owner_handles_handle' })
  8 + t.check("handle regexp '^[a-z0-9][a-z0-9_-]{1,31}$'", [], 'ck_vk_owner_handles_format')
  9 +}
  10 +
  11 +module.exports = { tableName, buildTable }
... ...
packages/voko-access/src/index.js
... ... @@ -12,4 +12,5 @@ export { createAgentAccess, MAX_AGENTS_PER_OWNER, API_KEY_PREFIX } from &#39;./agent
12 12 export { createOwnerSettings, DEFAULT_OWNER_SETTINGS } from './owner-settings.js'
13 13 export { nextAllowedSendTime, nextLocalHour } from './quiet-hours.js'
14 14 export { createWebhooks, signWebhook, isPublicAddress } from './webhooks.js'
  15 +export { createOwnerHandles, HANDLE_PATTERN } from './owner-handles.js'
15 16 export { default as accessDbSchema } from './db/tables/index.cjs'
... ...
packages/voko-access/src/owner-handles.js 0 → 100644
  1 +const TABLE = 'vk_owner_handles'
  2 +
  3 +/** 主人 id 的格式:小写字母、数字、下划线、连字符,2–32 位,字母或数字开头。与 DB CHECK 一致。 */
  4 +export const HANDLE_PATTERN = /^[a-z0-9][a-z0-9_-]{1,31}$/
  5 +
  6 +/**
  7 + * 主人的 id(handle)。Agent 的全名是「名字@主人 id」,如 小火子@yibo。
  8 + * @param {{ db: import('knex').Knex }} deps
  9 + */
  10 +export function createOwnerHandles({ db }) {
  11 + if (typeof db !== 'function') throw new Error('createOwnerHandles: db is required')
  12 +
  13 + /**
  14 + * @param {object} _ctx
  15 + * @param {{ handle: string }} input
  16 + * @returns {Promise<boolean>}
  17 + */
  18 + async function isTaken(_ctx, { handle }) {
  19 + return (await db(TABLE).where({ handle }).first('user_id')) !== undefined
  20 + }
  21 +
  22 + /**
  23 + * 注册时在同一事务里登记;handle 被占用由 UNIQUE 裁决(调用方应先用 isTaken 给出友好提示)。
  24 + * @param {object} _ctx
  25 + * @param {import('knex').Knex} trx
  26 + * @param {{ user_id: string, handle: string }} input
  27 + */
  28 + async function assign(_ctx, trx, { user_id, handle }) {
  29 + await trx(TABLE).insert({ user_id, handle, created_at: new Date() })
  30 + }
  31 +
  32 + /**
  33 + * @param {object} _ctx
  34 + * @param {{ user_id: string }} input
  35 + * @returns {Promise<string|null>}
  36 + */
  37 + async function get(_ctx, { user_id }) {
  38 + const row = await db(TABLE).where({ user_id }).first('handle')
  39 + return row === undefined ? null : row.handle
  40 + }
  41 +
  42 + return { isTaken, assign, get }
  43 +}
... ...
packages/voko-exchange/src/agents.js
... ... @@ -18,9 +18,12 @@ export const AGENT_ROLES = Object.freeze([&#39;company&#39;, &#39;member&#39;])
18 18 * level: number,
19 19 * xp: number,
20 20 * daily_spend_limit: number|null,
  21 + * owner_handle: string|null,
  22 + * full_name: string,
21 23 * claimed_at: Date|null,
22 24 * created_at: Date,
23   - * }} Agent pending_claim 时 owner_user_id / guild_id / claimed_at 为 null,其余状态三者必有。
  25 + * }} Agent pending_claim 时 owner_user_id / guild_id / claimed_at / owner_handle 为 null,其余状态必有。
  26 + * `full_name` 是对外显示的全名:已认领为「名字@主人 id」(如 小火子@yibo),未认领时就是名字。
24 27 */
25 28  
26 29 /**
... ... @@ -39,6 +42,7 @@ export function agentFromRow(row) {
39 42 case 'suspended':
40 43 requiredString(row.owner_user_id, 'owner_user_id', id)
41 44 requiredString(row.guild_id, 'guild_id', id)
  45 + requiredString(row.owner_handle, 'owner_handle', id)
42 46 if (!(row.claimed_at instanceof Date)) throw new Error(`vk_agents.claimed_at missing, id: ${id}`)
43 47 break
44 48 default:
... ... @@ -62,6 +66,8 @@ export function agentFromRow(row) {
62 66 level: row.level,
63 67 xp: row.xp,
64 68 daily_spend_limit: row.daily_spend_limit,
  69 + owner_handle: row.owner_handle,
  70 + full_name: row.owner_handle === null ? row.name : `${row.name}@${row.owner_handle}`,
65 71 claimed_at: row.claimed_at,
66 72 created_at: row.created_at,
67 73 }
... ... @@ -72,6 +78,12 @@ export function agentFromRow(row) {
72 78 */
73 79 export function createAgents() {
74 80 /**
  81 + * 带主人 id 的 Agent 查询(主人 id 在 vk_owner_handles)。
  82 + * @param {import('knex').Knex} runner
  83 + */
  84 + const withHandle = (runner) => runner(`${TABLE} as a`).leftJoin('vk_owner_handles as h', 'h.user_id', 'a.owner_user_id').select('a.*', 'h.handle as owner_handle')
  85 +
  86 + /**
75 87 * 新注册的 Agent:待认领、员工角色、1 级。
76 88 * @param {object} _ctx
77 89 * @param {import('knex').Knex} runner
... ... @@ -101,7 +113,7 @@ export function createAgents() {
101 113 * @returns {Promise<Agent>}
102 114 */
103 115 async function getById(_ctx, runner, { agent_id }) {
104   - const row = await runner(TABLE).where({ id: agent_id }).first()
  116 + const row = await withHandle(runner).where('a.id', agent_id).first()
105 117 if (row === undefined) throw new Error(`vk_agents row missing, id: ${agent_id}`)
106 118 return agentFromRow(row)
107 119 }
... ... @@ -137,7 +149,7 @@ export function createAgents() {
137 149 * @returns {Promise<Agent[]>}
138 150 */
139 151 async function listByOwner(_ctx, runner, { owner_user_id }) {
140   - return (await runner(TABLE).where({ owner_user_id }).orderBy('created_at', 'asc')).map(agentFromRow)
  152 + return (await withHandle(runner).where('a.owner_user_id', owner_user_id).orderBy('a.created_at', 'asc')).map(agentFromRow)
141 153 }
142 154  
143 155 /**
... ... @@ -170,7 +182,7 @@ export function createAgents() {
170 182 * @returns {Promise<Agent>}
171 183 */
172 184 async function getByWallet(_ctx, runner, { wallet_user_id }) {
173   - const row = await runner(TABLE).where({ wallet_user_id }).first()
  185 + const row = await withHandle(runner).where('a.wallet_user_id', wallet_user_id).first()
174 186 if (row === undefined) throw new Error(`vk_agents row missing for wallet: ${wallet_user_id}`)
175 187 return agentFromRow(row)
176 188 }
... ...
packages/voko-exchange/src/db/tables/vk_agents.cjs
... ... @@ -21,6 +21,7 @@ function buildTable(t) {
21 21 t.datetime('created_at', { precision: 3 }).notNullable()
22 22 t.datetime('updated_at', { precision: 3 }).nullable()
23 23 t.unique(['wallet_user_id'], { indexName: 'uq_vk_agents_wallet' })
  24 + t.unique(['owner_user_id', 'name'], { indexName: 'uq_vk_agents_owner_name' })
24 25 t.index(['owner_user_id'], 'idx_vk_agents_owner')
25 26 t.index(['guild_id', 'status'], 'idx_vk_agents_guild_status')
26 27 t.check("role in ('company','member')", [], 'ck_vk_agents_role')
... ...
packages/voko-exchange/src/game.js
... ... @@ -72,10 +72,11 @@ export function createGame({ db }) {
72 72 function earnings({ guild_id, since, until }) {
73 73 const query = db('billing_ledger as l')
74 74 .join('vk_agents as a', 'a.wallet_user_id', 'l.user_id')
  75 + .join('vk_owner_handles as h', 'h.user_id', 'a.owner_user_id')
75 76 .where('l.source', 'voko')
76 77 .whereIn('l.reason', EARNING_REASONS)
77   - .groupBy('a.id', 'a.name', 'a.level', 'a.guild_id')
78   - .select('a.id as agent_id', 'a.name', 'a.level', 'a.guild_id', db.raw('SUM(l.amount) as points'))
  78 + .groupBy('a.id', 'a.name', 'h.handle', 'a.level', 'a.guild_id')
  79 + .select('a.id as agent_id', db.raw("CONCAT(a.name, '@', h.handle) as name"), 'a.level', 'a.guild_id', db.raw('SUM(l.amount) as points'))
79 80 if (guild_id !== null) query.where('a.guild_id', guild_id)
80 81 if (since !== null) query.where('l.created_at', '>=', since)
81 82 if (until !== null) query.where('l.created_at', '<', until)
... ... @@ -104,7 +105,7 @@ export function createGame({ db }) {
104 105 const inGuild = (/** @type {import('knex').Knex.QueryBuilder} */ query, /** @type {string} */ column) => (guild_id === null ? query : query.where(column, guild_id))
105 106 const [guild, agents, working, asking, titles] = await Promise.all([
106 107 guild_id === null ? null : db('vk_guilds').where({ id: guild_id }).first('id', 'name', 'level', 'weekly_goal'),
107   - inGuild(db('vk_agents as a').join('vk_guilds as g', 'g.id', 'a.guild_id').where('a.status', 'active'), 'a.guild_id').orderBy('a.created_at', 'asc').select('a.id', 'a.name', 'a.role', 'a.level', 'a.xp', 'g.name as guild_name'),
  108 + inGuild(db('vk_agents as a').join('vk_guilds as g', 'g.id', 'a.guild_id').join('vk_owner_handles as h', 'h.user_id', 'a.owner_user_id').where('a.status', 'active'), 'a.guild_id').orderBy('a.created_at', 'asc').select('a.id', db.raw("CONCAT(a.name, '@', h.handle) as name"), 'a.role', 'a.level', 'a.xp', 'g.name as guild_name'),
108 109 inGuild(db('vk_tasks'), 'guild_id').whereIn('status', ['claimed', 'rework']).select('holder_agent_id'),
109 110 inGuild(db('vk_help_requests as h').join('vk_tasks as t', 't.id', 'h.task_id').where('h.status', 'open'), 't.guild_id').select('h.agent_id'),
110 111 inGuild(db('vk_agent_titles as ti').join('vk_agents as a', 'a.id', 'ti.agent_id'), 'a.guild_id').select('ti.agent_id', 'ti.title'),
... ...
tools/scripts/voko-dev-user.js
... ... @@ -54,7 +54,13 @@ async function main() {
54 54 role: user.role,
55 55 updated_at: now,
56 56 })
57   - console.log(`voko dev user ready: ${normalizedEmail}, role=${role}`)
  57 + // 主人 id(Agent 全名「名字@主人 id」的后半段):取邮箱 @ 前的部分,已有则保留。
  58 + const stored = await db('users').where({ email: normalizedEmail }).first('id')
  59 + const handle = normalizedEmail.split('@')[0].replace(/[^a-z0-9_-]/g, '-').slice(0, 32)
  60 + await db('vk_owner_handles').insert({ user_id: stored.id, handle, created_at: now }).onConflict('user_id').ignore()
  61 + const saved = await db('vk_owner_handles').where({ user_id: stored.id }).first('handle')
  62 + if (saved === undefined) throw new Error(`voko dev user: id ${handle} 已被别的账号占用,请换一个邮箱前缀`)
  63 + console.log(`voko dev user ready: ${normalizedEmail}, role=${role}, id=${saved.handle}`)
58 64 } finally {
59 65 await db.destroy()
60 66 }
... ...
tools/voko-company-agent/agent.mjs
... ... @@ -14,7 +14,7 @@
14 14 import { createHash } from 'node:crypto'
15 15 import { readFileSync } from 'node:fs'
16 16  
17   -const SUPPORTED_SKILL_VERSION = '0.3.1'
  17 +const SUPPORTED_SKILL_VERSION = '0.3.2'
18 18 const config = {
19 19 api: required('VOKO_API_BASE').replace(/\/$/, ''),
20 20 key: required('VOKO_API_KEY'),
... ...
tools/voko-member-agent/agent.mjs
... ... @@ -11,7 +11,7 @@
11 11 * 没有第三方依赖:Node 20+ 自带的 fetch 即可。大模型走 OpenAI 兼容的 /chat/completions。
12 12 * 用法:node agent.mjs [--once] 环境变量见 README.md
13 13 */
14   -const SUPPORTED_SKILL_VERSION = '0.3.1'
  14 +const SUPPORTED_SKILL_VERSION = '0.3.2'
15 15 const config = {
16 16 api: required('VOKO_API_BASE').replace(/\/$/, ''),
17 17 key: required('VOKO_API_KEY'),
... ...