Browse Source

fix: hide document feature for guests + enforce public tool whitelist

- TopNav: '文档' link only shows when loggedIn
- AgentToolbar: document button v-if="loggedIn"
- getAgentToolsForChatByAgentId: filter tools by agentPublicToolSlugs
  whitelist for guest users (previously only skipped adminOnly tools,
  exposing document and other non-public tools to unauthenticated users)

Co-authored-by: CodeFree <codefree@chinatelcom.cn>
feat/agent-independence
npmrun 2 months ago
parent
commit
dd2cf5adda
  1. 19
      app/components/TopNav.vue
  2. 9
      app/components/agent/AgentChatArea.vue
  3. 4
      app/components/agent/AgentSidebar.vue
  4. 4
      app/components/agent/AgentToolbar.vue
  5. 1
      app/pages/chat/[agentSlug]/index.vue
  6. 306
      docs/handover/2026-08-09-agent-document-tool.md
  7. BIN
      packages/drizzle-pkg/db.sqlite
  8. 13
      server/service/agent-tool/index.ts

19
app/components/TopNav.vue

@ -8,13 +8,18 @@ const route = useRoute()
const { loggedIn, user, clear, initialized } = useAuthSession()
const { config: globalConfig } = useGlobalConfig()
const links: any = [
{ to: "/ideas", label: "创意" },
{ to: "/projects", label: "项目" },
{ to: "/articles", label: "文章" },
{ to: "/agent-documents", label: "文档" },
{ to: "/collect", label: "收藏" },
]
const links: any = computed(() => {
const base = [
{ to: "/ideas", label: "创意" },
{ to: "/projects", label: "项目" },
{ to: "/articles", label: "文章" },
]
if (loggedIn.value) {
base.push({ to: "/agent-documents", label: "文档" })
}
base.push({ to: "/collect", label: "收藏" })
return base
})
let lastScrollY = 0
const HIDE_OFFSET = 520

9
app/components/agent/AgentChatArea.vue

@ -24,6 +24,7 @@ const props = defineProps<{
sidebarCollapsed: boolean;
errorMessage: string;
systemPrompt?: string;
sessionId?: string | null;
}>();
const emit = defineEmits<{
@ -76,6 +77,14 @@ watch(
}
},
);
watch(
() => props.sessionId,
() => {
editingMessageId.value = null;
inputRef.value?.setText("");
},
);
</script>
<template>

4
app/components/agent/AgentSidebar.vue

@ -54,10 +54,6 @@ const isAdmin = computed(() => props.user?.role === "admin");
<div class="sidebar-footer">
<div v-if="loggedIn" class="footer-links">
<button class="footer-link" @click="emit('showDocuments')">
<Icon name="lucide:file-text" />
<span>文档</span>
</button>
<NuxtLink to="/settings" class="footer-link">
<Icon name="lucide:settings" />
<span>设置</span>

4
app/components/agent/AgentToolbar.vue

