Procházet zdrojové kódy

feat(bot): 从 Grok Bot 模板导入(og 抓取+能力映射+粘贴兜底)

Samien před 1 měsícem
rodič
revize
73b8ffe35a

+ 52 - 0
src/client/views/ConversationsView.vue

@@ -17,6 +17,7 @@ import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
 import TaskPlan from '@/components/workspace/TaskPlan.vue'
 import type { AgentAssignment } from '@shared/agentTasks'
 import type { WorkspaceSendReceipt, WorkspaceTask } from '@shared/workspace'
+import { grokBotDraft } from '@shared/grokBotImport'
 import { workspaceHasUnread } from '@shared/workspace'
 import type { WorkspaceLifecycleAction, WorkspaceLifecyclePreview } from '@shared/workspaceLifecycle'
 import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
@@ -208,6 +209,35 @@ const form = reactive({
   autoReplyIds: [] as string[],
   maxReplyRounds: 3,
 })
+const importUrl = ref(''), importing = ref(false), importError = ref(''), pastedDescription = ref('')
+const importPreview = ref<{ name: string; description: string; imageDataUrl?: string; draft: { kind: string; job?: string; antiJobs?: string[]; actBias?: string; canManageTeam?: boolean; instructions: string; suggestions: string[] } } | null>(null)
+function applyGrokBotDraft(name: string, description: string, draft: NonNullable<typeof importPreview.value>['draft']) {
+  importPreview.value = { name, description, draft }
+  if (name) form.name = name
+  form.instructions = draft.instructions
+  form.job = draft.job ?? ''
+  form.antiJobs = draft.antiJobs ?? []
+  form.actBias = (draft.actBias ?? '') as typeof form.actBias
+  if (draft.canManageTeam) form.canManageTeam = true
+}
+async function runGrokBotImport() {
+  if (importing.value) return
+  if (!importUrl.value.trim()) { importError.value = '请粘贴 x.ai/bot/<id> 形式的 Grok Bot 链接'; return }
+  importing.value = true; importError.value = ''
+  try {
+    const result = await apiRequest<{ name: string; description: string; imageDataUrl?: string; draft: NonNullable<typeof importPreview.value>['draft'] }>('/api/app/agents/import-preview', { method: 'POST', body: { url: importUrl.value.trim() } })
+    if (result.imageDataUrl) form.avatar = result.imageDataUrl
+    applyGrokBotDraft(result.name, result.description, result.draft)
+  } catch (cause) { importError.value = cause instanceof Error ? cause.message : '导入失败,请稍后重试' }
+  finally { importing.value = false }
+}
+function applyPastedGrokBot() {
+  const text = pastedDescription.value.trim()
+  if (!text) { importError.value = '请粘贴 Bot 的描述文字'; return }
+  importError.value = ''
+  const name = form.name.trim() || '导入的 Bot'
+  applyGrokBotDraft(name, text.slice(0, 1200), grokBotDraft({ name, description: text.slice(0, 1200) }))
+}
 const selectedPresetId = ref('custom')
 const selectedPreset = computed(() => dialog.value === 'group' ? TEAM_PRESETS.find(p => p.id === selectedPresetId.value) : undefined)
 function applyPresetRoles(preset: TeamPreset) {
@@ -221,6 +251,7 @@ function choosePreset(preset?: TeamPreset) {
   const available = agents.value.filter(a => !a.archived && !a.temporaryGoalId)
   if (preset && available.length < preset.roles.length) return
   selectedPresetId.value = preset?.id ?? 'custom'
+  importUrl.value=''; importPreview.value=null; importError.value=''; pastedDescription.value=''
   Object.assign(form, { name: preset?.name ?? '', instructions: preset?.instructions ?? '', job: '', antiJobs: [], voice: '', voiceCustom: '', actBias: '', memberIds: preset ? available.slice(0, preset.roles.length).map(a => a.id) : [], memberRoles: {}, administratorId: '', mode: knowledgeEnabled.value ? 'discussion' : 'host', autoReplyIds: [], maxReplyRounds: 3 })
   if (preset) applyPresetRoles(preset)
 }
@@ -1055,6 +1086,18 @@ onBeforeUnmount(() => {
         <div class="editor-content">
         <p v-if="error" class="error" role="alert">{{ error }}</p>
         <label>名称<input v-model="form.name" :readonly="isRemoteAgent" required maxlength="100" /></label>
+        <div v-if="dialog === 'agent' && !isRemoteAgent" class="grok-import">
+          <label>从 Grok Bot 导入(可选)<div class="grok-import-row"><input v-model="importUrl" placeholder="https://x.ai/bot/…" @keydown.enter.prevent="runGrokBotImport" /><button type="button" :disabled="importing || !importUrl.trim()" @click="runGrokBotImport">{{ importing ? '导入中…' : '导入' }}</button></div></label>
+          <p v-if="importError" class="error" role="alert">{{ importError }}</p>
+          <details class="grok-import-manual"><summary>无法访问链接?粘贴 Bot 描述导入</summary>
+            <label>Bot 描述<textarea v-model="pastedDescription" rows="3" maxlength="1200" placeholder="粘贴 Grok Bot 页面上的描述文字,会按同一套模板生成职责与工作规则" /></label>
+            <button type="button" :disabled="!pastedDescription.trim()" @click="applyPastedGrokBot">按描述生成</button>
+          </details>
+          <div v-if="importPreview" class="grok-import-preview">
+            <p>{{ importPreview.draft.kind === 'manager' ? '已按「项目管理」模板生成职责与工作规则' : importPreview.draft.kind === 'housekeeper' ? '已按「家庭管家」模板生成职责与工作规则' : '已导入描述,可再补充一句话职责与反任务' }}</p>
+            <ul><li v-for="item in importPreview.draft.suggestions" :key="item">{{ item }}</li></ul>
+          </div>
+        </div>
         <details v-if="dialog === 'group'" class="group-options">
           <summary>从团队模板选择(可选)</summary>
           <TeamPresetPicker :selected="selectedPresetId" :available="agents.filter(a => !a.archived && !a.temporaryGoalId).length" @select="choosePreset" />
@@ -1290,4 +1333,13 @@ onBeforeUnmount(() => {
 .lifecycle-error button{display:grid;place-items:center;flex:0 0 28px;width:28px;height:28px;border:0;border-radius:6px;background:transparent;color:var(--text-secondary);cursor:pointer}
 .lifecycle-error button:hover{background:var(--surface-hover)}
 .lifecycle-error button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+.grok-import{display:grid;gap:8px;padding:10px 12px;border:1px dashed var(--line);border-radius:10px}
+.grok-import-row{display:flex;gap:8px}
+.grok-import-row input{flex:1;min-width:0}
+.grok-import-preview{display:grid;gap:6px;font-size:13px;color:var(--text-secondary)}
+.grok-import-preview p{margin:0}
+.grok-import-preview ul{margin:0;padding-left:18px;display:grid;gap:4px}
+.grok-import-manual{display:grid;gap:8px}
+.grok-import-manual summary{cursor:pointer;font-size:12.5px;color:var(--text-muted)}
+.grok-import-manual button{align-self:start}
 </style>

+ 13 - 0
src/server/workspaceRoutes.ts

@@ -9,6 +9,7 @@ import type Koa from 'koa'
 import { createReadStream, statSync } from 'node:fs'
 import { z } from 'zod'
 import { WorkspaceStore, agentInput, agentPatch, parse } from './workspaceStore.js'
+import { fetchGrokBotPage, parseGrokBotPage, fetchAvatarDataUrl, grokBotDraft, GrokBotImportError } from '../shared/grokBotImport.js'
 import { WorkspaceRuntime, sendInput } from './workspaceRuntime.js'
 import { WorkspaceNodes, type WorkspaceNode } from './workspaceGateway.js'
 import {
@@ -106,6 +107,18 @@ export function workspaceRouter(
     const input = parse(z.object({nodeId:z.string().uuid(),agentId:z.string().uuid()}).strict(),body(ctx))
     ctx.body = { agent: await nodes.importRemoteAgent(owner(ctx),input.nodeId,input.agentId) };ctx.status=201
   })
+  router.post('/api/app/agents/import-preview', async (ctx) => {
+    const input = parse(z.object({ url: z.string().max(500) }).strict(), body(ctx))
+    try {
+      const html = await fetchGrokBotPage(input.url)
+      const meta = parseGrokBotPage(html)!
+      const imageDataUrl = await fetchAvatarDataUrl(meta.imageUrl)
+      ctx.body = { name: meta.name, description: meta.description, imageDataUrl, draft: grokBotDraft(meta) }
+    } catch (error) {
+      if (error instanceof GrokBotImportError) { ctx.status = error.status; ctx.body = { error: error.message, code: error.code }; return }
+      throw error
+    }
+  })
   router.post('/api/app/agents', async (ctx) => {
     const user = owner(ctx),
       input = parse(agentInput, body(ctx))

+ 160 - 0
src/shared/grokBotImport.ts

@@ -0,0 +1,160 @@
+/** Grok Bot 模板导入:解析 x.ai/bot/<id> 公开页并翻译为夭夭 Bot 的人设草稿。 */
+export interface GrokBotMeta {
+  name: string
+  description: string
+  imageUrl?: string
+}
+
+export type GrokBotTemplateKind = 'housekeeper' | 'manager' | 'general'
+
+export interface GrokBotDraft {
+  kind: GrokBotTemplateKind
+  job?: string
+  antiJobs?: string[]
+  actBias?: 'ask_first' | 'ask_key_then_act' | 'act_now'
+  canManageTeam?: boolean
+  instructions: string
+  suggestions: string[]
+}
+
+export const GROK_BOT_URL_PATTERN = /^https:\/\/x\.ai\/bot\/([A-Za-z0-9_-]+)/
+
+function decodeEntities(text: string): string {
+  return text
+    .replace(/&amp;/g, '&')
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
+    .replace(/&quot;/g, '"')
+    .replace(/&#39;|&apos;/g, "'")
+    .replace(/&#x27;/g, "'")
+    .trim()
+}
+
+function metaContent(html: string, keys: string[]): string | undefined {
+  for (const key of keys) {
+    // Attributes on real pages are double-quoted; only exclude the quote
+    // actually in use so apostrophes inside descriptions survive.
+    const pattern = new RegExp(
+      `<meta[^>]+(?:property|name)="${key}"[^>]+content="([^"]*)"|<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="${key}"`,
+      'i',
+    )
+    const match = pattern.exec(html)
+    const value = match?.[1] ?? match?.[2]
+    if (value) return decodeEntities(value)
+  }
+  return undefined
+}
+
+/** Extract the public metadata from a fetched x.ai bot page. */
+export function parseGrokBotPage(html: string): GrokBotMeta | null {
+  const title = metaContent(html, ['og:title', 'twitter:title']) ?? (() => {
+    const match = /<title[^>]*>([^<]*)<\/title>/i.exec(html)
+    return match ? decodeEntities(match[1]!.replace(/\s*[-|]\s*Grok.*$/i, '')) : undefined
+  })()
+  const description = metaContent(html, ['og:description', 'twitter:description', 'description'])
+  if (!title) return null
+  return { name: title.slice(0, 100), description: (description ?? '').slice(0, 1200), imageUrl: metaContent(html, ['og:image', 'twitter:image']) }
+}
+
+export class GrokBotImportError extends Error {
+  code: string
+  status: number
+  constructor(message: string, code: string, status = 400) {
+    super(message)
+    this.code = code
+    this.status = status
+  }
+}
+
+/** Optional outbound proxy for x.ai only: HTTPS_PROXY / GROK_BOT_PROXY. */
+async function proxyDispatcher(target: string): Promise<unknown> {
+  const proxy = process.env.GROK_BOT_PROXY ?? process.env.HTTPS_PROXY ?? process.env.https_proxy
+  if (!proxy || !/^https:\/\//.test(target)) return undefined
+  try {
+    const undici = await import('undici')
+    return new undici.ProxyAgent(proxy)
+  } catch {
+    return undefined
+  }
+}
+
+export async function fetchGrokBotPage(url: string, fetchImpl: typeof fetch = fetch, timeoutMs = 12_000): Promise<string> {
+  if (!GROK_BOT_URL_PATTERN.test(url.trim())) throw new GrokBotImportError('请输入 x.ai/bot/<id> 形式的 Grok Bot 链接', 'invalid_grok_bot_url')
+  let response: Response
+  try {
+    const dispatcher = await proxyDispatcher(url)
+    response = await fetchImpl(url.trim(), { headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15' , accept: 'text/html' }, signal: AbortSignal.timeout(timeoutMs), redirect: 'follow', ...(dispatcher ? { dispatcher } : {}) } as RequestInit)
+  } catch {
+    throw new GrokBotImportError('无法访问 x.ai:当前网络可能需要代理(可配置 GROK_BOT_PROXY/HTTPS_PROXY),或改用“粘贴描述导入”', 'grok_bot_fetch_failed', 502)
+  }
+  if (!response.ok) throw new GrokBotImportError(`x.ai 返回 ${response.status},请确认链接可公开访问`, 'grok_bot_fetch_failed', 502)
+  const html = await response.text()
+  if (!parseGrokBotPage(html)) throw new GrokBotImportError('页面里找不到 Bot 信息,请确认这是 Grok Bot 分享链接', 'grok_bot_page_invalid', 502)
+  return html
+}
+
+export async function fetchAvatarDataUrl(imageUrl: string | undefined, fetchImpl: typeof fetch = fetch, maxBytes = 2_800_000): Promise<string | undefined> {
+  if (!imageUrl || !/^https:\/\//.test(imageUrl)) return undefined
+  try {
+    const dispatcher = await proxyDispatcher(imageUrl)
+    const response = await fetchImpl(imageUrl, { signal: AbortSignal.timeout(10_000), redirect: 'follow', ...(dispatcher ? { dispatcher } : {}) } as RequestInit)
+    if (!response.ok) return undefined
+    const type = (response.headers.get('content-type') ?? '').toLowerCase()
+    if (!/^image\/(png|jpeg|webp)/.test(type)) return undefined
+    const buffer = Buffer.from(await response.arrayBuffer())
+    if (buffer.byteLength > maxBytes) return undefined
+    return `data:${type.split(';')[0]};base64,${buffer.toString('base64')}`
+  } catch {
+    return undefined
+  }
+}
+
+const HOUSEKEEPER_PATTERN = /email|mail|calendar|邮件|日历|日程|invoice|账单|form|表单|rsvp|邀请函/i
+const MANAGER_PATTERN = /notion|project|项目|团队|team|task|排期|进度|delegate|委派|分派/i
+
+/** Translate a Grok Bot description into a yaoyao persona draft. */
+export function grokBotDraft(meta: GrokBotMeta): GrokBotDraft {
+  const summary = `${meta.description}`
+  if (MANAGER_PATTERN.test(summary)) {
+    return {
+      kind: 'manager',
+      job: '管理团队项目:每个项目一份任务计划和一个群聊,任务交给合适的 Bot 并跟进到完成',
+      antiJobs: ['绝不替专家 Bot 完成具体工作', '不隐瞒风险和延误,坏消息第一时间上报'],
+      actBias: 'ask_key_then_act',
+      canManageTeam: true,
+      instructions: [
+        `你从 Grok Bot 模板「${meta.name}」导入,原始描述:${summary}`,
+        '',
+        '工作方式(夭夭版):',
+        '1. 每个新项目:创建一份任务计划(写清目标、验收标准、截止时间),并建一个以项目命名的群聊,把相关 Bot 拉进来。',
+        '2. 任务拆解后写给最合适的成员 Bot,用 @成员名 明确委派;你是管理员,负责分派、跟进、汇总,不亲自做专家工作。',
+        '3. 每天在群里汇总一次进度:完成、进行中、阻塞,以及需要用户决策的事项。',
+        '4. 用户是最终决策者:涉及范围、预算、优先级的变化,先问再做。',
+      ].join('\n'),
+      suggestions: ['在「权限」页确认已勾选“允许组建团队”', '为它准备几个专家 Bot 作为队友(可再从 Grok Bot 模板导入或自建)'],
+    }
+  }
+  if (HOUSEKEEPER_PATTERN.test(summary)) {
+    return {
+      kind: 'housekeeper',
+      job: '盯家庭邮箱和日程,学校的表单、账单和回复邀请不遗漏',
+      antiJobs: ['绝不直接发送邮件或回复邀请', '绝不替用户提交任何表单', '不擅自改期或取消日程'],
+      actBias: 'ask_key_then_act',
+      instructions: [
+        `你从 Grok Bot 模板「${meta.name}」导入,原始描述:${summary}`,
+        '',
+        '工作方式(夭夭版):',
+        '1. 巡检时用 desktop_shell 在服务器 Mac 上执行 osascript 读取「邮件」App 的未读邮件和「日历」App 今明两天的日程。',
+        '2. 发现需要处理的事项(表单截止、账单、接送冲突、RSVP)时,在对话里列出清单并给出建议动作。',
+        '3. 需要回复的邮件:在对话里起草完整回复文本,等用户确认后仍由用户发送。',
+        '4. 保持每条汇报简短:事项、截止时间、建议,不写长篇解释。',
+      ].join('\n'),
+      suggestions: ['在「电脑与定时任务」勾选本机控制并完成文件与命令授权(邮件/日历靠它读取)', '创建每天早晚各一次的定时任务让它自动巡检'],
+    }
+  }
+  return {
+    kind: 'general',
+    instructions: `你从 Grok Bot 模板「${meta.name}」导入,原始描述:${summary}\n\n(此 Bot 从 Grok Bot 模板导入。上方为原始描述,可在「角色与规则」中补充一句话职责、反任务清单与行动偏好,让它更贴合你的使用。)`,
+    suggestions: ['打开「角色与规则」补一句话职责和反任务清单,结构化规范会自动生效'],
+  }
+}

+ 43 - 0
tests/server/grokBotImport.test.ts

@@ -0,0 +1,43 @@
+import {it,expect} from 'vitest'
+import {parseGrokBotPage,grokBotDraft,fetchGrokBotPage,GrokBotImportError} from '../../src/shared/grokBotImport'
+
+const tradbotHtml=`<!doctype html><html><head>
+<meta property="og:title" content="Tradbot">
+<meta property="og:description" content="Watches your personal email and calendar so school forms, bills, and RSVPs don't slip. Drafts the reply, catches the pickup clash, and never sends without you.">
+<meta property="og:image" content="https://x.ai/og/tradbot.png">
+</head><body></body></html>`
+const managerHtml=tradbotHtml.replace('Tradbot','Projects Manager').replace(/content="Watches[^"]*"/,'content="Runs your team\'s projects from Notion: one row per project, a channel per project, and tasks your specialist bots claim."')
+
+it('parses og metadata from a bot page',()=>{
+ const meta=parseGrokBotPage(tradbotHtml)!
+ expect(meta.name).toBe('Tradbot')
+ expect(meta.description).toContain('never sends without you')
+ expect(meta.imageUrl).toBe('https://x.ai/og/tradbot.png')
+})
+it('maps a housekeeper description onto the adapted template',()=>{
+ const draft=grokBotDraft(parseGrokBotPage(tradbotHtml)!)
+ expect(draft.kind).toBe('housekeeper')
+ expect(draft.antiJobs).toContain('绝不直接发送邮件或回复邀请')
+ expect(draft.instructions).toContain('desktop_shell')
+ expect(draft.instructions).toContain('Tradbot')
+ expect(draft.suggestions.join()).toContain('定时任务')
+})
+it('maps a manager description onto the team template',()=>{
+ const draft=grokBotDraft(parseGrokBotPage(managerHtml)!)
+ expect(draft.kind).toBe('manager')
+ expect(draft.canManageTeam).toBe(true)
+ expect(draft.job).toContain('任务计划')
+ expect(draft.antiJobs).toContain('绝不替专家 Bot 完成具体工作')
+})
+it('validates the x.ai bot URL before fetching',async()=>{
+ await expect(fetchGrokBotPage('https://example.com/bot/x')).rejects.toMatchObject({code:'invalid_grok_bot_url'})
+})
+it('rejects pages without bot metadata',async()=>{
+ const fake=(async()=>new Response('<html><body>login</body></html>',{status:200})) as unknown as typeof fetch
+ await expect(fetchGrokBotPage('https://x.ai/bot/_abc',fake)).rejects.toBeInstanceOf(GrokBotImportError)
+})
+it('fetches and parses through an injected fetch implementation',async()=>{
+ const fake=(async()=>new Response(tradbotHtml,{status:200,headers:{'content-type':'text/html'}})) as unknown as typeof fetch
+ const html=await fetchGrokBotPage('https://x.ai/bot/_abc',fake)
+ expect(parseGrokBotPage(html)!.name).toBe('Tradbot')
+})