第四次作业

这个作业属于哪个课程 https://home.cnblogs.com/u/lokijinjin/relation/schoolclasses
这个作业要求在哪里 https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/16760
这个作业的目标 理解智能体的交互与执行机制,构建能通过自然语言调用工具完成任务的 AI 助手
学号 王嵘烨 3124004179,李建鑫3124004170 ,周微 3124004189

TopViewbot:一个“能说会做”的 AI 智能体

一、项目简介与选题背景

学习和开发中,我们经常需要整理资料、修改文件、执行命令服务器。TopViewbot 面向这些场景,让用户通过自然语言描述目标,由智能体选择工具完成操作,并在网页中反馈结果。

系统基于 OpenCode 智能体核心,结合 Web 界面、模型服务、MCP 接入与浏览器控制。自然语言对话体现“能说”,文件读写、命令执行等工具体现“会做”。项目复用了 OpenCode 等开源组件。

image

二、项目目录结构

以下列出主要模块:

TopViewbot/
├── web/                            # Vue 网页界面
├── opencode/packages/opencode/src/
│   ├── session/                     # 对话与执行循环
│   ├── tool/                        # 内置工具
│   └── mcp/                         # MCP 客户端
├── packages/
│   ├── topviewbot/                  # 启动、配置与技能
│   ├── browser-mcp-server/          # 浏览器桥接
│   └── browser-extension/           # 浏览器扩展
└── docs/                           # 项目文档

三、技术选型与总体架构

项目主要使用 TypeScript,前端采用 Vue 3 和 Vite,后端使用 Bun、Hono,并通过 AI SDK 对接模型服务。MCP 模块使用官方 SDK,支持本地进程与远程服务接入。会话、消息等数据通过本地文件持久化保存。

flowchart TD U[用户] --> W[Vue 网页界面] W <-->|HTTP / SSE| A[OpenCode 智能体核心] A <--> L[大模型服务 API] A <--> S[会话记录与偏好文件] A --> T[内置文件、终端与浏览器工具] A --> M[MCP 客户端] M <--> X[外部 MCP 服务] T --> R[本地文件与浏览器]

前端负责输入和展示,核心负责组织上下文、处理模型输出、调度工具。模型选择工具,具体操作由程序执行。内置工具与外部 MCP 工具共同构成可用能力。

网页还提供模型切换、文件上传与预览、MCP 管理等入口,将对话和操作结果放在同一界面中。用户可以围绕当前会话的工作目录继续处理文件,减少在聊天窗口与本地工具之间反复切换。

image

四、业务流程与 Agent 执行机制

用户发送指令后,系统装载会话历史、用户偏好和工具描述。模型结合这些信息决定直接回答,或生成工具调用。执行结果会加入上下文,供模型判断是否继续操作,最后形成回复。

flowchart LR A[自然语言指令] --> B[装载上下文与工具描述] B --> C[模型判断下一步] C --> D{需要调用工具?} D -- 否 --> G[返回自然语言回复] D -- 是 --> E[权限判断与工具执行] E --> F[回传结果或错误] F --> C

这一循环让智能体能够连续完成多个步骤。前端通过 SSE 更新消息与工具状态,用户可以看到执行进展,也可以中止任务。工具错误会被记录,后续处理仍需结合模型判断和权限规则。

五、核心功能实现与关键代码

1. 对话与 MCP 工具调用

session/llm.ts 使用 streamText,将历史消息、系统提示词和可用工具交给模型。MCP 模块通过 listTools() 获取工具,再将描述、输入结构和执行函数适配为模型可调用的工具。

内置工具统一注册,外部工具动态发现;只有已连接 MCP 服务的工具会加入可用集合。

mcp/index.ts 中的工具适配与调用逻辑如下:

async function convertMcpTool(
  serverName: string,
  mcpTool: MCPToolDef,
  client: MCPClient,
  timeout?: number,
): Promise<Tool> {
  const inputSchema = mcpTool.inputSchema

  // Spread first, then override type to ensure it's always "object"
  const schema: JSONSchema7 = {
    ...(inputSchema as JSONSchema7),
    type: "object",
    properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
    additionalProperties: false,
  }

  return dynamicTool({
    description: mcpTool.description ?? "",
    inputSchema: jsonSchema(schema),
    execute: async (args: unknown) => {
      try {
        return await client.callTool(
          {
            name: mcpTool.name,
            arguments: args as Record<string, unknown>,
          },
          CallToolResultSchema,
          {
            resetTimeoutOnProgress: true,
            timeout,
          },
        )
      } catch (error) {
        if (!isAuthenticationError(error)) {
          throw error
        }

        const message = error instanceof Error ? error.message : String(error)
        await markFailed(serverName, message)

        const cfg = await Config.get()
        const entry = cfg.mcp?.[serverName]
        if (!entry || !isMcpConfigured(entry)) {
          throw error
        }

        const recovered = await ensureServerConnected(serverName, entry, { interactiveAuth: true })
        if (!recovered) {
          throw error
        }

        const refreshedClient = (await clients())[serverName]
        if (!refreshedClient) {
          throw error
        }

        return refreshedClient.callTool(
          {
            name: mcpTool.name,
            arguments: args as Record<string, unknown>,
          },
          CallToolResultSchema,
          {
            resetTimeoutOnProgress: true,
            timeout,
          },
        )
      }
    },
  })
}

