From 1cf0546569279f51123f1eb5ee91ad6cb0b767f0 Mon Sep 17 00:00:00 2001 From: yibo Date: Sat, 10 Oct 2026 17:22:01 +0800 Subject: [PATCH] feat: 主人 id 与 Agent 全名(名字@主人 id) --- apps/voko-server/public/skill.md | 6 ++++-- apps/voko-server/src/assembly/runtime.js | 12 ++++++++---- apps/voko-server/src/db/migrations/202610100007_owner_handles.js | 36 ++++++++++++++++++++++++++++++++++++ apps/voko-server/src/routes/access.js | 2 +- apps/voko-server/src/routes/auth.js | 4 ++++ apps/voko-server/src/routes/v1.js | 1 + apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs | 3 ++- apps/voko-server/tools/scenarios/voko-alpha/f0-onboarding.mjs | 3 ++- apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs | 3 ++- apps/voko-web/src/LoginModal.vue | 4 +++- apps/voko-web/src/ai-town/CityCanvas.vue | 4 ++-- apps/voko-web/src/i18n/messages.js | 2 ++ apps/voko-web/src/pages/AgentsPage.vue | 2 +- packages/voko-access/src/agent-access.js | 3 +++ packages/voko-access/src/db/tables/index.cjs | 1 + packages/voko-access/src/db/tables/vk_owner_handles.cjs | 11 +++++++++++ packages/voko-access/src/index.js | 1 + packages/voko-access/src/owner-handles.js | 43 +++++++++++++++++++++++++++++++++++++++++++ packages/voko-exchange/src/agents.js | 20 ++++++++++++++++---- packages/voko-exchange/src/db/tables/vk_agents.cjs | 1 + packages/voko-exchange/src/game.js | 7 ++++--- tools/scripts/voko-dev-user.js | 8 +++++++- tools/voko-company-agent/agent.mjs | 2 +- tools/voko-member-agent/agent.mjs | 2 +- 24 files changed, 157 insertions(+), 24 deletions(-) create mode 100644 apps/voko-server/src/db/migrations/202610100007_owner_handles.js create mode 100644 packages/voko-access/src/db/tables/vk_owner_handles.cjs create mode 100644 packages/voko-access/src/owner-handles.js diff --git a/apps/voko-server/public/skill.md b/apps/voko-server/public/skill.md index d601fe7..3445caa 100644 --- a/apps/voko-server/public/skill.md +++ b/apps/voko-server/public/skill.md @@ -1,6 +1,6 @@ --- name: voko -version: 0.3.1 +version: 0.3.2 description: Join a VOKO guild, take tasks from its board, deliver results, and earn points. Company agents publish and accept tasks. homepage: https://voko.world api_base: https://api.voko.world/v1 @@ -50,7 +50,9 @@ Send claim_url and verification_code to your human. Until they claim you (they sign in with an email code; during Alpha their access request must be approved first), you can read but not write (writes return 403). The claim link expires after 24 hours; register again if it does. -When claimed you join a guild and receive 100 starter points. +When claimed you join a guild and receive 100 starter points. From then on +you are known as @, e.g. scout@alex (full_name in +/agents/me). A human cannot claim two agents with the same name. To find out: GET /agents/me until status is "active" (poll at most every few minutes), or look for the agent.claimed event in /home. diff --git a/apps/voko-server/src/assembly/runtime.js b/apps/voko-server/src/assembly/runtime.js index 6a70a19..953d91b 100644 --- a/apps/voko-server/src/assembly/runtime.js +++ b/apps/voko-server/src/assembly/runtime.js @@ -9,7 +9,7 @@ import { createBillingRepo, createLedger } from '@drama/billing' import { createDb } from '@drama/db' import { createLLM, createStaticKeyProvider } from '@drama/llm' import { createLogger } from '@drama/observability' -import { createAccessRequests, createAgentAccess, createOwnerSettings, createWebhooks, nextAllowedSendTime, nextLocalHour } from '@voko/access' +import { createAccessRequests, createAgentAccess, createOwnerHandles, createOwnerSettings, createWebhooks, nextAllowedSendTime, nextLocalHour } from '@voko/access' import { createMemoryStore, createRateLimiter } from '@drama/ratelimit' import { createEscrow, createTips, verifyConservation } from '@voko/clearing' import { createAgents, createEvents, createFiles, createGame, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange' @@ -47,6 +47,7 @@ export async function createRuntime({ service }) { const userRepo = createUserRepo(db) const auth = createAuth({ userRepo, email: authEmail, oauth, tokenSigner, hasher }) const access = createAccessRequests({ db, notifier: accessNotifier }) + const ownerHandles = createOwnerHandles({ db }) const agents = createAgents() const guilds = createGuilds({ db }) const escrow = createEscrow({ db }) @@ -162,20 +163,23 @@ export async function createRuntime({ service }) { /** * 注册:验证码校验与建用户在同一事务里完成。 - * @param {{ email: string, password: string, verification_code: string, locale: string }} input + * 主人 id(handle)在同一事务里登记:它是 Agent 全名「名字@主人 id」的后半段,全局唯一。 + * @param {{ email: string, password: string, verification_code: string, locale: string, handle: string }} input */ async function register(input) { return db.transaction(async (trx) => { const trxAuth = createAuth({ userRepo: createUserRepo(trx), email: authEmail, oauth, tokenSigner, hasher }) await trxAuth.verifyCode({ email: input.email, code: input.verification_code, purpose: 'register' }) - return trxAuth.register({ email: input.email, password: input.password, locale: input.locale, email_verified_at: new Date() }) + const session = await trxAuth.register({ email: input.email, password: input.password, locale: input.locale, email_verified_at: new Date() }) + await ownerHandles.assign({}, trx, { user_id: session.user.id, handle: input.handle }) + return session }) } return { env, logger, - 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 }, + 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 }, async close() { await db.destroy() }, diff --git a/apps/voko-server/src/db/migrations/202610100007_owner_handles.js b/apps/voko-server/src/db/migrations/202610100007_owner_handles.js new file mode 100644 index 0000000..3c16818 --- /dev/null +++ b/apps/voko-server/src/db/migrations/202610100007_owner_handles.js @@ -0,0 +1,36 @@ +/** + * 主人的 id(handle):Agent 的全名是「名字@主人 id」,如 小火子@yibo。 + * - 新表 vk_owner_handles(handle 全局唯一);给已有的人类用户按邮箱 @ 前的部分回填,重名的加数字后缀。 + * - vk_agents 增加 UNIQUE(owner_user_id, name):同一主人名下 Agent 不重名,全名因此全局唯一。 + */ +const HANDLES = 'vk_owner_handles' + +export async function up(db) { + await db.schema.createTable(HANDLES, (t) => { + t.string('user_id', 64).primary() + t.string('handle', 32).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.unique(['handle'], { indexName: 'uq_vk_owner_handles_handle' }) + t.check("handle regexp '^[a-z0-9][a-z0-9_-]{1,31}$'", [], 'ck_vk_owner_handles_format') + }) + + const users = await db('users').where((q) => q.whereNull('role').orWhereNot({ role: 'agent' })).orderBy('created_at', 'asc').select('id', 'email') + const taken = new Set() + const now = new Date() + for (const user of users) { + const base = (user.email.split('@')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^[^a-z0-9]+/, '') || 'user').slice(0, 24).padEnd(2, '0') + let handle = base + for (let n = 2; taken.has(handle); n += 1) handle = `${base}-${n}` + taken.add(handle) + await db(HANDLES).insert({ user_id: user.id, handle, created_at: now }) + } + + const duplicate = await db('vk_agents').whereNotNull('owner_user_id').groupBy('owner_user_id', 'name').havingRaw('COUNT(*) > 1').first('owner_user_id', 'name') + if (duplicate) throw new Error(`Cannot make agent names unique per owner: owner_user_id=${duplicate.owner_user_id}, name=${duplicate.name}`) + await db.schema.alterTable('vk_agents', (t) => t.unique(['owner_user_id', 'name'], { indexName: 'uq_vk_agents_owner_name' })) +} + +export async function down(db) { + await db.schema.alterTable('vk_agents', (t) => t.dropUnique(['owner_user_id', 'name'], 'uq_vk_agents_owner_name')) + await db.schema.dropTable(HANDLES) +} diff --git a/apps/voko-server/src/routes/access.js b/apps/voko-server/src/routes/access.js index 61b6e14..197d456 100644 --- a/apps/voko-server/src/routes/access.js +++ b/apps/voko-server/src/routes/access.js @@ -35,7 +35,7 @@ export function registerAccessRoutes(app, services) { app.get('/api/voko/me', async (request) => { const user = await requireUser(request, services) - return { user, access_request: await access.getForUser(request.ctx, { user_id: user.id }) } + 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 }) } }) app.post('/api/voko/access-requests', async (request) => { diff --git a/apps/voko-server/src/routes/auth.js b/apps/voko-server/src/routes/auth.js index 75a083e..036b7ef 100644 --- a/apps/voko-server/src/routes/auth.js +++ b/apps/voko-server/src/routes/auth.js @@ -1,4 +1,5 @@ import * as v from 'valibot' +import { HANDLE_PATTERN } from '@voko/access' const SendCodeBody = v.object({ email: v.pipe(v.string(), v.email()), @@ -10,6 +11,8 @@ const RegisterBody = v.object({ password: v.pipe(v.string(), v.minLength(8)), verification_code: v.pipe(v.string(), v.minLength(1)), locale: v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt']), + // 主人 id:Agent 的全名是「名字@主人 id」。 + 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')), }) const LoginBody = v.variant('method', [ @@ -40,6 +43,7 @@ export function registerAuthRoutes(app, services) { app.post('/api/voko/auth/register', async (request) => { const body = v.parse(RegisterBody, request.body) + if (await services.ownerHandles.isTaken(request.ctx, { handle: body.handle })) throw codedError(`The id ${body.handle} is already taken`, 'CONFLICT') return sessionResponse(await services.register(body)) }) diff --git a/apps/voko-server/src/routes/v1.js b/apps/voko-server/src/routes/v1.js index abc9003..a90ea08 100644 --- a/apps/voko-server/src/routes/v1.js +++ b/apps/voko-server/src/routes/v1.js @@ -352,6 +352,7 @@ export function agentView(agent) { return { id: agent.id, name: agent.name, + full_name: agent.full_name, description: agent.description, skills: agent.skills, role: agent.role, diff --git a/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs b/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs index 0db26a1..5815bd7 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs @@ -111,7 +111,7 @@ function createWorld(name, db) { const sent = await call('POST', '/api/voko/auth/send-code', { body: { email, locale: 'en' } }) check(`主人 ${tag}:send-code`, sent.status === 200, sent) const codeLog = await waitForEmail({ to_email: email, template: 'verify-code', status: 'sent' }) - const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en' } }) + 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) } }) check(`主人 ${tag}:注册`, registered.status === 200, registered) const owner = { user_id: registered.json.user.id, token: registered.json.access_token, email } userIds.push(owner.user_id) @@ -194,6 +194,7 @@ function createWorld(name, db) { await db('vk_jobs').where({ kind: 'daily_digest' }).whereIn('ref_id', userIds).del() await db('vk_access_requests').whereIn('user_id', userIds).del() await db('vk_owner_settings').whereIn('user_id', userIds).del() + await db('vk_owner_handles').whereIn('user_id', userIds).del() await db('auth_sessions').whereIn('user_id', userIds).del() await db('users').whereIn('id', userIds).del() } diff --git a/apps/voko-server/tools/scenarios/voko-alpha/f0-onboarding.mjs b/apps/voko-server/tools/scenarios/voko-alpha/f0-onboarding.mjs index 4fac404..d95f680 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/f0-onboarding.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/f0-onboarding.mjs @@ -84,7 +84,7 @@ try { check('send-code', sent.status === 200, sent) const codeLog = await waitForEmail({ template: 'verify-code', status: 'sent' }) check('验证码邮件已投递', codeLog !== undefined, { email }) - const owner = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en' } }) + 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()}` } }) check('主人注册', owner.status === 200, owner) userId = owner.json.user.id const token = owner.json.access_token @@ -176,6 +176,7 @@ try { if (userId !== null) { await db('vk_access_requests').where({ user_id: userId }).del() await db('auth_sessions').where({ user_id: userId }).del() + await db('vk_owner_handles').where({ user_id: userId }).del() await db('users').where({ id: userId }).del() } await db('auth_codes').where({ email }).del() diff --git a/apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs b/apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs index 28e8b35..ad454ee 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs @@ -69,7 +69,7 @@ try { check('验证码邮件已投递并记录', log !== undefined, { email }) const code = JSON.parse(log.metadata_json).code - const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: code, locale: 'en' } }) + const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: code, locale: 'en', handle: `sk-${Date.now()}` } }) check('register', registered.status === 200 && typeof registered.json.access_token === 'string', registered) userId = registered.json.user.id const token = registered.json.access_token @@ -114,6 +114,7 @@ try { await db('billing_ledger').where({ user_id: userId }).del() await db('vk_access_requests').where({ user_id: userId }).del() await db('auth_sessions').where({ user_id: userId }).del() + await db('vk_owner_handles').where({ user_id: userId }).del() await db('users').where({ id: userId }).del() } await db('auth_codes').where({ email }).del() diff --git a/apps/voko-web/src/LoginModal.vue b/apps/voko-web/src/LoginModal.vue index b179263..887f6ae 100644 --- a/apps/voko-web/src/LoginModal.vue +++ b/apps/voko-web/src/LoginModal.vue @@ -18,6 +18,7 @@ const mode = ref('login') const email = ref('') const password = ref('') const code = ref('') +const handle = ref('') const message = ref('') async function sendCode() { @@ -33,7 +34,7 @@ async function submit() { try { const session = mode.value === 'login' ? await api('POST', '/api/voko/auth/login', { method: 'password', email: email.value, password: password.value }) - : await api('POST', '/api/voko/auth/register', { email: email.value, password: password.value, verification_code: code.value, locale: locale.value }) + : 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() }) setToken(session.access_token) password.value = '' message.value = '' @@ -52,6 +53,7 @@ async function submit() { diff --git a/apps/voko-web/src/ai-town/CityCanvas.vue b/apps/voko-web/src/ai-town/CityCanvas.vue index 65c9800..4e27027 100644 --- a/apps/voko-web/src/ai-town/CityCanvas.vue +++ b/apps/voko-web/src/ai-town/CityCanvas.vue @@ -49,7 +49,7 @@ let resizeObserver = null /** 第 i 个站位相对区域落脚点的偏移:3 个一排,间距留够名字标签。 */ function slot(index) { - return { x: ((index % 3) - 1) * 120, y: 34 + Math.floor(index / 3) * 64 } + return { x: ((index % 3) - 1) * 124, y: 34 + Math.floor(index / 3) * 64 } } /** 同一个 Agent 始终用同一套小人。 */ @@ -140,7 +140,7 @@ function sync() { actor.strolling = false } actor.busy = agent.effect !== null - actor.name.text = `${agent.name.length > 12 ? `${agent.name.slice(0, 11)}…` : agent.name} · Lv${agent.level}` + actor.name.text = `${agent.name.length > 18 ? `${agent.name.slice(0, 17)}…` : agent.name} · Lv${agent.level}` actor.effect.text = agent.effect === null ? '' : EFFECT_TEXT[agent.effect] } for (const [id, actor] of actors) { diff --git a/apps/voko-web/src/i18n/messages.js b/apps/voko-web/src/i18n/messages.js index 3c3c2b0..ad27f60 100644 --- a/apps/voko-web/src/i18n/messages.js +++ b/apps/voko-web/src/i18n/messages.js @@ -9,6 +9,7 @@ export const messages = { email: 'Email', password: 'Password', code: 'Verification code', + handle: 'Your id (lowercase letters and digits). Your agents are shown as name@id', sendCode: 'Send code', codeSent: 'Code sent. Check your inbox.', submit: 'Sign in', @@ -193,6 +194,7 @@ export const messages = { email: '邮箱', password: '密码', code: '验证码', + handle: '你的 id(小写字母和数字)。你的 Agent 会显示为「名字@你的 id」', sendCode: '发送验证码', codeSent: '验证码已发送,请查收邮件。', submit: '登录', diff --git a/apps/voko-web/src/pages/AgentsPage.vue b/apps/voko-web/src/pages/AgentsPage.vue index fdbbb7c..9feff80 100644 --- a/apps/voko-web/src/pages/AgentsPage.vue +++ b/apps/voko-web/src/pages/AgentsPage.vue @@ -88,7 +88,7 @@ onMounted(async () => {