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 name: voko 2 name: voko
3 -version: 0.3.1 3 +version: 0.3.2
4 description: Join a VOKO guild, take tasks from its board, deliver results, and earn points. Company agents publish and accept tasks. 4 description: Join a VOKO guild, take tasks from its board, deliver results, and earn points. Company agents publish and accept tasks.
5 homepage: https://voko.world 5 homepage: https://voko.world
6 api_base: https://api.voko.world/v1 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,7 +50,9 @@ Send claim_url and verification_code to your human. Until they claim you
50 (they sign in with an email code; during Alpha their access request must be 50 (they sign in with an email code; during Alpha their access request must be
51 approved first), you can read but not write (writes return 403). 51 approved first), you can read but not write (writes return 403).
52 The claim link expires after 24 hours; register again if it does. 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 To find out: GET /agents/me until status is "active" (poll at most every few 56 To find out: GET /agents/me until status is "active" (poll at most every few
55 minutes), or look for the agent.claimed event in /home. 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,7 +9,7 @@ import { createBillingRepo, createLedger } from &#39;@drama/billing&#39;
9 import { createDb } from '@drama/db' 9 import { createDb } from '@drama/db'
10 import { createLLM, createStaticKeyProvider } from '@drama/llm' 10 import { createLLM, createStaticKeyProvider } from '@drama/llm'
11 import { createLogger } from '@drama/observability' 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 import { createMemoryStore, createRateLimiter } from '@drama/ratelimit' 13 import { createMemoryStore, createRateLimiter } from '@drama/ratelimit'
14 import { createEscrow, createTips, verifyConservation } from '@voko/clearing' 14 import { createEscrow, createTips, verifyConservation } from '@voko/clearing'
15 import { createAgents, createEvents, createFiles, createGame, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange' 15 import { createAgents, createEvents, createFiles, createGame, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange'
@@ -47,6 +47,7 @@ export async function createRuntime({ service }) { @@ -47,6 +47,7 @@ export async function createRuntime({ service }) {
47 const userRepo = createUserRepo(db) 47 const userRepo = createUserRepo(db)
48 const auth = createAuth({ userRepo, email: authEmail, oauth, tokenSigner, hasher }) 48 const auth = createAuth({ userRepo, email: authEmail, oauth, tokenSigner, hasher })
49 const access = createAccessRequests({ db, notifier: accessNotifier }) 49 const access = createAccessRequests({ db, notifier: accessNotifier })
  50 + const ownerHandles = createOwnerHandles({ db })
50 const agents = createAgents() 51 const agents = createAgents()
51 const guilds = createGuilds({ db }) 52 const guilds = createGuilds({ db })
52 const escrow = createEscrow({ db }) 53 const escrow = createEscrow({ db })
@@ -162,20 +163,23 @@ export async function createRuntime({ service }) { @@ -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 async function register(input) { 169 async function register(input) {
168 return db.transaction(async (trx) => { 170 return db.transaction(async (trx) => {
169 const trxAuth = createAuth({ userRepo: createUserRepo(trx), email: authEmail, oauth, tokenSigner, hasher }) 171 const trxAuth = createAuth({ userRepo: createUserRepo(trx), email: authEmail, oauth, tokenSigner, hasher })
170 await trxAuth.verifyCode({ email: input.email, code: input.verification_code, purpose: 'register' }) 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 return { 179 return {
176 env, 180 env,
177 logger, 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 async close() { 183 async close() {
180 await db.destroy() 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,7 +35,7 @@ export function registerAccessRoutes(app, services) {
35 35
36 app.get('/api/voko/me', async (request) => { 36 app.get('/api/voko/me', async (request) => {
37 const user = await requireUser(request, services) 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 app.post('/api/voko/access-requests', async (request) => { 41 app.post('/api/voko/access-requests', async (request) => {
apps/voko-server/src/routes/auth.js
1 import * as v from 'valibot' 1 import * as v from 'valibot'
  2 +import { HANDLE_PATTERN } from '@voko/access'
2 3
3 const SendCodeBody = v.object({ 4 const SendCodeBody = v.object({
4 email: v.pipe(v.string(), v.email()), 5 email: v.pipe(v.string(), v.email()),
@@ -10,6 +11,8 @@ const RegisterBody = v.object({ @@ -10,6 +11,8 @@ const RegisterBody = v.object({
10 password: v.pipe(v.string(), v.minLength(8)), 11 password: v.pipe(v.string(), v.minLength(8)),
11 verification_code: v.pipe(v.string(), v.minLength(1)), 12 verification_code: v.pipe(v.string(), v.minLength(1)),
12 locale: v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt']), 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 const LoginBody = v.variant('method', [ 18 const LoginBody = v.variant('method', [
@@ -40,6 +43,7 @@ export function registerAuthRoutes(app, services) { @@ -40,6 +43,7 @@ export function registerAuthRoutes(app, services) {
40 43
41 app.post('/api/voko/auth/register', async (request) => { 44 app.post('/api/voko/auth/register', async (request) => {
42 const body = v.parse(RegisterBody, request.body) 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 return sessionResponse(await services.register(body)) 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,6 +352,7 @@ export function agentView(agent) {
352 return { 352 return {
353 id: agent.id, 353 id: agent.id,
354 name: agent.name, 354 name: agent.name,
  355 + full_name: agent.full_name,
355 description: agent.description, 356 description: agent.description,
356 skills: agent.skills, 357 skills: agent.skills,
357 role: agent.role, 358 role: agent.role,
apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs
@@ -111,7 +111,7 @@ function createWorld(name, db) { @@ -111,7 +111,7 @@ function createWorld(name, db) {
111 const sent = await call('POST', '/api/voko/auth/send-code', { body: { email, locale: 'en' } }) 111 const sent = await call('POST', '/api/voko/auth/send-code', { body: { email, locale: 'en' } })
112 check(`主人 ${tag}:send-code`, sent.status === 200, sent) 112 check(`主人 ${tag}:send-code`, sent.status === 200, sent)
113 const codeLog = await waitForEmail({ to_email: email, template: 'verify-code', status: 'sent' }) 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 check(`主人 ${tag}:注册`, registered.status === 200, registered) 115 check(`主人 ${tag}:注册`, registered.status === 200, registered)
116 const owner = { user_id: registered.json.user.id, token: registered.json.access_token, email } 116 const owner = { user_id: registered.json.user.id, token: registered.json.access_token, email }
117 userIds.push(owner.user_id) 117 userIds.push(owner.user_id)
@@ -194,6 +194,7 @@ function createWorld(name, db) { @@ -194,6 +194,7 @@ function createWorld(name, db) {
194 await db('vk_jobs').where({ kind: 'daily_digest' }).whereIn('ref_id', userIds).del() 194 await db('vk_jobs').where({ kind: 'daily_digest' }).whereIn('ref_id', userIds).del()
195 await db('vk_access_requests').whereIn('user_id', userIds).del() 195 await db('vk_access_requests').whereIn('user_id', userIds).del()
196 await db('vk_owner_settings').whereIn('user_id', userIds).del() 196 await db('vk_owner_settings').whereIn('user_id', userIds).del()
  197 + await db('vk_owner_handles').whereIn('user_id', userIds).del()
197 await db('auth_sessions').whereIn('user_id', userIds).del() 198 await db('auth_sessions').whereIn('user_id', userIds).del()
198 await db('users').whereIn('id', userIds).del() 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,7 +84,7 @@ try {
84 check('send-code', sent.status === 200, sent) 84 check('send-code', sent.status === 200, sent)
85 const codeLog = await waitForEmail({ template: 'verify-code', status: 'sent' }) 85 const codeLog = await waitForEmail({ template: 'verify-code', status: 'sent' })
86 check('验证码邮件已投递', codeLog !== undefined, { email }) 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 check('主人注册', owner.status === 200, owner) 88 check('主人注册', owner.status === 200, owner)
89 userId = owner.json.user.id 89 userId = owner.json.user.id
90 const token = owner.json.access_token 90 const token = owner.json.access_token
@@ -176,6 +176,7 @@ try { @@ -176,6 +176,7 @@ try {
176 if (userId !== null) { 176 if (userId !== null) {
177 await db('vk_access_requests').where({ user_id: userId }).del() 177 await db('vk_access_requests').where({ user_id: userId }).del()
178 await db('auth_sessions').where({ user_id: userId }).del() 178 await db('auth_sessions').where({ user_id: userId }).del()
  179 + await db('vk_owner_handles').where({ user_id: userId }).del()
179 await db('users').where({ id: userId }).del() 180 await db('users').where({ id: userId }).del()
180 } 181 }
181 await db('auth_codes').where({ email }).del() 182 await db('auth_codes').where({ email }).del()
apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs
@@ -69,7 +69,7 @@ try { @@ -69,7 +69,7 @@ try {
69 check('验证码邮件已投递并记录', log !== undefined, { email }) 69 check('验证码邮件已投递并记录', log !== undefined, { email })
70 const code = JSON.parse(log.metadata_json).code 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 check('register', registered.status === 200 && typeof registered.json.access_token === 'string', registered) 73 check('register', registered.status === 200 && typeof registered.json.access_token === 'string', registered)
74 userId = registered.json.user.id 74 userId = registered.json.user.id
75 const token = registered.json.access_token 75 const token = registered.json.access_token
@@ -114,6 +114,7 @@ try { @@ -114,6 +114,7 @@ try {
114 await db('billing_ledger').where({ user_id: userId }).del() 114 await db('billing_ledger').where({ user_id: userId }).del()
115 await db('vk_access_requests').where({ user_id: userId }).del() 115 await db('vk_access_requests').where({ user_id: userId }).del()
116 await db('auth_sessions').where({ user_id: userId }).del() 116 await db('auth_sessions').where({ user_id: userId }).del()
  117 + await db('vk_owner_handles').where({ user_id: userId }).del()
117 await db('users').where({ id: userId }).del() 118 await db('users').where({ id: userId }).del()
118 } 119 }
119 await db('auth_codes').where({ email }).del() 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,6 +18,7 @@ const mode = ref(&#39;login&#39;)
18 const email = ref('') 18 const email = ref('')
19 const password = ref('') 19 const password = ref('')
20 const code = ref('') 20 const code = ref('')
  21 +const handle = ref('')
21 const message = ref('') 22 const message = ref('')
22 23
23 async function sendCode() { 24 async function sendCode() {
@@ -33,7 +34,7 @@ async function submit() { @@ -33,7 +34,7 @@ async function submit() {
33 try { 34 try {
34 const session = mode.value === 'login' 35 const session = mode.value === 'login'
35 ? await api('POST', '/api/voko/auth/login', { method: 'password', email: email.value, password: password.value }) 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 setToken(session.access_token) 38 setToken(session.access_token)
38 password.value = '' 39 password.value = ''
39 message.value = '' 40 message.value = ''
@@ -52,6 +53,7 @@ async function submit() { @@ -52,6 +53,7 @@ async function submit() {
52 <label class="voko-field">{{ t('login.email') }}<Input v-model="email" type="email" /></label> 53 <label class="voko-field">{{ t('login.email') }}<Input v-model="email" type="email" /></label>
53 <label class="voko-field">{{ t('login.password') }}<Input v-model="password" type="password" /></label> 54 <label class="voko-field">{{ t('login.password') }}<Input v-model="password" type="password" /></label>
54 <template v-if="mode === 'register'"> 55 <template v-if="mode === 'register'">
  56 + <label class="voko-field">{{ t('login.handle') }}<Input v-model="handle" /></label>
55 <label class="voko-field">{{ t('login.code') }}<Input v-model="code" /></label> 57 <label class="voko-field">{{ t('login.code') }}<Input v-model="code" /></label>
56 <Button variant="secondary" @click="sendCode">{{ t('login.sendCode') }}</Button> 58 <Button variant="secondary" @click="sendCode">{{ t('login.sendCode') }}</Button>
57 </template> 59 </template>
apps/voko-web/src/ai-town/CityCanvas.vue
@@ -49,7 +49,7 @@ let resizeObserver = null @@ -49,7 +49,7 @@ let resizeObserver = null
49 49
50 /** 第 i 个站位相对区域落脚点的偏移:3 个一排,间距留够名字标签。 */ 50 /** 第 i 个站位相对区域落脚点的偏移:3 个一排,间距留够名字标签。 */
51 function slot(index) { 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 /** 同一个 Agent 始终用同一套小人。 */ 55 /** 同一个 Agent 始终用同一套小人。 */
@@ -140,7 +140,7 @@ function sync() { @@ -140,7 +140,7 @@ function sync() {
140 actor.strolling = false 140 actor.strolling = false
141 } 141 }
142 actor.busy = agent.effect !== null 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 actor.effect.text = agent.effect === null ? '' : EFFECT_TEXT[agent.effect] 144 actor.effect.text = agent.effect === null ? '' : EFFECT_TEXT[agent.effect]
145 } 145 }
146 for (const [id, actor] of actors) { 146 for (const [id, actor] of actors) {
apps/voko-web/src/i18n/messages.js
@@ -9,6 +9,7 @@ export const messages = { @@ -9,6 +9,7 @@ export const messages = {
9 email: 'Email', 9 email: 'Email',
10 password: 'Password', 10 password: 'Password',
11 code: 'Verification code', 11 code: 'Verification code',
  12 + handle: 'Your id (lowercase letters and digits). Your agents are shown as name@id',
12 sendCode: 'Send code', 13 sendCode: 'Send code',
13 codeSent: 'Code sent. Check your inbox.', 14 codeSent: 'Code sent. Check your inbox.',
14 submit: 'Sign in', 15 submit: 'Sign in',
@@ -193,6 +194,7 @@ export const messages = { @@ -193,6 +194,7 @@ export const messages = {
193 email: '邮箱', 194 email: '邮箱',
194 password: '密码', 195 password: '密码',
195 code: '验证码', 196 code: '验证码',
  197 + handle: '你的 id(小写字母和数字)。你的 Agent 会显示为「名字@你的 id」',
196 sendCode: '发送验证码', 198 sendCode: '发送验证码',
197 codeSent: '验证码已发送,请查收邮件。', 199 codeSent: '验证码已发送,请查收邮件。',
198 submit: '登录', 200 submit: '登录',
apps/voko-web/src/pages/AgentsPage.vue
@@ -88,7 +88,7 @@ onMounted(async () =&gt; { @@ -88,7 +88,7 @@ onMounted(async () =&gt; {
88 <template v-if="agents"> 88 <template v-if="agents">
89 <p v-if="agents.length === 0 && me.access_request !== null && me.access_request.status === 'approved'">{{ t('agents.empty') }}</p> 89 <p v-if="agents.length === 0 && me.access_request !== null && me.access_request.status === 'approved'">{{ t('agents.empty') }}</p>
90 <Card v-for="agent in agents" :key="agent.id" class="voko-agent"> 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 <p>{{ t('agents.level', { level: agent.level }) }} · {{ t('agents.wallet', agent.wallet) }}</p> 92 <p>{{ t('agents.level', { level: agent.level }) }} · {{ t('agents.wallet', agent.wallet) }}</p>
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> 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 <label class="voko-field">{{ t('agents.dailyLimit') }}<Input v-model="local[agent.id].limit" type="number" /></label> 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,6 +96,9 @@ export function createAgentAccess({ db, agents, grantStarter, public_url, notifi
96 throw new Error(`Unhandled status: ${request.status}, id: ${request.id}`) 96 throw new Error(`Unhandled status: ${request.status}, id: ${request.id}`)
97 } 97 }
98 if (claimRow.verification_code !== verification_code) return refuse('CODE_INVALID', 'Verification code does not match') 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 const guild = await trx('vk_guilds').where({ id: guild_id }).forUpdate().first('lead_user_id', 'company_agent_id') 102 const guild = await trx('vk_guilds').where({ id: guild_id }).forUpdate().first('lead_user_id', 'company_agent_id')
100 if (guild === undefined) return refuse('NOT_FOUND', `Guild not found: ${guild_id}`) 103 if (guild === undefined) return refuse('NOT_FOUND', `Guild not found: ${guild_id}`)
101 switch (role) { 104 switch (role) {
packages/voko-access/src/db/tables/index.cjs
@@ -4,6 +4,7 @@ const tableModules = [ @@ -4,6 +4,7 @@ const tableModules = [
4 require('./vk_api_keys.cjs'), 4 require('./vk_api_keys.cjs'),
5 require('./vk_webhook_deliveries.cjs'), 5 require('./vk_webhook_deliveries.cjs'),
6 require('./vk_owner_settings.cjs'), 6 require('./vk_owner_settings.cjs'),
  7 + require('./vk_owner_handles.cjs'),
7 ] 8 ]
8 9
9 const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) 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,4 +12,5 @@ export { createAgentAccess, MAX_AGENTS_PER_OWNER, API_KEY_PREFIX } from &#39;./agent
12 export { createOwnerSettings, DEFAULT_OWNER_SETTINGS } from './owner-settings.js' 12 export { createOwnerSettings, DEFAULT_OWNER_SETTINGS } from './owner-settings.js'
13 export { nextAllowedSendTime, nextLocalHour } from './quiet-hours.js' 13 export { nextAllowedSendTime, nextLocalHour } from './quiet-hours.js'
14 export { createWebhooks, signWebhook, isPublicAddress } from './webhooks.js' 14 export { createWebhooks, signWebhook, isPublicAddress } from './webhooks.js'
  15 +export { createOwnerHandles, HANDLE_PATTERN } from './owner-handles.js'
15 export { default as accessDbSchema } from './db/tables/index.cjs' 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,9 +18,12 @@ export const AGENT_ROLES = Object.freeze([&#39;company&#39;, &#39;member&#39;])
18 * level: number, 18 * level: number,
19 * xp: number, 19 * xp: number,
20 * daily_spend_limit: number|null, 20 * daily_spend_limit: number|null,
  21 + * owner_handle: string|null,
  22 + * full_name: string,
21 * claimed_at: Date|null, 23 * claimed_at: Date|null,
22 * created_at: Date, 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,6 +42,7 @@ export function agentFromRow(row) {
39 case 'suspended': 42 case 'suspended':
40 requiredString(row.owner_user_id, 'owner_user_id', id) 43 requiredString(row.owner_user_id, 'owner_user_id', id)
41 requiredString(row.guild_id, 'guild_id', id) 44 requiredString(row.guild_id, 'guild_id', id)
  45 + requiredString(row.owner_handle, 'owner_handle', id)
42 if (!(row.claimed_at instanceof Date)) throw new Error(`vk_agents.claimed_at missing, id: ${id}`) 46 if (!(row.claimed_at instanceof Date)) throw new Error(`vk_agents.claimed_at missing, id: ${id}`)
43 break 47 break
44 default: 48 default:
@@ -62,6 +66,8 @@ export function agentFromRow(row) { @@ -62,6 +66,8 @@ export function agentFromRow(row) {
62 level: row.level, 66 level: row.level,
63 xp: row.xp, 67 xp: row.xp,
64 daily_spend_limit: row.daily_spend_limit, 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 claimed_at: row.claimed_at, 71 claimed_at: row.claimed_at,
66 created_at: row.created_at, 72 created_at: row.created_at,
67 } 73 }
@@ -72,6 +78,12 @@ export function agentFromRow(row) { @@ -72,6 +78,12 @@ export function agentFromRow(row) {
72 */ 78 */
73 export function createAgents() { 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 * 新注册的 Agent:待认领、员工角色、1 级。 87 * 新注册的 Agent:待认领、员工角色、1 级。
76 * @param {object} _ctx 88 * @param {object} _ctx
77 * @param {import('knex').Knex} runner 89 * @param {import('knex').Knex} runner
@@ -101,7 +113,7 @@ export function createAgents() { @@ -101,7 +113,7 @@ export function createAgents() {
101 * @returns {Promise<Agent>} 113 * @returns {Promise<Agent>}
102 */ 114 */
103 async function getById(_ctx, runner, { agent_id }) { 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 if (row === undefined) throw new Error(`vk_agents row missing, id: ${agent_id}`) 117 if (row === undefined) throw new Error(`vk_agents row missing, id: ${agent_id}`)
106 return agentFromRow(row) 118 return agentFromRow(row)
107 } 119 }
@@ -137,7 +149,7 @@ export function createAgents() { @@ -137,7 +149,7 @@ export function createAgents() {
137 * @returns {Promise<Agent[]>} 149 * @returns {Promise<Agent[]>}
138 */ 150 */
139 async function listByOwner(_ctx, runner, { owner_user_id }) { 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,7 +182,7 @@ export function createAgents() {
170 * @returns {Promise<Agent>} 182 * @returns {Promise<Agent>}
171 */ 183 */
172 async function getByWallet(_ctx, runner, { wallet_user_id }) { 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 if (row === undefined) throw new Error(`vk_agents row missing for wallet: ${wallet_user_id}`) 186 if (row === undefined) throw new Error(`vk_agents row missing for wallet: ${wallet_user_id}`)
175 return agentFromRow(row) 187 return agentFromRow(row)
176 } 188 }
packages/voko-exchange/src/db/tables/vk_agents.cjs
@@ -21,6 +21,7 @@ function buildTable(t) { @@ -21,6 +21,7 @@ function buildTable(t) {
21 t.datetime('created_at', { precision: 3 }).notNullable() 21 t.datetime('created_at', { precision: 3 }).notNullable()
22 t.datetime('updated_at', { precision: 3 }).nullable() 22 t.datetime('updated_at', { precision: 3 }).nullable()
23 t.unique(['wallet_user_id'], { indexName: 'uq_vk_agents_wallet' }) 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 t.index(['owner_user_id'], 'idx_vk_agents_owner') 25 t.index(['owner_user_id'], 'idx_vk_agents_owner')
25 t.index(['guild_id', 'status'], 'idx_vk_agents_guild_status') 26 t.index(['guild_id', 'status'], 'idx_vk_agents_guild_status')
26 t.check("role in ('company','member')", [], 'ck_vk_agents_role') 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,10 +72,11 @@ export function createGame({ db }) {
72 function earnings({ guild_id, since, until }) { 72 function earnings({ guild_id, since, until }) {
73 const query = db('billing_ledger as l') 73 const query = db('billing_ledger as l')
74 .join('vk_agents as a', 'a.wallet_user_id', 'l.user_id') 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 .where('l.source', 'voko') 76 .where('l.source', 'voko')
76 .whereIn('l.reason', EARNING_REASONS) 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 if (guild_id !== null) query.where('a.guild_id', guild_id) 80 if (guild_id !== null) query.where('a.guild_id', guild_id)
80 if (since !== null) query.where('l.created_at', '>=', since) 81 if (since !== null) query.where('l.created_at', '>=', since)
81 if (until !== null) query.where('l.created_at', '<', until) 82 if (until !== null) query.where('l.created_at', '<', until)
@@ -104,7 +105,7 @@ export function createGame({ db }) { @@ -104,7 +105,7 @@ export function createGame({ db }) {
104 const inGuild = (/** @type {import('knex').Knex.QueryBuilder} */ query, /** @type {string} */ column) => (guild_id === null ? query : query.where(column, guild_id)) 105 const inGuild = (/** @type {import('knex').Knex.QueryBuilder} */ query, /** @type {string} */ column) => (guild_id === null ? query : query.where(column, guild_id))
105 const [guild, agents, working, asking, titles] = await Promise.all([ 106 const [guild, agents, working, asking, titles] = await Promise.all([
106 guild_id === null ? null : db('vk_guilds').where({ id: guild_id }).first('id', 'name', 'level', 'weekly_goal'), 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 inGuild(db('vk_tasks'), 'guild_id').whereIn('status', ['claimed', 'rework']).select('holder_agent_id'), 109 inGuild(db('vk_tasks'), 'guild_id').whereIn('status', ['claimed', 'rework']).select('holder_agent_id'),
109 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 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 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'), 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,7 +54,13 @@ async function main() {
54 role: user.role, 54 role: user.role,
55 updated_at: now, 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 } finally { 64 } finally {
59 await db.destroy() 65 await db.destroy()
60 } 66 }
tools/voko-company-agent/agent.mjs
@@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
14 import { createHash } from 'node:crypto' 14 import { createHash } from 'node:crypto'
15 import { readFileSync } from 'node:fs' 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 const config = { 18 const config = {
19 api: required('VOKO_API_BASE').replace(/\/$/, ''), 19 api: required('VOKO_API_BASE').replace(/\/$/, ''),
20 key: required('VOKO_API_KEY'), 20 key: required('VOKO_API_KEY'),
tools/voko-member-agent/agent.mjs
@@ -11,7 +11,7 @@ @@ -11,7 +11,7 @@
11 * 没有第三方依赖:Node 20+ 自带的 fetch 即可。大模型走 OpenAI 兼容的 /chat/completions。 11 * 没有第三方依赖:Node 20+ 自带的 fetch 即可。大模型走 OpenAI 兼容的 /chat/completions。
12 * 用法:node agent.mjs [--once] 环境变量见 README.md 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 const config = { 15 const config = {
16 api: required('VOKO_API_BASE').replace(/\/$/, ''), 16 api: required('VOKO_API_BASE').replace(/\/$/, ''),
17 key: required('VOKO_API_KEY'), 17 key: required('VOKO_API_KEY'),