架构
PKU harness 的框架层由 Agent 循环、设计系统和宿主服务组成,具体功能都由插件通过同一套接口提供。
PKU harness 的设计借鉴了 DeepSeek Harness 的两个思路。第一,App 的功能都由插件提供。第二,App 以 AI 助手为核心,但你和助手能做的事情完全相同。
为了做到第二点,每项功能只实现一次,形式是一个有类型的工具(tool)。你在界面上点按钮时,界面调用这个工具;你在对话里提出需求时,助手调用同一个工具。
分层结构
App 的代码分为四层,从上到下依次是界面、核心运行时、插件和原生模块。
界面 路由 · 页面 · 设计系统(设计令牌、组件、动效)· 生成式界面组件目录
│
核心运行时 插件注册表与权限 · Agent 循环 · 看板 · 定时任务 · 存储
校园会话管理(IAAA、各服务的会话)
│
插件 PLUGIN.md 插件文档(解释执行)· 内置 TypeScript 插件 · 远程 MCP 插件 · 模型服务商
│
原生模块 campus-http 模块(控制重定向、每个会话独立的 cookie、HTTP/1.1、不走代理)依赖关系只能向下。插件从不导入 App 的代码。插件需要做自身以外的事情时,要通过宿主服务(ctx.services);需要显示界面时,要通过 HostUI。
框架层的组成
我们把插件以下的这一层叫作框架(harness)。它由四个部分组成。
| 部分 | 插件使用的接口 |
|---|---|
| Agent 循环 | tools、instructions、context、contributes.commands |
| 设计系统 | HostUI(host.components、host.useTheme() 等) |
| 宿主服务 | HostServices(ctx.services、props.services) |
| 生成式界面 | ui.render 工具、HostUI.components.GenUI |
Agent 循环负责和模型对话。它基于 AI SDK,模型由任意一个模型服务商插件提供。每次请求时,它把所有已启用的工具交给模型,并以流式方式接收回答。模型可以按任务需要连续调用工具,单次回复没有步骤上限,直到模型给出回答或你按停止才结束。回复进行中,如果历史记录超过上下文预算,Agent 循环会把较早的步骤压缩成摘要,然后继续。它还为每个会话保存模型可见的历史记录,并记录 token 用量。无人值守的定时 Agent 任务仍有一个较小的步骤上限。
设计系统包括设计令牌、原生控件和动效。插件的页面用它搭建,所以看起来和 App 的原生页面一致。App 更新后,这些页面也能继续正常工作。
宿主服务是 App 提供给插件的一组服务,包括校园 HTTP 请求、定时任务、小组件、待办、课表、通知权限状态、文件、浏览器、加密、插件注册表和设置注册表。
生成式界面是一个组件目录。助手写出一段 JSON 描述,App 就按描述在对话或看板里显示对应的组件。
助手能操作一切
你在 App 里能做的每一件事,助手都能通过一个工具来做。各个页面只是进入同一份数据的另一条途径,而不是另一套逻辑。整个 App 分为三个部分。
| 部分 | 操作的对象 | 通过什么 |
|---|---|---|
| 校园服务和数据 | 校园服务、校园卡、日程、待办、看板、记忆、附件 | 各个插件的工具 |
| App 本身 | 所有设置:外观、语言、时区、触感反馈、AI 服务和语音服务、每个插件的开关和权限、每个插件自己的设置项 | 设置注册表和核心插件 settings |
| 手机 | 手机上的其他 App | Copilot(copilot.* 工具) |
设置注册表是一份由带类型的条目组成的列表。每个条目有键名、分组、标题、类型(choice、boolean、text 或 list)、可选值和当前值。它包括:
- 宿主自己的设置:
app.theme、app.language、app.timezone、app.haptics、ai.provider、ai.model和voice.provider; - 每个插件的
plugin.<id>.enabled(核心插件没有这一项)和plugin.<id>.permission.<effect>,取值为auto、ask或off; - 每个插件自己的设置项,在清单的
PluginDef.preferences中声明,键名为<plugin>.<name>,例如copilot.enabled、copilot.apps和copilot.screenshots。
插件只要声明设置项,就能加入注册表,宿主不需要任何改动。标记为 uiOnly 的条目(例如 API Key、校园账号和系统通知权限)会和它所在的页面一起列出。助手不能修改它们,只能为用户打开那个页面。
核心插件 settings 给助手提供三个工具:list(读取)、change(修改)和 open(打开页面)。修改遵循通常的权限模式:在改动任何内容之前拒绝未知的键和 uiOnly 的键,按条目校验每个值,并返回每个键修改前后的值。插件通过 HostServices.settings 使用同一个注册表。
四个标签页由谁提供
| 标签页 | 提供者 |
|---|---|
| 对话 | 框架 |
| 看板 | dashboard 插件 |
| 日程 | schedule 插件 |
| 设置 | 框架,其中部分设置项由插件提供 |
看板和日程这两个标签页在框架里只是两个空的位置,我们叫它插槽。插件通过 contributes.tabs 填充插槽。
插件
插件有三种形式:
- 插件文档:一个
PLUGIN.md文档,由 App 解释执行; - 内置插件:用
definePlugin写成的 TypeScript 对象,随 App 发布; - 远程插件:一个 MCP 服务器。MCP 是 Model Context Protocol(模型上下文协议)的缩写。
无论哪种形式,框架都会把它转换成同样的结构:一组工具,加上可选的界面。每个工具的 id 形如 <plugin>.<tool>,并带有效果(effect)和输入 schema。
下面是一个最简单的内置插件。它只有一个工具 greet,用来打招呼。
import { definePlugin, defineTool } from '@pku-harness/sdk';
import { z } from 'zod';
export const helloPlugin = definePlugin({
id: 'hello',
name: '你好',
description: '演示插件。',
version: '0.1.0',
scopes: [],
tools: {
greet: defineTool({
title: '打招呼',
description: 'Greet someone by name.',
input: z.object({ name: z.string() }),
effect: 'read',
execute: async ({ name }) => ({ greeting: `你好,${name}` }),
}),
},
});同一个插件也可以写成插件文档:一个带有 tools: { greet: … } 的 PLUGIN.md,工具的逻辑用 YAML 步骤来写,见声明式插件。三种插件的对比见插件。
数据存在哪里
| 数据 | 位置 |
|---|---|
| 校园账号密码、模型和语音输入的 API 密钥、远程插件的令牌 | 手机的密钥库(SecureStore) |
| 聊天会话、用量、小组件、定时任务、待办、课表、插件的键值存储 | 手机上的 SQLite 数据库 |
| 你主动开启的插件云端数据 | PKU harness Cloud 服务,端到端加密 |
默认情况下,数据不经过本项目的任何服务器。你配置的模型服务商会收到对话内容。校园服务收到的请求和浏览器发出的请求相同。
更新如何送达用户
不同的代码有不同的更新方式。
| 改动的内容 | 送达方式 |
|---|---|
插件文档(PLUGIN.md),包括随 App 内置的官方插件 | App 重新拉取它的链接 |
| 远程插件 | 服务器自己更新,App 每次实时调用 |
插件市场的条目(registry.json) | App 实时拉取 |
| App 的 JavaScript 代码、SDK、内置 TypeScript 插件 | 签名的空中更新(OTA),或者发布新版 App |
| 原生代码(原生模块、系统权限) | 发布新版 App |
插件文档的链接每天最多重新拉取一次。如果新版本的声明没有变化,它会直接生效。如果新版本要求更宽的权限,要等你确认后才生效。插件文档的更新完全不经过 OTA。
空中更新(over-the-air update,简称 OTA)从我们自己的更新服务器下发,下次启动 App 时生效。它基于 expo-updates,服务端是一个静态的、经过代码签名的服务器(https://pkuharness.com/updates/)。每次更新都由持续集成(CI)用只有维护者持有的密钥签名。App 内置了对应的证书,会拒绝其他来源的更新。
runtimeVersion 采用 fingerprint 策略。也就是说,只要原生代码有改动,运行时版本就会变化,旧的 APK 不会接收新的 OTA 更新,用户需要安装新的 APK。