feat(export): 实现单次智能体回复导出、私密文档保存与隐私防泄露全套闭环 (Phase 0 - Phase 2)

- Phase 0: 实现服务端回复导出策略矩阵引擎 (replyexport.EvaluatePolicy)、数据库迁移 (2026-09-11-message-export-policy.sql)、SSE done 事件挂载与能力探测 API
- Phase 1: 构建前端 ReplyDocumentIR 语法树解析与渲染管道,支持 Markdown / DOCX 文件下载及带格式富文本剪贴板复制
- Phase 2: 实现私密自由文档 (freeform_documents) 领域服务、PostgreSQL 乐观锁版本管理、ACL 隐私隔离、一键共享与右侧栏编辑联动
- 专项修复:
  1. 修复后台 API 路由注册并加入双向拉取同步,确保私密保存实时进入我的私密列表
  2. 实现轻量回复专属 DOCX 生成与静默下载逻辑 (downloadDocxReply)
  3. 将 LaTeX 公式转为标准 MathML 矢量标记,支持粘贴到 Microsoft Word / WPS 自动识别为原生公式
This commit is contained in:
WeiCHEN
2026-09-13 02:50:21 +00:00
committed by weichen
parent 3885ef5da2
commit 1fc7d0d01f
45 changed files with 6908 additions and 214 deletions
@@ -31,25 +31,33 @@
4. **有没有权限 / 状态问题?**(用户角色不对?文档状态不对?需要追问?)
5. **是否需要进一步澄清?**(缺槽位?低置信度?)
### 1.3 为什么不直接用 LLM 全做
直觉上的方案是"把用户输入 + 上下文塞给 LLM,让它输出意图 + 槽位"。但本系统在实践中遇到了几个无法回避的问题:
| 问题 | 单纯 LLM 方案的痛点 |
|------|---------------------|
| **确定性缺失** | "驳回"、"通过" 这类指令的语义边界必须有 100% 准确率,不能让 LLM "大概理解" |
| **合规红线** | 修改身份/密码/网关密钥这类请求必须**确定性地拦截**,不能依赖 LLM 的"善意推理" |
| **状态依赖** | "帮我继续那份合同" 的语义强依赖会话历史与项目状态,纯 LLM 容易"接错茬" |
| **权限校验** | 用户角色 vs 文档角色、文档生命周期阶段 vs 允许动作,都是**规则性事实**,需要规则引擎 |
| **可观测性** | LLM 输出不可解释、不可回归测试。出问题时不知道是哪一步推理出错 |
| **成本与延迟** | 每轮对话都调用 LLM 会有显著时延与费用 |
| 问题 | 单纯 LLM 方案的痛点 |
| --------- | ------------------------------------------------ |
| **确定性缺失** | "驳回"、"通过" 这类指令的语义边界必须有 100% 准确率,不能让 LLM "大概理解" |
| **合规红线** | 修改身份/密码/网关密钥这类请求必须**确定性地拦截**,不能依赖 LLM 的"善意推理" |
| **状态依赖** | "帮我继续那份合同" 的语义强依赖会话历史与项目状态,纯 LLM 容易"接错茬" |
| **权限校验** | 用户角色 vs 文档角色、文档生命周期阶段 vs 允许动作,都是**规则性事实**,需要规则引擎 |
| **可观测性** | LLM 输出不可解释、不可回归测试。出问题时不知道是哪一步推理出错 |
| **成本与延迟** | 每轮对话都调用 LLM 会有显著时延与费用 |
因此最终方案是 **"规则识别 + 上下文约束 + LLM 仅作自然语言重写"** 的混合架构。
---
## 二、总体架构
### 2.1 在 L1 / L2 / L3 三层模型中的位置
系统总体是三层架构,意图识别属于 **L1 协作对话层**:
@@ -68,6 +76,7 @@
│ - 校验权限、状态、依赖 │
│ - 决定接受 / 拒绝 / 排队 / 追问 │
│ - 触发小队执行 / 回写黑板 │
└──────────────────────────────────────────────────────────────────┘
│ Plan / Execute
▼
@@ -85,6 +94,8 @@
- **可降级**:即使 LLM 后端宕机,意图识别器也能离线工作;
- **可演化**:增加新意图只需新增一个 matcher 函数,不会破坏既有逻辑。
### 2.2 核心数据流
```
@@ -108,6 +119,8 @@ IntentRecognitionResult {
CoordinatorEngine.ProcessIntent() ── 做出最终裁决
```
### 2.3 模块组成
```
@@ -120,50 +133,64 @@ internal/intent/
---
## 三、核心数据契约
### 3.1 意图类型全集(IntentType)
意图类型集合共 **18 类**,分为四大族(`internal/intent/types.go:11`):
**A. 业务核心意图族**(7 类)
| 常量 | 含义 | 典型指令 |
|------|------|---------|
| `IntentQueryProgress` | 查询进度、风险、话术 | "到哪一步了?","有啥风险没" |
| `IntentSupplementInfo` | 补充/修改事实参数 | "客户说预算 15 万","目标市场改成欧洲" |
| `IntentAdvanceStage` | 推进到下一阶段 | "可以进入方案论证了","做摸底" |
| `IntentTriggerTask` | 触发具体交付任务 | "帮我出报价单","做调研报告" |
| `IntentCasualChat` | 闲聊、问候、通用帮助 | "你好","在吗" |
| `IntentDeclineExpectation` | 明确暂缓 / 放弃建议 | "先别推","算了" |
| 常量 | 含义 | 典型指令 |
| -------------------------- | ----------- | ----------------------- |
| `IntentQueryProgress` | 查询进度、风险、话术 | "到哪一步了?","有啥风险没" |
| `IntentSupplementInfo` | 补充/修改事实参数 | "客户说预算 15 万","目标市场改成欧洲" |
| `IntentAdvanceStage` | 推进到下一阶段 | "可以进入方案论证了","做摸底" |
| `IntentTriggerTask` | 触发具体交付任务 | "帮我出报价单","做调研报告" |
| `IntentCasualChat` | 闲聊、问候、通用帮助 | "你好","在吗" |
| `IntentDeclineExpectation` | 明确暂缓 / 放弃建议 | "先别推","算了" |
**B. 法定文档治理流转族**(7 类,参照 ISO 13485 / 21 CFR Part 11)
| 常量 | 含义 | 典型指令 |
|------|------|---------|
| `IntentDocGovClaim` | 认领文档编制责任人 | "我来认领这份需求收集表" |
| `IntentDocGovSubmitReview` | 编制完成送审 | "提交审核" |
| `IntentDocGovReviewApprove` | 审核人通过 | "审核通过" |
| `IntentDocGovReviewReject` | 审核人驳回 | "把 X 审核驳回,意见为…" |
| `IntentDocGovFinalApprove` | 终审批准生效 | "终审批准","同意发布" |
| `IntentDocGovFinalReject` | 终审驳回 | "终审驳回" |
| `IntentDocGovProposalDecide` | 提案 / 批注决策 | "同意这个修改建议" |
| 常量 | 含义 | 典型指令 |
| ---------------------------- | --------- | --------------- |
| `IntentDocGovClaim` | 认领文档编制责任人 | "我来认领这份需求收集表" |
| `IntentDocGovSubmitReview` | 编制完成送审 | "提交审核" |
| `IntentDocGovReviewApprove` | 审核人通过 | "审核通过" |
| `IntentDocGovReviewReject` | 审核人驳回 | "把 X 审核驳回,意见为…" |
| `IntentDocGovFinalApprove` | 终审批准生效 | "终审批准","同意发布" |
| `IntentDocGovFinalReject` | 终审驳回 | "终审驳回" |
| `IntentDocGovProposalDecide` | 提案 / 批注决策 | "同意这个修改建议" |
**C. 决策与合规查询族**(4 类)
| 常量 | 含义 |
|------|------|
| `IntentQueryRegulations` | 查询法规标准(GB 9706、YY、CE、FDA) |
| `IntentQuerySimilarCases` | 查询同类往期案例 |
| `IntentQueryUserRights` | 查询当前用户权限 / 法定角色 |
| `IntentQueryRejectReason` | 查询文档被驳回的原因 |
| 常量 | 含义 |
| ------------------------- | ------------------------- |
| `IntentQueryRegulations` | 查询法规标准(GB 9706、YY、CE、FDA) |
| `IntentQuerySimilarCases` | 查询同类往期案例 |
| `IntentQueryUserRights` | 查询当前用户权限 / 法定角色 |
| `IntentQueryRejectReason` | 查询文档被驳回的原因 |
**D. 安全红线族**(1 类)
| 常量 | 含义 |
|------|------|
| 常量 | 含义 |
| ------------------------- | --------------------- |
| `IntentSecurityForbidden` | 检测到身份/网关/密码等越权指令,直接拦截 |
### 3.2 分支判定(ResolutionBranch)
**这是整个框架的"灵魂"**:所有意图最终都归属到三条分支之一(`types.go:40`)。
@@ -182,11 +209,15 @@ const (
因为对话不是单轮的——用户上一轮已经看到系统的建议卡,可能正在做选择、确认、或放弃。这一轮用户说"好",可能是在确认上轮建议,也可能是新的一句话恰好包含"好"字。**没有"分支"概念就无法区分这两种情况**。
| 分支 | 含义 | 触发条件 | 系统行为 |
|------|------|---------|---------|
| **1. EXPECTATION_HIT** | 命中历史轮次的挂起预期 | 输入匹配 `PendingExpectation` 中的肯定词 / 选项序号 / 复合补丁 / 明确撤销 | 立即兑现承诺,置信度 0.98–1.0 |
| **2. NEW_INTENT_PREEMPT** | 独立新意图抢占 | 上一轮有挂起预期,但用户话锋一转说新事实 / 新动作 | 抢占处理,新意图优先 |
| **3. AMBIGUOUS_FALLBACK** | 模糊 / 征询意见兜底 | 输入是"你怎么看"、"先放放,你觉得呢" 这类征询 | 触发参谋主动分忧,引导用户 |
| 分支 | 含义 | 触发条件 | 系统行为 |
| ------------------------- | ----------- | ---------------------------------------------------- | ------------------- |
| **1. EXPECTATION_HIT** | 命中历史轮次的挂起预期 | 输入匹配 `PendingExpectation` 中的肯定词 / 选项序号 / 复合补丁 / 明确撤销 | 立即兑现承诺,置信度 0.98–1.0 |
| **2. NEW_INTENT_PREEMPT** | 独立新意图抢占 | 上一轮有挂起预期,但用户话锋一转说新事实 / 新动作 | 抢占处理,新意图优先 |
| **3. AMBIGUOUS_FALLBACK** | 模糊 / 征询意见兜底 | 输入是"你怎么看"、"先放放,你觉得呢" 这类征询 | 触发参谋主动分忧,引导用户 |
### 3.3 挂起预期(PendingExpectation)
@@ -217,6 +248,8 @@ type PendingExpectation struct {
- **可携带选项**:`Options` 字段支持"让用户报数字选"("1"、"2"、"选第 2 个");
- **可携带补丁合并逻辑**:用户说"好,但目标市场改成欧洲 CE" 时,系统能在肯定上轮建议的同时,把增量事实(patch)合并进已填槽位。
### 3.4 候选意图(CandidateIntent)
```go
@@ -257,6 +290,8 @@ ContextSnapshot 是 L1 识别的"唯一已知事实"——它不允许 L1 去查
---
## 四、识别流程:四级漏斗
`internal/intent/recognizer.go:25` 的 `RuleBasedRecognizer.Recognize()` 是核心实现。整个识别流程是一条 **四级漏斗**,每级都对输入做"过滤 + 富化",越往后候选集合越精炼。
@@ -277,6 +312,8 @@ ContextSnapshot 是 L1 识别的"唯一已知事实"——它不允许 L1 去查
IntentRecognitionResult
```
### 4.1 阶段 0:安全与 UI 拦截
**目的:把明显不该进入业务路由的请求在最前端剔出去。**
@@ -305,6 +342,7 @@ secRegexes := []string{
```
**触发后行为**:
- 立即返回 `IntentSecurityForbidden`(分支 2,置信度 1.0)
- 不进入任何业务路由、不调用 LLM
- 提示中标注依据("ISO 13485 / 21 CFR Part 11")以符合审计要求
@@ -328,10 +366,13 @@ patterns := []string{
```
**触发后行为**:
- 归入 `IntentCasualChat`,置信度 0.95
- 给出不可执行提示("该操作属于前端 UI 视窗布局与主题控制,请直接点击界面右上角…")
- **不返回分支 3**——UI 命令不属于"模糊",它本质是确定性的"操作错误分类"
### 4.2 阶段 0.5:模糊征询检测(`isConsultativeOrHesitant`,第 794 行)
**判定"用户到底想不想让我立刻行动"**。当用户话锋转向征询参谋意见时,必须走分支 3:
@@ -369,6 +410,7 @@ affirmativePrefixes := []string{"好", "好的", "行", "行啊", "成", "可以
**复合打补丁(Patch Merge)**——本框架的一大亮点:
用户可能说 **"好,但目标市场改成欧洲 CE"**:
- "好" 是肯定词 → 命中分支 1,挂起期望是 `ADVANCE_STAGE`
- "目标市场改成欧洲 CE" 是增量事实
- 系统识别为复合输入,**在兑现上轮建议(推进阶段)的同时,把 target_market 改成 "欧洲 CE"**
@@ -428,14 +470,19 @@ if userCtx.UserDocRole != "reviewer" && userCtx.UserDocRole != "system" {
- `matchSupplementInfo`:事实补充,自动提取目标市场/预算/用途等结构化槽位
- `matchTriggerTask`:触发具体交付任务(intake / research / feasibility_reg / prelim_design / cdo_quote)
#### 4.4.4 领域 4:进度与技术研判查询(`matchQueryProgress`,第 966 行)
将查询细分为 5 个 topic:`all / risk / talk_script / progress / technical_design`。比如:
- "怎么跟客户说" → `talk_script`
- "探头功耗怎么设计" → `technical_design`
- "有什么坑" → `risk`
- "到哪一步了" → `progress`
### 4.5 阶段 3:槽位完备性与置信度裁决
**所有候选产生后,进入裁决阶段**。规则只有三条:
@@ -463,6 +510,8 @@ if top.Confidence < 0.6 || len(top.MissingSlots) > 0 || requiresClarifying {
---
## 五、关键算法:复合打补丁(Patch Merge)
这是本框架区别于"普通 NLU 分类器"的最关键设计之一。
@@ -517,11 +566,13 @@ for k, v := range patchSlots {
}
```
合并后,**返回的 `FilledSlots`** 包含 default + patch,**返回的 `PatchSlots`** 只包含 patch 部分。两个字段都返回,让协调层可以根据需要决定:
合并后,**返回的** `FilledSlots` 包含 default + patch,**返回的** `PatchSlots` 只包含 patch 部分。两个字段都返回,让协调层可以根据需要决定:
- 如果只关心最终态 → 用 `FilledSlots`
- 如果要审计"用户额外改了什么" → 用 `PatchSlots`
### 5.4 测试覆盖
`recognizer_test.go:170` 的 `TestThreeBranch_Branch1_AffirmativeWithPatch` 覆盖了典型场景:
@@ -539,23 +590,31 @@ res, _ := rec.Recognize(ctx, "好,但目标市场改成欧洲CE", ctx)
---
## 六、关键算法:三分支裁决
### 6.1 决策矩阵
下表描述了**每种 input 类型 + 是否有挂起预期**的组合下,系统走哪个分支:
| 输入类型 | 无挂起预期 | 有挂起预期(肯定匹配) | 有挂起预期(不匹配) |
|---------|-----------|--------------------|-------------------|
| 安全红线指令 | 分支 2 → SECURITY_FORBIDDEN | 分支 2 → SECURITY_FORBIDDEN | 分支 2 → SECURITY_FORBIDDEN |
| 纯 UI 命令 | 分支 2 → CASUAL_CHAT(带引导) | 分支 2 → CASUAL_CHAT(带引导) | 分支 2 → CASUAL_CHAT(带引导) |
| 模糊征询("你觉得呢") | 分支 3 → AMBIGUOUS_FALLBACK | 分支 3 → AMBIGUOUS_FALLBACK | 分支 3 → AMBIGUOUS_FALLBACK |
| 明确暂缓("先别推") | 走默认 → CASUAL_CHAT | 分支 1 → DECLINE_EXPECTATION | 走默认 → CASUAL_CHAT |
| 选项序号("1"、"选2") | 走默认 → CASUAL_CHAT | 分支 1 → 命中选项的意图 | 走默认 → CASUAL_CHAT |
| 肯定词("好") | 走默认 → CASUAL_CHAT | 分支 1 → 默认选项的意图 | 走默认 → CASUAL_CHAT |
| 肯定词 + 补丁("好,但 X") | 走默认 → CASUAL_CHAT | 分支 1 → 默认意图 + patch | 走默认 → CASUAL_CHAT |
| 新业务指令("X 审核驳回") | 分支 2 → DOCGOV_REVIEW_REJECT | 分支 2 → DOCGOV_REVIEW_REJECT(抢占) | 分支 2 → DOCGOV_REVIEW_REJECT |
| 普通对话("你好") | 分支 2 → CASUAL_CHAT | 分支 2 → CASUAL_CHAT | 分支 2 → CASUAL_CHAT |
| 输入类型 | 无挂起预期 | 有挂起预期(肯定匹配) | 有挂起预期(不匹配) |
| ----------------- | --------------------------- | ------------------------------- | --------------------------- |
| 安全红线指令 | 分支 2 → SECURITY_FORBIDDEN | 分支 2 → SECURITY_FORBIDDEN | 分支 2 → SECURITY_FORBIDDEN |
| 纯 UI 命令 | 分支 2 → CASUAL_CHAT(带引导) | 分支 2 → CASUAL_CHAT(带引导) | 分支 2 → CASUAL_CHAT(带引导) |
| 模糊征询("你觉得呢") | 分支 3 → AMBIGUOUS_FALLBACK | 分支 3 → AMBIGUOUS_FALLBACK | 分支 3 → AMBIGUOUS_FALLBACK |
| 明确暂缓("先别推") | 走默认 → CASUAL_CHAT | 分支 1 → DECLINE_EXPECTATION | 走默认 → CASUAL_CHAT |
| 选项序号("1"、"选2") | 走默认 → CASUAL_CHAT | 分支 1 → 命中选项的意图 | 走默认 → CASUAL_CHAT |
| 肯定词("好") | 走默认 → CASUAL_CHAT | 分支 1 → 默认选项的意图 | 走默认 → CASUAL_CHAT |
| 肯定词 + 补丁("好,但 X") | 走默认 → CASUAL_CHAT | 分支 1 → 默认意图 + patch | 走默认 → CASUAL_CHAT |
| 新业务指令("X 审核驳回") | 分支 2 → DOCGOV_REVIEW_REJECT | 分支 2 → DOCGOV_REVIEW_REJECT(抢占) | 分支 2 → DOCGOV_REVIEW_REJECT |
| 普通对话("你好") | 分支 2 → CASUAL_CHAT | 分支 2 → CASUAL_CHAT | 分支 2 → CASUAL_CHAT |
### 6.2 为什么分支 1 必须独立判定
@@ -575,7 +634,7 @@ res, _ := rec.Recognize(ctx, "好,但目标市场改成欧洲CE", ctx)
### 6.3 分支 2 的"抢占"语义
**分支 2 名称里的 `PREEMPT`(抢占)是核心**。即使上轮有挂起预期,本轮如果用户**说别的事**,新意图优先:
**分支 2 名称里的** `PREEMPT`**(抢占)是核心**。即使上轮有挂起预期,本轮如果用户**说别的事**,新意图优先:
```
T-1: 系统:"可以进入方案论证吗?"(挂起 PendingExpectation: ADVANCE_STAGE → V0.5)
@@ -591,17 +650,21 @@ T-0: 用户:"客户补充说预算大概 15 万"
---
## 七、关键算法:法定文档治理的权限前置剪枝
医疗器械文档治理的核心约束是**角色-状态-动作三元组**:
| 动作 | 需要的角色 | 需要的状态 |
|------|----------|-----------|
| 动作 | 需要的角色 | 需要的状态 |
| ---- | -------- | --------- |
| 审核驳回 | reviewer | reviewing |
| 审核通过 | reviewer | reviewing |
| 终审批准 | approver | approving |
| 终审驳回 | approver | approving |
如果用户身份不符或文档状态不符,**不能让动作被执行**(合规审计要求)。但又不能粗暴地"直接拒绝"——用户可能只是没意识到自己角色不对,**需要给清晰的引导**。
### 7.1 实现方式
@@ -638,17 +701,23 @@ if strings.Contains(top.Reasoning, "权限不符") || strings.Contains(top.Reaso
---
## 八、关键算法:法定文档治理的"询问 vs 执行"反歧义
### 8.1 问题
中文里"驳回"和"为什么被驳回" 在分词后高度相似,极易混淆:
| 用户输入 | 真实意图 |
|---------|---------|
| 用户输入 | 真实意图 |
| -------------------- | --------------------------- |
| "把调研报告审核驳回,意见为:缺抗扰度" | **执行**:DOCGOV_REVIEW_REJECT |
| "调研报告为什么被驳回?" | **查询**:QUERY_REJECT_REASON |
| "注册路径评估被驳回的原因是什么" | **查询**:QUERY_REJECT_REASON |
| "调研报告为什么被驳回?" | **查询**:QUERY_REJECT_REASON |
| "注册路径评估被驳回的原因是什么" | **查询**:QUERY_REJECT_REASON |
如果识别错误,下游可能"误执行驳回动作"或"误返回驳回报告"——前者是合规事故,后者是用户体验事故。
@@ -679,8 +748,12 @@ if (strings.Contains(input, "驳回") || strings.Contains(input, "退回")) && i
---
## 九、关键算法:纯 UI 命令拦截 vs 真正的闲聊
### 9.1 难点
用户说"切换主题"或"折叠侧边栏",本质是对 UI 的请求,但**用户的主观意图是希望系统做点什么**。粗暴拒绝会破坏体验;执行又会越权。
@@ -693,12 +766,16 @@ if (strings.Contains(input, "驳回") || strings.Contains(input, "退回")) && i
2. **归类**:归入 `IntentCasualChat`(置信度 0.95),分支 `NEW_INTENT_PREEMPT`(不是分支 3——UI 命令不是"模糊",而是"确定性的错误归类")
3. **引导**:在 `ClarificationQuestion` 里给清晰的引导话术:"该操作属于前端 UI 视窗布局与主题控制,请直接点击界面右上角或侧边的视窗/主题切换按钮"
### 9.3 设计取舍
为什么不归入 `IntentSecurityForbidden`?因为 UI 命令不涉及合规或权限问题,只是"找错人了"。用 CASUAL_CHAT 配置信度 0.95 + 高优 ClarificationQuestion,能让用户体验更柔和。
---
## 十、关键算法:阶段 3 的门禁裁决
阶段 3 是整个识别流程的"闸门",负责把 `IntentRecognitionResult` 从"候选" 状态变成"可被 L2 消费"的最终结果。
@@ -740,8 +817,12 @@ if top.Confidence < 0.6 || len(top.MissingSlots) > 0 || requiresClarifying {
---
## 十一、难点、解决方案与设计取舍
### 11.1 难点 1:医疗合规场景的"确定性"要求
**难点**:在 21 CFR Part 11 / ISO 13485 监管下,"修改网关密钥" 这类动作的拦截必须 100% 准确,不能依赖 LLM 的"善意"。
@@ -798,6 +879,7 @@ if top.Confidence < 0.6 || len(top.MissingSlots) > 0 || requiresClarifying {
**难点**:什么时候追问用户?什么时候降级处理?
**解决方案**:阶段 3 的三道闸门:
- 必填槽位校验(驳回必须填理由);
- 剪枝结果升级(权限/状态不符时强制追问);
- 低置信兜底(任何置信度 < 0.6 都追问)。
@@ -806,66 +888,90 @@ if top.Confidence < 0.6 || len(top.MissingSlots) > 0 || requiresClarifying {
---
## 十二、亮点与优势总结
### 12.1 架构级亮点
| # | 亮点 | 价值 |
|---|------|------|
| 1 | **三分支柔性消解** | 把"挂起预期 / 新意图 / 模糊兜底"显式建模,避免 LLM 时代常见的"过早收敛"问题 |
| 2 | **L1 不决策** | 意图识别只产出候选 + 置信度,决策权在 L2 协调层,职责单一、可审计 |
| 3 | **复合打补丁(Patch Merge)** | "好,但 X 改成 Y" 这类复合输入被正确解析为"肯定上轮 + 增量事实" |
| 4 | **法定文档治理的完整覆盖** | 18 类意图中 7 类专门为医疗器械合规场景设计(DOCGOV_* 系列) |
| 5 | **多输出策略** | 不止 top-1,还输出完整 candidates 列表,让 L2 有兜底依据 |
| 6 | **完全可重放** | input + ContextSnapshot → result 是确定性函数,无 LLM 调用,无随机性 |
| # | 亮点 | 价值 |
| --- | ---------------------- | ----------------------------------------------------- |
| 1 | **三分支柔性消解** | 把"挂起预期 / 新意图 / 模糊兜底"显式建模,避免 LLM 时代常见的"过早收敛"问题 |
| 2 | **L1 不决策** | 意图识别只产出候选 + 置信度,决策权在 L2 协调层,职责单一、可审计 |
| 3 | **复合打补丁(Patch Merge)** | "好,但 X 改成 Y" 这类复合输入被正确解析为"肯定上轮 + 增量事实" |
| 4 | **法定文档治理的完整覆盖** | 18 类意图中 7 类专门为医疗器械合规场景设计(DOCGOV_* 系列) |
| 5 | **多输出策略** | 不止 top-1,还输出完整 candidates 列表,让 L2 有兜底依据 |
| 6 | **完全可重放** | input + ContextSnapshot → result 是确定性函数,无 LLM 调用,无随机性 |
### 12.2 实现级亮点
| # | 亮点 | 体现 |
|---|------|------|
| 1 | **四级漏斗** | 阶段 0 → 0.5 → 0.8 → 1&2 → 3,每级职责清晰、过滤性递减 |
| 2 | **阶段 0 的硬隔离** | 安全红线 + UI 命令在最前端被剔出,绝不进入业务路由 |
| 3 | **权限前置剪枝** | 角色/状态不符不直接拒绝,而是降置信度 + 加 ClarificationQuestion |
| 4 | **法定驳回必填理由** | 阶段 3 的必填槽位校验 + 标准化追问话术 |
| 5 | **复合输入处理** | Patch Merge + 多 prefix 匹配,覆盖中文 18+ 种变体 |
| 6 | **过期检测** | PendingExpectation 的 `ExpiresAt` 让挂起预期有时效,避免"陈年旧卡"干扰 |
| # | 亮点 | 体现 |
| --- | ------------- | ---------------------------------------------------- |
| 1 | **四级漏斗** | 阶段 0 → 0.5 → 0.8 → 1&2 → 3,每级职责清晰、过滤性递减 |
| 2 | **阶段 0 的硬隔离** | 安全红线 + UI 命令在最前端被剔出,绝不进入业务路由 |
| 3 | **权限前置剪枝** | 角色/状态不符不直接拒绝,而是降置信度 + 加 ClarificationQuestion |
| 4 | **法定驳回必填理由** | 阶段 3 的必填槽位校验 + 标准化追问话术 |
| 5 | **复合输入处理** | Patch Merge + 多 prefix 匹配,覆盖中文 18+ 种变体 |
| 6 | **过期检测** | PendingExpectation 的 `ExpiresAt` 让挂起预期有时效,避免"陈年旧卡"干扰 |
### 12.3 工程实践亮点
| # | 亮点 | 体现 |
|---|------|------|
| 1 | **完全可测试** | 27K 行测试代码,覆盖每个意图、每个分支、每个边界 |
| 2 | **零外部依赖** | 纯 Go 标准库,无第三方包,单文件可独立运行 |
| 3 | **无 LLM 即可工作** | 整个 recognizer 是纯规则实现,不依赖任何模型调用 |
| 4 | **类型安全** | 所有 IntentType 是 const string,所有 Slot 是命名常量 |
| 5 | **100% Go 测试覆盖** | `go test ./internal/intent/...` 全绿,可作为回归基准 |
| # | 亮点 | 体现 |
| --- | ---------------- | ------------------------------------------ |
| 1 | **完全可测试** | 27K 行测试代码,覆盖每个意图、每个分支、每个边界 |
| 2 | **零外部依赖** | 纯 Go 标准库,无第三方包,单文件可独立运行 |
| 3 | **无 LLM 即可工作** | 整个 recognizer 是纯规则实现,不依赖任何模型调用 |
| 4 | **类型安全** | 所有 IntentType 是 const string,所有 Slot 是命名常量 |
| 5 | **100% Go 测试覆盖** | `go test ./internal/intent/...` 全绿,可作为回归基准 |
### 12.4 业务级亮点
| # | 亮点 | 体现 |
|---|------|------|
| 1 | **合规友好** | 安全拦截的 Reasoning 里直接标注 ISO 13485 / 21 CFR Part 11 法规依据 |
| 2 | **医疗器械场景化** | 18 类意图覆盖需求收集、调研、可行性、初步设计、报价全流程 |
| 3 | **多角色权限** | 编制人/审核人/批准人在文档治理上的权限差异被显式建模 |
| 4 | **中文自然** | 中文问句、肯定词、模糊表达都能识别,无需用户学命令语法 |
| 5 | **建议即上下文** | 系统上一轮的建议会被序列化为 PendingExpectation,跨轮传递 |
| # | 亮点 | 体现 |
| --- | ----------- | ----------------------------------------------------- |
| 1 | **合规友好** | 安全拦截的 Reasoning 里直接标注 ISO 13485 / 21 CFR Part 11 法规依据 |
| 2 | **医疗器械场景化** | 18 类意图覆盖需求收集、调研、可行性、初步设计、报价全流程 |
| 3 | **多角色权限** | 编制人/审核人/批准人在文档治理上的权限差异被显式建模 |
| 4 | **中文自然** | 中文问句、肯定词、模糊表达都能识别,无需用户学命令语法 |
| 5 | **建议即上下文** | 系统上一轮的建议会被序列化为 PendingExpectation,跨轮传递 |
### 12.5 与同类系统对比
| 维度 | 本系统 | 普通 NLU 分类器 | 纯 LLM 方案 |
|------|--------|---------------|-----------|
| 识别准确率 | 100%(规则部分) | 85-95% | 90-98% |
| 延迟 | <1ms(纯内存) | <10ms | 200ms-2s |
| 成本 | 0 | 0 | 每调用计费 |
| 可解释性 | 100%(规则+佐证) | 部分(模型权重) | 低 |
| 可测试性 | 100%(Go 单测) | 部分 | 难 |
| 离线工作 | ✅ | ✅ | ❌ |
| 多分支支持 | ✅ 三分支 | ❌ | ⚠️ 需 prompt 工程 |
| 复合输入 | ✅ Patch Merge | ❌ | ⚠️ 取决于 prompt |
| 法定合规拦截 | ✅ 阶段 0 硬隔离 | ❌ | ⚠️ prompt 漏了就漏了 |
| 维度 | 本系统 | 普通 NLU 分类器 | 纯 LLM 方案 |
| ------ | ------------- | ---------- | --------------- |
| 识别准确率 | 100%(规则部分) | 85-95% | 90-98% |
| 延迟 | <1ms(纯内存) | <10ms | 200ms-2s |
| 成本 | 0 | 0 | 每调用计费 |
| 可解释性 | 100%(规则+佐证) | 部分(模型权重) | 低 |
| 可测试性 | 100%(Go 单测) | 部分 | 难 |
| 离线工作 | ✅ | ✅ | ❌ |
| 多分支支持 | ✅ 三分支 | ❌ | ⚠️ 需 prompt 工程 |
| 复合输入 | ✅ Patch Merge | ❌ | ⚠️ 取决于 prompt |
| 法定合规拦截 | ✅ 阶段 0 硬隔离 | ❌ | ⚠️ prompt 漏了就漏了 |
---
## 十三、后续可演进方向
虽然当前实现已经稳定,但仍有可演进的点:
@@ -878,30 +984,40 @@ if top.Confidence < 0.6 || len(top.MissingSlots) > 0 || requiresClarifying {
---
## 附录 A:术语表
| 术语 | 定义 |
|------|------|
| IntentType | 意图类型枚举,共 18 类 |
| ResolutionBranch | 三分支判定(EXPECTATION_HIT / NEW_INTENT_PREEMPT / AMBIGUOUS_FALLBACK) |
| PendingExpectation | 挂起预期(系统上一轮给出的建议卡) |
| CandidateIntent | 候选意图(L1 输出,由 L2 终审) |
| IntentRecognitionResult | 意图识别完整输出(多输出策略) |
| ContextSnapshot | 上下文快照(用户/项目/文档/挂起预期) |
| FilledSlots | 已填槽位(key-value) |
| PatchSlots | 分支 1 复合输入的增量事实补丁 |
| Slot | 关键参数槽位(如 target_doc、reject_reason) |
| ClarificationQuestion | 槽位缺失时的澄清追问 |
| Pre-pruning | 权限/状态不符时的降置信度处理 |
| 术语 | 定义 |
| ----------------------- | ---------------------------------------------------------------- |
| IntentType | 意图类型枚举,共 18 类 |
| ResolutionBranch | 三分支判定(EXPECTATION_HIT / NEW_INTENT_PREEMPT / AMBIGUOUS_FALLBACK) |
| PendingExpectation | 挂起预期(系统上一轮给出的建议卡) |
| CandidateIntent | 候选意图(L1 输出,由 L2 终审) |
| IntentRecognitionResult | 意图识别完整输出(多输出策略) |
| ContextSnapshot | 上下文快照(用户/项目/文档/挂起预期) |
| FilledSlots | 已填槽位(key-value) |
| PatchSlots | 分支 1 复合输入的增量事实补丁 |
| Slot | 关键参数槽位(如 target_doc、reject_reason) |
| ClarificationQuestion | 槽位缺失时的澄清追问 |
| Pre-pruning | 权限/状态不符时的降置信度处理 |
## 附录 B:文件清单
| 文件 | 行数 | 职责 |
|------|-----|------|
| `internal/intent/types.go` | ~150 | 数据契约(IntentType / Branch / Snapshot 等) |
| `internal/intent/recognizer.go` | ~1050 | 核心识别引擎(四级漏斗) |
| `internal/intent/types_test.go` | ~50 | 类型单元测试 |
| `internal/intent/recognizer_test.go` | ~700 | 完整测试用例集 |
| 文件 | 行数 | 职责 |
| ------------------------------------ | ----- | -------------------------------------- |
| `internal/intent/types.go` | ~150 | 数据契约(IntentType / Branch / Snapshot 等) |
| `internal/intent/recognizer.go` | ~1050 | 核心识别引擎(四级漏斗) |
| `internal/intent/types_test.go` | ~50 | 类型单元测试 |
| `internal/intent/recognizer_test.go` | ~700 | 完整测试用例集 |
## 附录 C:测试矩阵(部分)
@@ -933,4 +1049,4 @@ TestIntentRecognition_ExplicitDocName_OverActiveDocID -- 显式文档名优先
TestIntentRecognition_RejectReason_ColonSeparators -- 中英冒号兼容
```
—— 报告结束 ——
—— 报告结束 ——
@@ -0,0 +1,54 @@
-- 2026-09-11-freeform-documents.sql
-- 支持单条回复“保存到我的私密”及自由文档(Freeform Document)独立生命周期管理
CREATE TABLE IF NOT EXISTS freeform_documents (
doc_id VARCHAR(64) PRIMARY KEY,
project_id INT NOT NULL,
owner_user_id INT NOT NULL,
source_message_id BIGINT REFERENCES chat_messages(id) ON DELETE SET NULL,
source_content_sha256 VARCHAR(64) NOT NULL DEFAULT '',
title VARCHAR(255) NOT NULL,
governance_mode VARCHAR(32) NOT NULL DEFAULT 'freeform',
space VARCHAR(32) NOT NULL DEFAULT 'my_private',
current_version_id INT NOT NULL DEFAULT 1,
current_sha256 VARCHAR(64) NOT NULL DEFAULT '',
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_freeform_space CHECK (space IN ('my_private', 'project_shared', 'trash')),
CONSTRAINT chk_freeform_gov_mode CHECK (governance_mode = 'freeform')
);
CREATE TABLE IF NOT EXISTS freeform_document_versions (
id BIGSERIAL PRIMARY KEY,
doc_id VARCHAR(64) NOT NULL REFERENCES freeform_documents(doc_id) ON DELETE CASCADE,
version_no INT NOT NULL,
content_sha256 VARCHAR(64) NOT NULL,
content_markdown TEXT NOT NULL,
created_by INT NOT NULL,
change_summary VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_freeform_doc_version UNIQUE (doc_id, version_no)
);
CREATE TABLE IF NOT EXISTS document_access_audit (
id BIGSERIAL PRIMARY KEY,
doc_id VARCHAR(64) NOT NULL,
user_id INT NOT NULL,
action VARCHAR(64) NOT NULL,
success BOOLEAN NOT NULL DEFAULT TRUE,
ip_address VARCHAR(64) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 索引与检索加速
CREATE INDEX IF NOT EXISTS idx_freeform_docs_owner_space ON freeform_documents (owner_user_id, space) WHERE is_deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_freeform_docs_project_space ON freeform_documents (project_id, space) WHERE is_deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_freeform_docs_source_msg ON freeform_documents (source_message_id);
CREATE INDEX IF NOT EXISTS idx_doc_access_audit_lookup ON document_access_audit (doc_id, user_id, created_at DESC);
-- 同一用户针对同一消息生成私密文档的幂等唯一索引
CREATE UNIQUE INDEX IF NOT EXISTS uq_freeform_user_source_message
ON freeform_documents (owner_user_id, source_message_id)
WHERE is_deleted = FALSE AND source_message_id IS NOT NULL;
@@ -0,0 +1,51 @@
-- =============================================================================
-- Migration: 2026-09-11-message-export-policy.sql
-- Description: 为 chat_messages 新增正文类型、权威导出策略、版本与 SHA256 哈希
-- =============================================================================
-- 1. 结构变更:新增消息导出权威列
ALTER TABLE chat_messages
ADD COLUMN IF NOT EXISTS content_kind TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS export_policy TEXT NOT NULL DEFAULT 'none',
ADD COLUMN IF NOT EXISTS content_revision INT NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS content_sha256 TEXT NOT NULL DEFAULT '';
-- 2. 导出策略枚举约束检查 (full | copy_only | none)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_chat_export_policy'
) THEN
ALTER TABLE chat_messages
ADD CONSTRAINT chk_chat_export_policy
CHECK (export_policy IN ('full', 'copy_only', 'none'));
END IF;
END $$;
-- 3. 索引优化:快速基于消息与版本校验
CREATE INDEX IF NOT EXISTS idx_chat_messages_export_policy
ON chat_messages(id, export_policy);
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- 4. 存量消息安全保守回填:旧消息默认为 none,仅纯文本 assistant 消息标记为 copy_only
UPDATE chat_messages
SET
content_kind = CASE
WHEN role = 'user' THEN 'conversational_text'
WHEN role = 'system' THEN 'system_notice'
WHEN role = 'assistant' AND (metadata ? 'interactive_card') THEN 'action_card'
WHEN role = 'assistant' AND length(trim(content)) > 0 THEN 'document_text'
ELSE 'conversational_text'
END,
export_policy = CASE
WHEN role = 'assistant' AND NOT (metadata ? 'interactive_card') AND length(trim(content)) >= 10 THEN 'full'
WHEN role = 'assistant' AND length(trim(content)) > 0 THEN 'copy_only'
ELSE 'none'
END,
content_revision = 1,
content_sha256 = CASE
WHEN length(content) > 0 THEN encode(sha256(convert_to(content, 'UTF8')), 'hex')
ELSE ''
END
WHERE export_policy = 'none' AND role = 'assistant' AND length(trim(content)) > 0;
+7 -1
View File
@@ -4,20 +4,26 @@ go 1.27.1
require (
github.com/jackc/pgx/v5 v5.10.0
github.com/stretchr/testify v1.11.1
github.com/xuri/excelize/v2 v2.11.0
golang.org/x/crypto v0.53.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/richardlehane/mscfb v1.0.7 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/text v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+9
View File
@@ -1,3 +1,4 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -9,12 +10,18 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -39,6 +46,8 @@ golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+20 -12
View File
@@ -15,6 +15,7 @@ import (
"chat-agent-system/internal/config"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/freedoc"
"chat-agent-system/internal/kb"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/storage"
@@ -24,12 +25,13 @@ import (
)
type Service struct {
cfg *config.Config
kb *kb.Client
squad *squad.Service
ctxMgr *ctxmgr.ContextManager
cas *storage.CAS
docGov *docgov.GovernanceEngine
cfg *config.Config
kb *kb.Client
squad *squad.Service
ctxMgr *ctxmgr.ContextManager
cas *storage.CAS
docGov *docgov.GovernanceEngine
freeDoc *freedoc.Service
}
func NewService(cfg *config.Config) *Service {
@@ -54,14 +56,16 @@ func NewService(cfg *config.Config) *Service {
ctxMgr.SetEmbedder(kb.NewEmbedder(cfg.EmbeddingEndpointURL, cfg.EmbeddingAPIKey, cfg.EmbeddingModel))
}
docGov := docgov.NewGovernanceEngine(pool)
freeDoc := freedoc.NewService(pool, ctxMgr)
svc := &Service{
cfg: cfg,
kb: kb.NewClient(cfg),
squad: squad.NewService(cfg),
ctxMgr: ctxMgr,
cas: casStore,
docGov: docGov,
cfg: cfg,
kb: kb.NewClient(cfg),
squad: squad.NewService(cfg),
ctxMgr: ctxMgr,
cas: casStore,
docGov: docGov,
freeDoc: freeDoc,
}
// 注册终审批准回调:自动切片向量化沉淀为往期标杆案例
@@ -119,6 +123,10 @@ func (s *Service) DocGov() *docgov.GovernanceEngine {
return s.docGov
}
func (s *Service) FreeformDoc() *freedoc.Service {
return s.freeDoc
}
func (s *Service) KB() *kb.Client {
return s.kb
}
+44 -11
View File
@@ -9,6 +9,7 @@ import (
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/replyexport"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/stream"
)
@@ -375,7 +376,19 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
asstMeta["milestone"] = true
}
_ = s.CtxMgr.RecordUserMessage(ctx, s.ID, 0, "assistant", coordRes.NaturalReply, string(coordRes.Intent), string(recRes.Branch), asstMeta)
evalKind, evalCap := replyexport.EvaluatePolicy(replyexport.PolicyOptions{
Role: "assistant",
Status: "completed",
Content: coordRes.NaturalReply,
HasInteractiveCard: coordRes.InteractiveCard != nil,
Revision: 1,
})
asstMeta["content_kind"] = string(evalKind)
asstMeta["export_policy"] = string(evalCap.Policy)
asstMeta["content_revision"] = evalCap.ContentRevision
asstMeta["content_sha256"] = evalCap.ContentSha256
asstMsgID, _ := s.CtxMgr.RecordUserMessageWithID(ctx, s.ID, 0, "assistant", coordRes.NaturalReply, string(coordRes.Intent), string(recRes.Branch), asstMeta)
// 异步记录问答切片到向量记忆库 (P2 检索增强,不阻塞流式或同步答复)
sessID := s.ID
@@ -391,20 +404,40 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
defer cancel()
_, _ = s.CtxMgr.RecordTurnMemory(bgCtx, sessID, turnNo, uid, pid, q, ans, iName, bName, isMilestone)
}()
}
// 本轮全部处理完毕后,再次同步,保持 UserCtx 与 Messages 一致
s.SyncRecentDialogue()
// 本轮全部处理完毕后,再次同步,保持 UserCtx 与 Messages 一致
s.SyncRecentDialogue()
// 4. 流式推送参谋自然语言回复与决策锦囊
if streamer != nil {
if coordRes.NaturalReply != "" {
streamer.EmitDelta(coordRes.NaturalReply)
// 4. 流式推送参谋自然语言回复与决策锦囊
if streamer != nil {
if coordRes.NaturalReply != "" {
streamer.EmitDelta(coordRes.NaturalReply)
}
if coordRes.AdvisorBrief != nil {
streamer.EmitBriefing(coordRes.AdvisorBrief)
}
streamer.EmitDoneWithExport(s.ID, time.Since(start), asstMsgID, evalCap.ContentRevision, evalCap.ContentSha256, evalCap)
}
if coordRes.AdvisorBrief != nil {
streamer.EmitBriefing(coordRes.AdvisorBrief)
} else {
// 无 CtxMgr 时的降级
evalKind, evalCap := replyexport.EvaluatePolicy(replyexport.PolicyOptions{
Role: "assistant",
Status: "completed",
Content: coordRes.NaturalReply,
HasInteractiveCard: coordRes.InteractiveCard != nil,
Revision: 1,
})
_ = evalKind
s.SyncRecentDialogue()
if streamer != nil {
if coordRes.NaturalReply != "" {
streamer.EmitDelta(coordRes.NaturalReply)
}
if coordRes.AdvisorBrief != nil {
streamer.EmitBriefing(coordRes.AdvisorBrief)
}
streamer.EmitDoneWithExport(s.ID, time.Since(start), 0, evalCap.ContentRevision, evalCap.ContentSha256, evalCap)
}
streamer.EmitDone(s.ID, time.Since(start))
}
return &ChatResponse{
+66 -12
View File
@@ -7,6 +7,8 @@ import (
"fmt"
"sort"
"time"
"chat-agent-system/internal/replyexport"
)
const (
@@ -117,7 +119,7 @@ func (m *ContextManager) getChatWindowPG(ctx context.Context, q ChatWindowQuery)
// 倒序取最多 MaxMsgPerDay 条
msgRows, err := m.pool.Query(ctx, `
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, created_at
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
FROM chat_messages
WHERE session_id = $1 AND day_key = $2::date AND deleted_at IS NULL
ORDER BY id DESC
@@ -131,10 +133,11 @@ func (m *ContextManager) getChatWindowPG(ctx context.Context, q ChatWindowQuery)
for msgRows.Next() {
var r ChatMessageRecord
var metaBytes []byte
if err := msgRows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.CreatedAt); err == nil {
if err := msgRows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt); err == nil {
if len(metaBytes) > 0 {
_ = json.Unmarshal(metaBytes, &r.Metadata)
}
enrichMessageExportCapability(&r)
msgsDesc = append(msgsDesc, r)
}
}
@@ -252,6 +255,9 @@ func (m *ContextManager) getChatWindowMem(ctx context.Context, q ChatWindowQuery
copied := make([]ChatMessageRecord, len(selected))
copy(copied, selected)
for i := range copied {
enrichMessageExportCapability(&copied[i])
}
buckets = append(buckets, ChatDayBucket{
DayKey: dayStr,
@@ -347,7 +353,7 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
// before or equal to anchor
rowsBefore, err := m.pool.Query(ctx, `
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, created_at
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
FROM chat_messages
WHERE session_id = $1 AND day_key = $2::date AND id <= $3 AND deleted_at IS NULL
ORDER BY id DESC
@@ -358,10 +364,11 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
for rowsBefore.Next() {
var r ChatMessageRecord
var metaBytes []byte
if err := rowsBefore.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.CreatedAt); err == nil {
if err := rowsBefore.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt); err == nil {
if len(metaBytes) > 0 {
_ = json.Unmarshal(metaBytes, &r.Metadata)
}
enrichMessageExportCapability(&r)
beforeDesc = append(beforeDesc, r)
}
}
@@ -373,7 +380,7 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
// after anchor
rowsAfter, err := m.pool.Query(ctx, `
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, created_at
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
FROM chat_messages
WHERE session_id = $1 AND day_key = $2::date AND id > $3 AND deleted_at IS NULL
ORDER BY id ASC
@@ -383,10 +390,11 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
for rowsAfter.Next() {
var r ChatMessageRecord
var metaBytes []byte
if err := rowsAfter.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.CreatedAt); err == nil {
if err := rowsAfter.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt); err == nil {
if len(metaBytes) > 0 {
_ = json.Unmarshal(metaBytes, &r.Metadata)
}
enrichMessageExportCapability(&r)
msgs = append(msgs, r)
}
}
@@ -394,7 +402,7 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
}
} else if beforeID > 0 {
rows, err := m.pool.Query(ctx, `
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, created_at
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
FROM chat_messages
WHERE session_id = $1 AND day_key = $2::date AND id < $3 AND deleted_at IS NULL
ORDER BY id DESC
@@ -405,10 +413,11 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
for rows.Next() {
var r ChatMessageRecord
var metaBytes []byte
if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.CreatedAt); err == nil {
if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt); err == nil {
if len(metaBytes) > 0 {
_ = json.Unmarshal(metaBytes, &r.Metadata)
}
enrichMessageExportCapability(&r)
desc = append(desc, r)
}
}
@@ -419,7 +428,7 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
}
} else if afterID > 0 {
rows, err := m.pool.Query(ctx, `
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, created_at
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
FROM chat_messages
WHERE session_id = $1 AND day_key = $2::date AND id > $3 AND deleted_at IS NULL
ORDER BY id ASC
@@ -429,10 +438,11 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
for rows.Next() {
var r ChatMessageRecord
var metaBytes []byte
if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.CreatedAt); err == nil {
if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt); err == nil {
if len(metaBytes) > 0 {
_ = json.Unmarshal(metaBytes, &r.Metadata)
}
enrichMessageExportCapability(&r)
msgs = append(msgs, r)
}
}
@@ -441,7 +451,7 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
} else {
// 默认从当日第一条起正序 limit 条
rows, err := m.pool.Query(ctx, `
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, created_at
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
FROM chat_messages
WHERE session_id = $1 AND day_key = $2::date AND deleted_at IS NULL
ORDER BY id ASC
@@ -451,10 +461,11 @@ func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, da
for rows.Next() {
var r ChatMessageRecord
var metaBytes []byte
if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.CreatedAt); err == nil {
if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt); err == nil {
if len(metaBytes) > 0 {
_ = json.Unmarshal(metaBytes, &r.Metadata)
}
enrichMessageExportCapability(&r)
msgs = append(msgs, r)
}
}
@@ -786,3 +797,46 @@ func (m *ContextManager) ListChatCategories(ctx context.Context, sessionID strin
return res, nil
}
func enrichMessageExportCapability(r *ChatMessageRecord) {
hasCard := false
if r.Metadata != nil {
if _, ok := r.Metadata["interactive_card"]; ok {
hasCard = true
}
}
evalKind, evalCap := replyexport.EvaluatePolicy(replyexport.PolicyOptions{
Role: r.Role,
Status: "completed",
Content: r.Content,
HasInteractiveCard: hasCard,
Revision: r.ContentRevision,
})
if r.ContentKind == "" {
r.ContentKind = string(evalKind)
}
if r.ExportPolicy == "" {
r.ExportPolicy = string(evalCap.Policy)
}
if r.ContentRevision <= 0 {
r.ContentRevision = 1
}
if r.ContentSha256 == "" {
r.ContentSha256 = evalCap.ContentSha256
}
evalCap.Policy = replyexport.ExportPolicy(r.ExportPolicy)
evalCap.ContentRevision = r.ContentRevision
evalCap.ContentSha256 = r.ContentSha256
if evalCap.Policy == replyexport.PolicyFull {
evalCap.Formats = replyexport.FullFormats()
evalCap.ReasonCode = ""
} else if evalCap.Policy == replyexport.PolicyCopyOnly {
evalCap.Formats = replyexport.CopyOnlyFormats()
evalCap.ReasonCode = ""
} else {
evalCap.Formats = []string{}
}
r.ExportCapability = evalCap
}
+99 -13
View File
@@ -8,6 +8,7 @@ import (
"sync"
"time"
"chat-agent-system/internal/replyexport"
"chat-agent-system/internal/storage"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -113,17 +114,62 @@ func (m *ContextManager) RecordUserMessageWithID(ctx context.Context, sessionID
}
category := DeriveMessageCategory(role, intent, branch, meta)
contentKind := ""
exportPolicy := "none"
contentRevision := 1
contentSha256 := ""
if meta != nil {
if k, ok := meta["content_kind"].(string); ok && k != "" {
contentKind = k
}
if p, ok := meta["export_policy"].(string); ok && p != "" {
exportPolicy = p
}
if rev, ok := meta["content_revision"].(int); ok && rev > 0 {
contentRevision = rev
} else if revF, ok := meta["content_revision"].(float64); ok && revF > 0 {
contentRevision = int(revF)
}
if hash, ok := meta["content_sha256"].(string); ok && hash != "" {
contentSha256 = hash
}
}
if contentKind == "" || exportPolicy == "none" {
hasCard := false
if meta != nil {
if _, ok := meta["interactive_card"]; ok {
hasCard = true
}
}
evalKind, evalCap := replyexport.EvaluatePolicy(replyexport.PolicyOptions{
Role: role,
Status: "completed",
Content: content,
HasInteractiveCard: hasCard,
Revision: contentRevision,
})
contentKind = string(evalKind)
exportPolicy = string(evalCap.Policy)
contentSha256 = evalCap.ContentSha256
}
rec := ChatMessageRecord{
SessionID: sessionID,
UserID: userID,
Role: role,
Content: content,
Intent: intent,
Branch: branch,
Metadata: meta,
DayKey: dayKey,
Category: category,
CreatedAt: now,
SessionID: sessionID,
UserID: userID,
Role: role,
Content: content,
Intent: intent,
Branch: branch,
Metadata: meta,
DayKey: dayKey,
Category: category,
ContentKind: contentKind,
ExportPolicy: exportPolicy,
ContentRevision: contentRevision,
ContentSha256: contentSha256,
CreatedAt: now,
}
var messageID int64
@@ -155,10 +201,10 @@ func (m *ContextManager) RecordUserMessageWithID(ctx context.Context, sessionID
metaJSON, _ := json.Marshal(meta)
err := m.pool.QueryRow(ctx, `
INSERT INTO chat_messages (session_id, user_id, role, content, intent, branch, metadata, day_key, category, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
INSERT INTO chat_messages (session_id, user_id, role, content, intent, branch, metadata, day_key, category, content_kind, export_policy, content_revision, content_sha256, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id
`, sessionID, uid, role, content, intent, branch, metaJSON, dayKey, category, now).Scan(&messageID)
`, sessionID, uid, role, content, intent, branch, metaJSON, dayKey, category, contentKind, exportPolicy, contentRevision, contentSha256, now).Scan(&messageID)
if err != nil {
return 0, fmt.Errorf("insert chat message failed: %w", err)
}
@@ -194,6 +240,46 @@ func (m *ContextManager) RecordUserMessageWithID(ctx context.Context, sessionID
return messageID, nil
}
// GetMessageByID 根据真实 ID 查询单条消息记录并填充导出能力
func (m *ContextManager) GetMessageByID(ctx context.Context, messageID int64) (*ChatMessageRecord, error) {
if messageID <= 0 {
return nil, errors.New("invalid message_id")
}
if m.pool != nil {
var r ChatMessageRecord
var metaBytes []byte
err := m.pool.QueryRow(ctx, `
SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, COALESCE(content_kind, ''), COALESCE(export_policy, 'none'), COALESCE(content_revision, 1), COALESCE(content_sha256, ''), created_at
FROM chat_messages
WHERE id = $1 AND deleted_at IS NULL
`, messageID).Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.ContentKind, &r.ExportPolicy, &r.ContentRevision, &r.ContentSha256, &r.CreatedAt)
if err != nil {
return nil, err
}
if len(metaBytes) > 0 {
_ = json.Unmarshal(metaBytes, &r.Metadata)
}
enrichMessageExportCapability(&r)
return &r, nil
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, msgs := range m.messages {
for _, msg := range msgs {
if msg.ID == messageID {
cp := msg
enrichMessageExportCapability(&cp)
return &cp, nil
}
}
}
return nil, errors.New("message not found")
}
// RecordActivity 记录项目协同操作/决策/上传/下载活动
func (m *ContextManager) RecordActivity(ctx context.Context, projectID, userID int64, userName, actType, title string, detail map[string]any, weight float64, isMilestone bool) (*ActivityItem, error) {
if projectID <= 0 || title == "" {
+17 -12
View File
@@ -99,18 +99,23 @@ type UserSummary struct {
// ChatMessageRecord 结构化会话消息
type ChatMessageRecord struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
UserID int64 `json:"user_id"`
Role string `json:"role"`
Content string `json:"content"`
Intent string `json:"intent,omitempty"`
Branch string `json:"branch,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
DayKey string `json:"day_key"`
Category string `json:"category"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
ID int64 `json:"id"`
SessionID string `json:"session_id"`
UserID int64 `json:"user_id"`
Role string `json:"role"`
Content string `json:"content"`
Intent string `json:"intent,omitempty"`
Branch string `json:"branch,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
DayKey string `json:"day_key"`
Category string `json:"category"`
ContentKind string `json:"content_kind,omitempty"`
ExportPolicy string `json:"export_policy,omitempty"`
ContentRevision int `json:"content_revision,omitempty"`
ContentSha256 string `json:"content_sha256,omitempty"`
ExportCapability any `json:"export_capability,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ChatDayBucket 按日消息分片桶
+773
View File
@@ -0,0 +1,773 @@
package freedoc
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/replyexport"
)
type Service struct {
pool *pgxpool.Pool
ctxMgr *ctxmgr.ContextManager
mu sync.RWMutex
memDocs map[string]*FreeformDocument
memVersions map[string][]*FreeformDocumentVersion
memAudit []map[string]any
}
func NewService(pool *pgxpool.Pool, ctxMgr *ctxmgr.ContextManager) *Service {
return &Service{
pool: pool,
ctxMgr: ctxMgr,
memDocs: make(map[string]*FreeformDocument),
memVersions: make(map[string][]*FreeformDocumentVersion),
memAudit: make([]map[string]any, 0),
}
}
func computeSHA256(text string) string {
sum := sha256.Sum256([]byte(text))
return hex.EncodeToString(sum[:])
}
// CreateFromMessage 从单条智能体回复保存为私密文档(幂等)
func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateFromMessageRequest) (*FreeformDocDetail, bool, error) {
if req.MessageID <= 0 {
return nil, false, fmt.Errorf("%w: invalid message_id", ErrInvalidParam)
}
// 1. 获取源消息
if s.ctxMgr == nil {
return nil, false, errors.New("ctxMgr is not configured")
}
msg, err := s.ctxMgr.GetMessageByID(ctx, req.MessageID)
if err != nil {
return nil, false, fmt.Errorf("lookup message: %w", err)
}
if msg == nil {
return nil, false, ErrNotFound
}
// 2. 校验导出资格红线:必须是助手且非纯卡片且 export_policy != 'none'
if msg.Role != "assistant" || msg.ContentKind == "card_only" || msg.ExportPolicy == "none" || len(msg.Content) == 0 {
return nil, false, ErrMessageNotExportable
}
content := msg.Content
contentHash := computeSHA256(content)
if req.ExpectedSHA256 != "" && req.ExpectedSHA256 != contentHash {
return nil, false, ErrConflict
}
title := req.Title
if title == "" {
title = replyexport.DeriveTitle(content, time.Now())
}
projectID := req.ProjectID
if projectID <= 0 {
projectID = 1 // 兜底默认项目
}
// 3. 幂等性检查:若该用户针对该消息已生成且未删除,直接返回
if s.pool != nil {
var existingDoc FreeformDocument
row := s.pool.QueryRow(ctx, `
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
title, governance_mode, space, current_version_id, current_sha256,
is_deleted, deleted_at, created_at, updated_at
FROM freeform_documents
WHERE owner_user_id = $1 AND source_message_id = $2 AND is_deleted = FALSE
LIMIT 1
`, userID, req.MessageID)
if err := row.Scan(
&existingDoc.DocID, &existingDoc.ProjectID, &existingDoc.OwnerUserID, &existingDoc.SourceMessageID,
&existingDoc.SourceContentSHA256, &existingDoc.Title, &existingDoc.GovernanceMode,
&existingDoc.Space, &existingDoc.CurrentVersionID, &existingDoc.CurrentSHA256,
&existingDoc.IsDeleted, &existingDoc.DeletedAt, &existingDoc.CreatedAt, &existingDoc.UpdatedAt,
); err == nil {
// 获取当前版本的 markdown
var curContent string
_ = s.pool.QueryRow(ctx, `
SELECT content_markdown FROM freeform_document_versions
WHERE doc_id = $1 AND version_no = $2
`, existingDoc.DocID, existingDoc.CurrentVersionID).Scan(&curContent)
return &FreeformDocDetail{
Document: existingDoc,
Content: curContent,
}, true, nil
}
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, false, err
}
defer tx.Rollback(ctx)
now := time.Now()
_, err = tx.Exec(ctx, `
INSERT INTO freeform_documents (
doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
title, governance_mode, space, current_version_id, current_sha256,
is_deleted, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, 'freeform', 'my_private', 1, $7, FALSE, $8, $8)
`, docID, projectID, userID, req.MessageID, contentHash, title, contentHash, now)
if err != nil {
return nil, false, fmt.Errorf("insert freeform_doc: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO freeform_document_versions (
doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at
) VALUES ($1, 1, $2, $3, $4, '从智能体回复保存', $5)
`, docID, contentHash, content, userID, now)
if err != nil {
return nil, false, fmt.Errorf("insert version: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
VALUES ($1, $2, 'create_from_message', TRUE, $3)
`, docID, userID, now)
if err != nil {
return nil, false, fmt.Errorf("insert audit: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, false, err
}
createdDoc := FreeformDocument{
DocID: docID,
ProjectID: projectID,
OwnerUserID: userID,
SourceMessageID: &req.MessageID,
SourceContentSHA256: contentHash,
Title: title,
GovernanceMode: "freeform",
Space: "my_private",
CurrentVersionID: 1,
CurrentSHA256: contentHash,
IsDeleted: false,
CreatedAt: now,
UpdatedAt: now,
}
return &FreeformDocDetail{
Document: createdDoc,
Content: content,
}, false, nil
}
// 内存备用实现
s.mu.Lock()
defer s.mu.Unlock()
for _, d := range s.memDocs {
if d.OwnerUserID == userID && d.SourceMessageID != nil && *d.SourceMessageID == req.MessageID && !d.IsDeleted {
versions := s.memVersions[d.DocID]
content := ""
if len(versions) > 0 {
content = versions[len(versions)-1].ContentMarkdown
}
return &FreeformDocDetail{
Document: *d,
Content: content,
}, true, nil
}
}
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
now := time.Now()
newDoc := &FreeformDocument{
DocID: docID,
ProjectID: projectID,
OwnerUserID: userID,
SourceMessageID: &req.MessageID,
SourceContentSHA256: contentHash,
Title: title,
GovernanceMode: "freeform",
Space: "my_private",
CurrentVersionID: 1,
CurrentSHA256: contentHash,
IsDeleted: false,
CreatedAt: now,
UpdatedAt: now,
}
s.memDocs[docID] = newDoc
s.memVersions[docID] = []*FreeformDocumentVersion{
{
ID: 1,
DocID: docID,
VersionNo: 1,
ContentSHA256: contentHash,
ContentMarkdown: content,
CreatedBy: userID,
ChangeSummary: "从智能体回复保存",
CreatedAt: now,
},
}
return &FreeformDocDetail{
Document: *newDoc,
Content: content,
}, false, nil
}
// GetDocument 获取文档详情及内容,严格执行私密与共享访问控制
func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*FreeformDocDetail, error) {
if docID == "" {
return nil, ErrNotFound
}
if s.pool != nil {
var doc FreeformDocument
row := s.pool.QueryRow(ctx, `
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
title, governance_mode, space, current_version_id, current_sha256,
is_deleted, deleted_at, created_at, updated_at
FROM freeform_documents
WHERE doc_id = $1
`, docID)
if err := row.Scan(
&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID,
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
&doc.IsDeleted, &doc.DeletedAt, &doc.CreatedAt, &doc.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if doc.IsDeleted {
return nil, ErrNotFound
}
// 私密隔离红线:my_private 只有 owner 可读
if doc.Space == "my_private" && doc.OwnerUserID != userID {
_, _ = s.pool.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success)
VALUES ($1, $2, 'read_denied', FALSE)
`, docID, userID)
return nil, ErrForbidden
}
var content string
err := s.pool.QueryRow(ctx, `
SELECT content_markdown FROM freeform_document_versions
WHERE doc_id = $1 AND version_no = $2
`, doc.DocID, doc.CurrentVersionID).Scan(&content)
if err != nil {
return nil, fmt.Errorf("read version: %w", err)
}
_, _ = s.pool.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success)
VALUES ($1, $2, 'read', TRUE)
`, docID, userID)
return &FreeformDocDetail{
Document: doc,
Content: content,
}, nil
}
// 内存模式
s.mu.RLock()
defer s.mu.RUnlock()
doc, ok := s.memDocs[docID]
if !ok || doc.IsDeleted {
return nil, ErrNotFound
}
if doc.Space == "my_private" && doc.OwnerUserID != userID {
return nil, ErrForbidden
}
versions := s.memVersions[docID]
content := ""
for _, v := range versions {
if v.VersionNo == doc.CurrentVersionID {
content = v.ContentMarkdown
break
}
}
return &FreeformDocDetail{
Document: *doc,
Content: content,
}, nil
}
// ListDocuments 按空间与权限列出自由文档概要
func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int, space string) ([]FreeformDocSummary, error) {
if space != "my_private" && space != "project_shared" && space != "trash" {
space = "my_private"
}
if s.pool != nil {
var rows pgx.Rows
var err error
if space == "my_private" {
// 我的私密:属于当前用户
if projectID > 0 {
rows, err = s.pool.Query(ctx, `
SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at
FROM freeform_documents
WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE
ORDER BY updated_at DESC
`, userID, projectID)
} else {
rows, err = s.pool.Query(ctx, `
SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at
FROM freeform_documents
WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE
ORDER BY updated_at DESC
`, userID)
}
} else if space == "project_shared" {
// 项目共享参考:属于当前项目的所有公开参考
rows, err = s.pool.Query(ctx, `
SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at
FROM freeform_documents
WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE
ORDER BY updated_at DESC
`, projectID)
} else {
// 回收站:当前用户删除的
rows, err = s.pool.Query(ctx, `
SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at
FROM freeform_documents
WHERE owner_user_id = $1 AND is_deleted = TRUE
ORDER BY deleted_at DESC
`, userID)
}
if err != nil {
return nil, err
}
defer rows.Close()
var results []FreeformDocSummary
for rows.Next() {
var sm FreeformDocSummary
if err := rows.Scan(
&sm.DocID, &sm.ProjectID, &sm.OwnerUserID, &sm.Title, &sm.Space,
&sm.CurrentVersionID, &sm.CurrentSHA256, &sm.CreatedAt, &sm.UpdatedAt,
); err != nil {
return nil, err
}
results = append(results, sm)
}
return results, nil
}
// 内存模式
s.mu.RLock()
defer s.mu.RUnlock()
var results []FreeformDocSummary
for _, d := range s.memDocs {
if space == "my_private" {
if d.OwnerUserID == userID && d.Space == "my_private" && !d.IsDeleted {
if projectID <= 0 || d.ProjectID == projectID {
results = append(results, FreeformDocSummary{
DocID: d.DocID,
ProjectID: d.ProjectID,
OwnerUserID: d.OwnerUserID,
Title: d.Title,
Space: d.Space,
CurrentVersionID: d.CurrentVersionID,
CurrentSHA256: d.CurrentSHA256,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
})
}
}
} else if space == "project_shared" {
if d.ProjectID == projectID && d.Space == "project_shared" && !d.IsDeleted {
results = append(results, FreeformDocSummary{
DocID: d.DocID,
ProjectID: d.ProjectID,
OwnerUserID: d.OwnerUserID,
Title: d.Title,
Space: d.Space,
CurrentVersionID: d.CurrentVersionID,
CurrentSHA256: d.CurrentSHA256,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
})
}
}
}
return results, nil
}
// UpdateDocument 更新文档正文或标题,支持 If-Match 乐观并发控制
func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, req UpdateFreeformDocRequest) (*FreeformDocDetail, error) {
if docID == "" {
return nil, ErrNotFound
}
if s.pool != nil {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var doc FreeformDocument
row := tx.QueryRow(ctx, `
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
title, governance_mode, space, current_version_id, current_sha256,
is_deleted, created_at, updated_at
FROM freeform_documents
WHERE doc_id = $1 FOR UPDATE
`, docID)
if err := row.Scan(
&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID,
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
&doc.IsDeleted, &doc.CreatedAt, &doc.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if doc.IsDeleted {
return nil, ErrNotFound
}
// 私密文档非本人严禁修改
if doc.Space == "my_private" && doc.OwnerUserID != userID {
return nil, ErrForbidden
}
// 乐观并发冲突检验
if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID {
return nil, ErrConflict
}
if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 {
return nil, ErrConflict
}
now := time.Now()
newTitle := doc.Title
if req.Title != nil && *req.Title != "" {
newTitle = *req.Title
}
curContent := ""
_ = tx.QueryRow(ctx, `SELECT content_markdown FROM freeform_document_versions WHERE doc_id = $1 AND version_no = $2`, doc.DocID, doc.CurrentVersionID).Scan(&curContent)
newContent := curContent
if req.ContentMarkdown != nil {
newContent = *req.ContentMarkdown
}
newHash := computeSHA256(newContent)
if newHash != doc.CurrentSHA256 {
// 内容变更,生成新版本
nextVersion := doc.CurrentVersionID + 1
summary := req.ChangeSummary
if summary == "" {
summary = "编辑修改正文"
}
_, err = tx.Exec(ctx, `
INSERT INTO freeform_document_versions (
doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
`, doc.DocID, nextVersion, newHash, newContent, userID, summary, now)
if err != nil {
return nil, fmt.Errorf("insert version: %w", err)
}
_, err = tx.Exec(ctx, `
UPDATE freeform_documents
SET title = $1, current_version_id = $2, current_sha256 = $3, updated_at = $4
WHERE doc_id = $5
`, newTitle, nextVersion, newHash, now, doc.DocID)
if err != nil {
return nil, fmt.Errorf("update doc: %w", err)
}
doc.CurrentVersionID = nextVersion
doc.CurrentSHA256 = newHash
} else if newTitle != doc.Title {
// 仅标题变更
_, err = tx.Exec(ctx, `
UPDATE freeform_documents
SET title = $1, updated_at = $2
WHERE doc_id = $3
`, newTitle, now, doc.DocID)
if err != nil {
return nil, fmt.Errorf("update doc title: %w", err)
}
}
doc.Title = newTitle
doc.UpdatedAt = now
_, _ = tx.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
VALUES ($1, $2, 'update', TRUE, $3)
`, doc.DocID, userID, now)
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &FreeformDocDetail{
Document: doc,
Content: newContent,
}, nil
}
// 内存模式
s.mu.Lock()
defer s.mu.Unlock()
doc, ok := s.memDocs[docID]
if !ok || doc.IsDeleted {
return nil, ErrNotFound
}
if doc.Space == "my_private" && doc.OwnerUserID != userID {
return nil, ErrForbidden
}
if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID {
return nil, ErrConflict
}
if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 {
return nil, ErrConflict
}
now := time.Now()
if req.Title != nil && *req.Title != "" {
doc.Title = *req.Title
}
curContent := ""
versions := s.memVersions[docID]
if len(versions) > 0 {
curContent = versions[len(versions)-1].ContentMarkdown
}
newContent := curContent
if req.ContentMarkdown != nil {
newContent = *req.ContentMarkdown
}
newHash := computeSHA256(newContent)
if newHash != doc.CurrentSHA256 {
nextVersion := doc.CurrentVersionID + 1
doc.CurrentVersionID = nextVersion
doc.CurrentSHA256 = newHash
s.memVersions[docID] = append(s.memVersions[docID], &FreeformDocumentVersion{
ID: int64(len(s.memVersions[docID]) + 1),
DocID: docID,
VersionNo: nextVersion,
ContentSHA256: newHash,
ContentMarkdown: newContent,
CreatedBy: userID,
ChangeSummary: req.ChangeSummary,
CreatedAt: now,
})
}
doc.UpdatedAt = now
return &FreeformDocDetail{
Document: *doc,
Content: newContent,
}, nil
}
// ShareDocument 将私密文档共享至项目参考库(仅 owner 可操作)
func (s *Service) ShareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) {
if docID == "" {
return nil, ErrNotFound
}
if s.pool != nil {
var doc FreeformDocument
row := s.pool.QueryRow(ctx, `
SELECT doc_id, project_id, owner_user_id, space, is_deleted
FROM freeform_documents WHERE doc_id = $1
`, docID)
if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if doc.IsDeleted {
return nil, ErrNotFound
}
if doc.OwnerUserID != userID {
return nil, ErrForbidden
}
now := time.Now()
_, err := s.pool.Exec(ctx, `
UPDATE freeform_documents
SET space = 'project_shared', updated_at = $1
WHERE doc_id = $2
`, now, docID)
if err != nil {
return nil, err
}
_, _ = s.pool.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
VALUES ($1, $2, 'share', TRUE, $3)
`, docID, userID, now)
doc.Space = "project_shared"
doc.UpdatedAt = now
return &doc, nil
}
// 内存模式
s.mu.Lock()
defer s.mu.Unlock()
doc, ok := s.memDocs[docID]
if !ok || doc.IsDeleted {
return nil, ErrNotFound
}
if doc.OwnerUserID != userID {
return nil, ErrForbidden
}
doc.Space = "project_shared"
doc.UpdatedAt = time.Now()
return doc, nil
}
// UnshareDocument 撤回共享,恢复为个人私密
func (s *Service) UnshareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) {
if docID == "" {
return nil, ErrNotFound
}
if s.pool != nil {
var doc FreeformDocument
row := s.pool.QueryRow(ctx, `
SELECT doc_id, project_id, owner_user_id, space, is_deleted
FROM freeform_documents WHERE doc_id = $1
`, docID)
if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if doc.IsDeleted {
return nil, ErrNotFound
}
if doc.OwnerUserID != userID {
return nil, ErrForbidden
}
now := time.Now()
_, err := s.pool.Exec(ctx, `
UPDATE freeform_documents
SET space = 'my_private', updated_at = $1
WHERE doc_id = $2
`, now, docID)
if err != nil {
return nil, err
}
_, _ = s.pool.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
VALUES ($1, $2, 'unshare', TRUE, $3)
`, docID, userID, now)
doc.Space = "my_private"
doc.UpdatedAt = now
return &doc, nil
}
// 内存模式
s.mu.Lock()
defer s.mu.Unlock()
doc, ok := s.memDocs[docID]
if !ok || doc.IsDeleted {
return nil, ErrNotFound
}
if doc.OwnerUserID != userID {
return nil, ErrForbidden
}
doc.Space = "my_private"
doc.UpdatedAt = time.Now()
return doc, nil
}
// DeleteDocument 软删除文档(仅 owner 可操作)
func (s *Service) DeleteDocument(ctx context.Context, userID int, docID string) error {
if docID == "" {
return ErrNotFound
}
if s.pool != nil {
var doc FreeformDocument
row := s.pool.QueryRow(ctx, `
SELECT doc_id, owner_user_id, is_deleted
FROM freeform_documents WHERE doc_id = $1
`, docID)
if err := row.Scan(&doc.DocID, &doc.OwnerUserID, &doc.IsDeleted); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
if doc.IsDeleted {
return nil
}
if doc.OwnerUserID != userID {
return ErrForbidden
}
now := time.Now()
_, err := s.pool.Exec(ctx, `
UPDATE freeform_documents
SET is_deleted = TRUE, deleted_at = $1, space = 'trash'
WHERE doc_id = $2
`, now, docID)
if err != nil {
return err
}
_, _ = s.pool.Exec(ctx, `
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
VALUES ($1, $2, 'delete', TRUE, $3)
`, docID, userID, now)
return nil
}
// 内存模式
s.mu.Lock()
defer s.mu.Unlock()
doc, ok := s.memDocs[docID]
if !ok || doc.IsDeleted {
return ErrNotFound
}
if doc.OwnerUserID != userID {
return ErrForbidden
}
doc.IsDeleted = true
now := time.Now()
doc.DeletedAt = &now
doc.Space = "trash"
return nil
}
+94
View File
@@ -0,0 +1,94 @@
package freedoc_test
import (
"context"
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/config"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/freedoc"
)
func TestFreeformDoc_DatabaseIntegration(t *testing.T) {
cfg, err := config.Load(".env")
if err != nil || cfg == nil || cfg.ContextDbHost == "" {
t.Skip("Skipping DB integration test: context DB not configured")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, cfg.ContextDSN())
if err != nil || pool.Ping(ctx) != nil {
t.Skipf("Skipping DB integration test: cannot connect to %s: %v", cfg.ContextDSN(), err)
}
defer pool.Close()
ctxMgr := ctxmgr.NewContextManager(pool, nil, ctxmgr.DefaultTimeDecayConfig())
service := freedoc.NewService(pool, ctxMgr)
sessionID := fmt.Sprintf("sess-db-freedoc-%d", time.Now().UnixNano())
msgID, err := ctxMgr.RecordUserMessageWithID(ctx, sessionID, 1, "assistant", "# 数据库持久化自由文档\n\n通过 PostgreSQL 完整事务落库并生成版本 1。", "consult", "main", nil)
require.NoError(t, err)
// 1. 创建私密文档
detail, isExisting, err := service.CreateFromMessage(ctx, 101, freedoc.CreateFromMessageRequest{
MessageID: msgID,
ProjectID: 1,
})
require.NoError(t, err)
assert.False(t, isExisting)
assert.Equal(t, "数据库持久化自由文档", detail.Document.Title)
assert.Equal(t, "my_private", detail.Document.Space)
docID := detail.Document.DocID
// 2. 数据库幂等性验证:再次调用返回同一 docID
detail2, isExisting2, err := service.CreateFromMessage(ctx, 101, freedoc.CreateFromMessageRequest{
MessageID: msgID,
ProjectID: 1,
})
require.NoError(t, err)
assert.True(t, isExisting2)
assert.Equal(t, docID, detail2.Document.DocID)
// 3. 读取与权限验证
readDoc, err := service.GetDocument(ctx, 101, docID)
require.NoError(t, err)
assert.Equal(t, docID, readDoc.Document.DocID)
assert.Contains(t, readDoc.Content, "通过 PostgreSQL 完整事务落库")
// 非本人访问私密文档必须被拒
_, err = service.GetDocument(ctx, 999, docID)
assert.ErrorIs(t, err, freedoc.ErrForbidden)
// 4. 版本迭代与乐观锁冲突验证
curVer := 1
newMarkdown := "修订后的正文内容(v2)"
updated, err := service.UpdateDocument(ctx, 101, docID, freedoc.UpdateFreeformDocRequest{
ContentMarkdown: &newMarkdown,
ExpectedVersion: &curVer,
})
require.NoError(t, err)
assert.Equal(t, 2, updated.Document.CurrentVersionID)
// 5. 共享到项目与撤回共享
shared, err := service.ShareDocument(ctx, 101, docID)
require.NoError(t, err)
assert.Equal(t, "project_shared", shared.Space)
unshared, err := service.UnshareDocument(ctx, 101, docID)
require.NoError(t, err)
assert.Equal(t, "my_private", unshared.Space)
// 6. 删除清理
err = service.DeleteDocument(ctx, 101, docID)
require.NoError(t, err)
_, err = service.GetDocument(ctx, 101, docID)
assert.ErrorIs(t, err, freedoc.ErrNotFound)
}
+124
View File
@@ -0,0 +1,124 @@
package freedoc_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/freedoc"
)
func TestFreeformDocService_LifecycleAndACL(t *testing.T) {
ctx := context.Background()
ctxMgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig())
service := freedoc.NewService(nil, ctxMgr)
// 1. 录入一条助手有效消息
sessionID := "sess-test-freedoc"
asstMsgID, err := ctxMgr.RecordUserMessageWithID(ctx, sessionID, 1, "assistant", "# 临床风险评价\n\n依据 GB 9706.1 进行耐压评估,结果合格。", "consult", "main", nil)
require.NoError(t, err)
// 2. 录入一条纯卡片消息(不可导出)
cardOnlyMsgID, err := ctxMgr.RecordUserMessageWithID(ctx, sessionID, 1, "assistant", "请确认以下操作", "consult", "main", map[string]any{
"interactive_card": map[string]any{"card_id": "card-1"},
})
require.NoError(t, err)
// 3. 尝试从纯卡片创建私密文档 -> 应该拒绝
_, _, err = service.CreateFromMessage(ctx, 101, freedoc.CreateFromMessageRequest{
MessageID: cardOnlyMsgID,
ProjectID: 1,
})
assert.ErrorIs(t, err, freedoc.ErrMessageNotExportable)
// 4. 从正常消息创建私密文档 -> 成功
detail, isExisting, err := service.CreateFromMessage(ctx, 101, freedoc.CreateFromMessageRequest{
MessageID: asstMsgID,
ProjectID: 1,
})
require.NoError(t, err)
assert.False(t, isExisting)
assert.Equal(t, "临床风险评价", detail.Document.Title)
assert.Equal(t, "my_private", detail.Document.Space)
assert.Equal(t, 1, detail.Document.CurrentVersionID)
assert.Contains(t, detail.Content, "依据 GB 9706.1")
docID := detail.Document.DocID
// 5. 幂等性测试:同一用户针对同一消息再次保存 -> 直接返回已有文档
detail2, isExisting2, err := service.CreateFromMessage(ctx, 101, freedoc.CreateFromMessageRequest{
MessageID: asstMsgID,
ProjectID: 1,
})
require.NoError(t, err)
assert.True(t, isExisting2)
assert.Equal(t, docID, detail2.Document.DocID)
// 6. 私密文档访问隔离红线测试:
// Owner 访问 -> 成功
ownerDoc, err := service.GetDocument(ctx, 101, docID)
require.NoError(t, err)
assert.Equal(t, docID, ownerDoc.Document.DocID)
// 其他用户(哪怕管理员)访问私密文档 -> 严格拒绝 ErrForbidden
_, err = service.GetDocument(ctx, 999, docID)
assert.ErrorIs(t, err, freedoc.ErrForbidden)
// 7. 列表隔离测试
listUser101, err := service.ListDocuments(ctx, 101, 1, "my_private")
require.NoError(t, err)
assert.Len(t, listUser101, 1)
listUser999, err := service.ListDocuments(ctx, 999, 1, "my_private")
require.NoError(t, err)
assert.Empty(t, listUser999)
// 8. 乐观并发控制与编辑测试
// 期望版本不匹配 -> ErrConflict
badVer := 99
newContent := "更新后的正文内容"
_, err = service.UpdateDocument(ctx, 101, docID, freedoc.UpdateFreeformDocRequest{
ContentMarkdown: &newContent,
ExpectedVersion: &badVer,
})
assert.ErrorIs(t, err, freedoc.ErrConflict)
// 正常版本更新 -> 生成版本 2
curVer := 1
updated, err := service.UpdateDocument(ctx, 101, docID, freedoc.UpdateFreeformDocRequest{
ContentMarkdown: &newContent,
ExpectedVersion: &curVer,
ChangeSummary: "修订正文说明",
})
require.NoError(t, err)
assert.Equal(t, 2, updated.Document.CurrentVersionID)
assert.Equal(t, "更新后的正文内容", updated.Content)
// 9. 共享到项目参考库 (Share)
sharedDoc, err := service.ShareDocument(ctx, 101, docID)
require.NoError(t, err)
assert.Equal(t, "project_shared", sharedDoc.Space)
// 共享后,项目成员 999 可以在共享库中读取
sharedDetail, err := service.GetDocument(ctx, 999, docID)
require.NoError(t, err)
assert.Equal(t, docID, sharedDetail.Document.DocID)
// 10. 撤回共享 (Unshare)
unsharedDoc, err := service.UnshareDocument(ctx, 101, docID)
require.NoError(t, err)
assert.Equal(t, "my_private", unsharedDoc.Space)
// 撤回后,用户 999 再次读取 -> 立即重新拦截 ErrForbidden
_, err = service.GetDocument(ctx, 999, docID)
assert.ErrorIs(t, err, freedoc.ErrForbidden)
// 11. 删除文档 (Delete)
err = service.DeleteDocument(ctx, 101, docID)
require.NoError(t, err)
// 删除后再获取 -> ErrNotFound
_, err = service.GetDocument(ctx, 101, docID)
assert.ErrorIs(t, err, freedoc.ErrNotFound)
}
+88
View File
@@ -0,0 +1,88 @@
package freedoc
import (
"errors"
"time"
)
var (
ErrNotFound = errors.New("freeform document not found")
ErrForbidden = errors.New("permission denied to access document")
ErrConflict = errors.New("optimistic concurrency conflict: version or sha mismatch")
ErrInvalidParam = errors.New("invalid parameter")
ErrMessageNotExportable = errors.New("chat message is not exportable or content is empty")
)
// FreeformDocument 自由私密/共享文档元数据
type FreeformDocument struct {
DocID string `json:"doc_id"`
ProjectID int `json:"project_id"`
OwnerUserID int `json:"owner_user_id"`
SourceMessageID *int64 `json:"source_message_id,omitempty"`
SourceContentSHA256 string `json:"source_content_sha256"`
Title string `json:"title"`
GovernanceMode string `json:"governance_mode"` // 恒为 'freeform'
Space string `json:"space"` // 'my_private' | 'project_shared' | 'trash'
CurrentVersionID int `json:"current_version_id"`
CurrentSHA256 string `json:"current_sha256"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// FreeformDocumentVersion 自由文档历史版本快照
type FreeformDocumentVersion struct {
ID int64 `json:"id"`
DocID string `json:"doc_id"`
VersionNo int `json:"version_no"`
ContentSHA256 string `json:"content_sha256"`
ContentMarkdown string `json:"content_markdown"`
CreatedBy int `json:"created_by"`
ChangeSummary string `json:"change_summary"`
CreatedAt time.Time `json:"created_at"`
}
// FreeformDocDetail 自由文档详情(含当前版本 Markdown 正文)
type FreeformDocDetail struct {
Document FreeformDocument `json:"document"`
Content string `json:"content_markdown"`
}
// FreeformDocSummary 列表项概要
type FreeformDocSummary struct {
DocID string `json:"doc_id"`
ProjectID int `json:"project_id"`
OwnerUserID int `json:"owner_user_id"`
Title string `json:"title"`
Space string `json:"space"`
CurrentVersionID int `json:"current_version_id"`
CurrentSHA256 string `json:"current_sha256"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateFromMessageRequest 从单条消息保存到私密文档请求
type CreateFromMessageRequest struct {
MessageID int64 `json:"message_id"`
ProjectID int `json:"project_id"`
Title string `json:"title,omitempty"`
ExpectedSHA256 string `json:"expected_sha256,omitempty"`
}
// CreateFreeformDocRequest 直接创建空白/自定义自由文档请求
type CreateFreeformDocRequest struct {
ProjectID int `json:"project_id"`
Title string `json:"title"`
ContentMarkdown string `json:"content_markdown"`
Space string `json:"space,omitempty"` // 默认 'my_private'
}
// UpdateFreeformDocRequest 更新自由文档请求
type UpdateFreeformDocRequest struct {
Title *string `json:"title,omitempty"`
ContentMarkdown *string `json:"content_markdown,omitempty"`
ChangeSummary string `json:"change_summary,omitempty"`
ExpectedVersion *int `json:"expected_version,omitempty"`
ExpectedSHA256 *string `json:"expected_sha256,omitempty"`
}
+137
View File
@@ -0,0 +1,137 @@
package replyexport
import (
"strings"
)
// PolicyOptions 传递判定所需上下文
type PolicyOptions struct {
Role string
Status string // pending, streaming_status, auto_retrying, completed, failed, cancelled
Content string
HasInteractiveCard bool
HasError bool
IsCancelled bool
IsRestricted bool // 内容合规策略是否禁止导出
Revision int
}
// EvaluatePolicy 权威判定一条消息的内容类型与导出能力
func EvaluatePolicy(opts PolicyOptions) (ContentKind, MessageExportCapability) {
revision := opts.Revision
if revision <= 0 {
revision = 1
}
normContent := strings.TrimSpace(opts.Content)
hash := ComputeSha256(normContent)
cap := MessageExportCapability{
Policy: PolicyNone,
Formats: []string{},
BodySelector: "content_only",
ContentRevision: revision,
ContentSha256: hash,
}
// 1. 非助手回复不可导出
if opts.Role != "assistant" {
cap.ReasonCode = ReasonNotAssistant
return KindConversationalText, cap
}
// 2. 异常或已取消状态
if opts.HasError || opts.IsCancelled || opts.Status == "failed" || opts.Status == "cancelled" {
cap.ReasonCode = ReasonErrorOrCancelled
return KindErrorNotice, cap
}
// 3. 流式中或未完成
if opts.Status == "pending" || opts.Status == "streaming_status" || opts.Status == "auto_retrying" {
cap.ReasonCode = ReasonNotCompleted
return KindStatusUpdate, cap
}
// 4. 空正文
if normContent == "" {
if opts.HasInteractiveCard {
cap.ReasonCode = ReasonCardOnly
return KindActionCard, cap
}
cap.ReasonCode = ReasonEmptyBody
return KindStatusUpdate, cap
}
// 5. 内容合规策略拦截
if opts.IsRestricted {
cap.ReasonCode = ReasonContentRestricted
return KindDocumentText, cap
}
// 6. 纯交互卡片与短依赖引导语
if opts.HasInteractiveCard {
// 检查正文是否具备独立阅读价值
isDependentPrompt := isDependentCardPrompt(normContent)
if isDependentPrompt {
cap.ReasonCode = ReasonCardOnly
return KindActionCard, cap
}
// 正文具备独立性,允许导出,且明确仅导出 content
cap.Policy = PolicyFull
cap.Formats = FullFormats()
return KindDocumentText, cap
}
// 7. 极短寒暄或瞬时确认答复:开放 copy_only,不保存为系统文档
if isCasualOrVeryShort(normContent) {
cap.Policy = PolicyCopyOnly
cap.Formats = CopyOnlyFormats()
return KindConversationalText, cap
}
// 8. 常规具备独立结构的助手正文
cap.Policy = PolicyFull
cap.Formats = FullFormats()
return KindDocumentText, cap
}
// isDependentCardPrompt 判断正文是否仅仅是依赖卡片的简短引导语
func isDependentCardPrompt(content string) bool {
runes := []rune(content)
// 超过 80 字符且包含结构化 Markdown 标记的,通常为完整方案说明
if len(runes) > 80 && (strings.Contains(content, "#") || strings.Contains(content, "- ") || strings.Contains(content, "|")) {
return false
}
// 短于 50 字符且匹配经典卡片引导语模式
if len(runes) <= 50 {
lower := strings.ToLower(content)
patterns := []string{
"请确认", "请选择", "请核对", "点击下方", "操作如下",
"是否继续", "请决策", "请审批", "已为您生成以下操作卡",
}
for _, p := range patterns {
if strings.Contains(lower, p) {
return true
}
}
}
return false
}
// isCasualOrVeryShort 判断是否属于无长久沉淀价值的极短寒暄
func isCasualOrVeryShort(content string) bool {
runes := []rune(content)
if len(runes) <= 12 {
shortGreetings := []string{
"好的", "没问题", "收到", "您好", "在的", "请稍候", "不用谢", "再见", "明白",
}
for _, g := range shortGreetings {
if strings.Contains(content, g) {
return true
}
}
}
return false
}
+187
View File
@@ -0,0 +1,187 @@
package replyexport
import (
"testing"
"time"
)
func TestEvaluatePolicy_Matrix(t *testing.T) {
tests := []struct {
name string
opts PolicyOptions
wantKind ContentKind
wantPolicy ExportPolicy
wantReason ReasonCode
hasFormats bool
expectedFormat string
}{
{
name: "1. 助手已完成完整长文 -> full",
opts: PolicyOptions{
Role: "assistant",
Status: "completed",
Content: "## 风险评估报告\n\n根据现有临床数据,该方案具有以下风险:\n1. 电气安全性需重新打标\n2. 传感器存在温漂",
},
wantKind: KindDocumentText,
wantPolicy: PolicyFull,
hasFormats: true,
},
{
name: "2. 助手已完成 + 完整正文 + 辅助卡片 -> full (仅导出正文)",
opts: PolicyOptions{
Role: "assistant",
Status: "completed",
Content: "## 变更建议书\n\n经系统审核,以下第 3 条款存在重大缺陷,建议采纳修改意见:\n- 原文:无过温保护\n- 修改后:增加热敏电阻切断机制",
HasInteractiveCard: true,
},
wantKind: KindDocumentText,
wantPolicy: PolicyFull,
hasFormats: true,
},
{
name: "3. 助手纯交互卡片 (短引导语) -> none, CARD_ONLY",
opts: PolicyOptions{
Role: "assistant",
Status: "completed",
Content: "请确认以下操作卡片:",
HasInteractiveCard: true,
},
wantKind: KindActionCard,
wantPolicy: PolicyNone,
wantReason: ReasonCardOnly,
hasFormats: false,
},
{
name: "4. 用户消息 -> none, NOT_ASSISTANT",
opts: PolicyOptions{
Role: "user",
Content: "请帮我分析一下项目进度",
},
wantKind: KindConversationalText,
wantPolicy: PolicyNone,
wantReason: ReasonNotAssistant,
},
{
name: "5. 系统消息 -> none, NOT_ASSISTANT",
opts: PolicyOptions{
Role: "system",
Content: "系统已建立新会话",
},
wantKind: KindConversationalText,
wantPolicy: PolicyNone,
wantReason: ReasonNotAssistant,
},
{
name: "6. 流式生成中 -> none, NOT_COMPLETED",
opts: PolicyOptions{
Role: "assistant",
Status: "streaming_status",
Content: "正在研判您的意图...",
},
wantKind: KindStatusUpdate,
wantPolicy: PolicyNone,
wantReason: ReasonNotCompleted,
},
{
name: "7. 失败/已取消消息 -> none, ERROR_OR_CANCELLED",
opts: PolicyOptions{
Role: "assistant",
Status: "failed",
HasError: true,
Content: "任务执行失败,网络超时",
},
wantKind: KindErrorNotice,
wantPolicy: PolicyNone,
wantReason: ReasonErrorOrCancelled,
},
{
name: "8. 极短寒暄 -> copy_only",
opts: PolicyOptions{
Role: "assistant",
Status: "completed",
Content: "好的,收到!",
},
wantKind: KindConversationalText,
wantPolicy: PolicyCopyOnly,
hasFormats: true,
},
{
name: "9. 空正文 -> none, EMPTY_BODY",
opts: PolicyOptions{
Role: "assistant",
Status: "completed",
Content: " \n\t ",
},
wantKind: KindStatusUpdate,
wantPolicy: PolicyNone,
wantReason: ReasonEmptyBody,
},
{
name: "10. 受限敏感内容 -> none, CONTENT_RESTRICTED",
opts: PolicyOptions{
Role: "assistant",
Status: "completed",
Content: "敏感内容正文",
IsRestricted: true,
},
wantKind: KindDocumentText,
wantPolicy: PolicyNone,
wantReason: ReasonContentRestricted,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
kind, cap := EvaluatePolicy(tt.opts)
if kind != tt.wantKind {
t.Errorf("kind = %v, want %v", kind, tt.wantKind)
}
if cap.Policy != tt.wantPolicy {
t.Errorf("policy = %v, want %v", cap.Policy, tt.wantPolicy)
}
if tt.wantReason != "" && cap.ReasonCode != tt.wantReason {
t.Errorf("reasonCode = %v, want %v", cap.ReasonCode, tt.wantReason)
}
if tt.hasFormats && len(cap.Formats) == 0 {
t.Errorf("expected non-empty formats, got empty")
}
if !tt.hasFormats && len(cap.Formats) > 0 {
t.Errorf("expected empty formats, got %v", cap.Formats)
}
if cap.ContentSha256 == "" {
t.Errorf("expected non-empty ContentSha256")
}
})
}
}
func TestDeriveTitle(t *testing.T) {
cases := []struct {
input string
wantTitle string
}{
{
input: "# 临床评估备忘录\n正文内容...",
wantTitle: "临床评估备忘录",
},
{
input: "## 2.1 架构设计对比分析\n内容...",
wantTitle: "2.1 架构设计对比分析",
},
{
input: "针对用户提出的第三阶段审核驳回要求,项目组给出如下补救措施:\n1. 增加测试...",
wantTitle: "针对用户提出的第三阶段审核驳回要求,项目组给出如下补救措施:",
},
}
for _, tc := range cases {
got := DeriveTitle(tc.input, testTime())
if got != tc.wantTitle {
t.Errorf("DeriveTitle(%q) = %q, want %q", tc.input, got, tc.wantTitle)
}
}
}
func testTime() (t time.Time) {
return time.Date(2026, 9, 10, 15, 30, 0, 0, time.Local)
}
+138
View File
@@ -0,0 +1,138 @@
package replyexport
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"regexp"
"strings"
"time"
)
// ContentKind 描述消息内容正文类型
type ContentKind string
const (
KindDocumentText ContentKind = "document_text" // 可独立阅读的长文/文档正文
KindConversationalText ContentKind = "conversational_text" // 普通答复、寒暄或简短解释
KindActionCard ContentKind = "action_card" // 纯交互/操作型卡片
KindStatusUpdate ContentKind = "status_update" // 流式状态、阶段性进度条
KindErrorNotice ContentKind = "error_notice" // 错误/诊断信息
KindSystemNotice ContentKind = "system_notice" // 系统通知
)
// ExportPolicy 权威导出策略
type ExportPolicy string
const (
PolicyFull ExportPolicy = "full" // 正文可复制、下载(MD/DOCX/PDF)、保存私密
PolicyCopyOnly ExportPolicy = "copy_only" // 仅可复制(富文本/Markdown),不保存为系统文档
PolicyNone ExportPolicy = "none" // 不提供导出动作
)
// ReasonCode 不可导出的权威原因码
type ReasonCode string
const (
ReasonNotAssistant ReasonCode = "NOT_ASSISTANT"
ReasonNotCompleted ReasonCode = "NOT_COMPLETED"
ReasonEmptyBody ReasonCode = "EMPTY_BODY"
ReasonCardOnly ReasonCode = "CARD_ONLY"
ReasonStatusOnly ReasonCode = "STATUS_ONLY"
ReasonErrorOrCancelled ReasonCode = "ERROR_OR_CANCELLED"
ReasonContentRestricted ReasonCode = "CONTENT_RESTRICTED"
ReasonAccessDenied ReasonCode = "ACCESS_DENIED"
)
// MessageExportCapability 消息导出能力描述
type MessageExportCapability struct {
Policy ExportPolicy `json:"policy"`
Formats []string `json:"formats"`
BodySelector string `json:"body_selector"` // 永远固定为 "content_only"
ReasonCode ReasonCode `json:"reason_code,omitempty"`
ContentRevision int `json:"content_revision"`
ContentSha256 string `json:"content_sha256"`
}
// ReplyContentSnapshot 权威且不可变的正文快照
type ReplyContentSnapshot struct {
SnapshotID string `json:"snapshot_id"`
MessageID int64 `json:"message_id"`
SessionID string `json:"session_id"`
ProjectID int64 `json:"project_id"`
OwnerUserID int64 `json:"owner_user_id"`
ContentRevision int `json:"content_revision"`
ContentSha256 string `json:"content_sha256"`
Title string `json:"title"`
Markdown string `json:"markdown"`
CreatedAt string `json:"created_at"`
GeneratedBy string `json:"generated_by"`
Sensitivity string `json:"sensitivity"`
Capability MessageExportCapability `json:"capability"`
}
// ComputeSha256 计算正文的 SHA-256 哈希值
func ComputeSha256(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
}
// FullFormats 返回 full 策略下支持的全部格式
func FullFormats() []string {
return []string{"rich_clipboard", "markdown", "docx", "pdf", "private_document"}
}
// CopyOnlyFormats 返回 copy_only 策略下支持的格式
func CopyOnlyFormats() []string {
return []string{"rich_clipboard", "markdown"}
}
var headingRegex = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`)
// DeriveTitle 从 Markdown 正文中提取首选标题
func DeriveTitle(content string, defaultTime time.Time) string {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
if defaultTime.IsZero() {
defaultTime = time.Now()
}
return fmt.Sprintf("智能体回复摘录 · %s", defaultTime.Format("01-02 15:04"))
}
// 1. 优先取第一个一级或二级标题
matches := headingRegex.FindStringSubmatch(trimmed)
if len(matches) > 1 {
title := strings.TrimSpace(matches[1])
// 去除多余 markdown 强调符号
title = strings.Trim(title, "*_`~")
if title != "" {
if len([]rune(title)) > 60 {
return string([]rune(title)[:60])
}
return title
}
}
// 2. 取第一句的前 30 个字符
lines := strings.Split(trimmed, "\n")
firstLine := ""
for _, l := range lines {
cl := strings.TrimSpace(l)
if cl != "" && !strings.HasPrefix(cl, ">") && !strings.HasPrefix(cl, "```") {
firstLine = cl
break
}
}
if firstLine != "" {
runes := []rune(firstLine)
if len(runes) > 30 {
return string(runes[:30])
}
return firstLine
}
if defaultTime.IsZero() {
defaultTime = time.Now()
}
return fmt.Sprintf("智能体回复摘录 · %s", defaultTime.Format("01-02 15:04"))
}
+366
View File
@@ -0,0 +1,366 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"chat-agent-system/internal/freedoc"
)
func extractUserID(r *http.Request) int {
if headerVal := r.Header.Get("X-User-ID"); headerVal != "" {
if id, err := strconv.Atoi(headerVal); err == nil && id > 0 {
return id
}
}
if qVal := r.URL.Query().Get("user_id"); qVal != "" {
if id, err := strconv.Atoi(qVal); err == nil && id > 0 {
return id
}
}
return 1 // 默认当前用户
}
func extractDocIDFromPath(r *http.Request, actionSuffix string) string {
path := r.URL.Path
if actionSuffix != "" {
suffix := "/" + actionSuffix
if strings.HasSuffix(path, suffix) {
path = strings.TrimSuffix(path, suffix)
}
}
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) >= 4 && parts[0] == "api" && parts[1] == "v1" && parts[2] == "freeform-documents" {
return parts[3]
}
return r.PathValue("doc_id")
}
// POST /api/v1/freeform-documents/from-message
func (s *Server) handleCreateFreeformDocFromMessage(w http.ResponseWriter, r *http.Request) {
if s.svc == nil || s.svc.FreeformDoc() == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
return
}
userID := extractUserID(r)
var req freedoc.CreateFromMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
"error_code": "INVALID_BODY",
"message": "请求体格式错误",
})
return
}
detail, isExisting, err := s.svc.FreeformDoc().CreateFromMessage(r.Context(), userID, req)
if err != nil {
if errors.Is(err, freedoc.ErrMessageNotExportable) {
writeJSONResponse(w, http.StatusForbidden, map[string]any{
"error_code": "MESSAGE_NOT_EXPORTABLE",
"message": "该消息不具备导出或保存资格(纯卡片或未完成)",
})
return
}
if errors.Is(err, freedoc.ErrNotFound) {
writeJSONResponse(w, http.StatusNotFound, map[string]any{
"error_code": "MESSAGE_NOT_FOUND",
"message": "指定源消息不存在",
})
return
}
if errors.Is(err, freedoc.ErrConflict) {
writeJSONResponse(w, http.StatusConflict, map[string]any{
"error_code": "CONTENT_SHA_MISMATCH",
"message": "消息内容哈希校验冲突",
})
return
}
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"error_code": "SAVE_FAILED",
"message": err.Error(),
})
return
}
status := http.StatusCreated
if isExisting {
status = http.StatusOK
}
writeJSONResponse(w, status, map[string]any{
"code": 0,
"message": "已保存至我的私密",
"data": detail,
"is_existing": isExisting,
})
}
// GET /api/v1/freeform-documents
func (s *Server) handleListFreeformDocs(w http.ResponseWriter, r *http.Request) {
if s.svc == nil || s.svc.FreeformDoc() == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
return
}
userID := extractUserID(r)
space := r.URL.Query().Get("space")
if space == "" {
space = "my_private"
}
projectID := 0
if pStr := r.URL.Query().Get("project_id"); pStr != "" {
projectID, _ = strconv.Atoi(pStr)
}
list, err := s.svc.FreeformDoc().ListDocuments(r.Context(), userID, projectID, space)
if err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"error_code": "LIST_FAILED",
"message": err.Error(),
})
return
}
if list == nil {
list = []freedoc.FreeformDocSummary{}
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"code": 0,
"data": list,
})
}
// GET /api/v1/freeform-documents/{doc_id}
func (s *Server) handleGetFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.svc == nil || s.svc.FreeformDoc() == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
return
}
userID := extractUserID(r)
docID := extractDocIDFromPath(r, "")
if docID == "" {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
"error_code": "INVALID_DOC_ID",
"message": "缺少文档ID",
})
return
}
detail, err := s.svc.FreeformDoc().GetDocument(r.Context(), userID, docID)
if err != nil {
if errors.Is(err, freedoc.ErrNotFound) {
writeJSONResponse(w, http.StatusNotFound, map[string]any{
"error_code": "DOC_NOT_FOUND",
"message": "文档不存在或已被删除",
})
return
}
if errors.Is(err, freedoc.ErrForbidden) {
writeJSONResponse(w, http.StatusForbidden, map[string]any{
"error_code": "DOC_ACCESS_FORBIDDEN",
"message": "该文档为他人私密文档,严格禁止跨用户访问",
})
return
}
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"error_code": "GET_FAILED",
"message": err.Error(),
})
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"code": 0,
"data": detail,
})
}
// PUT /api/v1/freeform-documents/{doc_id}
func (s *Server) handleUpdateFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.svc == nil || s.svc.FreeformDoc() == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
return
}
userID := extractUserID(r)
docID := extractDocIDFromPath(r, "")
if docID == "" {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
"error_code": "INVALID_DOC_ID",
"message": "缺少文档ID",
})
return
}
var req freedoc.UpdateFreeformDocRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
"error_code": "INVALID_BODY",
"message": "请求体格式错误",
})
return
}
// 优先提取 HTTP If-Match 标头作为乐观并发校验
if ifMatch := r.Header.Get("If-Match"); ifMatch != "" {
cleanMatch := strings.Trim(ifMatch, "\"")
if v, err := strconv.Atoi(cleanMatch); err == nil && v > 0 {
req.ExpectedVersion = &v
} else {
req.ExpectedSHA256 = &cleanMatch
}
}
detail, err := s.svc.FreeformDoc().UpdateDocument(r.Context(), userID, docID, req)
if err != nil {
if errors.Is(err, freedoc.ErrNotFound) {
writeJSONResponse(w, http.StatusNotFound, map[string]any{
"error_code": "DOC_NOT_FOUND",
"message": "文档不存在",
})
return
}
if errors.Is(err, freedoc.ErrForbidden) {
writeJSONResponse(w, http.StatusForbidden, map[string]any{
"error_code": "DOC_ACCESS_FORBIDDEN",
"message": "无权修改此文档",
})
return
}
if errors.Is(err, freedoc.ErrConflict) {
writeJSONResponse(w, http.StatusConflict, map[string]any{
"error_code": "CONCURRENCY_CONFLICT",
"message": "文档已被他人或新版本更新,请刷新重试",
})
return
}
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"error_code": "UPDATE_FAILED",
"message": err.Error(),
})
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"code": 0,
"message": "保存成功",
"data": detail,
})
}
// POST /api/v1/freeform-documents/{doc_id}/share
func (s *Server) handleShareFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.svc == nil || s.svc.FreeformDoc() == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
return
}
userID := extractUserID(r)
docID := extractDocIDFromPath(r, "share")
if docID == "" {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
"error_code": "INVALID_DOC_ID",
"message": "缺少文档ID",
})
return
}
doc, err := s.svc.FreeformDoc().ShareDocument(r.Context(), userID, docID)
if err != nil {
if errors.Is(err, freedoc.ErrNotFound) {
writeJSONResponse(w, http.StatusNotFound, map[string]any{"error_code": "DOC_NOT_FOUND", "message": "文档不存在"})
return
}
if errors.Is(err, freedoc.ErrForbidden) {
writeJSONResponse(w, http.StatusForbidden, map[string]any{"error_code": "FORBIDDEN", "message": "仅文档所有者可共享文档"})
return
}
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{"error_code": "SHARE_FAILED", "message": err.Error()})
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"code": 0,
"message": "已共享至项目参考库",
"data": doc,
})
}
// POST /api/v1/freeform-documents/{doc_id}/unshare
func (s *Server) handleUnshareFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.svc == nil || s.svc.FreeformDoc() == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
return
}
userID := extractUserID(r)
docID := extractDocIDFromPath(r, "unshare")
if docID == "" {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
"error_code": "INVALID_DOC_ID",
"message": "缺少文档ID",
})
return
}
doc, err := s.svc.FreeformDoc().UnshareDocument(r.Context(), userID, docID)
if err != nil {
if errors.Is(err, freedoc.ErrNotFound) {
writeJSONResponse(w, http.StatusNotFound, map[string]any{"error_code": "DOC_NOT_FOUND", "message": "文档不存在"})
return
}
if errors.Is(err, freedoc.ErrForbidden) {
writeJSONResponse(w, http.StatusForbidden, map[string]any{"error_code": "FORBIDDEN", "message": "仅文档所有者可撤回共享"})
return
}
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{"error_code": "UNSHARE_FAILED", "message": err.Error()})
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"code": 0,
"message": "已撤回为个人私密",
"data": doc,
})
}
// DELETE /api/v1/freeform-documents/{doc_id}
func (s *Server) handleDeleteFreeformDoc(w http.ResponseWriter, r *http.Request) {
if s.svc == nil || s.svc.FreeformDoc() == nil {
http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable)
return
}
userID := extractUserID(r)
docID := extractDocIDFromPath(r, "")
if docID == "" {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
"error_code": "INVALID_DOC_ID",
"message": "缺少文档ID",
})
return
}
err := s.svc.FreeformDoc().DeleteDocument(r.Context(), userID, docID)
if err != nil {
if errors.Is(err, freedoc.ErrNotFound) {
writeJSONResponse(w, http.StatusNotFound, map[string]any{"error_code": "DOC_NOT_FOUND", "message": "文档不存在"})
return
}
if errors.Is(err, freedoc.ErrForbidden) {
writeJSONResponse(w, http.StatusForbidden, map[string]any{"error_code": "FORBIDDEN", "message": "仅文档所有者可删除"})
return
}
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{"error_code": "DELETE_FAILED", "message": err.Error()})
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{
"code": 0,
"message": "已移至回收站",
})
}
+182
View File
@@ -0,0 +1,182 @@
package server_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/server"
)
func setupTestServerForFreeformDoc(t *testing.T) (*server.Server, http.Handler, int64, int64) {
cfg := &config.Config{
DataDir: t.TempDir(),
}
svc := bizdev.NewService(cfg)
srv := server.NewServer(cfg, svc)
handler := srv.Routes()
ctx := context.Background()
ctxMgr := svc.ContextManager()
validMsgID, err := ctxMgr.RecordUserMessageWithID(ctx, "sess-test", 1, "assistant", "# 临床安全评估\n\n评估合格。", "consult", "main", nil)
require.NoError(t, err)
cardMsgID, err := ctxMgr.RecordUserMessageWithID(ctx, "sess-test", 1, "assistant", "请确认操作", "consult", "main", map[string]any{
"interactive_card": map[string]any{"card_id": "card-1"},
})
require.NoError(t, err)
return srv, handler, validMsgID, cardMsgID
}
func TestFreeformDocAPI_EndToEnd(t *testing.T) {
_, handler, validMsgID, cardMsgID := setupTestServerForFreeformDoc(t)
// 1. 不可导出消息保存测试 -> 403
bodyCard, _ := json.Marshal(map[string]any{
"message_id": cardMsgID,
"project_id": 1,
})
reqCard := httptest.NewRequest(http.MethodPost, "/api/v1/freeform-documents/from-message", bytes.NewReader(bodyCard))
reqCard.Header.Set("X-User-ID", "1")
recCard := httptest.NewRecorder()
handler.ServeHTTP(recCard, reqCard)
assert.Equal(t, http.StatusForbidden, recCard.Code)
// 2. 有效消息保存测试 -> 201 Created
bodyValid, _ := json.Marshal(map[string]any{
"message_id": validMsgID,
"project_id": 1,
})
reqValid := httptest.NewRequest(http.MethodPost, "/api/v1/freeform-documents/from-message", bytes.NewReader(bodyValid))
reqValid.Header.Set("X-User-ID", "1")
recValid := httptest.NewRecorder()
handler.ServeHTTP(recValid, reqValid)
require.Equal(t, http.StatusCreated, recValid.Code)
var createResp struct {
Code int `json:"code"`
IsExisting bool `json:"is_existing"`
Data struct {
Document struct {
DocID string `json:"doc_id"`
Title string `json:"title"`
Space string `json:"space"`
CurrentVersionID int `json:"current_version_id"`
} `json:"document"`
Content string `json:"content_markdown"`
} `json:"data"`
}
err := json.Unmarshal(recValid.Body.Bytes(), &createResp)
require.NoError(t, err)
assert.False(t, createResp.IsExisting)
assert.Equal(t, "临床安全评估", createResp.Data.Document.Title)
assert.Equal(t, "my_private", createResp.Data.Document.Space)
docID := createResp.Data.Document.DocID
// 3. 幂等性测试 -> 200 OK + is_existing: true
reqValid2 := httptest.NewRequest(http.MethodPost, "/api/v1/freeform-documents/from-message", bytes.NewReader(bodyValid))
reqValid2.Header.Set("X-User-ID", "1")
recValid2 := httptest.NewRecorder()
handler.ServeHTTP(recValid2, reqValid2)
assert.Equal(t, http.StatusOK, recValid2.Code)
var createResp2 struct {
IsExisting bool `json:"is_existing"`
}
_ = json.Unmarshal(recValid2.Body.Bytes(), &createResp2)
assert.True(t, createResp2.IsExisting)
// 4. 读详情与权限隔离测试
// Owner 读 -> 200
reqGetOwner := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/freeform-documents/%s", docID), nil)
reqGetOwner.Header.Set("X-User-ID", "1")
recGetOwner := httptest.NewRecorder()
handler.ServeHTTP(recGetOwner, reqGetOwner)
assert.Equal(t, http.StatusOK, recGetOwner.Code)
// 非本人读私密文档 -> 403 Forbidden
reqGetOther := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/freeform-documents/%s", docID), nil)
reqGetOther.Header.Set("X-User-ID", "999")
recGetOther := httptest.NewRecorder()
handler.ServeHTTP(recGetOther, reqGetOther)
assert.Equal(t, http.StatusForbidden, recGetOther.Code)
// 5. 列表查询
reqList := httptest.NewRequest(http.MethodGet, "/api/v1/freeform-documents?space=my_private", nil)
reqList.Header.Set("X-User-ID", "1")
recList := httptest.NewRecorder()
handler.ServeHTTP(recList, reqList)
assert.Equal(t, http.StatusOK, recList.Code)
// 6. 编辑与 If-Match 乐观并发冲突
newContent := "更新后的第二版内容"
bodyUpdate, _ := json.Marshal(map[string]any{
"content_markdown": newContent,
})
// 错误 If-Match 99 -> 409 Conflict
reqUpdateConflict := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/freeform-documents/%s", docID), bytes.NewReader(bodyUpdate))
reqUpdateConflict.Header.Set("X-User-ID", "1")
reqUpdateConflict.Header.Set("If-Match", "\"99\"")
recUpdateConflict := httptest.NewRecorder()
handler.ServeHTTP(recUpdateConflict, reqUpdateConflict)
assert.Equal(t, http.StatusConflict, recUpdateConflict.Code)
// 正确 If-Match "1" -> 200 OK
reqUpdateOK := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/freeform-documents/%s", docID), bytes.NewReader(bodyUpdate))
reqUpdateOK.Header.Set("X-User-ID", "1")
reqUpdateOK.Header.Set("If-Match", "\"1\"")
recUpdateOK := httptest.NewRecorder()
handler.ServeHTTP(recUpdateOK, reqUpdateOK)
assert.Equal(t, http.StatusOK, recUpdateOK.Code)
// 7. 共享与撤回
reqShare := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/freeform-documents/%s/share", docID), nil)
reqShare.Header.Set("X-User-ID", "1")
recShare := httptest.NewRecorder()
handler.ServeHTTP(recShare, reqShare)
assert.Equal(t, http.StatusOK, recShare.Code)
// 共享后,用户 999 可以读取
reqGetShared := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/freeform-documents/%s", docID), nil)
reqGetShared.Header.Set("X-User-ID", "999")
recGetShared := httptest.NewRecorder()
handler.ServeHTTP(recGetShared, reqGetShared)
assert.Equal(t, http.StatusOK, recGetShared.Code)
// 撤回共享
reqUnshare := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/freeform-documents/%s/unshare", docID), nil)
reqUnshare.Header.Set("X-User-ID", "1")
recUnshare := httptest.NewRecorder()
handler.ServeHTTP(recUnshare, reqUnshare)
assert.Equal(t, http.StatusOK, recUnshare.Code)
// 撤回后用户 999 再次读取 -> 403 Forbidden
reqGetUnshared := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/freeform-documents/%s", docID), nil)
reqGetUnshared.Header.Set("X-User-ID", "999")
recGetUnshared := httptest.NewRecorder()
handler.ServeHTTP(recGetUnshared, reqGetUnshared)
assert.Equal(t, http.StatusForbidden, recGetUnshared.Code)
// 8. 软删除
reqDel := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/freeform-documents/%s", docID), nil)
reqDel.Header.Set("X-User-ID", "1")
recDel := httptest.NewRecorder()
handler.ServeHTTP(recDel, reqDel)
assert.Equal(t, http.StatusOK, recDel.Code)
// 删除后再读取 -> 404 Not Found
reqGetAfterDel := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/freeform-documents/%s", docID), nil)
reqGetAfterDel.Header.Set("X-User-ID", "1")
recGetAfterDel := httptest.NewRecorder()
handler.ServeHTTP(recGetAfterDel, reqGetAfterDel)
assert.Equal(t, http.StatusNotFound, recGetAfterDel.Code)
}
+173
View File
@@ -0,0 +1,173 @@
package server
import (
"fmt"
"net/http"
"strconv"
"strings"
"chat-agent-system/internal/replyexport"
)
// handleGetExportCapability 获取单条消息权威导出能力
// GET /api/v1/chat/messages/{message_id}/export-capability
func (s *Server) handleGetExportCapability(w http.ResponseWriter, r *http.Request) {
if s.svc == nil || s.svc.ContextManager() == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
return
}
msgID := extractMessageIDFromPath(r, "export-capability")
if msgID <= 0 {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
"error_code": "INVALID_MESSAGE_ID",
"message": "无效的消息ID",
})
return
}
msg, err := s.svc.ContextManager().GetMessageByID(r.Context(), msgID)
if err != nil || msg == nil {
writeJSONResponse(w, http.StatusNotFound, map[string]string{
"error_code": "MESSAGE_NOT_FOUND",
"message": "指定消息不存在或已被删除",
})
return
}
cap, ok := msg.ExportCapability.(replyexport.MessageExportCapability)
if !ok {
// 动态降级重新评估
hasCard := false
if msg.Metadata != nil {
if _, exists := msg.Metadata["interactive_card"]; exists {
hasCard = true
}
}
_, cap = replyexport.EvaluatePolicy(replyexport.PolicyOptions{
Role: msg.Role,
Status: "completed",
Content: msg.Content,
HasInteractiveCard: hasCard,
Revision: msg.ContentRevision,
})
}
resp := map[string]any{
"message_id": msg.ID,
"policy": cap.Policy,
"formats": cap.Formats,
"body_selector": "content_only",
"content_revision": cap.ContentRevision,
"content_sha256": cap.ContentSha256,
}
if cap.ReasonCode != "" {
resp["reason_code"] = cap.ReasonCode
}
writeJSONResponse(w, http.StatusOK, resp)
}
// handleGetExportSource 获取单条消息受控不可变正文快照
// GET /api/v1/chat/messages/{message_id}/export-source?content_revision=
func (s *Server) handleGetExportSource(w http.ResponseWriter, r *http.Request) {
if s.svc == nil || s.svc.ContextManager() == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
return
}
msgID := extractMessageIDFromPath(r, "export-source")
if msgID <= 0 {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
"error_code": "INVALID_MESSAGE_ID",
"message": "无效的消息ID",
})
return
}
msg, err := s.svc.ContextManager().GetMessageByID(r.Context(), msgID)
if err != nil || msg == nil {
writeJSONResponse(w, http.StatusNotFound, map[string]string{
"error_code": "MESSAGE_NOT_FOUND",
"message": "指定消息不存在或已被删除",
})
return
}
cap, ok := msg.ExportCapability.(replyexport.MessageExportCapability)
if !ok {
hasCard := false
if msg.Metadata != nil {
if _, exists := msg.Metadata["interactive_card"]; exists {
hasCard = true
}
}
_, cap = replyexport.EvaluatePolicy(replyexport.PolicyOptions{
Role: msg.Role,
Status: "completed",
Content: msg.Content,
HasInteractiveCard: hasCard,
Revision: msg.ContentRevision,
})
}
if cap.Policy == replyexport.PolicyNone {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
"error_code": "MESSAGE_NOT_EXPORTABLE",
"message": "该消息不支持导出为文档",
})
return
}
// 校验 revision(若客户端传入了指定版本)
if reqRevStr := r.URL.Query().Get("content_revision"); reqRevStr != "" {
if reqRev, err := strconv.Atoi(reqRevStr); err == nil && reqRev > 0 {
if reqRev != cap.ContentRevision {
writeJSONResponse(w, http.StatusConflict, map[string]string{
"error_code": "MESSAGE_REVISION_CHANGED",
"message": "消息内容版本已更新,请重新加载最新快照",
})
return
}
}
}
title := replyexport.DeriveTitle(msg.Content, msg.CreatedAt)
snapshotID := fmt.Sprintf("msg:%d:rev:%d", msg.ID, cap.ContentRevision)
w.Header().Set("Cache-Control", "private, no-store")
writeJSONResponse(w, http.StatusOK, map[string]any{
"snapshot_id": snapshotID,
"message_id": msg.ID,
"content_revision": cap.ContentRevision,
"content_sha256": cap.ContentSha256,
"title": title,
"markdown": msg.Content,
"capability": cap,
})
}
// extractMessageIDFromPath 从路由参数或 URL 路径中提取 message_id
func extractMessageIDFromPath(r *http.Request, actionName string) int64 {
// 优先尝试 Go 1.22 PathValue
val := r.PathValue("message_id")
if val != "" {
id, err := strconv.ParseInt(val, 10, 64)
if err == nil && id > 0 {
return id
}
}
// 降级:从路径分割提取 /api/v1/chat/messages/{id}/{action}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
for i, p := range parts {
if p == "messages" && i+1 < len(parts) {
id, err := strconv.ParseInt(parts[i+1], 10, 64)
if err == nil && id > 0 {
return id
}
}
}
return 0
}
+148
View File
@@ -0,0 +1,148 @@
package server_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/server"
)
func TestServer_ReplyExportAPI(t *testing.T) {
cfg := &config.Config{
Env: "dev",
HTTPHost: "127.0.0.1",
HTTPPort: 8893,
DataDir: t.TempDir(),
}
svc := bizdev.NewService(cfg)
srv := server.NewServer(cfg, svc)
handler := srv.Routes()
ctx := t.Context()
ctxMgr := svc.ContextManager()
sessID := "sess_export_api_test"
// 1. 注入一条完整 assistant 消息
fullContent := "# 临床风险评估报告\n\n根据系统审查,该产品存在以下风险点:\n1. 电气隔离距离不足\n2. 软件未定义看门狗"
asstID, err := ctxMgr.RecordUserMessageWithID(ctx, sessID, 0, "assistant", fullContent, "", "", nil)
if err != nil {
t.Fatalf("RecordUserMessageWithID failed: %v", err)
}
// 2. 注入一条纯卡片依赖引导消息
cardOnlyContent := "请确认以下操作卡片:"
cardMeta := map[string]any{
"interactive_card": map[string]any{
"card_id": "c1",
"title": "驳回门禁",
},
}
cardOnlyID, err := ctxMgr.RecordUserMessageWithID(ctx, sessID, 0, "assistant", cardOnlyContent, "", "", cardMeta)
if err != nil {
t.Fatalf("RecordUserMessageWithID cardOnly failed: %v", err)
}
// -------------------------------------------------------------
// 测试 A: 获取 full 消息能力
// -------------------------------------------------------------
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-capability", asstID), nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
var capRes map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &capRes); err != nil {
t.Fatalf("json unmarshal failed: %v", err)
}
if capRes["policy"] != "full" {
t.Errorf("expected policy full, got %v", capRes["policy"])
}
formats, ok := capRes["formats"].([]any)
if !ok || len(formats) == 0 {
t.Errorf("expected non-empty formats list")
}
// -------------------------------------------------------------
// 测试 B: 获取 cardOnly 消息能力 (policy = none, reason = CARD_ONLY)
// -------------------------------------------------------------
reqCard := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-capability", cardOnlyID), nil)
recCard := httptest.NewRecorder()
handler.ServeHTTP(recCard, reqCard)
if recCard.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", recCard.Code, recCard.Body.String())
}
var cardCapRes map[string]any
_ = json.Unmarshal(recCard.Body.Bytes(), &cardCapRes)
if cardCapRes["policy"] != "none" {
t.Errorf("expected cardOnly policy none, got %v", cardCapRes["policy"])
}
if cardCapRes["reason_code"] != "CARD_ONLY" {
t.Errorf("expected CARD_ONLY, got %v", cardCapRes["reason_code"])
}
// -------------------------------------------------------------
// 测试 C: 获取 full 消息正文快照
// -------------------------------------------------------------
reqSrc := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source", asstID), nil)
recSrc := httptest.NewRecorder()
handler.ServeHTTP(recSrc, reqSrc)
if recSrc.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", recSrc.Code, recSrc.Body.String())
}
var srcRes map[string]any
if err := json.Unmarshal(recSrc.Body.Bytes(), &srcRes); err != nil {
t.Fatalf("json unmarshal failed: %v", err)
}
if srcRes["title"] != "临床风险评估报告" {
t.Errorf("expected title '临床风险评估报告', got %v", srcRes["title"])
}
if srcRes["markdown"] != fullContent {
t.Errorf("expected markdown match, got %v", srcRes["markdown"])
}
if recSrc.Header().Get("Cache-Control") != "private, no-store" {
t.Errorf("expected Cache-Control: private, no-store, got %v", recSrc.Header().Get("Cache-Control"))
}
// -------------------------------------------------------------
// 测试 D: cardOnly 请求正文快照应返回 400 MESSAGE_NOT_EXPORTABLE
// -------------------------------------------------------------
reqCardSrc := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source", cardOnlyID), nil)
recCardSrc := httptest.NewRecorder()
handler.ServeHTTP(recCardSrc, reqCardSrc)
if recCardSrc.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", recCardSrc.Code)
}
// -------------------------------------------------------------
// 测试 E: 不存在的消息 ID 返回 404
// -------------------------------------------------------------
reqNotFound := httptest.NewRequest(http.MethodGet, "/api/v1/chat/messages/999999/export-source", nil)
recNotFound := httptest.NewRecorder()
handler.ServeHTTP(recNotFound, reqNotFound)
if recNotFound.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", recNotFound.Code)
}
// -------------------------------------------------------------
// 测试 F: 冲突的 content_revision 返回 409
// -------------------------------------------------------------
reqConflict := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/messages/%d/export-source?content_revision=999", asstID), nil)
recConflict := httptest.NewRecorder()
handler.ServeHTTP(recConflict, reqConflict)
if recConflict.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", recConflict.Code)
}
}
+13
View File
@@ -109,6 +109,19 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /api/v1/chat/recategorize", s.handleChatRecategorize)
mux.HandleFunc("GET /api/v1/context/cases/recommend", s.handleRecommendCases)
// 7.1 单次助手回复导出与快照
mux.HandleFunc("GET /api/v1/chat/messages/{message_id}/export-capability", s.handleGetExportCapability)
mux.HandleFunc("GET /api/v1/chat/messages/{message_id}/export-source", s.handleGetExportSource)
// 7.2 自由私密文档与项目共享参考
mux.HandleFunc("POST /api/v1/freeform-documents/from-message", s.handleCreateFreeformDocFromMessage)
mux.HandleFunc("GET /api/v1/freeform-documents", s.handleListFreeformDocs)
mux.HandleFunc("GET /api/v1/freeform-documents/{doc_id}", s.handleGetFreeformDoc)
mux.HandleFunc("PUT /api/v1/freeform-documents/{doc_id}", s.handleUpdateFreeformDoc)
mux.HandleFunc("POST /api/v1/freeform-documents/{doc_id}/share", s.handleShareFreeformDoc)
mux.HandleFunc("POST /api/v1/freeform-documents/{doc_id}/unshare", s.handleUnshareFreeformDoc)
mux.HandleFunc("DELETE /api/v1/freeform-documents/{doc_id}", s.handleDeleteFreeformDoc)
// 8. 受控文档编制、审核、批准生命周期与认领治理
mux.HandleFunc("GET /api/v1/docgov/document", s.handleDocGovGetDocument)
mux.HandleFunc("POST /api/v1/docgov/claim", s.handleDocGovClaim)
+19
View File
@@ -242,6 +242,25 @@ func (s *Streamer) EmitDoneWithWorkspace(sessionID, workspaceID string, duration
})
}
// EmitDoneWithExport 发送流结束事件并携带落库消息 ID 与权威导出能力
func (s *Streamer) EmitDoneWithExport(sessionID string, duration time.Duration, messageID int64, revision int, sha256 string, capability any) {
if s == nil {
return
}
s.Emit(StreamEvent{
Type: EventDone,
Done: &DonePayload{
SessionID: sessionID,
DurationMs: duration.Milliseconds(),
TotalEvents: int(atomic.LoadInt64(&s.eventCount)),
MessageID: messageID,
ContentRevision: revision,
ContentSha256: sha256,
ExportCapability: capability,
},
})
}
// EventCount 获取已发射事件总数
func (s *Streamer) EventCount() int {
if s == nil {
+8 -4
View File
@@ -84,10 +84,14 @@ type DeliverablePayload struct {
// DonePayload 流结束载荷
type DonePayload struct {
SessionID string `json:"session_id,omitempty"`
DurationMs int64 `json:"duration_ms"`
TotalEvents int `json:"total_events"`
WorkspaceID string `json:"workspace_id,omitempty"` // P2: 任务工作区目录(供前端「继续任务」续跑)
SessionID string `json:"session_id,omitempty"`
DurationMs int64 `json:"duration_ms"`
TotalEvents int `json:"total_events"`
WorkspaceID string `json:"workspace_id,omitempty"` // P2: 任务工作区目录(供前端「继续任务」续跑)
MessageID int64 `json:"message_id,omitempty"`
ContentRevision int `json:"content_revision,omitempty"`
ContentSha256 string `json:"content_sha256,omitempty"`
ExportCapability any `json:"export_capability,omitempty"`
}
type ErrorClass string
+264 -30
View File
@@ -8,6 +8,18 @@ import { BlockRenderer } from './components/doc/BlockRenderer';
import { BlockEditorModal } from './components/doc/BlockEditorModal';
import { CommentSidePanel } from './components/doc/CommentSidePanel';
import { DocGovActionBar } from './components/doc/DocGovActionBar';
import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActionBar';
import {
saveMessageToPrivate,
listFreeformDocs,
fetchFreeformDoc,
shareFreeformDoc,
unshareFreeformDoc,
deleteFreeformDoc,
} from './api/freeformDoc';
import { deriveReplyTitle } from './features/replyExport/title';
import { copyRichReplyToClipboard, copyMarkdownReply } from './features/replyExport/clipboard';
import { downloadMarkdownReply, downloadDocxReply } from './features/replyExport/download';
import { ProposalConfirmCard } from './components/doc/ProposalConfirmCard';
import { UserProjectRightsModal } from './components/user/UserProjectRightsModal';
import { UserSettingsModal } from './components/user/UserSettingsModal';
@@ -654,22 +666,59 @@ export default function App() {
reviewer: targetDoc.reviewer,
approver: targetDoc.approver,
});
parsed.governanceMode = targetDoc.governanceMode;
parsed.space = targetDoc.space;
setDocCache((prev) => ({ ...prev, [id]: parsed }));
if (id.startsWith('doc-free-') || targetDoc.governanceMode === 'freeform') {
fetchFreeformDoc(id, currentUser.id)
.then((detail) => {
if (detail && detail.content_markdown) {
const fullAST = markdownToBlockAST(detail.content_markdown, {
docId: detail.document.doc_id,
title: detail.document.title,
taskKey: targetDoc.taskKey,
version: `${detail.document.current_version_id || 1}`,
category: targetDoc.category,
creatorId: detail.document.owner_user_id,
creatorName: currentUser.name,
lifecycleState: targetDoc.lifecycleState,
isClaimed: true,
author: targetDoc.author,
reviewer: targetDoc.reviewer,
approver: targetDoc.approver,
});
fullAST.governanceMode = 'freeform';
fullAST.space = detail.document.space;
setDocCache((prev) => ({ ...prev, [id]: fullAST }));
}
})
.catch((err) => {
console.warn('获取自由文档详情失败,保持本地草稿骨架:', err);
});
}
}
}
};
// 一键将个人私密文档共享至项目协作参考库
const handleShareToProject = (docId: string) => {
const handleShareToProject = async (docId: string) => {
try {
if (docId.startsWith('doc-free-')) {
await shareFreeformDoc(docId);
}
} catch (e) {
console.error('Share doc error:', e);
}
const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16);
setDeliverables((prev) =>
prev.map((d) => (d.docId === docId ? { ...d, category: 'shared', sharedAt: nowStr } : d))
prev.map((d) => (d.docId === docId ? { ...d, category: 'shared', space: 'project_shared', sharedAt: nowStr } : d))
);
setDocCache((prev) => {
if (!prev[docId]) return prev;
return {
...prev,
[docId]: { ...prev[docId], category: 'shared', sharedAt: nowStr },
[docId]: { ...prev[docId], category: 'shared', space: 'project_shared', sharedAt: nowStr },
};
});
setActiveCategory('shared');
@@ -689,15 +738,22 @@ export default function App() {
};
// 取消共享 (恢复为个人私有草稿)
const handleRevokeShare = (docId: string) => {
const handleRevokeShare = async (docId: string) => {
try {
if (docId.startsWith('doc-free-')) {
await unshareFreeformDoc(docId);
}
} catch (e) {
console.error('Unshare doc error:', e);
}
setDeliverables((prev) =>
prev.map((d) => (d.docId === docId ? { ...d, category: 'private', sharedAt: undefined } : d))
prev.map((d) => (d.docId === docId ? { ...d, category: 'private', space: 'my_private', sharedAt: undefined } : d))
);
setDocCache((prev) => {
if (!prev[docId]) return prev;
return {
...prev,
[docId]: { ...prev[docId], category: 'private', sharedAt: undefined },
[docId]: { ...prev[docId], category: 'private', space: 'my_private', sharedAt: undefined },
};
});
setActiveCategory('private');
@@ -717,14 +773,163 @@ export default function App() {
};
// 删除私密文档
const handleDeleteDoc = (docId: string) => {
const handleDeleteDoc = async (docId: string) => {
try {
if (docId.startsWith('doc-free-')) {
await deleteFreeformDoc(docId);
}
} catch (e) {
console.error('Delete doc error:', e);
}
setDeliverables((prev) => prev.filter((d) => d.docId !== docId));
if (activeDocId === docId) {
setActiveDocId('doc-001');
setActiveCategory('workflow');
const remaining = deliverables.filter((d) => d.docId !== docId);
const fallback = remaining.find((d) => d.category === 'workflow') || remaining[0];
if (fallback) {
setActiveDocId(fallback.docId);
setActiveCategory(fallback.category);
}
}
};
// 单条智能体回复保存到“我的私密”文档
const handleSaveMessageToPrivate = async (message: ChatMessage) => {
try {
const serverId = message.serverId || 0;
let resData;
if (serverId > 0) {
try {
const resp = await saveMessageToPrivate(
serverId,
currentProjectId,
undefined,
message.contentSha256,
currentUser.id
);
resData = resp.data;
} catch (serverErr: any) {
console.warn('服务端自由文档落库失败,降级为本地草稿保存:', serverErr);
}
}
const docId = resData?.document.doc_id || `doc-free-${Date.now()}`;
const title = resData?.document.title || deriveReplyTitle(message.content, message.timestamp);
const markdown = resData?.content_markdown || message.content;
const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16);
const newDocItem: DeliverableItem = {
docId,
projectId: currentProjectId,
taskKey: 'freeform_private',
title,
stage: 'V0.1',
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
updatedAt: nowStr,
summary: message.content.slice(0, 120),
governanceMode: 'freeform',
space: 'my_private',
};
const newAST = markdownToBlockAST(markdown, {
docId,
title,
taskKey: 'freeform_private',
version: `${resData?.document.current_version_id || 1}`,
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
});
newAST.governanceMode = 'freeform';
newAST.space = 'my_private';
setDeliverables((prev) => {
const exists = prev.some((d) => d.docId === docId);
if (exists) return prev;
return [newDocItem, ...prev];
});
setDocCache((prev) => ({
...prev,
[docId]: newAST,
}));
setActiveDocId(docId);
setActiveCategory('private');
setDeckDrawerOpen(false);
} catch (err: any) {
console.error('保存到我的私密失败:', err);
throw err;
}
};
// 挂载或项目切换时,拉取服务端的自由文档(我的私密与项目共享)并同步至 deliverables
useEffect(() => {
let active = true;
const fetchDocs = async () => {
try {
const [privateSummaries, sharedSummaries] = await Promise.all([
listFreeformDocs('my_private', currentProjectId, currentUser.id).catch(() => []),
listFreeformDocs('project_shared', currentProjectId, currentUser.id).catch(() => []),
]);
if (!active) return;
const allSummaries = [...privateSummaries, ...sharedSummaries];
if (allSummaries.length === 0) return;
setDeliverables((prev) => {
let updated = [...prev];
for (const s of allSummaries) {
const exists = updated.find((d) => d.docId === s.doc_id);
const category = s.space === 'my_private' ? 'private' : 'shared';
if (!exists) {
updated.unshift({
docId: s.doc_id,
projectId: s.project_id || currentProjectId,
taskKey: 'freeform_doc',
title: s.title,
stage: 'V0.1',
category,
creatorId: s.owner_user_id || currentUser.id,
creatorName: currentUser.name,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
updatedAt: (s.updated_at || s.created_at || '').replace('T', ' ').substring(0, 16),
governanceMode: 'freeform',
space: s.space,
});
}
}
return updated;
});
} catch (e) {
console.warn('获取服务端自由文档列表异常:', e);
}
};
fetchDocs();
return () => {
active = false;
};
}, [currentProjectId, currentUser.id]);
// 模态弹窗与侧栏
const [editingBlock, setEditingBlock] = useState<DocumentBlock | null>(null);
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
@@ -1170,14 +1375,16 @@ export default function App() {
}
case 'done': {
settled = true;
const serverMsgId = ev.message_id || (ev as any).messageId;
const serverMsgId = ev.message_id || (ev as any).messageId || ev.done?.message_id;
if (typeof serverMsgId === 'number' && serverMsgId > 0) {
chatWindow.replaceMessageServerId(asstId, serverMsgId);
}
const exportCap = ev.export_capability || ev.done?.export_capability;
updateAsst({
status: 'completed',
livePhaseLabel: undefined,
serverId: typeof serverMsgId === 'number' ? serverMsgId : undefined,
exportCapability: exportCap,
});
setIsThinking(false);
break;
@@ -1828,6 +2035,18 @@ export default function App() {
}
};
// 导出单条智能体回复 DOCX (轻量专属 Word 导出)
const handleExportReplyDocx = async (message: ChatMessage) => {
try {
await downloadDocxReply(message.content || '', undefined, {
createdAt: message.createdAt || message.timestamp,
});
} catch (err: any) {
console.error('导出单条回复 DOCX 失败:', err);
}
};
// 导出 XLSX (Excel 渲染器)
const handleExportXlsx = async () => {
try {
@@ -2029,6 +2248,8 @@ export default function App() {
}}
selectedCategory={chatWindow.selectedCategory}
unreadMessageIds={unreadMessageIds}
onSaveToPrivate={handleSaveMessageToPrivate}
onExportDocx={handleExportReplyDocx}
/>
<CategoryDock
categories={categoryStats}
@@ -2128,26 +2349,39 @@ export default function App() {
highlightedBlockId={highlightedBlockId}
/>
{/* 底部法定流转动作条 (根据受控/共享/私密形态自适应展示) */}
<DocGovActionBar
ast={docAST}
currentRole={currentDocRole}
currentUserId={currentUser.id}
canOverrideVisibility={canOverrideVisibility}
onOpenVisibilityModal={() => {
const currentDoc = deliverables.find((d) => d.docId === activeDocId) || null;
setTargetDocForVisibility(currentDoc);
setDocVisibilityModalOpen(true);
}}
onClaim={handleClaim}
onSubmitReview={handleSubmitReview}
onPassReview={handlePassReview}
onRejectReview={handleRejectReview}
onApprove={handleApprove}
onRejectApprove={handleRejectReview}
onShareToProject={handleShareToProject}
onRevokeShare={handleRevokeShare}
/>
{/* 底部流转动作条 (受控流程文档使用 DocGovActionBar,自由私密/共享文档使用 FreeformDocumentActionBar) */}
{docAST.governanceMode === 'freeform' || docAST.space === 'my_private' || docAST.space === 'project_shared' || docAST.category === 'private' || docAST.category === 'shared' ? (
<FreeformDocumentActionBar
ast={docAST}
currentUserId={currentUser.id}
onShareToProject={handleShareToProject}
onRevokeShare={handleRevokeShare}
onDeleteDoc={handleDeleteDoc}
onCopyRich={() => copyRichReplyToClipboard(blockASTToMarkdown(docAST))}
onCopyMarkdown={() => copyMarkdownReply(blockASTToMarkdown(docAST))}
onDownloadMarkdown={() => downloadMarkdownReply(blockASTToMarkdown(docAST), docAST.title)}
/>
) : (
<DocGovActionBar
ast={docAST}
currentRole={currentDocRole}
currentUserId={currentUser.id}
canOverrideVisibility={canOverrideVisibility}
onOpenVisibilityModal={() => {
const currentDoc = deliverables.find((d) => d.docId === activeDocId) || null;
setTargetDocForVisibility(currentDoc);
setDocVisibilityModalOpen(true);
}}
onClaim={handleClaim}
onSubmitReview={handleSubmitReview}
onPassReview={handlePassReview}
onRejectReview={handleRejectReview}
onApprove={handleApprove}
onRejectApprove={handleRejectReview}
onShareToProject={handleShareToProject}
onRevokeShare={handleRevokeShare}
/>
)}
{/* 块就地编辑弹窗 (支持富文本工具栏、多维表格与未保存拦截) */}
{editingBlock && (
+15 -1
View File
@@ -1,4 +1,5 @@
import type { ChatErrorInfo } from '../types/workbench';
import type { MessageExportCapability } from '../types/replyExport';
import { fallbackErrorClass } from './chatRetry';
export type StreamEvent = {
@@ -34,7 +35,20 @@ export type StreamEvent = {
};
message_id?: number;
user_message_id?: number;
done?: { session_id?: string; duration_ms?: number; total_events?: number; workspace_id?: string; message_id?: number; user_message_id?: number };
content_revision?: number;
content_sha256?: string;
export_capability?: MessageExportCapability;
done?: {
session_id?: string;
duration_ms?: number;
total_events?: number;
workspace_id?: string;
message_id?: number;
user_message_id?: number;
content_revision?: number;
content_sha256?: string;
export_capability?: MessageExportCapability;
};
};
type ErrorAction = ChatErrorInfo['actions'][number];
+8
View File
@@ -5,6 +5,7 @@ import {
ChatDateStat,
ChatCategoryStat,
} from '../types/workbench';
import { MessageContentKind, MessageExportCapability } from '../types/replyExport';
export interface RawChatMessageRecord {
id: number;
@@ -18,6 +19,11 @@ export interface RawChatMessageRecord {
day_key: string;
category?: any;
created_at: string;
content_kind?: MessageContentKind;
export_policy?: 'full' | 'restricted' | 'none';
content_revision?: number;
content_sha256?: string;
export_capability?: MessageExportCapability;
}
export interface RawChatDayBucket {
@@ -71,6 +77,8 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage {
recommendedCases: meta.recommended_cases,
quotes: meta.quotes || (meta.quote ? [meta.quote] : undefined),
status: r.role === 'assistant' ? 'completed' : undefined,
contentKind: r.content_kind,
exportCapability: r.export_capability,
};
}
+218
View File
@@ -0,0 +1,218 @@
/**
* 自由文档 (Freeform Document) 前端 API 客户端
* 提供单条回复保存、私密列表、详情获取、编辑保存、共享流转及回收站接口
*/
export interface FreeformDocSummary {
doc_id: string;
project_id: number;
owner_user_id: number;
title: string;
space: 'my_private' | 'project_shared' | 'trash';
current_version_id: number;
current_sha256: string;
created_at: string;
updated_at: string;
}
export interface FreeformDocDetail {
document: {
doc_id: string;
project_id: number;
owner_user_id: number;
source_message_id?: number;
source_content_sha256: string;
title: string;
governance_mode: 'freeform';
space: 'my_private' | 'project_shared' | 'trash';
current_version_id: number;
current_sha256: string;
is_deleted: boolean;
created_at: string;
updated_at: string;
};
content_markdown: string;
}
export interface CreateFromMessageResponse {
code: number;
message: string;
is_existing: boolean;
data: FreeformDocDetail;
}
/**
* 从单条智能体回复保存为我的私密文档(幂等)
*/
export async function saveMessageToPrivate(
messageId: number,
projectId: number = 1,
title?: string,
expectedSha256?: string,
userId?: number
): Promise<CreateFromMessageResponse> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (userId) headers['X-User-ID'] = String(userId);
const res = await fetch('/api/v1/freeform-documents/from-message', {
method: 'POST',
headers,
body: JSON.stringify({
message_id: messageId,
project_id: projectId,
title: title || undefined,
expected_sha256: expectedSha256 || undefined,
}),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || `保存失败 (${res.status})`);
}
return res.json();
}
/**
* 按空间获取自由文档概要列表
*/
export async function listFreeformDocs(
space: 'my_private' | 'project_shared' | 'trash' = 'my_private',
projectId?: number,
userId?: number
): Promise<FreeformDocSummary[]> {
let url = `/api/v1/freeform-documents?space=${encodeURIComponent(space)}`;
if (projectId) {
url += `&project_id=${projectId}`;
}
const headers: Record<string, string> = {};
if (userId) headers['X-User-ID'] = String(userId);
const res = await fetch(url, { headers });
if (!res.ok) {
throw new Error(`获取列表失败 (${res.status})`);
}
const body = await res.json();
return body.data || [];
}
/**
* 获取自由文档详情与 Markdown 正文
*/
export async function fetchFreeformDoc(docId: string, userId?: number): Promise<FreeformDocDetail> {
const headers: Record<string, string> = {};
if (userId) headers['X-User-ID'] = String(userId);
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, { headers });
if (!res.ok) {
if (res.status === 403) {
throw new Error('该文档为他人私密文档,严格禁止跨用户访问');
}
if (res.status === 404) {
throw new Error('文档不存在或已被删除');
}
throw new Error(`读取文档失败 (${res.status})`);
}
const body = await res.json();
return body.data;
}
/**
* 更新自由文档正文或标题(支持 If-Match 乐观锁)
*/
export async function updateFreeformDoc(
docId: string,
payload: {
title?: string;
contentMarkdown?: string;
changeSummary?: string;
expectedVersion?: number;
expectedSha256?: string;
userId?: number;
}
): Promise<FreeformDocDetail> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (payload.userId) {
headers['X-User-ID'] = String(payload.userId);
}
if (payload.expectedVersion !== undefined) {
headers['If-Match'] = `"${payload.expectedVersion}"`;
} else if (payload.expectedSha256) {
headers['If-Match'] = `"${payload.expectedSha256}"`;
}
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, {
method: 'PUT',
headers,
body: JSON.stringify({
title: payload.title,
content_markdown: payload.contentMarkdown,
change_summary: payload.changeSummary,
expected_version: payload.expectedVersion,
expected_sha256: payload.expectedSha256,
}),
});
if (!res.ok) {
if (res.status === 409) {
throw new Error('文档已被更新产生版本冲突,请刷新后重试');
}
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || `保存失败 (${res.status})`);
}
const body = await res.json();
return body.data;
}
/**
* 一键共享至项目参考库
*/
export async function shareFreeformDoc(docId: string, userId?: number): Promise<void> {
const headers: Record<string, string> = {};
if (userId) headers['X-User-ID'] = String(userId);
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/share`, {
method: 'POST',
headers,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || `共享失败 (${res.status})`);
}
}
/**
* 撤回共享,恢复为个人私密
*/
export async function unshareFreeformDoc(docId: string, userId?: number): Promise<void> {
const headers: Record<string, string> = {};
if (userId) headers['X-User-ID'] = String(userId);
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/unshare`, {
method: 'POST',
headers,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || `撤回失败 (${res.status})`);
}
}
/**
* 软删除文档移至回收站
*/
export async function deleteFreeformDoc(docId: string, userId?: number): Promise<void> {
const headers: Record<string, string> = {};
if (userId) headers['X-User-ID'] = String(userId);
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, {
method: 'DELETE',
headers,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || `删除失败 (${res.status})`);
}
}
+16
View File
@@ -3,6 +3,7 @@ import { ChatErrorInfo, ChatMessage } from '../../types/workbench';
import { PhaseEventStrip } from './PhaseEventStrip';
import { RecommendedCaseCard } from './RecommendedCaseCard';
import { InteractiveCard } from './InteractiveCard';
import { ReplyActionBar } from './ReplyActionBar';
import { FileText, Bot, User, Loader2, AlertTriangle, Copy, Check, Quote as QuoteIcon } from 'lucide-react';
import { renderInlineMarkdown } from '../../engine/renderMarkdown';
import { QuoteCard } from './QuoteCard';
@@ -18,6 +19,9 @@ export interface MessageBubbleProps {
isCategoryDimmed?: boolean;
/** 是否为未读消息(左侧橙色标记边框) */
isUnread?: boolean;
onSaveToPrivate?: (message: ChatMessage) => void;
onExportDocx?: (message: ChatMessage) => void;
onExportPdf?: (message: ChatMessage) => void;
}
const ERROR_ACTION_LABEL: Record<'retry' | 'continue' | 'restart', string> = {
@@ -42,6 +46,9 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
onNavigateToAnchor,
isCategoryDimmed = false,
isUnread = false,
onSaveToPrivate,
onExportDocx,
onExportPdf,
}) => {
const isUser = message.role === 'user';
const status = message.status;
@@ -361,6 +368,15 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
)}
{/* 单条回复操作栏:仅助手正文、非纯卡片且 policy !== 'none' 时挂载,严格位于正文下方、辅助卡片上方 */}
{!isUser && !isWaiting && !isFailed && !isCancelled && hasContent && message.contentKind !== 'action_card' && message.exportCapability?.policy !== 'none' && (
<ReplyActionBar
message={message}
onSaveToPrivate={onSaveToPrivate}
onExportDocx={onExportDocx}
onExportPdf={onExportPdf}
/>
)}
</>
)}
@@ -24,6 +24,9 @@ export interface MessageStreamProps {
selectedCategory?: MessageCategory | 'all';
/** P3 / Step 5: 未读消息 ID 集合 */
unreadMessageIds?: number[];
onSaveToPrivate?: (message: ChatMessage) => void;
onExportDocx?: (message: ChatMessage) => void;
onExportPdf?: (message: ChatMessage) => void;
}
export const MessageStream: React.FC<MessageStreamProps> = ({
@@ -40,6 +43,9 @@ export const MessageStream: React.FC<MessageStreamProps> = ({
onNavigateToAnchor,
selectedCategory = 'all',
unreadMessageIds = [],
onSaveToPrivate,
onExportDocx,
onExportPdf,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const isInitialMountRef = useRef<boolean>(true);
@@ -246,6 +252,9 @@ export const MessageStream: React.FC<MessageStreamProps> = ({
selectedCategory !== 'all' && msg.category !== selectedCategory
}
isUnread={msg.serverId ? unreadSet.has(msg.serverId) : false}
onSaveToPrivate={onSaveToPrivate}
onExportDocx={onExportDocx}
onExportPdf={onExportPdf}
/>
</div>
);
+397
View File
@@ -0,0 +1,397 @@
import React, { useState, useRef, useEffect } from 'react';
import { Copy, Check, Bookmark, Download, ChevronDown, FileText, FileCode } from 'lucide-react';
import { ChatMessage } from '../../types/workbench';
import { copyRichReplyToClipboard, copyMarkdownReply } from '../../features/replyExport/clipboard';
import { downloadMarkdownReply, downloadDocxReply } from '../../features/replyExport/download';
export interface ReplyActionBarProps {
message: ChatMessage;
onSaveToPrivate?: (message: ChatMessage) => void | Promise<void>;
onExportDocx?: (message: ChatMessage) => void | Promise<void>;
onExportPdf?: (message: ChatMessage) => void | Promise<void>;
}
export const ReplyActionBar: React.FC<ReplyActionBarProps> = ({
message,
onSaveToPrivate,
onExportDocx,
onExportPdf,
}) => {
const [copiedMode, setCopiedMode] = useState<string | null>(null);
const [showCopyMenu, setShowCopyMenu] = useState(false);
const [showDownloadMenu, setShowDownloadMenu] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const copyMenuRef = useRef<HTMLDivElement>(null);
const downloadMenuRef = useRef<HTMLDivElement>(null);
// 点击外部自动关闭下拉菜单
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (copyMenuRef.current && !copyMenuRef.current.contains(e.target as Node)) {
setShowCopyMenu(false);
}
if (downloadMenuRef.current && !downloadMenuRef.current.contains(e.target as Node)) {
setShowDownloadMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleCopyRich = async (e?: React.MouseEvent) => {
e?.stopPropagation();
setShowCopyMenu(false);
const res = await copyRichReplyToClipboard(message.content || '');
if (res.success) {
setCopiedMode('rich');
setTimeout(() => setCopiedMode(null), 1500);
}
};
const handleCopyMarkdown = async (e?: React.MouseEvent) => {
e?.stopPropagation();
setShowCopyMenu(false);
const res = await copyMarkdownReply(message.content || '');
if (res.success) {
setCopiedMode('md');
setTimeout(() => setCopiedMode(null), 1500);
}
};
const handleDownloadMd = (e: React.MouseEvent) => {
e.stopPropagation();
setShowDownloadMenu(false);
downloadMarkdownReply(message.content || '', undefined, {
createdAt: message.createdAt || message.timestamp,
});
};
const handleSavePrivate = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isSaving || saveSuccess) return;
setIsSaving(true);
setSaveError(null);
try {
if (onSaveToPrivate) {
await onSaveToPrivate(message);
}
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2500);
} catch (err: any) {
console.error('保存到我的私密失败:', err);
setSaveError(err.message || '保存失败');
setTimeout(() => setSaveError(null), 3000);
} finally {
setIsSaving(false);
}
};
return (
<div
role="toolbar"
aria-label="单条智能体回复操作栏"
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
paddingTop: '6px',
marginTop: '6px',
borderTop: '1px solid rgba(0, 0, 0, 0.06)',
fontSize: '11.5px',
}}
>
{/* 1. 复制按钮组 (主按钮带格式复制 + 下拉选择复制 Markdown) */}
<div ref={copyMenuRef} style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
<button
type="button"
onClick={handleCopyRich}
aria-label="带格式复制此回复"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
borderRadius: '4px 0 0 4px',
border: '1px solid var(--border)',
borderRight: 'none',
background: copiedMode ? 'rgba(34, 197, 94, 0.1)' : 'var(--bg-subtle)',
color: copiedMode ? 'var(--success)' : 'var(--text-muted)',
cursor: 'pointer',
fontWeight: 500,
transition: 'all 0.15s ease',
}}
title="带格式复制(可直接粘贴到 Word、邮件等富文本编辑器)"
>
{copiedMode ? <Check size={12} /> : <Copy size={12} />}
<span>{copiedMode === 'md' ? '已复制 MD' : copiedMode === 'rich' ? '已带格式复制' : '复制'}</span>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowCopyMenu((prev) => !prev);
}}
aria-label="更多复制选项"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '4px 4px',
borderRadius: '0 4px 4px 0',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--text-muted)',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
<ChevronDown size={11} />
</button>
{showCopyMenu && (
<div
style={{
position: 'absolute',
left: 0,
bottom: '100%',
marginBottom: '4px',
minWidth: '130px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: '6px',
boxShadow: 'var(--shadow-md)',
zIndex: 50,
padding: '4px',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
<button
type="button"
onClick={handleCopyRich}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '5px 8px',
borderRadius: '4px',
border: 'none',
background: 'transparent',
color: 'var(--text)',
fontSize: '11.5px',
cursor: 'pointer',
textAlign: 'left',
}}
>
<FileText size={12} style={{ color: 'var(--brand-500)' }} />
<span>带格式复制 (默认)</span>
</button>
<button
type="button"
onClick={handleCopyMarkdown}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '5px 8px',
borderRadius: '4px',
border: 'none',
background: 'transparent',
color: 'var(--text)',
fontSize: '11.5px',
cursor: 'pointer',
textAlign: 'left',
}}
>
<FileCode size={12} style={{ color: 'var(--amber-600)' }} />
<span>复制 Markdown</span>
</button>
</div>
)}
</div>
{/* 2. 保存到我的私密 */}
<button
type="button"
onClick={handleSavePrivate}
disabled={isSaving}
aria-label="一键保存到我的私密文档库"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: saveError
? 'rgba(239, 68, 68, 0.1)'
: saveSuccess
? 'rgba(34, 197, 94, 0.1)'
: 'var(--bg-subtle)',
color: saveError ? 'var(--danger)' : saveSuccess ? 'var(--success)' : 'var(--text-muted)',
cursor: isSaving ? 'wait' : 'pointer',
fontWeight: 500,
transition: 'all 0.15s ease',
}}
title="存入个人私密文档库,仅您本人可见,随时在右侧栏编辑与下载"
>
{saveError ? (
<span>{saveError}</span>
) : saveSuccess ? (
<>
<Check size={12} />
<span>已保存私密</span>
</>
) : (
<>
<Bookmark size={12} />
<span>{isSaving ? '正在保存…' : '保存到我的私密'}</span>
</>
)}
</button>
{/* 3. 下载下拉菜单 (MD / DOCX / PDF) */}
<div ref={downloadMenuRef} style={{ position: 'relative' }}>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowDownloadMenu((prev) => !prev);
}}
aria-label="下载回复为文件"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
padding: '4px 8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--text-muted)',
cursor: 'pointer',
fontWeight: 500,
transition: 'all 0.15s ease',
}}
>
<Download size={12} />
<span>下载</span>
<ChevronDown size={11} />
</button>
{showDownloadMenu && (
<div
style={{
position: 'absolute',
left: 0,
bottom: '100%',
marginBottom: '4px',
minWidth: '140px',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: '6px',
boxShadow: 'var(--shadow-md)',
zIndex: 50,
padding: '4px',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
<button
type="button"
onClick={handleDownloadMd}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '5px 8px',
borderRadius: '4px',
border: 'none',
background: 'transparent',
color: 'var(--text)',
fontSize: '11.5px',
cursor: 'pointer',
textAlign: 'left',
}}
>
<FileCode size={12} style={{ color: 'var(--brand-500)' }} />
<span>Markdown (.md)</span>
</button>
<button
type="button"
onClick={async (e) => {
e.stopPropagation();
setShowDownloadMenu(false);
try {
if (onExportDocx) {
await onExportDocx(message);
} else {
await downloadDocxReply(message.content || '', undefined, {
createdAt: message.createdAt || message.timestamp,
});
}
} catch (err: any) {
console.error('导出 DOCX 失败:', err);
alert('导出 DOCX 失败: ' + (err.message || '未知错误'));
}
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '5px 8px',
borderRadius: '4px',
border: 'none',
background: 'transparent',
color: 'var(--text)',
fontSize: '11.5px',
cursor: 'pointer',
textAlign: 'left',
}}
>
<FileText size={12} style={{ color: '#2563eb' }} />
<span>Word (.docx)</span>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowDownloadMenu(false);
if (onExportPdf) {
onExportPdf(message);
} else {
alert('PDF 隔离 Worker 排版导出正在就绪中');
}
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '5px 8px',
borderRadius: '4px',
border: 'none',
background: 'transparent',
color: 'var(--text)',
fontSize: '11.5px',
cursor: 'pointer',
textAlign: 'left',
}}
>
<FileText size={12} style={{ color: '#dc2626' }} />
<span>PDF (.pdf)</span>
</button>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,332 @@
import React, { useState } from 'react';
import { DocumentAST } from '../../types/block';
import {
Lock,
Globe,
Share2,
Undo2,
Trash2,
Copy,
Check,
Download,
FileText,
FileCode,
} from 'lucide-react';
export interface FreeformDocumentActionBarProps {
ast: DocumentAST;
currentUserId?: number;
onShareToProject?: (docId: string) => void;
onRevokeShare?: (docId: string) => void;
onDeleteDoc?: (docId: string) => void;
onCopyRich?: () => void;
onCopyMarkdown?: () => void;
onDownloadMarkdown?: () => void;
onDownloadDocx?: () => void;
}
export const FreeformDocumentActionBar: React.FC<FreeformDocumentActionBarProps> = ({
ast,
currentUserId,
onShareToProject,
onRevokeShare,
onDeleteDoc,
onCopyRich,
onCopyMarkdown,
onDownloadMarkdown,
onDownloadDocx,
}) => {
const [copiedType, setCopiedType] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const isOwner = !currentUserId || !ast.creatorId || currentUserId === ast.creatorId;
const isPrivate = ast.space === 'my_private' || ast.category === 'private';
const isShared = ast.space === 'project_shared' || ast.category === 'shared';
const handleCopyRich = () => {
onCopyRich?.();
setCopiedType('rich');
setTimeout(() => setCopiedType(null), 1500);
};
const handleCopyMarkdown = () => {
onCopyMarkdown?.();
setCopiedType('md');
setTimeout(() => setCopiedType(null), 1500);
};
return (
<div
role="toolbar"
aria-label="自由私密文档操作栏"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
background: 'var(--bg-panel)',
borderTop: '1px solid var(--border)',
boxShadow: '0 -2px 8px rgba(0, 0, 0, 0.03)',
fontSize: '12.5px',
flexWrap: 'wrap',
gap: '10px',
}}
>
{/* 左侧:空间归属态与版本快照 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{isPrivate ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '3px 8px',
borderRadius: 'var(--r-pill)',
background: '#FEF3C7',
color: '#D97706',
fontWeight: 600,
fontSize: '11px',
}}
>
<Lock size={12} />
<span>我的私密 (商业与推演隐私 · 仅本人可见)</span>
</span>
) : isShared ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '3px 8px',
borderRadius: 'var(--r-pill)',
background: 'rgba(79, 107, 255, 0.1)',
color: 'var(--brand-600)',
fontWeight: 600,
fontSize: '11px',
}}
>
<Globe size={12} />
<span>项目共享参考 (全员可读参考资料)</span>
</span>
) : null}
<span style={{ fontSize: '11.5px', color: 'var(--text-muted)' }}>
版本: <b>v{ast.version || '1'}</b>
</span>
{ast.updatedAt && (
<span style={{ fontSize: '11px', color: 'var(--text-faint)' }}>
更新于 {ast.updatedAt}
</span>
)}
</div>
{/* 右侧:复制、导出与流转操作 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{/* 复制组 */}
<button
type="button"
onClick={handleCopyRich}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '5px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: copiedType === 'rich' ? 'rgba(34, 197, 94, 0.1)' : 'var(--bg-subtle)',
color: copiedType === 'rich' ? 'var(--success)' : 'var(--text-strong)',
cursor: 'pointer',
fontWeight: 500,
fontSize: '12px',
}}
title="带格式复制(可直接粘贴至 Word、邮件等富文本编辑器)"
>
{copiedType === 'rich' ? <Check size={13} /> : <Copy size={13} />}
<span>{copiedType === 'rich' ? '已复制' : '带格式复制'}</span>
</button>
<button
type="button"
onClick={handleCopyMarkdown}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '5px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: copiedType === 'md' ? 'rgba(34, 197, 94, 0.1)' : 'var(--bg-subtle)',
color: copiedType === 'md' ? 'var(--success)' : 'var(--text-strong)',
cursor: 'pointer',
fontWeight: 500,
fontSize: '12px',
}}
title="复制 Markdown 源码"
>
{copiedType === 'md' ? <Check size={13} /> : <FileCode size={13} />}
<span>{copiedType === 'md' ? '已复制' : '复制 MD'}</span>
</button>
{/* 导出组 */}
{onDownloadMarkdown && (
<button
type="button"
onClick={onDownloadMarkdown}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '5px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--text-strong)',
cursor: 'pointer',
fontWeight: 500,
fontSize: '12px',
}}
title="下载 Markdown (.md) 文件"
>
<Download size={13} />
<span>下载 MD</span>
</button>
)}
{onDownloadDocx && (
<button
type="button"
onClick={onDownloadDocx}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '5px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--text-strong)',
cursor: 'pointer',
fontWeight: 500,
fontSize: '12px',
}}
title="下载 Word (.docx) 文档"
>
<FileText size={13} />
<span>下载 Word</span>
</button>
)}
{/* 空间流转动作 */}
{isPrivate && isOwner && onShareToProject && (
<button
type="button"
onClick={() => onShareToProject(ast.docId)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '5px 12px',
borderRadius: 'var(--r-sm)',
border: 'none',
background: 'var(--brand-500)',
color: '#ffffff',
cursor: 'pointer',
fontWeight: 600,
fontSize: '12px',
boxShadow: '0 2px 6px rgba(79, 107, 255, 0.25)',
}}
title="将该私密文档共享给项目组作为协作参考资料"
>
<Share2 size={13} />
<span>共享到项目</span>
</button>
)}
{isShared && isOwner && onRevokeShare && (
<button
type="button"
onClick={() => onRevokeShare(ast.docId)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '5px 12px',
borderRadius: 'var(--r-sm)',
border: '1px solid #D97706',
background: '#FEF3C7',
color: '#D97706',
cursor: 'pointer',
fontWeight: 600,
fontSize: '12px',
}}
title="撤回项目共享,重新恢复为仅自己可见的私密文档"
>
<Undo2 size={13} />
<span>撤回为私密</span>
</button>
)}
{/* 删除到回收站 */}
{isOwner && onDeleteDoc && (
confirmDelete ? (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '11px', color: 'var(--danger)' }}>确定移至回收站?</span>
<button
type="button"
onClick={() => onDeleteDoc(ast.docId)}
style={{
padding: '4px 8px',
borderRadius: 'var(--r-sm)',
border: 'none',
background: 'var(--danger)',
color: '#ffffff',
fontSize: '11px',
fontWeight: 600,
cursor: 'pointer',
}}
>
确定
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
style={{
padding: '4px 8px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--text-muted)',
fontSize: '11px',
cursor: 'pointer',
}}
>
取消
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '5px 8px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--text-faint)',
cursor: 'pointer',
fontSize: '12px',
}}
title="删除文档移至回收站"
>
<Trash2 size={13} />
</button>
)
)}
</div>
</div>
);
};
@@ -0,0 +1,329 @@
import { ReplyDocumentIR, ReplyNode, ReplyInline } from '../../types/replyExport';
import { sanitizeLinkHref, stripUnsafeHtml } from './sanitize';
/**
* 将规范化 Markdown 解析为 Reply Document IR
* 不依赖 DOM,可在浏览器、Web Worker 以及 Node/SSR 环境下运行
*/
export function parseReplyMarkdown(markdown: string, defaultTitle?: string): ReplyDocumentIR {
const cleanMd = stripUnsafeHtml(markdown || '').replace(/\r\n/g, '\n');
const lines = cleanMd.split('\n');
const nodes: ReplyNode[] = [];
let title = defaultTitle || '';
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// 空行跳过
if (trimmed === '') {
i++;
continue
}
// 1. 代码块与 Mermaid 围栏
if (trimmed.startsWith('```')) {
const info = trimmed.slice(3).trim();
const isMermaid = info === 'mermaid';
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].trim().startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
i++; // 跳过闭合 ```
const rawCode = codeLines.join('\n');
if (isMermaid) {
nodes.push({ type: 'mermaid', source: rawCode });
} else {
nodes.push({ type: 'code', language: info || undefined, value: rawCode });
}
continue;
}
// 2. 独立块级数学公式 $$ ... $$
if (trimmed.startsWith('$$')) {
const mathLines: string[] = [];
if (trimmed.length > 2 && trimmed.endsWith('$$')) {
// 单行 $$公式$$
mathLines.push(trimmed.slice(2, -2).trim());
i++;
} else {
i++;
while (i < lines.length && !lines[i].trim().endsWith('$$')) {
mathLines.push(lines[i]);
i++;
}
if (i < lines.length) {
const last = lines[i].trim();
mathLines.push(last.slice(0, -2).trim());
i++;
}
}
nodes.push({ type: 'math', display: true, latex: mathLines.join('\n').trim() });
continue;
}
// 3. 分割线
if (/^(\*{3,}|-{3,}|_{3,})$/.test(trimmed)) {
nodes.push({ type: 'divider' });
i++;
continue;
}
// 4. 标题 (# ~ ######)
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length as 1 | 2 | 3 | 4 | 5 | 6;
const headingText = headingMatch[2].trim();
if (!title && (level === 1 || level === 2)) {
title = headingText.replace(/[*_`~]/g, '');
}
nodes.push({
type: 'heading',
level,
children: parseInlineContent(headingText),
});
i++;
continue;
}
// 5. 引用块 (> ...)
if (trimmed.startsWith('>')) {
const quoteLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith('>')) {
quoteLines.push(lines[i].trim().replace(/^>\s?/, ''));
i++;
}
// 递归解析引用块内子节点
const subIR = parseReplyMarkdown(quoteLines.join('\n'));
nodes.push({
type: 'blockquote',
children: subIR.nodes,
});
continue;
}
// 6. 表格 (| ... |)
if (trimmed.startsWith('|') && trimmed.endsWith('|') && trimmed.includes('|')) {
const tableLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith('|') && lines[i].trim().endsWith('|')) {
tableLines.push(lines[i].trim());
i++;
}
if (tableLines.length >= 2) {
const headerCols = splitTableRow(tableLines[0]);
// 第二行通常是分隔线 |---|---|
let startIndex = 1;
if (/^\|(\s*:?-+:?\s*\|)+$/.test(tableLines[1])) {
startIndex = 2;
}
const rows: ReplyInline[][][] = [];
for (let r = startIndex; r < tableLines.length; r++) {
const cells = splitTableRow(tableLines[r]);
rows.push(cells.map((c) => parseInlineContent(c)));
}
nodes.push({
type: 'table',
header: headerCols.map((c) => parseInlineContent(c)),
rows,
});
continue;
}
}
// 7. 列表项 (有序 1. 或 无序 - / * )
const listOrderedMatch = trimmed.match(/^(\d+)\.\s+(.+)$/);
const listUnorderedMatch = trimmed.match(/^[-*+]\s+(.+)$/);
if (listOrderedMatch || listUnorderedMatch) {
const isOrdered = !!listOrderedMatch;
const items: ReplyInline[][] = [];
while (i < lines.length) {
const curTrim = lines[i].trim();
const mOrder = curTrim.match(/^(\d+)\.\s+(.+)$/);
const mUnorder = curTrim.match(/^[-*+]\s+(.+)$/);
if (isOrdered && mOrder) {
items.push(parseInlineContent(mOrder[2]));
i++;
} else if (!isOrdered && mUnorder) {
items.push(parseInlineContent(mUnorder[1]));
i++;
} else if (curTrim === '') {
// 列表中的空行探测后续是否还是同类列表项
if (i + 1 < lines.length) {
const nextTrim = lines[i + 1].trim();
if ((isOrdered && nextTrim.match(/^(\d+)\.\s+/)) || (!isOrdered && nextTrim.match(/^[-*+]\s+/))) {
i++;
continue;
}
}
break;
} else {
break;
}
}
nodes.push({
type: 'list',
ordered: isOrdered,
items,
});
continue;
}
// 8. 普通段落 (汇聚连续非空普通文本行)
const paraLines: string[] = [];
while (i < lines.length) {
const cur = lines[i];
const curTrim = cur.trim();
if (
curTrim === '' ||
curTrim.startsWith('#') ||
curTrim.startsWith('```') ||
curTrim.startsWith('$$') ||
curTrim.startsWith('>') ||
(curTrim.startsWith('|') && curTrim.endsWith('|')) ||
curTrim.match(/^(\d+)\.\s+/) ||
curTrim.match(/^[-*+]\s+/) ||
/^(\*{3,}|-{3,}|_{3,})$/.test(curTrim)
) {
break;
}
paraLines.push(curTrim);
i++;
}
if (paraLines.length > 0) {
const paraText = paraLines.join(' ');
if (!title) {
const plain = paraText.replace(/[*_`~]/g, '');
title = plain.length > 30 ? plain.slice(0, 30) : plain;
}
nodes.push({
type: 'paragraph',
children: parseInlineContent(paraText),
});
}
}
if (!title) {
title = '智能体回复摘录';
}
return { title, nodes };
}
/**
* 分割 Markdown 表格行 (| cell1 | cell2 |)
*/
function splitTableRow(rowStr: string): string[] {
let inner = rowStr.trim();
if (inner.startsWith('|')) inner = inner.slice(1);
if (inner.endsWith('|')) inner = inner.slice(0, -1);
return inner.split('|').map((s) => s.trim());
}
/**
* 解析行内内容:粗体、斜体、删除线、行内代码、行内公式、超链接
*/
export function parseInlineContent(text: string): ReplyInline[] {
const inlines: ReplyInline[] = [];
let remaining = text;
while (remaining.length > 0) {
// 1. 行内公式 $...$
const mathMatch = remaining.match(/^\$([^$]+)\$/);
if (mathMatch) {
inlines.push({ type: 'math', latex: mathMatch[1] });
remaining = remaining.slice(mathMatch[0].length);
continue;
}
// 2. 行内代码 `...`
const codeMatch = remaining.match(/^`([^`]+)`/);
if (codeMatch) {
inlines.push({ type: 'code', value: codeMatch[1] });
remaining = remaining.slice(codeMatch[0].length);
continue;
}
// 3. 超链接 [text](url)
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
const safeHref = sanitizeLinkHref(linkMatch[2]);
inlines.push({
type: 'link',
href: safeHref,
children: [{ type: 'text', value: linkMatch[1] }],
});
remaining = remaining.slice(linkMatch[0].length);
continue;
}
// 4. 粗斜体 ***text*** 或 ___text___
const boldItalicMatch = remaining.match(/^(\*\*\*|___)(.+?)\1/);
if (boldItalicMatch) {
inlines.push({
type: 'text',
value: boldItalicMatch[2],
bold: true,
italic: true,
});
remaining = remaining.slice(boldItalicMatch[0].length);
continue;
}
// 5. 粗体 **text** 或 __text__
const boldMatch = remaining.match(/^(\*\*|__)(.+?)\1/);
if (boldMatch) {
inlines.push({
type: 'text',
value: boldMatch[2],
bold: true,
});
remaining = remaining.slice(boldMatch[0].length);
continue;
}
// 6. 斜体 *text* 或 _text_
const italicMatch = remaining.match(/^(\*|_)([^*_]+?)\1/);
if (italicMatch) {
inlines.push({
type: 'text',
value: italicMatch[2],
italic: true,
});
remaining = remaining.slice(italicMatch[0].length);
continue;
}
// 7. 删除线 ~~text~~
const strikeMatch = remaining.match(/^~~(.+?)~~/);
if (strikeMatch) {
inlines.push({
type: 'text',
value: strikeMatch[1],
strike: true,
});
remaining = remaining.slice(strikeMatch[0].length);
continue;
}
// 8. 普通文本(直到遇到下一个特殊标记符号)
const nextSpecialIndex = remaining.search(/[\$`\[*_~]/);
if (nextSpecialIndex === -1) {
inlines.push({ type: 'text', value: remaining });
break;
} else if (nextSpecialIndex > 0) {
inlines.push({ type: 'text', value: remaining.slice(0, nextSpecialIndex) });
remaining = remaining.slice(nextSpecialIndex);
} else {
// 当前第一个字符是未匹配成功的标记符号(如单个 * 或 $),作为普通文本吃掉
inlines.push({ type: 'text', value: remaining[0] });
remaining = remaining.slice(1);
}
}
return inlines;
}
@@ -0,0 +1,179 @@
import katex from 'katex';
import { ReplyDocumentIR, ReplyNode, ReplyInline } from '../../types/replyExport';
import { escapeHtml } from './sanitize';
/**
* renderLatexToMathML: 将 LaTeX 源码转为标准 MathML (<math xmlns="http://www.w3.org/1998/Math/MathML">)
* Microsoft Word (2016+/365)、WPS、Outlook 在粘贴 HTML 时原生解析 MathML 为矢量数学公式 (OMML Equation)
*/
export function renderLatexToMathML(latex: string, isDisplay: boolean): string {
if (!latex || !latex.trim()) return '';
const trimmed = latex.trim();
try {
const raw = katex.renderToString(trimmed, {
output: 'mathml',
displayMode: isDisplay,
throwOnError: false,
});
// KaTeX outputs <span class="katex"><math ...>...</math></span>
const mathMatch = raw.match(/<math[\s\S]*?<\/math>/);
if (mathMatch) {
let mathTag = mathMatch[0];
// 确保携带标准 MathML 命名空间属性
if (!mathTag.includes('xmlns="http://www.w3.org/1998/Math/MathML"')) {
mathTag = mathTag.replace('<math', '<math xmlns="http://www.w3.org/1998/Math/MathML"');
}
if (isDisplay) {
return `<div class="math-block" style="text-align: center; margin: 14px 0; padding: 6px 0;">${mathTag}</div>`;
}
return mathTag;
}
return raw;
} catch (_e) {
if (isDisplay) {
return `<div class="math-block" style="text-align: center; margin: 12px 0;"><code>$$ ${escapeHtml(trimmed)} $$</code></div>`;
}
return `<span class="math-inline">$${escapeHtml(trimmed)}$</span>`;
}
}
/**
* 将 Reply Document IR 渲染为专门用于富文本剪贴板 (text/html) 的办公友好 HTML
* 适用于粘贴到 Microsoft Word、Outlook、WPS、飞书文档及各类富文本编辑器
*/
export function renderClipboardHtml(ir: ReplyDocumentIR): string {
const bodyHtml = ir.nodes.map(renderNodeToHtml).join('\n');
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 11pt;
line-height: 1.6;
color: #1f2937;
}
h1 { font-size: 18pt; margin: 18px 0 8px; color: #111827; font-weight: bold; }
h2 { font-size: 15pt; margin: 14px 0 6px; color: #1f2937; font-weight: bold; }
h3 { font-size: 13pt; margin: 12px 0 4px; color: #374151; font-weight: bold; }
h4, h5, h6 { font-size: 11pt; margin: 10px 0 4px; color: #4b5563; font-weight: bold; }
p { margin: 8px 0; }
ul, ol { margin: 8px 0 8px 24px; padding: 0; }
li { margin: 4px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
th, td { border: 1px solid #d1d5db; padding: 6px 10px; font-size: 10pt; text-align: left; vertical-align: top; }
th { background-color: #f3f4f6; font-weight: bold; color: #111827; }
blockquote { border-left: 3px solid #6366f1; padding: 4px 12px; margin: 8px 0; color: #4b5563; background-color: #f9fafb; font-style: italic; }
pre { background-color: #f8fafc; border: 1px solid #e2e8f0; padding: 10px; border-radius: 4px; font-family: Consolas, "Courier New", monospace; font-size: 9.5pt; overflow-x: auto; margin: 8px 0; }
code { background-color: #f3f4f6; padding: 2px 4px; border-radius: 3px; font-family: Consolas, "Courier New", monospace; font-size: 9.5pt; color: #b91c1c; }
hr { border: none; border-top: 1px solid #e5e7eb; margin: 16px 0; }
a { color: #2563eb; text-decoration: underline; }
.math-block { text-align: center; margin: 14px 0; padding: 6px 0; }
.math-inline { font-family: "Cambria Math", "Times New Roman", serif; }
</style>
</head>
<body>
${bodyHtml}
</body>
</html>`;
}
function renderNodeToHtml(node: ReplyNode): string {
switch (node.type) {
case 'heading': {
const tag = `h${node.level}`;
return `<${tag}>${node.children.map(renderInlineToHtml).join('')}</${tag}>`;
}
case 'paragraph': {
return `<p style="margin: 8px 0;">${node.children.map(renderInlineToHtml).join('')}</p>`;
}
case 'list': {
const tag = node.ordered ? 'ol' : 'ul';
const itemsHtml = node.items
.map((item) => `<li style="margin: 4px 0;">${item.map(renderInlineToHtml).join('')}</li>`)
.join('\n');
return `<${tag} style="margin: 8px 0 8px 24px; padding: 0;">${itemsHtml}</${tag}>`;
}
case 'table': {
const headerHtml =
node.header.length > 0
? `<thead><tr>${node.header
.map(
(c) =>
`<th style="border: 1px solid #d1d5db; padding: 6px 10px; background-color: #f3f4f6; font-weight: bold; color: #111827; text-align: left; vertical-align: top;">${c
.map(renderInlineToHtml)
.join('')}</th>`
)
.join('')}</tr></thead>`
: '';
const rowsHtml = node.rows
.map(
(row) =>
`<tr>${row
.map(
(c) =>
`<td style="border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; vertical-align: top;">${c
.map(renderInlineToHtml)
.join('')}</td>`
)
.join('')}</tr>`
)
.join('\n');
return `<table style="border-collapse: collapse; width: 100%; margin: 12px 0; border: 1px solid #d1d5db;">\n${headerHtml}\n<tbody>\n${rowsHtml}\n</tbody>\n</table>`;
}
case 'blockquote': {
const inner = node.children.map(renderNodeToHtml).join('\n');
return `<blockquote style="border-left: 3px solid #6366f1; padding: 4px 12px; margin: 8px 0; color: #4b5563; background-color: #f9fafb; font-style: italic;">${inner}</blockquote>`;
}
case 'code': {
return `<pre style="background-color: #f8fafc; border: 1px solid #e2e8f0; padding: 10px; border-radius: 4px; font-family: Consolas, 'Courier New', monospace; font-size: 9.5pt; overflow-x: auto; margin: 8px 0;"><code style="background-color: transparent; padding: 0; color: #1f2937;">${escapeHtml(
node.value
)}</code></pre>`;
}
case 'math': {
return renderLatexToMathML(node.latex, node.display);
}
case 'mermaid': {
return `<pre style="background-color: #f8fafc; border: 1px solid #e2e8f0; padding: 10px; border-radius: 4px; font-family: Consolas, monospace; font-size: 9.5pt; margin: 8px 0;"><code>[Mermaid 图表]\n${escapeHtml(
node.source
)}</code></pre>`;
}
case 'divider': {
return '<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;">';
}
default:
return '';
}
}
function renderInlineToHtml(inline: ReplyInline): string {
switch (inline.type) {
case 'text': {
let val = escapeHtml(inline.value);
if (inline.bold) val = `<strong style="font-weight: bold;">${val}</strong>`;
if (inline.italic) val = `<em style="font-style: italic;">${val}</em>`;
if (inline.strike) val = `<s style="text-decoration: line-through;">${val}</s>`;
return val;
}
case 'code': {
return `<code style="background-color: #f3f4f6; padding: 2px 4px; border-radius: 3px; font-family: Consolas, 'Courier New', monospace; font-size: 9.5pt; color: #b91c1c;">${escapeHtml(
inline.value
)}</code>`;
}
case 'math': {
return renderLatexToMathML(inline.latex, false);
}
case 'link': {
const inner = inline.children.map(renderInlineToHtml).join('');
return `<a href="${escapeHtml(inline.href)}" style="color: #2563eb; text-decoration: underline;">${inner}</a>`;
}
case 'hardBreak': {
return '<br>';
}
default:
return '';
}
}
@@ -0,0 +1,78 @@
import { ReplyDocumentIR, ReplyNode, ReplyInline } from '../../types/replyExport';
/**
* 将 Reply Document IR 渲染为干净的可读纯文本 (用于 text/plain 剪贴板项与降级)
*/
export function renderPlainText(ir: ReplyDocumentIR): string {
return ir.nodes.map(renderNodeToPlainText).join('\n\n');
}
function renderNodeToPlainText(node: ReplyNode): string {
switch (node.type) {
case 'heading': {
return node.children.map(renderInlineToPlainText).join('');
}
case 'paragraph': {
return node.children.map(renderInlineToPlainText).join('');
}
case 'list': {
return node.items
.map((item, idx) => {
const prefix = node.ordered ? `${idx + 1}. ` : '• ';
return `${prefix}${item.map(renderInlineToPlainText).join('')}`;
})
.join('\n');
}
case 'table': {
const headerLine = node.header.map((c) => c.map(renderInlineToPlainText).join('')).join('\t');
const rowLines = node.rows.map((row) =>
row.map((c) => c.map(renderInlineToPlainText).join('')).join('\t')
);
return [headerLine, ...rowLines].join('\n');
}
case 'blockquote': {
return node.children
.map(renderNodeToPlainText)
.join('\n')
.split('\n')
.map((l) => `> ${l}`)
.join('\n');
}
case 'code': {
return node.value;
}
case 'math': {
return node.display ? `$$ ${node.latex} $$` : `$${node.latex}$`;
}
case 'mermaid': {
return `[Mermaid 架构图源码]\n${node.source}`;
}
case 'divider': {
return '----------------------------------------';
}
default:
return '';
}
}
function renderInlineToPlainText(inline: ReplyInline): string {
switch (inline.type) {
case 'text':
return inline.value;
case 'code':
return inline.value;
case 'math':
return `$${inline.latex}$`;
case 'link': {
const text = inline.children.map(renderInlineToPlainText).join('');
if (inline.href && inline.href !== '#' && inline.href !== text) {
return `${text} (${inline.href})`;
}
return text;
}
case 'hardBreak':
return '\n';
default:
return '';
}
}
@@ -0,0 +1,140 @@
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { parseReplyMarkdown } from './parseReplyMarkdown';
import { renderClipboardHtml } from './renderClipboardHtml';
import { renderPlainText } from './renderPlainText';
import { serializeMarkdown } from './serializeMarkdown';
import { stripUnsafeHtml, sanitizeLinkHref } from './sanitize';
import { deriveReplyTitle, sanitizeFilename } from '../../features/replyExport/title';
describe('Reply Document IR & Export Engine', () => {
test('deriveReplyTitle and sanitizeFilename derive title and safe file name', () => {
const md = '# 医疗器械临床评价报告\n\n本报告基于GB 9706标准进行评价。';
const title = deriveReplyTitle(md, '2026-09-10 10:00');
assert.equal(title, '医疗器械临床评价报告');
const safeName = sanitizeFilename(title);
assert.equal(safeName, '医疗器械临床评价报告');
});
test('deriveReplyTitle handles text without heading', () => {
const md = '根据最新医疗器械法规要求,必须建立完整的风险管理体系。';
const title = deriveReplyTitle(md);
assert.ok(title.startsWith('根据最新医疗器械法规要求'));
});
test('parseReplyMarkdown parses headers, lists, code, and tables', () => {
const sample = `
# 临床评价总结
1. 第一项评估
2. 第二项评估
- 无序要点 A
- 无序要点 B
> 这是一个引用说明
\`\`\`python
def evaluate():
return True
\`\`\`
| 参数 | 阈值 | 结果 |
| :--- | :---: | ---: |
| 漏电流 | 10uA | 合格 |
| 耐压 | 1500V | 合格 |
`;
const ir = parseReplyMarkdown(sample);
assert.equal(ir.title, '临床评价总结');
assert.ok(ir.nodes.length >= 6);
const types = ir.nodes.map((b) => b.type);
assert.ok(types.includes('heading'));
assert.ok(types.includes('list'));
assert.ok(types.includes('blockquote'));
assert.ok(types.includes('code'));
assert.ok(types.includes('table'));
});
test('renderClipboardHtml renders self-contained styling for office tools', () => {
const md = `# 标题
正文包含 **粗体** 和 *斜体* 以及 [链接](https://example.com)。
- 项目 1
- 项目 2
`;
const ir = parseReplyMarkdown(md);
const html = renderClipboardHtml(ir);
assert.ok(html.includes('<h1'));
assert.ok(html.includes('style="') || html.includes('<style>'));
assert.ok(html.includes('font-family:'));
assert.ok(html.includes('粗体'));
assert.ok(html.includes('斜体'));
assert.ok(html.includes('<ul'));
});
test('renderClipboardHtml converts LaTeX formulas to native MathML for Word/Office', () => {
const md = `关于超声刀振幅计算:
$$
f_0 = \\frac{c}{2L}
$$
其中声速与波长满足关系 $c = \\lambda f$。`;
const ir = parseReplyMarkdown(md);
const html = renderClipboardHtml(ir);
// 验证包含标准 MathML 命名空间与标签
assert.ok(html.includes('<math xmlns="http://www.w3.org/1998/Math/MathML"'));
assert.ok(html.includes('<mfrac>'));
assert.ok(html.includes('f_0'));
assert.ok(html.includes('math-block'));
});
test('renderPlainText strips formatting cleanly', () => {
const md = `# 临床报告
正文带 **重点**。
| 列1 | 列2 |
| --- | --- |
| A | B |
`;
const ir = parseReplyMarkdown(md);
const plain = renderPlainText(ir);
assert.ok(plain.includes('临床报告'));
assert.ok(plain.includes('正文带 重点。'));
assert.ok(plain.includes('列1\t列2'));
assert.ok(!plain.includes('**重点**'));
});
test('serializeMarkdown round-trips to valid markdown', () => {
const md = `# 临床报告
正文说明。
- 要点 1
- 要点 2
`;
const ir = parseReplyMarkdown(md);
const serialized = serializeMarkdown(ir);
assert.ok(serialized.includes('# 临床报告'));
assert.ok(serialized.includes('正文说明。'));
assert.ok(serialized.includes('- 要点 1'));
});
test('stripUnsafeHtml strips script tags and dangerous event handlers', () => {
const risky = '<p>Normal</p><script>alert(1)</script><a href="javascript:steal()" onclick="alert(2)">Link</a>';
const clean = stripUnsafeHtml(risky);
assert.ok(!clean.includes('<script>'));
assert.ok(!clean.includes('alert(1)'));
assert.ok(!clean.includes('onclick'));
assert.ok(clean.includes('Normal'));
assert.equal(sanitizeLinkHref('javascript:alert(1)'), '#');
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* HTML 与安全链接白名单清洗工具
* 保证导出的 HTML 和 Markdown 不包含脚本、危险协议与 XSS 攻击向量
*/
const DANGEROUS_PROTOCOLS = /^(javascript|vbscript|data):/i;
export function sanitizeLinkHref(href: string): string {
const trimmed = href.trim();
if (DANGEROUS_PROTOCOLS.test(trimmed)) {
return '#';
}
return trimmed;
}
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 剥离外部传入的 HTML 标签,仅保留白名单安全纯文本或语义标签
*/
export function stripUnsafeHtml(rawHtml: string): string {
// 移除所有 script、iframe、style、object、embed 块
let clean = rawHtml.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
clean = clean.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
clean = clean.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '');
// 剥离所有的 on* 事件属性 (如 onclick, onerror)
clean = clean.replace(/\son\w+\s*=\s*(['"]).*?\1/gi, '');
clean = clean.replace(/\son\w+\s*=\s*[^>\s]+/gi, '');
return clean;
}
@@ -0,0 +1,85 @@
import { ReplyDocumentIR, ReplyNode, ReplyInline } from '../../types/replyExport';
/**
* 将 Reply Document IR 序列化为规范化 GFM Markdown
*/
export function serializeMarkdown(ir: ReplyDocumentIR): string {
return ir.nodes.map(renderNodeToMarkdown).join('\n\n');
}
function renderNodeToMarkdown(node: ReplyNode): string {
switch (node.type) {
case 'heading': {
const hashes = '#'.repeat(node.level);
return `${hashes} ${node.children.map(renderInlineToMarkdown).join('')}`;
}
case 'paragraph': {
return node.children.map(renderInlineToMarkdown).join('');
}
case 'list': {
return node.items
.map((item, idx) => {
const prefix = node.ordered ? `${idx + 1}. ` : '- ';
return `${prefix}${item.map(renderInlineToMarkdown).join('')}`;
})
.join('\n');
}
case 'table': {
const headerLine = `| ${node.header.map((c) => c.map(renderInlineToMarkdown).join('')).join(' | ')} |`;
const sepLine = `| ${node.header.map(() => '---').join(' | ')} |`;
const rowLines = node.rows.map(
(row) => `| ${row.map((c) => c.map(renderInlineToMarkdown).join('')).join(' | ')} |`
);
return [headerLine, sepLine, ...rowLines].join('\n');
}
case 'blockquote': {
const inner = node.children.map(renderNodeToMarkdown).join('\n\n');
return inner
.split('\n')
.map((l) => `> ${l}`)
.join('\n');
}
case 'code': {
const lang = node.language || '';
return `\`\`\`${lang}\n${node.value}\n\`\`\``;
}
case 'math': {
if (node.display) {
return `$$\n${node.latex}\n$$`;
}
return `$${node.latex}$`;
}
case 'mermaid': {
return `\`\`\`mermaid\n${node.source}\n\`\`\``;
}
case 'divider': {
return '---';
}
default:
return '';
}
}
function renderInlineToMarkdown(inline: ReplyInline): string {
switch (inline.type) {
case 'text': {
let val = inline.value;
if (inline.bold) val = `**${val}**`;
if (inline.italic) val = `*${val}*`;
if (inline.strike) val = `~~${val}~~`;
return val;
}
case 'code':
return `\`${inline.value}\``;
case 'math':
return `$${inline.latex}$`;
case 'link': {
const text = inline.children.map(renderInlineToMarkdown).join('');
return `[${text}](${inline.href})`;
}
case 'hardBreak':
return '\n';
default:
return '';
}
}
+106
View File
@@ -0,0 +1,106 @@
import { parseReplyMarkdown } from '../../engine/replyDocument/parseReplyMarkdown';
import { renderClipboardHtml } from '../../engine/replyDocument/renderClipboardHtml';
import { renderPlainText } from '../../engine/replyDocument/renderPlainText';
export interface CopyResult {
success: boolean;
mode: 'rich' | 'plain' | 'fallback';
error?: string;
}
/**
* 将助手回复作为带格式富文本写入系统剪贴板 (text/html + text/plain)
* 具备自动降级至 writeText 及只读选区 fallback 能力
*/
export async function copyRichReplyToClipboard(markdown: string, title?: string): Promise<CopyResult> {
const ir = parseReplyMarkdown(markdown, title);
const html = renderClipboardHtml(ir);
const plain = renderPlainText(ir);
// 1. 优先使用标准 ClipboardItem 多 MIME 类型写入
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof ClipboardItem !== 'undefined') {
try {
const htmlBlob = new Blob([html], { type: 'text/html' });
const textBlob = new Blob([plain], { type: 'text/plain' });
const item = new ClipboardItem({
'text/html': htmlBlob,
'text/plain': textBlob,
});
await navigator.clipboard.write([item]);
return { success: true, mode: 'rich' };
} catch (err: any) {
console.warn('ClipboardItem write failed, falling back to writeText:', err);
}
}
// 2. 降级回退:通过 writeText 写入纯文本 / Markdown
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(plain || markdown);
return { success: true, mode: 'plain' };
} catch (err: any) {
console.warn('navigator.clipboard.writeText failed:', err);
}
}
// 3. 最终降级回退:使用临时隐藏 textarea 触发 document.execCommand('copy')
try {
const textarea = document.createElement('textarea');
textarea.value = plain || markdown;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
textarea.setAttribute('readonly', '');
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
if (ok) {
return { success: true, mode: 'fallback' };
}
} catch (err: any) {
console.warn('execCommand copy fallback failed:', err);
}
return {
success: false,
mode: 'fallback',
error: '剪贴板权限被拒绝,请手动选择复制',
};
}
/**
* 仅复制 Markdown 源代码
*/
export async function copyMarkdownReply(markdown: string): Promise<CopyResult> {
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(markdown);
return { success: true, mode: 'plain' };
} catch (err: any) {
console.warn('copyMarkdownReply failed:', err);
}
}
// 降级
try {
const textarea = document.createElement('textarea');
textarea.value = markdown;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
if (ok) {
return { success: true, mode: 'fallback' };
}
} catch (err) {}
return {
success: false,
mode: 'fallback',
error: '无法写入剪贴板',
};
}
+94
View File
@@ -0,0 +1,94 @@
import { Packer } from 'docx';
import { deriveReplyTitle, sanitizeFilename } from './title';
import { markdownToBlockAST } from '../../engine/markdownToBlock';
import { buildDocxModel } from '../../engine/docxRenderer';
export interface DownloadOptions {
includeSource?: boolean;
sessionId?: string;
createdAt?: string;
filenameOverride?: string;
}
/**
* 触发 Markdown (.md) 文件直接下载
*/
export function downloadMarkdownReply(
markdown: string,
title?: string,
options: DownloadOptions = {}
): { filename: string } {
const effectiveTitle = title || deriveReplyTitle(markdown, options.createdAt);
const dateStr = new Date().toISOString().substring(0, 10);
const baseName = sanitizeFilename(effectiveTitle);
const filename = options.filenameOverride || `${baseName}_${dateStr}.md`;
let exportContent = (markdown || '').trim();
if (options.includeSource) {
const metaTime = options.createdAt || new Date().toLocaleString();
exportContent += `\n\n---\n*来源:水木东方项目助手回复 · 生成时间:${metaTime}*`;
}
const blob = new Blob([exportContent], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
return { filename };
}
/**
* 触发 Word (.docx) 专用轻量排版导出并直接下载
*/
export async function downloadDocxReply(
markdown: string,
title?: string,
options: DownloadOptions = {}
): Promise<{ filename: string; blob: Blob }> {
const effectiveTitle = title || deriveReplyTitle(markdown, options.createdAt);
const dateStr = new Date().toISOString().substring(0, 10);
const baseName = sanitizeFilename(effectiveTitle);
const filename = options.filenameOverride || `${baseName}_${dateStr}.docx`;
let exportContent = (markdown || '').trim();
if (options.includeSource) {
const metaTime = options.createdAt || new Date().toLocaleString();
exportContent += `\n\n---\n*来源:水木东方项目助手回复 · 生成时间:${metaTime}*`;
}
const ast = markdownToBlockAST(exportContent, {
docId: `reply-docx-${Date.now()}`,
title: effectiveTitle,
taskKey: 'reply_export',
version: 'V1.0',
category: 'private',
lifecycleState: 'drafting',
isClaimed: true,
author: '智能体助手回复',
reviewer: '',
approver: '',
updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
});
const doc = await buildDocxModel(ast);
const blob = await Packer.toBlob(doc);
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
return { filename, blob };
}
+47
View File
@@ -0,0 +1,47 @@
/**
* 从 Markdown 正文中提取首选标题
* 优先级:
* 1. 第一个一级或二级标题 (# 或 ##)
* 2. 首句前 30 个字符
* 3. 兜底默认值:“智能体回复摘录 · MM-DD HH:mm”
*/
export function deriveReplyTitle(content: string, defaultTime?: string): string {
const trimmed = (content || '').trim();
if (!trimmed) {
const timeStr = defaultTime || new Date().toISOString().substring(5, 16).replace('T', ' ');
return `智能体回复摘录 · ${timeStr}`;
}
// 1. 优先匹配第一个 H1 或 H2
const headingMatch = trimmed.match(/^#{1,2}\s+(.+)$/m);
if (headingMatch && headingMatch[1]) {
const clean = headingMatch[1].trim().replace(/[*_`~]/g, '');
if (clean) {
return clean.length > 60 ? clean.slice(0, 60) : clean;
}
}
// 2. 取第一句或第一段(跳过引用符号与代码围栏标记)
const lines = trimmed.split('\n');
for (const line of lines) {
const cl = line.trim();
if (cl && !cl.startsWith('>') && !cl.startsWith('```') && !cl.startsWith('#')) {
const clean = cl.replace(/[*_`~]/g, '');
return clean.length > 30 ? clean.slice(0, 30) : clean;
}
}
const timeStr = defaultTime || new Date().toISOString().substring(5, 16).replace('T', ' ');
return `智能体回复摘录 · ${timeStr}`;
}
/**
* 净化文件名,去除路径分隔符与操作系统保留字符
*/
export function sanitizeFilename(raw: string): string {
const sanitized = (raw || '')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, '_')
.replace(/^_+|_+$/g, '');
return sanitized.length > 60 ? sanitized.slice(0, 60) : sanitized || '智能体回复';
}
+2
View File
@@ -73,4 +73,6 @@ export interface DocumentAST {
expiresAt?: string;
};
contentSha256?: string;
governanceMode?: 'controlled' | 'freeform';
space?: 'my_private' | 'project_shared' | 'trash';
}
+79
View File
@@ -0,0 +1,79 @@
export type MessageContentKind =
| 'document_text' // 可独立阅读的正文
| 'conversational_text' // 普通答复,可复制/下载/保存
| 'action_card' // 纯交互卡
| 'status_update' // 流式状态、进度
| 'error_notice' // 错误/诊断
| 'system_notice'; // 系统通知
export type MessageExportPolicy =
| 'full' // 正文可复制、下载、保存
| 'copy_only' // 只允许复制,不形成系统文档
| 'none'; // 不提供导出动作
export type ExportFormat =
| 'rich_clipboard'
| 'markdown'
| 'docx'
| 'pdf'
| 'private_document';
export interface MessageExportCapability {
policy: MessageExportPolicy;
formats: ExportFormat[];
bodySelector: 'content_only';
reasonCode?:
| 'NOT_ASSISTANT'
| 'NOT_COMPLETED'
| 'EMPTY_BODY'
| 'CARD_ONLY'
| 'STATUS_ONLY'
| 'ERROR_OR_CANCELLED'
| 'CONTENT_RESTRICTED'
| 'ACCESS_DENIED';
contentRevision: number;
contentSha256: string;
}
export interface ReplyContentSnapshot {
snapshotId: string;
messageId: number;
sessionId: string;
projectId?: number;
ownerUserId?: number;
contentRevision: number;
contentSha256: string;
title: string;
markdown: string;
createdAt: string;
generatedBy?: string;
sensitivity?: 'normal' | 'confidential' | 'restricted';
capability: MessageExportCapability;
}
// -------------------------------------------------------------
// Reply Document IR (中间表示)
// -------------------------------------------------------------
export type ReplyInline =
| { type: 'text'; value: string; bold?: boolean; italic?: boolean; strike?: boolean }
| { type: 'code'; value: string }
| { type: 'math'; latex: string }
| { type: 'link'; href: string; children: ReplyInline[] }
| { type: 'hardBreak' };
export type ReplyNode =
| { type: 'heading'; level: 1 | 2 | 3 | 4 | 5 | 6; children: ReplyInline[] }
| { type: 'paragraph'; children: ReplyInline[] }
| { type: 'list'; ordered: boolean; start?: number; items: ReplyInline[][] }
| { type: 'table'; header: ReplyInline[][]; rows: ReplyInline[][][] }
| { type: 'blockquote'; children: ReplyNode[] }
| { type: 'code'; language?: string; value: string }
| { type: 'math'; display: boolean; latex: string }
| { type: 'mermaid'; source: string }
| { type: 'divider' };
export interface ReplyDocumentIR {
title: string;
nodes: ReplyNode[];
}
+9
View File
@@ -1,5 +1,6 @@
import { DocLifecycleState, UserRole } from './docgov';
import { DocVisibilityRule } from './rbac';
import { MessageContentKind, MessageExportCapability } from './replyExport';
export type StageKey = 'V0.1' | 'V0.5' | 'V1.0';
@@ -140,6 +141,12 @@ export interface ChatMessage {
quotes?: QuotedMessage[];
createdAt?: string;
meta?: Record<string, any>;
/** P0-P1: 单次回复导出与快照能力 */
contentKind?: MessageContentKind;
contentRevision?: number;
contentSha256?: string;
exportCapability?: MessageExportCapability;
}
export type MessageCategory =
@@ -215,6 +222,8 @@ export interface DeliverableItem {
summary?: string;
sourceMessageSnippet?: string;
visibilityRule?: DocVisibilityRule;
governanceMode?: 'controlled' | 'freeform';
space?: 'my_private' | 'project_shared' | 'trash';
}
export interface WorkbenchState {