Harness 模块:AI 助手

harness 模块允许 AI 代理从管理面板中处理应用程序数据。

代理不会执行任意代码或任意 SQL 查询。它仅拥有一组基于应用程序模型生成的、规模小且明确的工具:

  • 用于读取数据的工具,
  • 用于打开表单或删除确认窗口的工具。

app/harness 包本身不了解应用程序领域。工具目录、实体字段注册表和权限规则由根据 herd/_model.goat 生成的代码提供,与仪表板的情况类似。

最重要的原则:AI 不会修改数据

Harness 工具分为两类:

  • 后端 — 在服务器上执行,仅用于读取,
  • 前端 — 永远不会在服务器上执行。

前端工具不会保存或删除数据。它会将代理的请求转换为浏览器操作:打开预先填充的表单,或者打开包含真实记录数据的删除确认窗口。

只有当用户亲自提交表单或确认删除时,才会发生修改。

此外,在一次对话轮次中最多只能发生一个前端操作。循环会在首次此类调用时停止,以确保不会在用户不知情的情况下发生任何操作。

相同的规则也会加入模型的系统提示词中:

  • 模型不能自行创建、修改或删除数据,
  • 后端工具仅用于读取,
  • 模型不能编造工具名称、字段名称或记录标识符,
  • 模型使用用户所用的语言回答。

为实体启用模块

模块定义

该模块在模型中定义为具有两组字段的实体模块:

entity:module:def --name harness --property:list:list=<<MODULEEOF
    def --required
MODULEEOF --property:list:persist=<<MODULEEOF
    def --required
MODULEEOF
  • list — 代理可读取的字段:由查询工具返回,并显示在删除确认窗口中,
  • persist — 代理可在创建或编辑表单中预先填充的字段。

将模块添加到实体

通过 module:add --name=harness 将实体提供给代理。以下是实体 user 的示例:

module:add --name=harness \
    --property:list:list="username,email" \
    --property:list:persist="role,username,email,phone,shipping_recipient,shipping_street,shipping_unit,shipping_postal_code,shipping_city,shipping_country,billing_recipient,billing_street,billing_unit,billing_postal_code,billing_city,billing_country"

persist 集合有意省略了 password 和 email_verified_at。即使用户角色拥有写入这些字段的权限,代理也不能建议这些字段的值。

请像选择 crud 模块的字段一样选择这些字段:这是对助手能够查看什么以及能够建议什么的明确决定。

对话实体

对话历史记录保存在两个模型实体中:

  • ai_conversation — 属于单个用户的对话,
  • ai_message — 对话中的单条消息。

这两个实体都必须存在于 herd/_model.goat 中。它们没有 crud 模块,因此不会通过公共 CRUD API 暴露。只有 harness 端点会读取和写入它们。

仅当模型同时满足以下条件时,集成才会启用:

  • 至少包含一个具有 harness 模块的实体,
  • 包含 ai_conversation 和 ai_message 实体。

否则,生成的 InitHarness 函数不会执行任何操作,且不会注册端点。

代码生成

修改模型后,请重新生成应用程序:

goat re

生成器会创建以下文件:

goatapp/app/api/modelapi/harness_gen.go
goatapp/app/harness/store_gen.go

以及每个实体的模型包中的以下掩码:

<实体>HarnessListMask
<实体>HarnessPersistMask

掩码 list 始终包含字段 id,以便智能体能够指明具体记录。

请勿手动编辑这些文件。目录 goatapp/ 完全由系统生成。

生成的工具

对于每个具有模块 harness 的实体,都会生成一组三个工具:

工具类型操作
query_<encja>后端读取记录以供分析。绝不修改数据。
open_<encja>_form前端打开创建或编辑表单,可选择预先填充。
confirm_delete_<encja>前端使用当前记录数据打开删除确认窗口。

例如,对于实体 product,这些工具分别为 query_product、open_product_form 和 confirm_delete_product。

query_<encja> 参数

{
  "fields": ["title", "sku"],
  "filters": {"status": "active"},
  "limit": 20
}
  • fields — 要返回的字段子集;省略时表示所有可用字段,
  • filters — 按字段名称进行精确匹配的过滤条件,
  • limit — 最大行数;默认值为 20,最大值为 50。

过滤条件仅比较相等性。不支持范围运算符、全文搜索或排序。

open_<encja>_form 参数

{
  "id": "opcjonalny-identyfikator-rekordu",
  "fields": {"title": "Nowy produkt"}
}
  • 不带 id 时,将打开创建表单,
  • 带有 id 时,将打开现有记录的编辑表单,
  • fields 包含用于预先填充的值。

confirm_delete_<encja> 参数

{"id": "identyfikator-rekordu"}

字段 id 为必填项。

权限

智能体的有效访问权限始终是以下两种掩码的交集:

  • 已登录用户角色的掩码,
  • 该实体模块 harness 的掩码。

仅有其中任意一种都不足够。代表具有 user 角色的用户运行的智能体,即使字段位于集合 list 中,也无法看到该角色无权读取的字段。同样,即使角色可以读取某些字段,也无法看到 list 之外的字段。