函数将工具描述和参数结构交给模型,在执行时发送工具名与参数;认证错误会触发连接恢复与重试,其他错误继续上抛。调用结果返回执行循环。

2. 文件操作与权限控制

tool/write.ts 接收文件路径与内容,在写入前检查目录、已有文件状态和操作权限。关键代码为:

const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(ctx.cwd, params.filePath)

await assertExternalDirectory(ctx, filepath)

const file = Bun.file(filepath)
const exists = await file.exists()
const contentOld = exists ? await file.text() : ""
if (exists) await FileTime.assert(ctx.sessionID, filepath)

const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
await ctx.ask({
  permission: "edit",
  patterns: [path.relative(Instance.worktree, filepath)],
  always: ["*"],
  metadata: {
    filepath,
    diff,
  },
})

await Bun.write(filepath, params.content)
await Bus.publish(File.Event.Edited, {
  file: filepath,
})
await Bus.publish(FileWatcher.Event.Updated, {
  file: filepath,
  event: exists ? "change" : "add",
})
FileTime.read(ctx.sessionID, filepath)

权限模块根据规则决定放行、询问或拒绝,并非每次都弹出确认。通过检查后,程序才将内容写入文件,实现从语言指令到实际产物的转换。

3. 多轮对话与用户偏好

系统按会话保存并装载历史,实现连续交流。session/instruction.ts 从本地文件读取偏好,加入缓存,并处理文件缺失和解析失败:

async function loadTopViewbotPreferences(): Promise<string> {
  const now = Date.now()

  // 检查缓存是否有效
  if (cachedPreferencesPrompt !== '' && now - preferencesLastLoadTime < PREFERENCES_CACHE_TTL) {
    return cachedPreferencesPrompt
  }

  const preferencesPath = process.env.TOPVIEWBOT_PREFERENCES_PATH
  if (!preferencesPath) {
    // 回退到旧的环境变量方式
    return process.env.TOPVIEWBOT_PREFERENCES_PROMPT || ''
  }

  try {
    const file = Bun.file(preferencesPath)
    if (!await file.exists()) {
      cachedPreferencesPrompt = ''
      preferencesLastLoadTime = now
      return ''
    }

    const content = await file.text()
    const data = JSON.parse(content)
    if (!data.preferences?.length) {
      cachedPreferencesPrompt = ''
      preferencesLastLoadTime = now
      return ''
    }

    const items = data.preferences.map((p: any, i: number) => `${i + 1}. ${p.content}`).join('\n')
    cachedPreferencesPrompt = `<user-preferences>
以下是用户设置的偏好,请在回复中遵循这些偏好:

${items}

这些偏好由用户明确设置,优先级高于默认行为。
</user-preferences>`
    preferencesLastLoadTime = now
    return cachedPreferencesPrompt
  } catch (e) {
    log.warn("Failed to load TopViewbot preferences", { error: e })
    // 返回旧缓存或空字符串
    return cachedPreferencesPrompt || process.env.TOPVIEWBOT_PREFERENCES_PROMPT || ''
  }
}

读取后,偏好被加入系统提示词:

const preferencesPrompt = await loadTopViewbotPreferences()
if (preferencesPrompt) {
  result.push(preferencesPrompt)
}

会话历史帮助理解“修改刚才的文件”,偏好用于传递“使用中文回复”等习惯。长对话还需考虑上下文长度与压缩造成的信息损失。

4. 浏览器自动化控制

tool/browser.ts 提供导航、页面快照、点击、表单填写等工具。桥接层将调用转为浏览器操作,页面导航的关键代码为:

