merge: feature/trace-panel-to-chat-2026-09-21
This commit is contained in:
+4
-4
@@ -5,15 +5,15 @@
|
||||
|
||||
## 焦点
|
||||
|
||||
1. **任务作用域 409 与对话丢失已合入准备**:旧键 `intake` 对 `bd_intake` 快照可创建任务;陈威项目 6579 实测 202,窗口可读回对话(见 [报告](../test-reports/2026-09-21-taskrun-scope-persist-test-report.md))。test/main 未晋升。
|
||||
2. **任务错误透传已合入 dev**:`RunRecord` snake_case 与前端真实失败原因(见 [报告](../test-reports/2026-09-21-taskrun-error-transparency-test-report.md))。test/main 未晋升。
|
||||
2. **阶段回溯与影响闭环已合入 dev**:T1–T3 与 B1–B3 在 `dev`。空追踪 JSON `null` 已修复,登录后显示「已索引 0 份文档 · 条目 0」(见 [报告](../test-reports/2026-09-21-trace-panel-null-crash-test-report.md))。test/main 未晋升。不改 `production_ready`,不签 D7 对话 ADR。
|
||||
1. **需求与影响已改为对话**:常驻面板已从 dev 页面移除;新待复核与下游影响写入已有项目会话。见 [报告](../test-reports/2026-09-21-trace-panel-to-chat-test-report.md)。test/main 未晋升。
|
||||
2. **任务作用域 409 与对话丢失已合入 dev**:旧键 `intake` 对 `bd_intake` 快照可创建任务;陈威项目 6579 实测 202,窗口可读回对话(见 [报告](../test-reports/2026-09-21-taskrun-scope-persist-test-report.md))。test/main 未晋升。
|
||||
3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。
|
||||
|
||||
## 并行认领
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|-------|------|--------|----------|-------|--------|
|
||||
| Cursor | 2026-09-21-trace-panel-to-chat | feature/trace-panel-to-chat-2026-09-21 | - | trace-impact+web | done |
|
||||
| Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | done |
|
||||
| Cursor | 2026-09-21-taskrun-error-transparency | fix/taskrun-error-transparency-2026-09-21 | - | chat+web | done |
|
||||
| Cursor | 2026-09-21-trace-panel-null-crash | fix/trace-panel-null-crash-2026-09-21 | - | trace-index+web | done |
|
||||
@@ -174,7 +174,7 @@
|
||||
|
||||
| holder | phase | since | scope | queue |
|
||||
|--------|-------|-------|-------|-------|
|
||||
| - | idle | 2026-09-21 | 任务作用域键归一化已在 dev 容器验收;`/health` schema_version=59;项目 6579 任务 `run_1789967333594919025` 为 running | - |
|
||||
| - | idle | 2026-09-21 | 需求与影响面板已从 dev 前端包移除;`/health` schema_version=59;pi-agent 为 MiniMax-M2.7-highspeed | - |
|
||||
|
||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: "需求与影响改为对话输出"
|
||||
status: done
|
||||
created: 2026-09-21
|
||||
updated: 2026-09-21
|
||||
owner: Cursor
|
||||
branch: feature/trace-panel-to-chat-2026-09-21
|
||||
worktree: "-"
|
||||
mutex: trace-impact+web
|
||||
---
|
||||
|
||||
# 需求与影响改为对话输出
|
||||
|
||||
## 目标
|
||||
|
||||
聊天栏不再常驻「需求与影响」。待复核标记和新的下游影响通过助手消息给出;用户也可以说「分析影响」。
|
||||
|
||||
## 范围
|
||||
|
||||
- 移除 `ProjectTracePanel`
|
||||
- 新的 recheck mark 写入操作者已有项目会话,卡片类型 `RECHECK_NOTICE`,「无需变更」仍调用原 clear 接口
|
||||
- `document.downstream_impact_detected` 向文档编制人的已有会话写一条助手消息
|
||||
- 影响意图增加「影响哪些 / 改动影响 / 变更影响」
|
||||
|
||||
## 非目标
|
||||
|
||||
- 无会话时不新建会话
|
||||
- 不改阶段推进必须确认的门禁
|
||||
- 不晋升 test/main
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 面板移除与卡片
|
||||
2. [x] 后端通知与意图
|
||||
3. [x] dev rebuild 与页面确认
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 已选项目时聊天区顶部没有「需求与影响」
|
||||
- [x] 新标记会进入已有会话;重复分析不重复发
|
||||
- [x] 「这次改动影响哪些文档」识别为影响分析
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Test report: `.doc/test-reports/2026-09-21-trace-panel-to-chat-test-report.md`
|
||||
@@ -62,7 +62,7 @@
|
||||
| IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | done | 加固 | [v3 full inventory](../audit/authorization-matrix-v3-full.md);[session 中断](../test-reports/2026-09-16-iam-route-inventory-test-report.md);[fail-closed](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md);[410 策略](../audit/2026-09-16-bug11-410-retire-decision.md);[R13](../audit/2026-09-16-r13-data-safety-checklist.md) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 | `authz` |
|
||||
| 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 项目 143 只读;不终止 | `docgov` |
|
||||
| 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md);[proposed/authoritative](../ops/short-term/2026-09-20-proposed-authoritative-reports.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口。lineage GET 已按 URL 项目和文档可见性裁剪。test_manager 血缘的下游/上游版本改读文档版本,不再写死 1。Agent 候选报告为 `proposed`,正式提交才写 authoritative 后继并挂到版本;拒绝、替代后旧报告不能再出建议。人工修订、导入和聊天卡仍未分开预估与正式报告,见 [T1](../ops/short-term/2026-09-20-impact-loop-t1.md)。项目追踪索引见下一行 | `docgov` |
|
||||
| 阶段回溯与影响闭环 | 五表追踪索引、影响报告、L1 文档修订、L2 阶段再打开、待复核软标记、对话修订提案 | done | deferred_enhancement(不挡首发) | [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md);[Track T](../remediation/2026-09-20-capability-enablement-impact-plan.md) | 已合入 dev。dev schema 59。test/main 未升。未复核不拦截推进。不签 D7 对话 ADR,不改 `production_ready` | `trace-index` |
|
||||
| 阶段回溯与影响闭环 | 五表追踪索引、影响报告、L1 文档修订、L2 阶段再打开、待复核软标记、对话修订提案 | done | deferred_enhancement(不挡首发) | [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md);[Track T](../remediation/2026-09-20-capability-enablement-impact-plan.md);[对话化报告](../test-reports/2026-09-21-trace-panel-to-chat-test-report.md) | 已合入 dev。常驻「需求与影响」面板已退役,新待复核与下游影响改走已有项目会话。dev schema 59。test/main 未升。阶段推进确认门禁仍在。不签 D7 对话 ADR,不改 `production_ready` | `trace-index` |
|
||||
| PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md);[最小化输入](../ops/short-term/2026-09-20-pi-narrative-clipped-diff.md) | flag 默认 off。`facts.json` 含截断后的 hunk 前后文和影响说明,以及完整 hunk/impact 计数与省略数;摘要 digest 仍不含正文。叙事请求已带 `tool_profile=reasoning`。运行中的 runner 尚未切到 `--no-tools`。不打开开关 | `docgov` |
|
||||
| 前端工作台 | 三栏 + 块文档 + SSE;错误卡/进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` |
|
||||
| PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | 文档生成仍使用读写和搜索工具。`tool_profile=reasoning` 的 argv 是 `--no-tools` 且不加载搜索扩展;未知 profile 拒绝启动。dev 挂载了 `pi-args.mjs`,但本轮未重启容器 | `pi-agent` |
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# 需求与影响对话化 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
聊天栏顶部常驻「需求与影响」。没有索引时也占位置,待复核只能在这块面板里点「无需变更」。产品要求改成助手在对话里提醒,并且只在需要时出现。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- 已选项目时,聊天区不再挂载追踪面板。
|
||||
- 新产生的待复核标记写入操作者已有的项目会话;没有会话时不新建;同一标记不重复播报。
|
||||
- 「分析影响 / 改动影响哪些文档 / 变更影响」识别为影响分析。
|
||||
- 下游影响事件向已有会话写一条助手消息。
|
||||
- 阶段推进仍要确认。dev 容器健康,真实 PI 模型仍是 MiniMax-M2.7-highspeed。
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- OS:Linux。Go 模块 `chat-agent-system`。前端 `web/` Node 测试与容器内 `npm run build`。
|
||||
- 后端:dev 容器 `shuimu-agent-app-dev`、`shuimu-agent-events-dev`,HTTP `127.0.0.1:8080`。PI:`shuimu-pi-agent-dev`,`127.0.0.1:18865`。
|
||||
- 层级:`traceimpact` / `docnotify` / `intent` 单测;前端 `npm test`;dev 镜像重建后检查已发布的 JS。
|
||||
- 项目 6579 当前 `project_trace_items` 为 0、`stage_recheck_marks` 为 0。不为了造标记去改这份数据,标记注入用单测覆盖。
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
- 页面包中不再出现 `ProjectTracePanel`、「已索引」、「份文档 · 条目」。
|
||||
- 页面包包含 `RECHECK_NOTICE` 与 `recheck_no_change`。
|
||||
- `App.tsx` 仍挂载 `StageAdvanceBanner`。
|
||||
- `/health` 的 `schema_version` 为 59,`agent_backend` 为 `pi-agent`。
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### 用例 1:单测
|
||||
|
||||
- **测试输入 (Input)**:`go test -count=1 ./internal/traceimpact/ ./internal/app/docnotify/ ./internal/intent/ ./internal/app/ ./internal/server/`
|
||||
- **实际输出 (Actual Output)**:
|
||||
|
||||
```text
|
||||
ok chat-agent-system/internal/traceimpact 0.003s
|
||||
ok chat-agent-system/internal/app/docnotify 0.002s
|
||||
ok chat-agent-system/internal/intent 0.007s
|
||||
ok chat-agent-system/internal/app 0.003s [no tests to run]
|
||||
ok chat-agent-system/internal/server 0.128s
|
||||
```
|
||||
|
||||
覆盖:新标记只通知一次、无会话不写消息、有会话写入助手消息、「请分析影响 / 这次改动影响哪些文档 / 帮我做一下变更影响」识别为影响分析。
|
||||
|
||||
- **评价判定 (Evaluation / Verdict)**:PASS
|
||||
- **性能与耗时 (Duration & Latency)**:上述包合计不到 1 秒。
|
||||
|
||||
### 用例 2:前端测试
|
||||
|
||||
- **测试输入 (Input)**:`cd web && npm test`
|
||||
- **实际输出 (Actual Output)**:
|
||||
|
||||
```text
|
||||
✔ trace panel is not mounted above the chat stream (1.076438ms)
|
||||
ℹ tests 442
|
||||
ℹ suites 37
|
||||
ℹ pass 442
|
||||
ℹ fail 0
|
||||
```
|
||||
|
||||
- **评价判定 (Evaluation / Verdict)**:PASS
|
||||
- **性能与耗时 (Duration & Latency)**:整套约 3 秒。
|
||||
|
||||
### 用例 3:dev 重建与已发布页面
|
||||
|
||||
- **测试输入 (Input)**:`./scripts/env-service.sh dev rebuild`,然后请求首页与主包 `index-DEguWhkN.js`。
|
||||
- **实际输出 (Actual Output)**:
|
||||
|
||||
```text
|
||||
Image agent-dev-app Built
|
||||
Image agent-dev-events Built
|
||||
Container shuimu-agent-app-dev Started
|
||||
Container shuimu-agent-events-dev Started
|
||||
==> [dev] 镜像与容器已更新
|
||||
```
|
||||
|
||||
`GET /health`:
|
||||
|
||||
```json
|
||||
{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","schema_version":59,"schema_ready":true,"task_store_ready":true}
|
||||
```
|
||||
|
||||
`GET http://127.0.0.1:18865/health`:`ok=true`,`model_id=MiniMax-M2.7-highspeed`。
|
||||
|
||||
主包计数:`ProjectTracePanel` 0,`已索引` 0,`份文档 · 条目` 0,`RECHECK_NOTICE` 2,`待复核` 5,`recheck_no_change` 1。浏览器打开 `http://127.0.0.1:8080/` 进入登录页,标题为「水木东方 · 医疗器械现代 AI 协同工作台」,服务可打开。未登录,因此没有进入已选项目的聊天栏。
|
||||
|
||||
- **评价判定 (Evaluation / Verdict)**:PASS。常驻面板文案已不在发布包中;待复核卡片类型在包中。
|
||||
- **性能与耗时 (Duration & Latency)**:重建约 23 秒。
|
||||
|
||||
### 用例 4:项目 6579 不造标记
|
||||
|
||||
- **测试输入 (Input)**:查询 `project_trace_items` 与 `stage_recheck_marks`。
|
||||
- **实际输出 (Actual Output)**:条目 0,未关闭标记 0。会话 4518(用户 398)与 4517(用户 9)仍在。
|
||||
- **评价判定 (Evaluation / Verdict)**:PASS(按约定不改这份数据)。新标记注入由用例 1 证明。
|
||||
- **性能与耗时 (Duration & Latency)**:查询不到 1 秒。
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
本次改动相关包与前端套件全部通过,见用例 1、2。未再跑会写入 dev 库的全量 `go test -short ./...`(上一轮已记录与本次无关的既有失败)。容器内 `npm run build` 成功,产物即当前 8080 页面。
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
达到合入 dev 的标准。阶段推进确认条仍留在 `StageAdvanceBanner`。项目一旦有追踪索引,新的待复核会进操作者已有会话;没有会话则跳过。test/main 未晋升。
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"chat-agent-system/internal/app/docproject"
|
||||
"chat-agent-system/internal/app/intentflow"
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/db"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/events"
|
||||
@@ -75,6 +76,7 @@ func main() {
|
||||
)
|
||||
_ = disp.Register(events.ConsumerNotify, (&docnotify.EventConsumer{
|
||||
Notify: notify, Docs: gov, Env: cfg.Env,
|
||||
Chat: ctxmgr.NewContextManager(pool, nil, ctxmgr.TimeDecayConfig{}),
|
||||
}).HandleLease)
|
||||
_ = disp.Register(events.ConsumerWorkflowAdvance, (&docadvance.EventConsumer{
|
||||
Inst: instSvc, Docs: gov, PW: pwSvc, Notify: notify, Env: cfg.Env,
|
||||
|
||||
+2
-1
@@ -196,6 +196,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
||||
traceSvc := traceimpact.Open(pool)
|
||||
traceSvc.SetStageReopener(traceStage{inst: instSvc})
|
||||
traceSvc.SetDocUnlocker(traceUnlock{gov: parts.Gov})
|
||||
bindRecheckChat(traceSvc, parts.Ctx)
|
||||
if instSvc != nil {
|
||||
instSvc.SetRecheckGate(traceRecheck{svc: traceSvc})
|
||||
}
|
||||
@@ -255,7 +256,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
||||
instSvc.SetAuthorOnlyPolicySource(&sysadminAuthorOnlyPolicyAdapter{svc: rt.SysAdmin})
|
||||
svc.SetInstanceDutySource(&projectInstanceDutyAdapter{inst: instSvc})
|
||||
}
|
||||
rt.DocNotify = &docnotify.Subscriber{Notify: rt.Notify, Docs: parts.Gov}
|
||||
rt.DocNotify = &docnotify.Subscriber{Notify: rt.Notify, Docs: parts.Gov, Chat: parts.Ctx}
|
||||
rt.DocNotify.SkipWrite = func(ctx context.Context) bool {
|
||||
return rt.WriterLive(ctx, events.ConsumerNotify)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package docnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
)
|
||||
|
||||
// PostProjectNotice writes an assistant message into the actor's latest project session.
|
||||
// It does not create a session. Missing chat, user, or session is a no-op.
|
||||
func PostProjectNotice(ctx context.Context, chat *ctxmgr.ContextManager, projectID, userID int64, content string, meta map[string]any) error {
|
||||
if chat == nil || projectID <= 0 || userID <= 0 || content == "" {
|
||||
return nil
|
||||
}
|
||||
sessions, err := chat.ListChatSessions(ctx, ctxmgr.SessionFilter{UserID: userID, ProjectID: projectID, Limit: 1})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sessions) == 0 || sessions[0].SessionID == "" {
|
||||
return nil
|
||||
}
|
||||
if meta == nil {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
if _, ok := meta["project_id"]; !ok {
|
||||
meta["project_id"] = projectID
|
||||
}
|
||||
_, err = chat.RecordUserMessageWithID(ctx, sessions[0].SessionID, 0, "assistant", content, "IMPACT_ANALYZE", "", meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("post project notice: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downstreamChatBody(docID, impacted string) string {
|
||||
switch {
|
||||
case docID != "" && impacted != "":
|
||||
return fmt.Sprintf("文档 %s 的变更可能影响 %s。需要时可以说「分析影响」。", docID, impacted)
|
||||
case docID != "":
|
||||
return fmt.Sprintf("文档 %s 有变更,下游内容可能受影响。需要时可以说「分析影响」。", docID)
|
||||
default:
|
||||
return "有文档变更,下游内容可能受影响。需要时可以说「分析影响」。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package docnotify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
)
|
||||
|
||||
func TestPostProjectNoticeSkipsWithoutSession(t *testing.T) {
|
||||
chat := ctxmgr.NewContextManager(nil, nil, ctxmgr.TimeDecayConfig{})
|
||||
if err := PostProjectNotice(t.Context(), chat, 7, 3, "上游有变更", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err := chat.GetChatHistory(t.Context(), "sess-missing", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(history) != 0 {
|
||||
t.Fatalf("must not create a session, history=%d", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostProjectNoticeWritesActorSession(t *testing.T) {
|
||||
chat := ctxmgr.NewContextManager(nil, nil, ctxmgr.TimeDecayConfig{})
|
||||
if _, err := chat.EnsureChatSession(t.Context(), "sess-p7-u3", 3, 7, "项目对话"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := PostProjectNotice(t.Context(), chat, 7, 3, "文档 d 的变更可能影响 down。", map[string]any{
|
||||
"interactive_card": map[string]any{"cardType": "RECHECK_NOTICE"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err := chat.GetChatHistory(t.Context(), "sess-p7-u3", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(history) != 1 || history[0].Role != "assistant" {
|
||||
t.Fatalf("history=%+v", history)
|
||||
}
|
||||
if history[0].Metadata["interactive_card"] == nil {
|
||||
t.Fatalf("metadata=%+v", history[0].Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownstreamChatBody(t *testing.T) {
|
||||
got := downstreamChatBody("doc-a", "doc-b")
|
||||
if !strings.Contains(got, "doc-a") || !strings.Contains(got, "doc-b") || !strings.Contains(got, "分析影响") {
|
||||
t.Fatalf("body=%q", got)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/events"
|
||||
"chat-agent-system/internal/notification"
|
||||
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
type EventConsumer struct {
|
||||
Notify *notification.Service
|
||||
Docs DocumentLookup
|
||||
Chat *ctxmgr.ContextManager
|
||||
Env string
|
||||
}
|
||||
|
||||
@@ -57,7 +59,14 @@ func (c *EventConsumer) HandleLease(ctx context.Context, lease events.Lease, tx
|
||||
return events.ErrQuarantine
|
||||
}
|
||||
p := notification.DocumentEventPayload{ProjectID: projectID, DocID: docID}
|
||||
return c.Notify.EmitVisibleDocumentEvent(ctx, tx, lease.EventID, kind, title, body, recipient, p)
|
||||
if err := c.Notify.EmitVisibleDocumentEvent(ctx, tx, lease.EventID, kind, title, body, recipient, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if lease.EventName == events.EventDownstreamImpact && c.Chat != nil {
|
||||
impacted, _ := payload["impacted_doc_id"].(string)
|
||||
_ = PostProjectNotice(ctx, c.Chat, projectID, recipient, downstreamChatBody(docID, impacted), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func IntendedNotifyDigest(eventID, kind string, recipient int64) string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
"chat-agent-system/internal/notification"
|
||||
@@ -19,6 +20,7 @@ type DocumentLookup interface {
|
||||
type Subscriber struct {
|
||||
Notify notificationEmitter
|
||||
Docs DocumentLookup
|
||||
Chat *ctxmgr.ContextManager
|
||||
// SkipWrite is true when the durable notify consumer is live.
|
||||
// Bind stays registered while mode=off; the closure must not dual-write after switch.
|
||||
SkipWrite func(ctx context.Context) bool
|
||||
@@ -88,7 +90,10 @@ func (s *Subscriber) Handle(ctx context.Context, eventType string, payload map[s
|
||||
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocDownstreamImpact,
|
||||
"下游文档受影响", "上游文档变更,请复核关联文档。",
|
||||
fmt.Sprintf("doc_impact:%s:%s:%d", docID, impacted, recipient), recipient, p)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return PostProjectNotice(ctx, s.Chat, projectID, recipient, downstreamChatBody(docID, impacted), nil)
|
||||
case notification.EventDocChangeReportDegraded:
|
||||
if v, ok := payload["report_id"].(int64); ok {
|
||||
p.ReportID = v
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/app/docnotify"
|
||||
"chat-agent-system/internal/coordinator"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
"chat-agent-system/internal/traceimpact"
|
||||
@@ -60,15 +62,54 @@ func (t traceUnlock) ReopenApprovedDocs(ctx context.Context, projectID int64, do
|
||||
|
||||
type traceChat struct{ svc *traceimpact.Service }
|
||||
|
||||
func (t traceChat) AnalyzeImpact(ctx context.Context, projectID, _ int64, text string) (string, error) {
|
||||
func (t traceChat) AnalyzeImpact(ctx context.Context, projectID, userID int64, text string) (string, error) {
|
||||
if t.svc == nil {
|
||||
return "", fmt.Errorf("trace: unavailable")
|
||||
}
|
||||
ctx = traceimpact.WithActor(ctx, userID)
|
||||
rep, err := t.svc.Analyze(ctx, projectID, traceimpact.DocRef{DocID: "chat", Markdown: text, Visible: true}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rep.Summary, nil
|
||||
return formatImpactReply(rep), nil
|
||||
}
|
||||
|
||||
func formatImpactReply(rep *traceimpact.Report) string {
|
||||
if rep == nil || len(rep.Impacts) == 0 {
|
||||
if rep.Summary != "" {
|
||||
return rep.Summary
|
||||
}
|
||||
return "目前没有已确认的影响。可以说「分析影响」并带上条目编号,例如 UR-001。"
|
||||
}
|
||||
var b strings.Builder
|
||||
if rep.Summary != "" {
|
||||
b.WriteString(rep.Summary)
|
||||
b.WriteString("。")
|
||||
}
|
||||
limit := len(rep.Impacts)
|
||||
if limit > 8 {
|
||||
limit = 8
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
imp := rep.Impacts[i]
|
||||
label := imp.ItemKey
|
||||
if label == "" {
|
||||
label = imp.DocID
|
||||
}
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("\n- ")
|
||||
b.WriteString(label)
|
||||
if imp.Reason != "" {
|
||||
b.WriteString(":")
|
||||
b.WriteString(imp.Reason)
|
||||
}
|
||||
}
|
||||
if len(rep.Impacts) > limit {
|
||||
fmt.Fprintf(&b, "\n另有 %d 条未展开。", len(rep.Impacts)-limit)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func projectIDFromControlledDoc(docID string) int64 {
|
||||
@@ -86,6 +127,42 @@ func projectIDFromControlledDoc(docID string) int64 {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func bindRecheckChat(svc *traceimpact.Service, chat *ctxmgr.ContextManager) {
|
||||
if svc == nil || chat == nil {
|
||||
return
|
||||
}
|
||||
svc.SetRecheckHook(func(ctx context.Context, mark traceimpact.RecheckMark) {
|
||||
userID := traceimpact.ActorID(ctx)
|
||||
reason := mark.Reason
|
||||
if reason == "" {
|
||||
reason = mark.SourceDoc
|
||||
}
|
||||
content := fmt.Sprintf("阶段 %s 待复核(%s)。需要细节可以说「分析影响」。", mark.StageKey, reason)
|
||||
card := map[string]any{
|
||||
"cardId": fmt.Sprintf("recheck-%d", mark.ID),
|
||||
"cardType": "RECHECK_NOTICE",
|
||||
"title": "阶段待复核",
|
||||
"proposedContent": content,
|
||||
"status": "active",
|
||||
"ttlTurns": 8,
|
||||
"actions": []any{
|
||||
map[string]any{
|
||||
"actionId": "recheck_no_change",
|
||||
"label": "无需变更",
|
||||
"style": "primary",
|
||||
"payload": map[string]string{
|
||||
"project_id": strconv.FormatInt(mark.ProjectID, 10),
|
||||
"mark_id": strconv.FormatInt(mark.ID, 10),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_ = docnotify.PostProjectNotice(ctx, chat, mark.ProjectID, userID, content, map[string]any{
|
||||
"interactive_card": card,
|
||||
})
|
||||
})
|
||||
}
|
||||
func (t traceChat) ProposeRevision(ctx context.Context, projectID, userID int64, text string, confirmed bool) (string, error) {
|
||||
if t.svc == nil {
|
||||
return "", fmt.Errorf("trace: unavailable")
|
||||
|
||||
@@ -108,6 +108,48 @@ func (m *ContextManager) FinalizeAssistantTaskMessage(ctx context.Context, messa
|
||||
return errors.New("message not found")
|
||||
}
|
||||
|
||||
// SetInteractiveCardStatus marks a stored chat card resolved or otherwise closed.
|
||||
func (m *ContextManager) SetInteractiveCardStatus(ctx context.Context, messageID int64, status, summary string) error {
|
||||
if m == nil || messageID <= 0 || status == "" {
|
||||
return errors.New("invalid card status update")
|
||||
}
|
||||
if m.pool != nil || hasUoW(ctx) {
|
||||
_, err := execChat(ctx, m.pool, `
|
||||
UPDATE chat_messages
|
||||
SET metadata = jsonb_set(
|
||||
jsonb_set(COALESCE(metadata, '{}'::jsonb), '{interactive_card,status}', to_jsonb($2::text), true),
|
||||
'{interactive_card,resolutionSummary}', to_jsonb($3::text), true)
|
||||
WHERE id = $1
|
||||
`, messageID, status, summary)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set interactive card status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for sid, msgs := range m.messages {
|
||||
for i := range msgs {
|
||||
if msgs[i].ID != messageID {
|
||||
continue
|
||||
}
|
||||
card, _ := msgs[i].Metadata["interactive_card"].(map[string]any)
|
||||
if card == nil {
|
||||
card = map[string]any{}
|
||||
}
|
||||
card["status"] = status
|
||||
card["resolutionSummary"] = summary
|
||||
if msgs[i].Metadata == nil {
|
||||
msgs[i].Metadata = map[string]any{}
|
||||
}
|
||||
msgs[i].Metadata["interactive_card"] = card
|
||||
m.messages[sid][i] = msgs[i]
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("message not found")
|
||||
}
|
||||
|
||||
func hasUoW(ctx context.Context) bool {
|
||||
_, ok := uow.ScopeFrom(ctx)
|
||||
return ok
|
||||
|
||||
@@ -1240,7 +1240,8 @@ func isGreetingOrHelp(input string) bool {
|
||||
|
||||
func matchImpactIntent(input string) *CandidateIntent {
|
||||
propose := strings.Contains(input, "修订提案") || strings.Contains(input, "生成修订") || strings.Contains(input, "提议修改文档")
|
||||
analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些")
|
||||
analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些") ||
|
||||
strings.Contains(input, "影响哪些") || strings.Contains(input, "改动影响") || strings.Contains(input, "变更影响")
|
||||
if !propose && !analyze {
|
||||
return nil
|
||||
}
|
||||
@@ -1249,8 +1250,8 @@ func matchImpactIntent(input string) *CandidateIntent {
|
||||
intent = IntentRevisePropose
|
||||
}
|
||||
return &CandidateIntent{
|
||||
Intent: intent,
|
||||
Confidence: 0.9,
|
||||
Intent: intent,
|
||||
Confidence: 0.9,
|
||||
FilledSlots: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,3 +773,16 @@ func TestIntentRecognition_PermissionMismatch_NotHijackedBySupplement(t *testing
|
||||
t.Errorf("expected branch NEW_INTENT_PREEMPT for permission mismatch, got %s", res.Branch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentRecognition_ImpactPhrases(t *testing.T) {
|
||||
rec := intent.NewRecognizer()
|
||||
for _, text := range []string{"请分析影响", "这次改动影响哪些文档", "帮我做一下变更影响"} {
|
||||
res, err := rec.Recognize(context.Background(), text, &intent.ContextSnapshot{SnapshotID: "impact"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.TopIntent.Intent != intent.IntentImpactAnalyze {
|
||||
t.Fatalf("%q intent=%s", text, res.TopIntent.Intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (s *Server) handleProjectTraceIndex(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (s *Server) handleProjectTraceAnalyze(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, _, ok := s.traceProject(w, r)
|
||||
projectID, user, ok := s.traceProject(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (s *Server) handleProjectTraceAnalyze(w http.ResponseWriter, r *http.Reques
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
|
||||
return
|
||||
}
|
||||
rep, err := s.rt.Trace.Analyze(r.Context(), projectID, body.DocRef, body.Others)
|
||||
rep, err := s.rt.Trace.Analyze(traceimpact.WithActor(r.Context(), user.ID), projectID, body.DocRef, body.Others)
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
|
||||
return
|
||||
@@ -176,7 +176,8 @@ func (s *Server) handleProjectTraceRecheckClear(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Reason string `json:"reason"`
|
||||
Reason string `json:"reason"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF {
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
|
||||
@@ -202,6 +203,9 @@ func (s *Server) handleProjectTraceRecheckClear(w http.ResponseWriter, r *http.R
|
||||
writeJSONResponse(w, http.StatusBadRequest, map[string]string{"error": "TRACE_RECHECK_CLEAR_FAILED", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.MessageID > 0 && s.ctxMgr != nil {
|
||||
_ = s.ctxMgr.SetInteractiveCardStatus(r.Context(), body.MessageID, "resolved", "已标记为无需变更")
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,10 @@ type DocUnlocker interface {
|
||||
|
||||
// Service is the project trace index and the stage-backtrack ledger.
|
||||
type Service struct {
|
||||
store Store
|
||||
reopen StageReopener
|
||||
unlock DocUnlocker
|
||||
store Store
|
||||
reopen StageReopener
|
||||
unlock DocUnlocker
|
||||
onRecheck func(ctx context.Context, mark RecheckMark)
|
||||
}
|
||||
|
||||
func NewService(store Store) *Service {
|
||||
@@ -40,6 +41,30 @@ func NewMemoryService() *Service {
|
||||
func (s *Service) SetStageReopener(r StageReopener) { s.reopen = r }
|
||||
func (s *Service) SetDocUnlocker(u DocUnlocker) { s.unlock = u }
|
||||
|
||||
// SetRecheckHook is called once when a new open recheck mark is stored.
|
||||
// Repeat analyzes of the same source do not call it again.
|
||||
func (s *Service) SetRecheckHook(fn func(ctx context.Context, mark RecheckMark)) {
|
||||
if s != nil {
|
||||
s.onRecheck = fn
|
||||
}
|
||||
}
|
||||
|
||||
type actorCtxKey struct{}
|
||||
|
||||
// WithActor records who triggered this analysis so a recheck notice can reach their chat.
|
||||
func WithActor(ctx context.Context, userID int64) context.Context {
|
||||
if userID <= 0 {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, actorCtxKey{}, userID)
|
||||
}
|
||||
|
||||
// ActorID is the user from WithActor, or 0.
|
||||
func ActorID(ctx context.Context) int64 {
|
||||
id, _ := ctx.Value(actorCtxKey{}).(int64)
|
||||
return id
|
||||
}
|
||||
|
||||
// IndexDocument projects explicit keys into items and same-line pairs into confirmed edges.
|
||||
// Untyped IDs become candidate items only.
|
||||
func (s *Service) IndexDocument(ctx context.Context, projectID int64, doc DocRef) (IndexState, error) {
|
||||
@@ -186,10 +211,22 @@ func (s *Service) Analyze(ctx context.Context, projectID int64, source DocRef, o
|
||||
Reason: fmt.Sprintf("%s 经已确认关系受本次修改影响", it.BusinessKey),
|
||||
})
|
||||
if target.StageStatus == "completed" && target.StageKey != "" {
|
||||
_, _ = s.store.PutMark(ctx, RecheckMark{
|
||||
already := false
|
||||
if existing, listErr := s.store.ListOpenMarks(ctx, projectID, target.StageKey); listErr == nil {
|
||||
for _, m := range existing {
|
||||
if m.SourceDoc == source.DocID {
|
||||
already = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
saved, putErr := s.store.PutMark(ctx, RecheckMark{
|
||||
ProjectID: projectID, StageKey: target.StageKey, SourceDoc: source.DocID,
|
||||
Status: MarkOpen, Reason: "上游或同项目条目已变化",
|
||||
})
|
||||
if putErr == nil && !already && s.onRecheck != nil {
|
||||
s.onRecheck(ctx, saved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ func TestAnalyze_PendingIsNotNoImpact(t *testing.T) {
|
||||
func TestAnalyze_ConfirmedDownstreamAndRecheck(t *testing.T) {
|
||||
svc := NewMemoryService()
|
||||
ctx := t.Context()
|
||||
var notices int
|
||||
svc.SetRecheckHook(func(_ context.Context, mark RecheckMark) {
|
||||
notices++
|
||||
if mark.StageKey != "V0.5" || mark.ID <= 0 {
|
||||
t.Fatalf("mark %+v", mark)
|
||||
}
|
||||
})
|
||||
if _, err := svc.IndexDocument(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 用户需要记录"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -98,6 +105,17 @@ func TestAnalyze_ConfirmedDownstreamAndRecheck(t *testing.T) {
|
||||
if err := svc.ClearRecheck(ctx, marks[0].ID, "auto"); err == nil {
|
||||
t.Fatal("auto clear must fail")
|
||||
}
|
||||
if notices != 1 {
|
||||
t.Fatalf("recheck hook calls=%d", notices)
|
||||
}
|
||||
if _, err := svc.Analyze(ctx, 7, DocRef{DocID: "up", Markdown: "UR-001 改为必须记录"}, []DocRef{{
|
||||
DocID: "down", StageKey: "V0.5", StageStatus: "completed", Visible: true,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notices != 1 {
|
||||
t.Fatalf("repeat analyze must not notify again, calls=%d", notices)
|
||||
}
|
||||
if err := svc.ClearRecheck(ctx, marks[0].ID, ClearNoChange); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
import { DocumentLineage } from './types/projectWorkflow';
|
||||
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
|
||||
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
|
||||
import { ProjectTracePanel } from './components/workflow/ProjectTracePanel';
|
||||
import { OnDemand } from './components/lazy/OnDemand';
|
||||
import { fetchProjectSettings } from './api/projectSettings';
|
||||
import { LoginView } from './components/auth/LoginView';
|
||||
@@ -435,7 +434,6 @@ export default function App() {
|
||||
isCreator={currentWfState.creatorId === currentUser.id}
|
||||
/>
|
||||
)}
|
||||
{currentProjectId > 0 && <ProjectTracePanel projectId={currentProjectId} />}
|
||||
<StaleDocumentNotice
|
||||
lineages={projectLineages}
|
||||
onTriggerRecheck={() => {
|
||||
|
||||
@@ -49,11 +49,16 @@ export async function fetchProjectTrace(projectId: number): Promise<ProjectTrace
|
||||
};
|
||||
}
|
||||
|
||||
export async function clearRecheckMark(projectId: number, markId: number, reason: 'no_change' | 'revision_started'): Promise<void> {
|
||||
export async function clearRecheckMark(
|
||||
projectId: number,
|
||||
markId: number,
|
||||
reason: 'no_change' | 'revision_started',
|
||||
messageId?: number,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace/recheck/${markId}/clear`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify({ reason }),
|
||||
body: JSON.stringify({ reason, message_id: messageId || 0 }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`关闭待复核失败: ${res.status}`);
|
||||
|
||||
@@ -82,6 +82,16 @@ export function getCardTypeMeta(type: InteractiveCardType) {
|
||||
badgeColor: 'var(--info)',
|
||||
badgeBorder: 'rgba(43, 108, 201, 0.2)',
|
||||
};
|
||||
case 'RECHECK_NOTICE':
|
||||
return {
|
||||
label: '待复核',
|
||||
badgeText: '待复核',
|
||||
icon: AlertTriangle,
|
||||
accentColor: 'var(--amber-500)',
|
||||
badgeBg: 'var(--amber-50)',
|
||||
badgeColor: 'var(--amber-600)',
|
||||
badgeBorder: 'rgba(242, 169, 59, 0.3)',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: '协同卡片',
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { clearRecheckMark, fetchProjectTrace, ProjectTraceView } from '../../api/projectTrace';
|
||||
|
||||
interface ProjectTracePanelProps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
export const ProjectTracePanel: React.FC<ProjectTracePanelProps> = ({ projectId }) => {
|
||||
const [view, setView] = useState<ProjectTraceView | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setView(await fetchProjectTrace(projectId));
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (error) {
|
||||
return <div style={boxStyle}>需求与影响暂时不可用:{error}</div>;
|
||||
}
|
||||
if (!view) return null;
|
||||
|
||||
return (
|
||||
<div style={boxStyle}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>需求与影响</div>
|
||||
<div style={{ color: 'var(--text-muted)' }}>
|
||||
已索引 {view.index.length} 份文档 · 条目 {view.items.length}
|
||||
{view.hidden_count > 0 ? ` · 另有 ${view.hidden_count} 条不可见` : ''}
|
||||
</div>
|
||||
{view.recheck_marks.length > 0 && (
|
||||
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
|
||||
{view.recheck_marks.map((mark) => (
|
||||
<li key={mark.id}>
|
||||
阶段 {mark.stage_key} 待复核({mark.reason || mark.source_doc_id})
|
||||
<button
|
||||
type="button"
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
clearRecheckMark(projectId, mark.id, 'no_change').then(load).catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : '关闭失败');
|
||||
});
|
||||
}}
|
||||
>
|
||||
无需变更
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{view.items.length > 0 && (
|
||||
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
|
||||
{view.items.slice(0, 8).map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.business_key}
|
||||
{item.statement ? ` · ${item.statement}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const boxStyle: React.CSSProperties = {
|
||||
margin: '8px 12px 0',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
background: 'var(--bg-subtle, #f8fafc)',
|
||||
fontSize: 12,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
test('trace panel is not mounted above the chat stream', () => {
|
||||
const app = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
|
||||
assert.equal(app.includes('ProjectTracePanel'), false);
|
||||
const card = readFileSync(new URL('../chat/InteractiveCard.tsx', import.meta.url), 'utf8');
|
||||
assert.equal(card.includes("case 'RECHECK_NOTICE'"), true);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { clearRecheckMark } from '../api/projectTrace';
|
||||
import {
|
||||
fetchIntentPlan,
|
||||
submitIntentPlanDecision,
|
||||
@@ -69,6 +70,32 @@ export function useCardActions(input: CardActionsInput) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetCard) return;
|
||||
|
||||
if (targetCard.cardType === 'RECHECK_NOTICE' && actionId === 'recheck_no_change') {
|
||||
const projectId = Number(payload?.project_id || currentProjectId);
|
||||
const markId = Number(payload?.mark_id);
|
||||
const serverId = messages.find((m) => m.interactiveCard?.cardId === cardId)?.serverId;
|
||||
void clearRecheckMark(projectId, markId, 'no_change', serverId)
|
||||
.then(() => {
|
||||
setMessages((prev) => resolveCardInMessages(prev, cardId, '已标记为无需变更', currentUser.name));
|
||||
})
|
||||
.catch(() => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'recheck-fail-' + Date.now(),
|
||||
sessionId: activeSessionId,
|
||||
role: 'assistant',
|
||||
authorName: '项目助手',
|
||||
content: '没能关闭这条待复核,请稍后再试。',
|
||||
timestamp: new Date().toTimeString().substring(0, 5),
|
||||
},
|
||||
]);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlanConfirmCard(targetCard, payload)) {
|
||||
const planId = String(payload?.plan_id || targetCard?.planId || '');
|
||||
const expectedRevision = Number(payload?.expected_revision || targetCard?.planRevision || 0);
|
||||
|
||||
@@ -73,7 +73,8 @@ export type InteractiveCardType =
|
||||
| 'PLAN_CONFIRM'
|
||||
| 'SLOT_FILLING'
|
||||
| 'DIFF_PROPOSAL'
|
||||
| 'REMINDER_NOTICE';
|
||||
| 'REMINDER_NOTICE'
|
||||
| 'RECHECK_NOTICE';
|
||||
|
||||
export type CardActionStatus =
|
||||
| 'active'
|
||||
|
||||
Reference in New Issue
Block a user