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:
@@ -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;
|
||||
+1411
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
@@ -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 按日消息分片桶
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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": "已移至回收站",
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)'), '#');
|
||||
});
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 剥离外部传入的 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 '';
|
||||
}
|
||||
}
|
||||
@@ -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: '无法写入剪贴板',
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 || '智能体回复';
|
||||
}
|
||||
@@ -73,4 +73,6 @@ export interface DocumentAST {
|
||||
expiresAt?: string;
|
||||
};
|
||||
contentSha256?: string;
|
||||
governanceMode?: 'controlled' | 'freeform';
|
||||
space?: 'my_private' | 'project_shared' | 'trash';
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user