@ -55,8 +55,8 @@ const emit = defineEmits<{
<Icon name="lucide:settings-2" />
</button>
<!-- Documents button -->
<button class="icon-btn" title="文档" @click="emit('showDocuments')">
<!-- Documents button (logged-in only) -->
<button v-if="loggedIn" class="icon-btn" title="文档" @click="emit('showDocuments')">
<Icon name="lucide:file-text" />
</button>
</div>

1
app/pages/chat/[agentSlug]/index.vue

@ -293,6 +293,7 @@ onMounted(async () => {
:sidebar-collapsed="sidebarCollapsed"
:error-message="chat.errorMessage.value"
:system-prompt="systemPromptText"
:session-id="sessions.currentSessionId.value"
@send="handleSend"
@stop="chat.stopGeneration()"
@update:model-id="currentModelId = $event"

306
docs/handover/2026-08-09-agent-document-tool.md

@ -0,0 +1,306 @@
# Agent 文档功能 — 交接文档
> 日期:2026-08-09
> 分支:feat/agent-independence
> Commits:b62a417 → 10688be(共 16 个 commit)
## 一、功能概述
Agent 文档功能让 agent 能够跨会话持久化信息,分为两种类型:
| 类型 | 用途 | 上下文行为 |
|------|------|------------|
| `memory` | 内部记忆(用户偏好、身份信息等) | 每次对话自动注入 system prompt |
| `document` | 交付物(周报、需求文档、会议纪要等) | 不自动注入,需主动调用工具读取 |
### 核心能力
1. **Agent 工具**:LLM 可通过 `document` 工具 create/read/update/delete/list 文档
2. **记忆注入**:`type=memory` 的文档在每次对话时自动拼接到 system prompt,LLM 无需调工具即可使用
3. **用户界面**:右侧抽屉组件,在会话页面内查看/搜索/删除文档,不跳转页面
4. **REST API**:5 个端点供前端直接操作文档
5. **权限隔离**:所有操作强制 `WHERE userId = ?`,用户级隔离
6. **审批机制**:delete 操作默认需用户审批
## 二、架构设计
```
┌─────────────────────────────────────────────────────────┐
│ 前端 │
│ ┌──────────────┐ ┌──────────────────────────────────┐ │
│ │ AgentToolbar │ │ AgentSidebar (desktop + mobile) │ │
│ │ 文档按钮 │ │ 文档按钮 │ │
│ └──────┬───────┘ └────────────┬─────────────────────┘ │
│ │ emit('showDocuments')│ │
│ └──────────┬────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ AgentChatArea → emit('showDocuments') │ │
│ └──────────────────────┬───────────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ chat/[agentSlug]/index.vue │ │
│ │ showDocumentDrawer = true │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ AgentDocumentDrawer.vue │ │ │
│ │ │ ┌──────────┐ ┌────────────────────────┐ │ │ │
│ │ │ │ 列表视图 │ │ 详情视图 (markdown) │ │ │ │
│ │ │ │ 筛选+搜索 │ │ 删除按钮 │ │ │ │
│ │ │ │ 删除按钮 │ │ AgentMarkdown 渲染 │ │ │ │
│ │ │ └──────────┘ └────────────────────────┘ │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 后端 │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ REST API │ │ chat-engine │ │ agent-tool │ │
│ │ 5 endpoints │ │ │ │ registry │ │
│ │ │ │ getMemory │ │ │ │
│ │ GET /api │ │ Context() │ │ document │ │
│ │ POST /api │ │ → systemPrompt│ │ executor │ │
│ │ GET /:id │ │ │ │ │ │
│ │ PATCH /:id │ └──────────────┘ └───────┬───────┘ │
│ │ DELETE /:id │ │ │
│ └──────┬──────┘ │ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ agent-document service (CRUD + 权限隔离) │ │
│ │ createDocument / getDocumentById / listDocuments│ │
│ │ updateDocument / deleteDocument │ │
│ │ getMemoryContext (查 memory 类型,拼接上下文) │ │
│ └──────────────────────┬───────────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ SQLite: agent_documents 表 │ │
│ │ id / userId / sessionId / type / title / │ │
│ │ content / summary / tags / createdAt / updatedAt │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## 三、文件清单
### 数据层
| 文件 | 说明 |
|------|------|
| `packages/drizzle-pkg/lib/schema/agent-document.ts` | `agentDocuments` 表 schema,`createdAt`/`updatedAt` 用 `mode: "timestamp_ms"` |
| `packages/drizzle-pkg/lib/schema/agent-tool.ts` | `AgentToolTypes` 新增 `"document"` |
| `packages/drizzle-pkg/migrations/0002_noisy_ronan.sql` | 建表迁移 |
| `packages/drizzle-pkg/seed.ts` | document 工具种子数据(id=`tool_document`) |
### Service 层
| 文件 | 说明 |
|------|------|
| `server/service/agent-document/index.ts` | CRUD + `getMemoryContext()` + `deserializeTags` |
| `server/service/agent-document/index.test.ts` | 32 个单元测试 |
### Executor 层
| 文件 | 说明 |
|------|------|
| `server/service/agent-tool/executors/document/config.ts` | document executor config(审批白名单、maxContentLength 等) |
| `server/service/agent-tool/executors/document/document.ts` | executor 实现,5 种 action 分发,`toISO()` 序列化 Date |
| `server/service/agent-tool/registry.ts` | `ToolContext` 扩展 `approved?: boolean` |
| `server/service/agent-tool/index.ts` | document executor 注册(line 128-133) |
### API 层
| 文件 | 说明 |
|------|------|
| `server/api/agent-documents/index.get.ts` | GET 列表(分页 + 类型筛选 + 关键词搜索) |
| `server/api/agent-documents/index.post.ts` | POST 创建 |
| `server/api/agent-documents/[id].get.ts` | GET 详情 |
| `server/api/agent-documents/[id].patch.ts` | PATCH 更新 |
| `server/api/agent-documents/[id].delete.ts` | DELETE 删除 |
### 前端
| 文件 | 说明 |
|------|------|
| `app/components/agent/AgentDocumentDrawer.vue` | 右侧抽屉(列表 + 详情 + 删除 + 筛选 + 搜索) |
| `app/components/agent/AgentToolbar.vue` | 工具栏文档按钮 → emit `showDocuments` |
| `app/components/agent/AgentSidebar.vue` | 侧边栏文档按钮(desktop + mobile)→ emit `showDocuments` |
| `app/components/agent/AgentChatArea.vue` | 透传 `showDocuments` 事件 |
| `app/pages/chat/[agentSlug]/index.vue` | 接入 `showDocumentDrawer` 状态 + 渲染抽屉 |
| `app/pages/agent-documents/index.vue` | 独立列表页(保留,导航入口在 TopNav) |
| `app/pages/agent-documents/[id].vue` | 独立详情页(保留) |
### Chat Engine
| 文件 | 说明 |
|------|------|
| `server/service/agent/chat-engine.ts` | 记忆注入 system prompt(line 250-260)+ `buildModelMessages` 修复 |
### 测试
| 文件 | 说明 |
|------|------|
| `e2e/specs/agent-documents.spec.ts` | 10 个 E2E 测试用例 |
### 文档
| 文件 | 说明 |
|------|------|
| `docs/superpowers/specs/2026-08-09-agent-document-tool-design.md` | 设计文档 |
| `docs/superpowers/plans/2026-08-09-agent-document-tool.md` | 实施计划 |
| `docs/handover/2026-08-09-agent-document-tool.md` | 本交接文档 |
## 四、关键实现细节
### 4.1 记忆注入上下文
```typescript
// chat-engine.ts line 250-260
let systemPrompt = baseSystemPrompt;
if (user) {
const memoryContext = await getMemoryContext(user.id);
if (memoryContext) {
const memoryInstruction =
"\n\n# 持久记忆(已注入上下文)\n" +
"以下 <memory> 标签内的信息是你的持久记忆,每次对话开始时自动加载,始终是最新的。\n" +
"**禁止调用 document 工具的 read 或 list 操作来读取这些记忆** — 它们已经在你的上下文中了。\n" +
"直接使用 <memory> 中的内容回答用户问题即可。\n" +
"只有需要创建新记忆或修改已有记忆时,才使用 document 工具。";
systemPrompt = systemPrompt
? `${systemPrompt}${memoryInstruction}\n${memoryContext}`
: `${memoryInstruction}\n${memoryContext}`;
}
}
```
- **实时查询**:每次 `executeChat` 调用时从数据库查 `type=memory` 的文档,文档更新后下一轮对话自动生效
- **格式**:每条记忆用 `## {title}\n{content}` 格式,整体包裹在 `<memory>` 标签内
- **防冗余调用**:system prompt 中明确禁止 LLM 调用 document 工具读取已有记忆
### 4.2 getMemoryContext 实现
```typescript
// agent-document/index.ts
export async function getMemoryContext(userId: number): Promise<string | null> {
const rows = await dbGlobal
.select({ title: agentDocuments.title, content: agentDocuments.content })
.from(agentDocuments)
.where(and(eq(agentDocuments.userId, userId), eq(agentDocuments.type, "memory")))
.orderBy(desc(agentDocuments.updatedAt));
if (rows.length === 0) return null;
const parts = rows.map((r) => `## ${r.title}\n${r.content}`);
return `<memory>\n${parts.join("\n\n")}\n</memory>`;
}
```
### 4.3 Date 序列化问题
drizzle `timestamp_ms` mode 返回 `Date` 对象,AI SDK Zod 校验拒绝 Date(期望 string)。executor 中用 `toISO()` helper 序列化:
```typescript
function toISO(date: Date | null | undefined): string | null {
return date ? date.toISOString() : null;
}
```
### 4.4 buildModelMessages 审批修复
`approval-responded` + `hasResult=false` 时只 push `tool-approval-request`,不 push 无 output 的 `tool-call`(AI SDK 校验失败)。
### 4.5 抽屉组件
- `Teleport to="body"` + `Transition` 实现滑入动画
- 列表视图:类型筛选 tabs(全部/文档/记忆)+ 搜索框(400ms 防抖)+ 卡片列表
- 详情视图:返回按钮 + 标题 + `AgentMarkdown` 渲染 + 删除按钮
- 每次 `show` 变 true 都重新 fetch(不缓存)
- 删除带 `confirm()` 确认,删除后自动从列表移除
### 4.6 agent-tool 关联
工具必须通过 `agent_tool_associations` 表关联到 agent 才能被 LLM 使用,仅 `enabled=1` 不够。已为 default agent(id=1)手动插入关联记录。
## 五、数据库表结构
```sql
CREATE TABLE agent_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId INTEGER NOT NULL,
sessionId TEXT,
type TEXT NOT NULL DEFAULT 'document', -- 'memory' | 'document'
title TEXT NOT NULL,
content TEXT NOT NULL,
summary TEXT,
tags TEXT, -- JSON array string
createdAt INTEGER NOT NULL, -- timestamp_ms
updatedAt INTEGER NOT NULL -- timestamp_ms
);
-- 索引
CREATE INDEX idx_agent_documents_userId ON agent_documents(userId);
CREATE INDEX idx_agent_documents_userId_type ON agent_documents(userId, type);
CREATE INDEX idx_agent_documents_userId_updatedAt ON agent_documents(userId, updatedAt);
```
## 六、API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/agent-documents?page=1&pageSize=20&type=memory&keyword=xxx` | 列表查询 |
| POST | `/api/agent-documents` | 创建文档 |
| GET | `/api/agent-documents/:id` | 获取详情 |
| PATCH | `/api/agent-documents/:id` | 更新文档 |
| DELETE | `/api/agent-documents/:id` | 删除文档 |
响应格式:`{ code: 0, message: 'success', data: ... }`
## 七、Agent 工具接口
LLM 可调用的 `document` 工具,通过 `action` 字段分发:
| action | 必填参数 | 可选参数 | 说明 |
|--------|----------|----------|------|
| create | title, content | type, tags | 创建文档/记忆 |
| read | id | fullContent | 读取详情(默认仅返回 summary) |
| update | id | title, content, tags | 更新文档 |
| delete | id | — | 删除文档(默认需审批) |
| list | — | type, keyword, page, pageSize | 列表查询 |
## 八、已知问题与注意事项
1. **seed.ts 不是 upsert**:admin 用户已存在时重跑 seed 会 UNIQUE 冲突;开发库需手动插入新工具记录
2. **guess-who 残留**:`packages/drizzle-pkg/seed.ts` line 215-225 有 guess-who 种子数据,但 executor 不存在
3. **前端无 agent 编辑页**:无法通过 UI 管理 agent-tool 关联,需手动插库
4. **typecheck 预先存在错误**:search、chat、favorite、ideas、llm 模块有 TS 错误,与本次改动无关
5. **记忆无 token 上限**:当前 `getMemoryContext` 拼接所有 memory 文档,如果记忆数量多可能撑爆上下文。后续可加 token 预算截断
## 九、Commit 历史
| Commit | 说明 |
|--------|------|
| `b62a417` | feat: add agent_documents table schema, migration, and seed data |
| `2bd451a` | feat: add agent-document service layer with CRUD, permission isolation, and tests |
| `b40bfa4` | feat: add document tool executor with config, registration, and approved flag |
| `e5d7e68` | feat: add REST API endpoints for agent documents |
| `d06a642` | feat: add agent-documents frontend pages (list + detail) and nav entry |
| `0c8d51a` | feat: add POST create endpoint and E2E tests for agent-documents |
| `6b381d6` | docs: add agent-documents E2E test entry to AGENTS.md |
| `322accb` | fix: buildModelMessages approval-responded without result should not emit tool-call |
| `0ae318a` | feat(agent): add document entry in sidebar footer and toolbar |
| `50f0ca4` | fix(agent-document): serialize Date fields to ISO strings in executor output |
| `c5e9ae9` | fix(agent-document): deserialize tags in listDocuments + null guard in template |
| `72659af` | feat(agent-documents): replace page navigation with right-side drawer |
| `bf88935` | feat(agent-documents): add delete in drawer + inject memory into system prompt |
| `c2bf347` | fix(chat-engine): instruct LLM not to call document tool for existing memory |
| `10688be` | fix(chat-engine): strengthen memory instruction to prevent redundant tool calls |
## 十、后续可优化方向
1. **记忆 token 预算**:`getMemoryContext` 加 token 上限,超限时按优先级截断
2. **向量检索**:文档量大时用向量检索替代 SQL 模糊搜索
3. **agent 编辑页**:前端 UI 管理 agent-tool 关联,避免手动插库
4. **记忆分类**:memory 细分为 preference/identity/fact 等,按类别注入
5. **文档分享**:document 类型支持生成分享链接
6. **版本历史**:文档更新时保留历史版本

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

13
server/service/agent-tool/index.ts

@ -8,6 +8,7 @@ import { z } from "zod";
import { registerToolType, getExecutor, type ToolContext, type ToolResult } from "./registry";
import { writeToolLog } from "./log";
import { getConfigGlobal } from "#server/utils/context";
import { parseFetchConfig, DEFAULT_FETCH_CONFIG } from "./executors/fetch/config";
import { fetchExecutor, fetchInputSchema } from "./executors/fetch/fetch";
@ -534,6 +535,15 @@ export async function getAgentToolsForChatByAgentId(params: {
return { tools: {} };
}
const isGuest = !userId;
let publicToolSlugs: string[] = [];
if (isGuest) {
publicToolSlugs = await getConfigGlobal("agentPublicToolSlugs");
if (publicToolSlugs.length === 0) {
return { tools: {} };
}
}
const rows = await dbGlobal
.select({
tool: agentTools,
@ -546,13 +556,12 @@ export async function getAgentToolsForChatByAgentId(params: {
.orderBy(asc(agentToolAssociations.sortOrder), asc(agentTools.sortOrder));
const isAdmin = userRole === "admin";
const isGuest = !userId;
const result: Record<string, any> = {};
for (const row of rows) {
const agentTool = row.tool;
if (!isAdmin && agentTool.adminOnly) continue;
if (isGuest && agentTool.adminOnly) continue;
if (isGuest && !publicToolSlugs.includes(agentTool.slug)) continue;
const executor = getExecutor(agentTool.type);
if (!executor) continue;
let config: unknown;

Loading…
Cancel
Save