在实践中,这意味着:

  • 实体未知的字段会被拒绝并报错,
  • 用户无权访问的字段会在查询结果中被省略,
  • 按无权访问的字段进行过滤会导致错误,
  • 表单预填充值会通过角色的写入掩码和集合 persist 进行过滤,
  • 删除窗口中的记录预览仅包含可读取的字段。

如果记录不存在,或者其没有任何字段可访问,预览将为空。用户无法通过这种方式区分是没有访问权限还是记录不存在。

表名和列名来自生成的注册表,并会经过验证。模型传入的值仅会作为查询参数进入 SQL。

POST /api/ai/chat 端点

该端点需要已登录的会话。

请求:

{
  "conversationId": "opcjonalny-identyfikator-rozmowy",
  "message": "Pokaż ostatnie zamówienia"
}

响应:

{
  "conversationId": "identyfikator-rozmowy",
  "reply": "I've opened a form to add this product. Please review it and submit to confirm.",
  "action": {
    "path": "/admin/product",
    "queryParams": {"aiAction": "create", "aiPrefill": "{\"title\":\"Nowy produkt\"}"}
  }
}

字段 action 仅在模型请求前端工具时出现。在这种情况下,reply 是由服务器生成的固定消息(目前为英文),而非模型编写的文本。

错误代码:

  • 401 — 没有已登录的会话,
  • 400 — 消息为空或 JSON 无效,
  • 500 — 读取历史记录或调用模型时出错。

单轮流程

对话历史 + 新消息
        ↓
模型选择工具
        ↓
后端:执行读取并返回给模型
前端:将操作返回给浏览器并结束本轮
        ↓
文本响应

单次轮次最多可执行 5 轮后端工具调用。超过该限制后,端点会返回错误,以防模型陷入无限循环。

对话历史

仅保存用户和助手消息。中间工具调用仅存在于单个轮次内,不会传递到后续请求。

对话属于创建它的用户。提供 conversationId 的他人对话会被视为该对话不存在。

前端

仪表盘小部件

聊天是一个类型为 ai_chat 的仪表盘小部件。在此应用中,它定义于 herd/_model.goat:

section:add --name=assistant --type=cards --roles=admin,manager,user
widget:add --name=ai_assistant --type=ai_chat --label="AI Assistant"

ai_chat 小部件不接受数据源、聚合、图表或查询。它仅与 /api/ai/chat 交互。

执行操作

当响应包含 action 时,AiChatService 服务会跳转到生成的实体视图:

/admin/<encja-w-kebab-case>

并携带以下参数:

  • aiAction — create、edit 或 delete,
  • aiPrefill — 用于预填创建表单的值的 JSON,
  • aiId — 要编辑或删除的记录标识符,
  • aiPreview — 用于删除确认窗口中记录预览的 JSON。

生成的实体管理器会读取这些参数,打开相应的表单或窗口,并从 URL 中移除这些参数。

预填仅适用于创建表单。对于 edit,管理器会根据 aiId 打开现有记录,并显示其当前值。

配置

OpenAI 客户端通过环境变量配置:

变量含义默认值
GOAT_AI_OPENAI_API_KEY助手使用的 OpenAI API 密钥无
GOAT_AI_OPENAI_MODEL助手使用的聊天模型gpt-4o-mini

如果密钥为空,端点仍会被注册,但每个请求都会以错误结束,小部件将显示助手不可用的消息。

不要将这些变量与用于翻译脚本的 GOAT_OPENAI_API_KEY 混淆(herd/translate.goat、translate-docs.goat)。

该密钥是机密信息。请将其存储在本地 .env 或部署配置中,且绝不要将其提交到仓库。herd/deploy.goat 将 GOAT_AI_OPENAI_API_KEY 声明为机密,并将 GOAT_AI_OPENAI_MODEL 声明为普通变量。

数据隐私

后端工具结果会作为对话的一部分发送给模型。因此,用户可读取的 list 集合中的所有字段都可能被发送到 OpenAI。

将字段添加到 list 即表示决定将其提供给外部 AI 服务商。请勿在其中放入密码、令牌或不得在应用外处理的数据。

测试

该包的逻辑在不连接 OpenAI 的情况下进行测试。Run 函数接受 Completer 接口,测试会用伪客户端替代它:

(cd goatapp && GOWORK=off go test ./app/harness/)

生成的 E2E 测试 goatapp/web/e2e-server/harness.spec.ts 始终会验证匿名请求(401)和空消息(400)会被拒绝。

完整对话会调用已配置的 AI 服务商,因此仅在运行 Playwright 测试时显式设置变量后才会执行:

GOAT_E2E_HARNESS_CHAT=1

总结

  • harness 模块通过三个生成的工具向 AI 助手公开实体。
  • 读取受角色掩码与 list 集合的合取限制。
  • 表单预填充受角色写入掩码与 persist 集合的限制。
  • 助手绝不会自行写入或删除数据;始终由人工执行。
  • 运行需要 ai_conversation 和 ai_message 实体,以及 GOAT_AI_OPENAI_API_KEY 密钥。