Browse Source

移除从 Grok Bot 导入功能

Samien 1 tháng trước cách đây
mục cha
commit
e64d847

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

@@ -16,7 +16,6 @@ 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'
@@ -199,36 +198,6 @@ 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.description = description.slice(0, 500)
-  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) {
@@ -242,7 +211,6 @@ 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)
 }
@@ -1079,18 +1047,6 @@ 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" />
@@ -1320,13 +1276,4 @@ 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>

+ 0 - 13
src/server/workspaceRoutes.ts

@@ -10,7 +10,6 @@ import { createReadStream, statSync } from 'node:fs'
 import { z } from 'zod'
 import { WorkspaceStore, agentInput, agentPatch, parse } from './workspaceStore.js'
 import { botModelOptions, resolveBotModelSettings, botModelTarget } from './botModelSettings.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 {
@@ -107,18 +106,6 @@ export function workspaceRouter(
     auth.requireAdmin(ctx)
     throw new HttpError(410, '远程机器人已停用', 'remote_agent_removed')
   })
-  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))

+ 0 - 160
src/shared/grokBotImport.ts

@@ -1,160 +0,0 @@
-/** 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: ['打开「角色与规则」补一句话职责和反任务清单,结构化规范会自动生效'],
-  }
-}

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

@@ -1,43 +0,0 @@
-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')
-})