export const BrowserNavigateTool = Tool.define("browser_navigate", {
  description: `Navigate a browser tab: go to URL, back, forward, reload, or manage tabs.

Parameters:
- tabId: Tab ID (from browser_status)
- action: "goto" (default), "back", "forward", "reload", "new_tab", "close_tab"
- url: Required for "goto" and optional for "new_tab"`,
  parameters: z.object({
    tabId: z.string().describe("Tab ID"),
    browser: browserParam,
    url: z.string().optional().describe("URL to navigate to (required for goto)"),
    action: z
      .enum(["goto", "back", "forward", "reload", "new_tab", "close_tab"])
      .optional()
      .describe('Navigation action (default: "goto")'),
  }),
  async execute(params, ctx) {
    await ctx.ask({
      permission: "browser_navigate",
      patterns: [params.url ?? "*"],
      always: ["*"],
      metadata: { tabId: params.tabId, url: params.url },
    })

    const bridge = requireBridge()
    const result = await bridge.navigate(
      params.tabId,
      { url: params.url, action: params.action },
      params.browser as BrowserTarget | undefined,
    )

    const action = params.action ?? "goto"
    const msg =
      action === "goto"
        ? `Navigated to ${params.url}`
        : action === "new_tab"
          ? `New tab created${result.tabId ? ` (ID: ${result.tabId})` : ""}`
          : `Action: ${action}`

    return {
      title: msg,
      output: msg,
      metadata: { newTabId: result.tabId },
    }
  },
})

智能体可以先读取页面快照,再定位元素、执行操作,完成资料检索和网页交互任务。

5. Skills 技能扩展

项目内置 PDF、Word、PPT 等技能。tool/skill.ts 按名称查找技能,检查权限,解析说明文件,并把内容和所在目录返回模型:

async execute(params: z.infer<typeof parameters>, ctx) {
  const skill = await Skill.get(params.name)

  if (!skill) {
    const available = await Skill.all().then((x) => Object.keys(x).join(", "))
    throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
  }

  await ctx.ask({
    permission: "skill",
    patterns: [params.name],
    always: [params.name],
    metadata: {},
  })
  // Load and parse skill content
  const parsed = await ConfigMarkdown.parse(skill.location)
  const dir = path.dirname(skill.location)

  // Format output similar to plugin pattern
  const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n")

  return {
    title: `Loaded skill: ${skill.name}`,
    output,
    metadata: {
      name: skill.name,
      dir,
    },
  }
},

解析后的内容会返回模型,指导后续工具调用。新增技能可以复用专门任务的处理步骤,减少重复描述。

6. 并行会话与状态管理

useParallelSessions.ts 按会话维护运行状态,下面展示 SSE 事件对运行、空闲和错误状态的处理:

function handleGlobalSSEEvent(event: SSEEvent) {
  const sessionID = extractSessionID(event)
  if (!sessionID) return

  switch (event.type) {
    case 'session.status':
      // status event: { sessionID, status: { type: 'busy' | 'idle' | 'retry' } }
      const status = event.properties?.status
      if (status) {
        runningStates[sessionID] = status.type === 'busy' || status.type === 'retry'
      }
      break

    case 'session.idle':
      // Session finished
      runningStates[sessionID] = false
      break

    case 'session.error':
      // Session error - also stopped
      runningStates[sessionID] = false
      break
  }
}

前端设置了 10 个并行会话的上限,达到上限时提示等待。SSE 事件同步运行、空闲和错误状态,用户切换会话后仍能跟踪其他任务的进展。

六、使用演示

下图展示实际对话与执行结果
image

七、当前局限与改进方向

工具选择和参数生成仍依赖模型,复杂任务可能出现理解偏差;外部服务也会受到网络、认证和调用成本影响。后续可完善目录权限、失败恢复和操作回滚,并增加常用任务模板,提升执行的稳定性。

八、GitHub 地址与小组分工

代码仓库:TopViewbot。

成员 学号 分工内容
王嵘烨 3124004179 负责会话与 Memory 管理部分的梳理,整理聊天历史保存、用户偏好注入、缓存和上下文压缩流程,编写对应实现说明。
周微 3124004189 负责 Skills 部分的梳理,整理技能查找、权限检查和说明加载流程,规划后续技能消融实验及提示词 A/B 对比方案。
李建鑫 3124004170 负责 MCP 与工具调用部分的梳理,整理工具发现、参数约束、执行回传及认证恢复机制,编写对应实现说明。

九、成员心得

成员 心得
王嵘烨 梳理了会话和 Memory 管理。聊天历史通过 sessionID 区分,用户偏好则从文件读取后加入提示词。看完这两条流程,我理解了多轮追问和跨会话偏好的区别。十秒缓存和历史压缩也让我注意到,保存了信息,还要考虑模型何时能够读到
周微 我主要关注 Skills 的加载。Skill.get 找到技能后,还要检查权限,再读取说明文件交给模型。这让我理解了技能与工具如何配合。后续想固定模型和任务集,做技能开关的消融实验,再做提示词 A/B 对比,记录成功率、Token 和耗时,具体看看效果差在哪里。
李建鑫 这次作业让我系统了解了 MCP。梳理 listTools 和 callTool 后,我理解了工具发现、参数传递和结果回传的过程。印象较深的是工具描述要写清楚,参数也要用 Schema 约束。接口调通后,认证过期、超时和调用失败这些情况也需要处理

十、学习资源与参考资料

posted @ 2026-10-21 12:59  lokijinjin  阅读(11)  评论(0)    收藏  举报