feat(chat): cut over to single SSE endpoint
This commit is contained in:
@@ -195,6 +195,14 @@
|
|||||||
- 定义:Harness 对同一技术操作 attempt 数和可重试失败类型的显式策略。
|
- 定义:Harness 对同一技术操作 attempt 数和可重试失败类型的显式策略。
|
||||||
- 边界:Router 与 SemanticGuard 的技术失败最多两次 attempt;Diagnosis Agent、Tool 和 Evidence repair 只有一次 attempt。Agent 正常 ReAct 轮次不是 retry,`NO_EVIDENCE`、业务拒绝、取消和预算耗尽不可重试。
|
- 边界:Router 与 SemanticGuard 的技术失败最多两次 attempt;Diagnosis Agent、Tool 和 Evidence repair 只有一次 attempt。Agent 正常 ReAct 轮次不是 retry,`NO_EVIDENCE`、业务拒绝、取消和预算耗尽不可重试。
|
||||||
|
|
||||||
|
### Chat Application Use Case
|
||||||
|
- 定义:一次 Chat 请求的唯一业务入口,拥有 Session/Run、意图路由、固定执行器、PreviousTurn 和最终持久化。
|
||||||
|
- 边界:不拥有 HTTP/SSE 连接,也不把 ChatModel 或 Tool 选择权交给 Controller。
|
||||||
|
|
||||||
|
### Chat SSE Contract
|
||||||
|
- 定义:Chat 公开入口的五事件协议,顺序固定为 `metadata -> status* -> content|failure -> done`。
|
||||||
|
- 边界:过程状态实时发送,最终安全内容最多释放一次;它不是 Token streaming,也不包含内部计划、Prompt、raw Tool 数据或异常。
|
||||||
|
|
||||||
### Verifier Skill Isolation
|
### Verifier Skill Isolation
|
||||||
- 定义:Chat Verifier 与 skill 系统隔离,只校验 Executor 答案和 `tool_trace_summary`。
|
- 定义:Chat Verifier 与 skill 系统隔离,只校验 Executor 答案和 `tool_trace_summary`。
|
||||||
- 使用场景:防止 Verifier 把 playbook 指令当作事实证据;Verifier 只判断已有证据是否支持结论。
|
- 使用场景:防止 Verifier 把 playbook 指令当作事实证据;Verifier 只判断已有证据是否支持结论。
|
||||||
|
|||||||
@@ -42,3 +42,4 @@
|
|||||||
| 2026-07-21 | single-react-diagnosis-agent | Single internal Diagnosis ReactAgent with Harness-controlled model/tool loop, bounded context and typed Draft | Harness/Diagnosis Agent/ReAct | ISS-014, ReactAgent, DiagnosisDraft, PreviousTurn, ToolInterceptor, ModelInterceptor, budget | openspec/changes/archive/2026-07-21-single-react-diagnosis-agent | archived |
|
| 2026-07-21 | single-react-diagnosis-agent | Single internal Diagnosis ReactAgent with Harness-controlled model/tool loop, bounded context and typed Draft | Harness/Diagnosis Agent/ReAct | ISS-014, ReactAgent, DiagnosisDraft, PreviousTurn, ToolInterceptor, ModelInterceptor, budget | openspec/changes/archive/2026-07-21-single-react-diagnosis-agent | archived |
|
||||||
| 2026-07-21 | single-react-evidence-semantic-guards | Deterministic evidence validation, isolated semantic review and fail-closed diagnosis release | Harness/EvidenceGuard/SemanticGuard/Release | ISS-014, EvidenceGuard, verified snapshot, SemanticGuard, repair, fallback, release policy | openspec/changes/archive/2026-07-21-single-react-evidence-semantic-guards | archived |
|
| 2026-07-21 | single-react-evidence-semantic-guards | Deterministic evidence validation, isolated semantic review and fail-closed diagnosis release | Harness/EvidenceGuard/SemanticGuard/Release | ISS-014, EvidenceGuard, verified snapshot, SemanticGuard, repair, fallback, release policy | openspec/changes/archive/2026-07-21-single-react-evidence-semantic-guards | archived |
|
||||||
| 2026-07-21 | single-react-chat-application-usecase | Internal Chat application use case with isolated routing, fixed executors and safe PreviousTurn | Harness/Chat application/Run persistence | ISS-014, Intent Router, PreviousTurn, PublishedResult, V012, observer, cancellation | openspec/changes/archive/2026-07-21-single-react-chat-application-usecase | archived |
|
| 2026-07-21 | single-react-chat-application-usecase | Internal Chat application use case with isolated routing, fixed executors and safe PreviousTurn | Harness/Chat application/Run persistence | ISS-014, Intent Router, PreviousTurn, PublishedResult, V012, observer, cancellation | openspec/changes/archive/2026-07-21-single-react-chat-application-usecase | archived |
|
||||||
|
| 2026-07-21 | single-react-chat-sse-cutover | Unique named-event Chat SSE endpoint, bounded production Harness wiring and strict frontend consumer | Chat/SSE/Harness production wiring | ISS-014, /api/chat, SSE, metadata, status, content, failure, done, disconnect, bounded executor | openspec/changes/archive/2026-07-22-single-react-chat-sse-cutover | archived |
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Acceptance: single-react-chat-sse-cutover
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
- Status: archived
|
||||||
|
- OpenSpec tasks: 14/14 complete
|
||||||
|
- Interface impact: L4 breaking HTTP/frontend contract
|
||||||
|
- Public Chat protocol: unique named-event SSE `POST /api/chat`
|
||||||
|
|
||||||
|
## Static Verification
|
||||||
|
|
||||||
|
- 组合路由保持 `/api/chat`、`/api/ai_ops`、`/api/chat/clear`、`/api/chat/session/{sessionId}` 和 `/runs`;`/api/chat_stream` 已删除。
|
||||||
|
- 生产 Controller/前端 legacy Chat token scan:0。
|
||||||
|
- `ChatController` forbidden dependency scan:0;结构测试固定其三个 protocol/application dependencies。
|
||||||
|
- SSE payload 只包含固定 metadata/status/content/failure/done fields,`CANCELLED` 不作为公开 done outcome。
|
||||||
|
- mode selector DOM、state、consumer 和 CSS 已删除。
|
||||||
|
|
||||||
|
## Script Verification
|
||||||
|
|
||||||
|
- `mvn -q -DskipTests compile`:通过。
|
||||||
|
- Stage 2-6B + AiOps regression selection:24 suites / 101 tests,0 failure/error/skipped。
|
||||||
|
- `openspec validate single-react-chat-sse-cutover --strict`:通过。
|
||||||
|
- `node --check src/main/resources/static/app.js`:通过。
|
||||||
|
- `git diff --check`:通过,仅有仓库既存 LF/CRLF 提示。
|
||||||
|
|
||||||
|
## Browser or Manual Verification
|
||||||
|
|
||||||
|
- 本阶段未运行浏览器人工验证;前端协议由静态 contract test 和 JavaScript syntax check 覆盖。
|
||||||
|
|
||||||
|
## Not Verified
|
||||||
|
|
||||||
|
- 未运行 live 模型、Redis、日志和 MySQL E2E;按 ISS-014 阶段门禁统一留到阶段 7。
|
||||||
|
- 未验证外部第三方 Chat API consumer;L4 变更不提供兼容分支,外部消费者必须同步迁移到 named-event SSE。
|
||||||
|
|
||||||
|
## Migration and Rollback
|
||||||
|
|
||||||
|
- 部署必须将后端 SSE endpoint 与 bundled frontend consumer 作为同一版本原子发布。
|
||||||
|
- 回滚必须同时回滚 Controller 和 frontend 到阶段 6A commit;V012 additive nullable migration 可保留。
|
||||||
|
- 不允许通过恢复 `/api/chat_stream`、同步 JSON consumer 或旧 message wrapper 形成双轨兼容。
|
||||||
|
|
||||||
|
## Remaining Work
|
||||||
|
|
||||||
|
- 阶段 7:物理删除旧 Agent/Graph/Hook/ThreadLocal/ChatService 路径和过时测试,更新文档并完成最终 live E2E、日志与数据库核验。
|
||||||
|
|
||||||
|
## Archive
|
||||||
|
|
||||||
|
- `.archive-ready`: created
|
||||||
|
- OpenSpec archive: `openspec/changes/archive/2026-07-22-single-react-chat-sse-cutover`
|
||||||
|
- Main spec sync: `openspec/specs/single-react-chat-sse-cutover/spec.md` (10 requirements added)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Brief: single-react-chat-sse-cutover
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
阶段 6A 已建立 protocol-neutral `ChatApplicationUseCase`,但公开 Chat 仍有同步 `/api/chat` 和伪流式 `/api/chat_stream` 两条旧链路,Controller 直接拥有模型、Tools、Session 历史和无界线程池,前端也保留两套消费者。
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
将公开 Chat 原子切换为唯一 `POST /api/chat` named-event SSE,并让 Controller 只承担校验、HTTP/SSE 和连接生命周期;所有公开内容必须来自阶段 6A 的安全释放结果。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- 唯一 `/api/chat` SSE 与 `metadata -> status* -> content|failure -> done` 状态机。
|
||||||
|
- exact Run disconnect/timeout/send-failure cancellation。
|
||||||
|
- Spring-managed bounded Chat/model executors 和完整 Harness production Bean graph。
|
||||||
|
- 前端唯一 named-event consumer、typed renderer 和 metadata identity 保存。
|
||||||
|
- AiOps 模型/Tool acquisition 下沉到 service,并将 AiOps/Session endpoint 从 Chat Controller 职责中隔离。
|
||||||
|
- L4 前后端迁移、成对回滚和 focused regression。
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 不做 Token streaming、最终答案切片、断线续传、事件重放、轮询或 WebSocket。
|
||||||
|
- 不修改 `/api/ai_ops` 的公开 URL、请求和 SSE message-wrapper 行为。
|
||||||
|
- 不在本阶段物理删除旧多 Agent、ChatService、Hook 或 ThreadLocal;阶段 7 统一清理。
|
||||||
|
- 不运行 live 模型、Redis、日志或 MySQL E2E;阶段 7 统一验收。
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
- Scale: complex
|
||||||
|
- Interface impact: L4 breaking HTTP/frontend contract
|
||||||
|
- OpenSpec: `single-react-chat-sse-cutover`
|
||||||
|
- Parent issue: `ISS-014`
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Decisions: single-react-chat-sse-cutover
|
||||||
|
|
||||||
|
## Discover Status
|
||||||
|
|
||||||
|
- Checkpoint: Discover
|
||||||
|
- Capability source: `sm-flow` + `grill-with-docs`;`codebase-retrieval`、LSP 和 GitNexus MCP 当前不可用,使用 `rg` 引用核对、源码阅读和 focused tests 降级。
|
||||||
|
- Scale: complex。涉及 L4 HTTP/SSE 协议、前端消费者、异步连接生命周期、生产 Bean 装配和阶段 7 删除边界。
|
||||||
|
- `devflow/index.md` 命中阶段 0-6A、ISS-013 和 session-run-trace-isolation;ISS-014 是更新且已冻结的最终协议源。
|
||||||
|
|
||||||
|
## Question Pool
|
||||||
|
|
||||||
|
| # | 维度 | 问题 | 模式 | 状态 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Q1 | 术语 | “真正 SSE”是 Token streaming,还是过程事件实时 + 最终内容一次释放? | evidence-driven | 已解决 |
|
||||||
|
| Q2 | 协议 | 唯一 endpoint、事件名称、payload、顺序、互斥和终态是什么? | evidence-driven | 已解决 |
|
||||||
|
| Q3 | 边界 | Controller、Application Use Case、Harness 和 SSE adapter 各自拥有何种职责? | evidence-driven | 已解决 |
|
||||||
|
| Q4 | 生命周期 | disconnect/timeout/send failure 如何取消同一个 Run,正常 complete 如何避免误取消? | evidence-driven | 已解决 |
|
||||||
|
| Q5 | 执行器 | 如何消除 Controller 自建无界线程池并处理饱和? | evidence-driven | 已解决 |
|
||||||
|
| Q6 | 装配 | 阶段 2-6A plain Java components 如何形成可启动的生产 Bean graph? | evidence-driven | 已解决 |
|
||||||
|
| Q7 | 前端 | 快速/流式双模式如何迁移到唯一 SSE consumer? | evidence-driven | 已解决 |
|
||||||
|
| Q8 | 安全 | 哪些内部状态/内容禁止进入 SSE? | evidence-driven | 已解决 |
|
||||||
|
| Q9 | 兼容 | 是否保留同步 `/api/chat` 或 `/api/chat_stream` 兼容? | evidence-driven | 已解决 |
|
||||||
|
| Q10 | 范围 | `/api/ai_ops` 和旧多 Agent 何时处理? | evidence-driven | 已解决 |
|
||||||
|
| Q11 | 验收 | 如何证明 event order、single terminal、same IDs、cancel 和 production wiring? | evidence-driven | 已解决 |
|
||||||
|
|
||||||
|
## Evidence-driven
|
||||||
|
|
||||||
|
| 结论 | 证据来源 | 是否已汇报用户 |
|
||||||
|
|---|---|---|
|
||||||
|
| 真正 SSE 固定为状态实时发送、最终 typed content 一次释放,不做 Token/字符切片。 | ISS-014 4.7 | 已汇报 |
|
||||||
|
| 唯一入口是 `POST /api/chat`;顺序为 `metadata -> status* -> content|failure -> done`。 | ISS-014 4.7、阶段 6B | 已汇报 |
|
||||||
|
| metadata/status/content/failure/done 使用 named SSE event;payload 不再包含旧 `type/data` 包装。 | ISS-014 最小事件结构 | 已汇报 |
|
||||||
|
| 当前 Controller 同时拥有旧 ChatService、模型、Tools、SessionManager、同步/伪流式流程和 cached thread pool。 | `ChatController.java` | 已汇报 |
|
||||||
|
| 当前前端 quick 调 `/chat` JSON,stream 调 `/chat_stream` 并保留大量旧格式 fallback。 | `static/app.js` | 已汇报 |
|
||||||
|
| `ChatApplicationUseCase` 已提供 observer、同一 run control、typed content 和 stable failure code,但组件尚无完整生产 Bean graph。 | 阶段 6A code + Spring annotation scan | 已汇报 |
|
||||||
|
| 客户端断开必须通过 observer 得到的 `ChatRunControl` 取消,同一引用需覆盖断开早于 onStarted 的竞态。 | 阶段 6A design + `SseEmitter` lifecycle | 已汇报 |
|
||||||
|
| `/api/ai_ops` 是独立公开入口,不属于阶段 6B Chat 原子切换;旧实现阶段 7 清理/处置。 | ISS-014 阶段 6B/7 | 已汇报 |
|
||||||
|
|
||||||
|
## User-interview
|
||||||
|
|
||||||
|
- 无新增 user-interview。唯一 endpoint、破坏性迁移、事件 schema、非 Token 流、自动 Apply/Archive/commit 均由 ISS-014 与用户持续授权冻结。
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- Controller 使用构造注入的 `ChatApplicationUseCase` 与受控 `TaskExecutor`;不注入 ChatModel、Tools、ChatService 或 SessionManager 来处理 Chat。
|
||||||
|
- SSE adapter 为每个请求维护单一 state machine 和 `AtomicReference<ChatRunControl>`;disconnect 标记先于 onStarted 时,onStarted 立即取消。
|
||||||
|
- 正常 result 只产生一次 typed content 和 done;异常只产生 failure 和 done;IOException/timeout/disconnect 只取消,不尝试补发终态。
|
||||||
|
- executor rejection 在没有 Run 时发送稳定 failure/done;worker 启动后所有 failure code 来自 `ChatApplicationException`,不暴露 cause。
|
||||||
|
- 生产装配使用集中 `harness.chat` properties 和 Spring-managed bounded executors;所有模型调用复用同一 `ChatModel` 和 `GuardModelCall`。
|
||||||
|
- 前端移除 mode selector 和 quick path,只保留一个严格 named-event parser;unknown event/schema fail closed。
|
||||||
|
- 不创建 ADR:L4 方案已经在 ISS-014 设计冻结,本 change 负责原子落地和迁移说明。
|
||||||
|
|
||||||
|
## OpenSpec Backfill
|
||||||
|
|
||||||
|
- 需进入 proposal/design/spec/tasks:唯一 SSE、五事件 state machine、typed payload、安全 failure、same IDs、disconnect cancellation、bounded executors、production assembly、frontend migration、L4 rollback。
|
||||||
|
- 非目标:AiOps 协议、旧类物理删除、live E2E。
|
||||||
|
|
||||||
|
## Cross-artifact Alignment
|
||||||
|
|
||||||
|
| 上游 -> 下游 | 检查内容 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| ISS-014/brief -> proposal | 唯一 SSE、五事件、断开取消、前端迁移、生产装配、阶段 7 边界 | 已对齐 |
|
||||||
|
| proposal -> design | named events、state machine、bounded executors、Bean graph、AiOps 隔离、L4 rollback | 已对齐 |
|
||||||
|
| design -> specs/tasks | 每项 ownership/lifecycle/safety/migration 均有可观察 requirement 和纵向切片 | 已对齐 |
|
||||||
|
| specs -> tasks | 10 组 requirements 覆盖 wiring、SSE、cancel、frontend、AiOps regression 和 verification | 已对齐 |
|
||||||
|
|
||||||
|
## Architecture Audit
|
||||||
|
|
||||||
|
- Capability source: `zoom-out`,使用 Chat Application Use Case、RunContext、Run Lifecycle、Diagnosis Harness 和 Chat SSE Contract 术语。
|
||||||
|
- 链路为 `browser -> Controller -> SSE session -> bounded worker -> Application Use Case -> Harness -> typed release -> SSE session`;业务真理源不进入 Controller。
|
||||||
|
- SSE session 独占协议状态,Application 独占 Run/dispatch/persistence,Core 独占 cancel/budget,configuration 独占 infrastructure graph。
|
||||||
|
- 最大风险是 disconnect/onStarted 与 terminal callback 竞态,design/tasks 已固定 pending-disconnect、atomic terminal 和 no-send-after-close tests。
|
||||||
|
- L4 部署必须 Controller/frontend 同版本;回滚成对返回阶段 6A commit,V012 可保留。
|
||||||
|
|
||||||
|
## Interface Impact
|
||||||
|
|
||||||
|
- Level: L4 breaking HTTP/frontend contract。
|
||||||
|
- 删除同步 JSON `POST /api/chat`、`POST /api/chat_stream` 和旧 Chat SSE wrapper;新增 named-event SSE `POST /api/chat`。
|
||||||
|
- 消费者:bundled `static/app.js`、外部 Chat callers、Controller/MockMvc tests;Trace/feedback 继续使用 metadata IDs。
|
||||||
|
- 迁移/回滚:前后端同 commit 原子部署/回滚;不提供兼容开关、双 endpoint 或旧 parser。
|
||||||
|
|
||||||
|
## Commit Gate Preflight
|
||||||
|
|
||||||
|
- proposal、design、specs、tasks 完整,change strict validation 通过。
|
||||||
|
- Question pool 全部 evidence-driven 解决并已汇报,无 user-interview、未判级接口或未接受架构风险。
|
||||||
|
- Cross-artifact 四段对齐无 gap;L4 migration/rollback、production wiring、disconnect race 和阶段 7 边界均进入 design/spec/tasks。
|
||||||
|
- `openspec-propose` 补全产物,`zoom-out` 完成架构审计;可提交为 Committed OpenSpec。
|
||||||
|
|
||||||
|
## Apply Progress
|
||||||
|
|
||||||
|
- 1.1-1.4 完成:新增 `ChatHarnessProperties`、bounded worker/model executors 和显式 `HarnessChatConfiguration` graph;空 MySQL datasource 只允许 fail-closed unknown logical ID。
|
||||||
|
- 2.1-2.4 完成:新增 named-event `ChatSseEvent`/`ChatSseSession`,Chat 唯一 SSE Controller,移除 `/chat_stream` 和同步 Chat;AiOps 模型/Tool 依赖下沉到 service。
|
||||||
|
- 3.1-3.3 完成:前端移除 quick/stream 双轨,统一 named-event parser、typed renderer 和静态 contract test。
|
||||||
|
- `JpaChatRunStore` 生产构造器改为注入集中 `PublishedResultPolicy`,避免读写边界漂移;这是实现偏差修复,无需改需求方向。
|
||||||
|
|
||||||
|
## Apply Review
|
||||||
|
|
||||||
|
- Review 发现 `ChatController` 虽然 Chat path 已经只调用应用用例,但类本身仍承载 AiOps 和 Session 管理依赖,不完全满足“Chat Controller 只负责 Chat 协议”的 ownership 要求。
|
||||||
|
- 用户确认将职责拆分为 `ChatController`(仅 `/api/chat`)、`AiOpsController`(保持 `/api/ai_ops`)和 `ChatSessionController`(保持 clear/session/runs URL)。
|
||||||
|
- 该修正保持所有公开 URL、AiOps message-wrapper payload 和 Session observable behavior,不修改 Committed OpenSpec 的范围或方向。
|
||||||
|
- `styles.css` 中 mode selector/dropdown 死样式随前端双轨删除一并移除。
|
||||||
|
- AGENTS.md 指定的 `codebase-retrieval` 和 LSP 工具在当前环境不可用;使用 OpenSpec 全量上下文、`rg` 引用检查、Java 编译、结构测试和综合回归完成等价影响面确认。
|
||||||
|
|
||||||
|
## Verification and Migration
|
||||||
|
|
||||||
|
- Stage 2-6B + AiOps regression:24 suites / 101 tests,0 failure/error/skipped;包含 `HarnessChatConfigurationTest` Spring wiring。
|
||||||
|
- `mvn -q -DskipTests compile`、`openspec validate single-react-chat-sse-cutover --strict`、`node --check src/main/resources/static/app.js` 均通过。
|
||||||
|
- 生产 Controller/前端遗留 token 静态扫描为 0;`ChatController` 中模型、Tool、ChatService、AiOps、Session 和 Trace service 禁止依赖扫描为 0。
|
||||||
|
- L4 部署必须将后端 `/api/chat` SSE 与 bundled frontend consumer 同版本原子部署;回滚必须成对回滚到阶段 6A commit,不提供兼容 endpoint、旧 parser 或双轨开关。
|
||||||
|
- live 模型、Redis、日志和 MySQL E2E 按 ISS-014 门禁明确延后到阶段 7,本阶段没有把 focused/Mock 验证表述为 live 验收。
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Evidence: single-react-chat-sse-cutover
|
||||||
|
|
||||||
|
## Code and Contract Evidence
|
||||||
|
|
||||||
|
- `ChatApplicationUseCase` 已提供 typed result、observer 和 exact `ChatRunControl`,Controller 无需拥有模型、Tool 或业务路由。
|
||||||
|
- `ChatSseSession` 使用 first-terminal-wins 状态和 pending disconnect,覆盖断开早于 `onStarted`、send failure、late terminal 与正常 completion 竞态。
|
||||||
|
- `ChatSseEvent` 固定 metadata/status/content/failure/done payload;content 引用阶段 6A typed public content,failure 只公开 stable code/message。
|
||||||
|
- `HarnessChatConfiguration` 组装同一个 Core、model boundary、canonical store、ToolBoundary、Diagnosis Agent、Guards、Router、executors 和 application use case。
|
||||||
|
- `ChatHarnessProperties` 集中 worker/model queue、Run、SSE、canonical store、Agent、Router、Guard 和 single-turn limits;executors 使用有限队列与 `AbortPolicy`。
|
||||||
|
- bundled frontend 只向 `/api/chat` 发起 streaming POST,按完整 named SSE frame 严格解析并 fail closed。
|
||||||
|
|
||||||
|
## Ownership Review
|
||||||
|
|
||||||
|
- Apply review 将原 Controller 拆为 `ChatController`、`AiOpsController` 和 `ChatSessionController`。
|
||||||
|
- `ChatController` 只注入 `ChatApplicationUseCase`、bounded worker 和 Chat properties;禁止依赖扫描为 0。
|
||||||
|
- `/api/ai_ops`、`/api/chat/clear`、session info 和 run list 的 URL 与 payload 字段保持不变。
|
||||||
|
- mode selector/dropdown DOM、JS state 和 CSS 已全部移除,不保留双轨开关。
|
||||||
|
|
||||||
|
## Safety and Lifecycle Evidence
|
||||||
|
|
||||||
|
- success 测试验证 `metadata,status,content,done` 严格顺序和 typed content。
|
||||||
|
- failure 测试验证内部 provider detail 不进入 failure payload,且 content/failure 互斥。
|
||||||
|
- disconnect-before-start 与 send-failure 测试验证 exact Run 取消和 late terminal 阻断。
|
||||||
|
- frontend contract test 验证唯一 request target、五类 event branch 与旧 consumer token 删除。
|
||||||
|
- static scan:生产 Controller/前端 legacy Chat token 0;Chat Controller forbidden dependency 0。
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
- Stage 2-6B + AiOps regression:24 suites / 101 tests,0 failure/error/skipped。
|
||||||
|
- `HarnessChatConfigurationTest` 覆盖 bounded queues、共享依赖、空 MySQL fail-closed 和 Spring context wiring。
|
||||||
|
- Maven compile、strict OpenSpec validation 和 JavaScript syntax check 通过。
|
||||||
|
- 真实模型、Redis、日志和 MySQL live E2E 按 ISS-014 串行门禁留到阶段 7。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# ISS-014 单体 ReAct Agent、Harness 与 ACI 工具瘦身
|
# ISS-014 单体 ReAct Agent、Harness 与 ACI 工具瘦身
|
||||||
|
|
||||||
**状态**:实施中(阶段 0-6A 已归档,下一阶段 6B)
|
**状态**:实施中(阶段 0-6B 已归档,下一阶段 7)
|
||||||
**严重程度**:高
|
**严重程度**:高
|
||||||
**发现时间**:2026-07-20
|
**发现时间**:2026-07-20
|
||||||
**目标分支**:`refactor/chat-single-react-harness`
|
**目标分支**:`refactor/chat-single-react-harness`
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
archive-ready
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
committed
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
schema: spec-driven
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
公开 Chat 仍有同步 `/api/chat` 和伪流式 `/api/chat_stream` 两条旧 `ChatService` 链路;Controller 直接读取 Session 历史、获取模型/Tools、选择策略并维护 cached thread pool。阶段 6A 已完成 protocol-neutral `ChatApplicationUseCase`、observer 和 `ChatRunControl`,但阶段 2-6A 的 plain Java components 尚未形成生产 Bean graph。前端同时保留 quick JSON 与 stream SSE 两套消费者。
|
||||||
|
|
||||||
|
本 change 是 L4 破坏性协议迁移。ISS-014 已冻结 event schema、无兼容分支和阶段 7 清理边界;本阶段必须原子切换 Controller、生产装配与前端,不能出现旧路径绕过 release boundary。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- 唯一 `POST /api/chat` 返回 named SSE events,固定顺序和互斥规则。
|
||||||
|
- Controller 只负责校验、SSE/HTTP 和连接生命周期,业务执行只调用 `ChatApplicationUseCase`。
|
||||||
|
- 使用同一个 Run control 处理断开/timeout/send failure,并禁止断开后的终态发送。
|
||||||
|
- 生产装配阶段 2-6A 的 Core、Tools、Agent、Guards、executors、store 和 use case。
|
||||||
|
- 使用集中配置与 Spring-managed bounded executors。
|
||||||
|
- 前端唯一 consumer 严格消费新契约并保存 exact session/run IDs。
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- 不做 Token streaming 或最终内容切片。
|
||||||
|
- 不修改 `/api/ai_ops` 的 URL、请求或事件协议。
|
||||||
|
- 不物理删除旧多 Agent、ChatService、Hooks 或 ThreadLocal;阶段 7 处理。
|
||||||
|
- 不做断线续传、event replay、WebSocket、轮询或 live E2E。
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Named SSE event 是唯一协议层类型
|
||||||
|
|
||||||
|
新增 immutable SSE payload:`metadata(session_id,run_id)`、`status(code,message)`、`content(content_type,payload)`、`failure(code,message)`、`done(outcome)`。`SseEmitter.event().name(type).data(payload)` 直接使用业务 event name,不再发送旧 `message + {type,data}` 包装。
|
||||||
|
|
||||||
|
替代继续用统一 `message` event;拒绝,因为它允许 type 漂移并让浏览器保留旧兼容分支。`content.payload` 直接引用阶段 6A sealed typed content,禁止 Map/Object 临时协议。
|
||||||
|
|
||||||
|
### 2. 每个连接使用显式 first-terminal-wins SSE session
|
||||||
|
|
||||||
|
新增 protocol adapter 持有 emitter、`AtomicReference<ChatRunControl>`、disconnect flag、metadata flag 和 terminal flag。`onStarted` 固定发送 metadata;若 disconnect 已先发生则立即取消新得到的 Run control。status 只能在 metadata 后、terminal 前发送。success 原子发送 content + done 后 complete;failure 原子发送 failure + done 后 complete。
|
||||||
|
|
||||||
|
`onTimeout`、`onError`、异常 send 和非正常 `onCompletion` 只标记 disconnected 并调用 `cancelClientDisconnect()`;不尝试补发 failure/done。正常 complete 在回调前标记 terminal,避免把成功误判为断开。替代仅在 IOException catch 取消;拒绝,因为 timeout 和 disconnect-before-onStarted 会漏掉。
|
||||||
|
|
||||||
|
### 3. 请求接纳失败不进入 SSE 事件序列
|
||||||
|
|
||||||
|
空 query 在创建 emitter/Run 前返回 HTTP 400;bounded worker rejection 在创建 Run 前返回 HTTP 503。五事件顺序只适用于已接纳并成功持久化 Run 的请求,因而 metadata 始终是第一个 SSE event。Session ID 格式错误由 use case 形成有 Run 的稳定 failure,除非它在 startRun 前失败;Controller 只做 query shape 校验。
|
||||||
|
|
||||||
|
### 4. Chat Controller 不拥有模型、Tools、Session 或业务执行
|
||||||
|
|
||||||
|
删除同步 Chat 方法、`/chat_stream`、ChatResponse、历史 helper、内容切片和 Chat 使用的 `ChatService/SessionManager/ChatModel/ToolCallbackProvider`。Controller 构造注入 `ChatApplicationUseCase` 和 qualifier Chat TaskExecutor。
|
||||||
|
|
||||||
|
同一类中的 `/api/ai_ops` 仍需旧模型/Tools。为保持其公开协议不变又清理 Controller 依赖,向 `AiOpsService` 增加内部 overload,由 service 注入 ChatModel/ToolCallbackProvider 并调用既有执行方法;Controller 只传 request/session/run。替代本阶段迁移 AiOps 到新 use case;拒绝,因为超出阶段 6B。
|
||||||
|
|
||||||
|
### 5. Production assembly 使用一个显式 Bean graph
|
||||||
|
|
||||||
|
新增 `HarnessChatConfiguration`,按依赖顺序装配:
|
||||||
|
|
||||||
|
```text
|
||||||
|
properties/Clock/executors
|
||||||
|
-> DiagnosisHarnessCore + HarnessRetryExecutor + GuardModelCall
|
||||||
|
-> RedisCanonicalInvocationStore + ToolBoundary
|
||||||
|
-> RAG/log/MySQL adapters + HarnessEvidenceTools
|
||||||
|
-> DiagnosisAgentFactory/UseCase
|
||||||
|
-> EvidenceGuard/Repair + SemanticGuard + ReleaseUseCase
|
||||||
|
-> Router + System/Knowledge/Diagnosis executors
|
||||||
|
-> ChatApplicationUseCase(JpaChatRunStore)
|
||||||
|
```
|
||||||
|
|
||||||
|
所有模型入口复用同一 Spring `ChatModel` 和 model executor。RAG/log adapters 复用现有 `LookupKnowledgeTool`/`QueryLogsTools` legacy executors,但所有 Agent-facing output 仍经过 ToolBoundary projection。MySQL 从 `MysqlToolProperties` 生成独立 datasource map;空配置时 Tool 仍注册但 fail closed 为 unknown datasource。
|
||||||
|
|
||||||
|
### 6. 并发与预算全部集中配置
|
||||||
|
|
||||||
|
新增 `harness.chat` configuration properties,覆盖 Chat worker/model pool、Run deadline/budget、canonical TTL/bytes、Agent/Router/Guard/single-turn/Knowledge limits 和 key prefix。worker 与 model executor 均使用 bounded `ThreadPoolExecutor + AbortPolicy`,由 Spring 负责 shutdown;Controller 不持有或创建 executor。
|
||||||
|
|
||||||
|
Retry attempt audit 首版写结构化 debug log,不新增持久化表。替代硬编码构造参数;拒绝,因为阶段 0 已要求上限集中可校准。
|
||||||
|
|
||||||
|
### 7. 前端删除 quick/stream 双轨
|
||||||
|
|
||||||
|
移除 mode selector DOM、`currentMode`、`sendQuickMessage` 和旧 `/chat_stream` parser。`sendMessage` 只调用 `sendChatMessage`,POST `/api/chat` 并按空行分隔完整 SSE frame,解析 named event 与 JSON payload。metadata 更新 trace target;status 更新等待说明;content 一次渲染 typed payload;failure 显示安全消息;done 校验 outcome 并结束。
|
||||||
|
|
||||||
|
typed payload 使用专用 renderer 转换 SYSTEM_CHAT、KNOWLEDGE、DIAGNOSIS_REPORT、FALLBACK,不把对象隐式拼接为字符串。unknown event、重复 metadata/content/failure/done、content/failure 同时出现或流结束前无 done 均 fail closed。
|
||||||
|
|
||||||
|
## Module and Ownership Audit
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser fetch POST /api/chat
|
||||||
|
-> ChatController (validation + connection admission)
|
||||||
|
-> ChatSseSession (event order + connection cancellation)
|
||||||
|
-> bounded chat worker
|
||||||
|
-> ChatApplicationUseCase (session/run/routing/dispatch/terminal persistence)
|
||||||
|
-> Harness Core / Agent / Tools / Guards
|
||||||
|
-> typed content or stable failure
|
||||||
|
-> ChatSseSession (single content|failure + done)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Controller owns HTTP/SSE only;SSE session owns event state;Application owns business Run;Core owns cancellation/budget;Store owns MySQL;Harness configuration owns infrastructure graph。
|
||||||
|
- 数据真理源仍为 `diagnosis_run`;SSE metadata 只透出同一 use case 生成的 IDs。
|
||||||
|
- 最大生命周期风险是 disconnect 与 onStarted 竞态;atomic control + pending disconnect 解决。
|
||||||
|
- 最大部署风险是 L4 前后端不同步;Controller/前端必须同一 commit 原子部署,回滚也必须成对。
|
||||||
|
- 无 ADR 冲突;ISS-013 的 token-like “incremental content”被更新的 ISS-014 明确收敛为实时 status + 单次安全 content。
|
||||||
|
|
||||||
|
## Interface Impact
|
||||||
|
|
||||||
|
- Level: L4 breaking HTTP/frontend contract。
|
||||||
|
- Removed: synchronous JSON `POST /api/chat`、`POST /api/chat_stream`、旧 `ChatResponse` 和 message-wrapper Chat SSE。
|
||||||
|
- Added: SSE `POST /api/chat` with five named events and exact snake_case payload fields。
|
||||||
|
- Consumers: bundled `static/app.js`、外部 Chat API callers、Controller tests/MockMvc;Trace/feedback continue using metadata IDs。
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [客户端仍调用旧 endpoint] -> 同 commit 迁移 bundled frontend;外部消费者必须按 L4 文档迁移,不提供兼容层。
|
||||||
|
- [发送中断后 worker 继续] -> every send failure/timeout/error cancels exact Run control;late result blocked by Core。
|
||||||
|
- [executor 饱和] -> bounded queue + HTTP 503 before Run;不 caller-runs、不无界排队。
|
||||||
|
- [Spring graph 缺 Bean/歧义] -> focused configuration context test + Maven compile。
|
||||||
|
- [legacy Tool side effects] -> Agent-facing output 仍经新 boundary;legacy classes stage 7 physically removed。
|
||||||
|
- [typed diagnosis payload frontend rendering复杂] -> deterministic renderer and JS contract tests/static assertions。
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. 部署前确保 V012 已迁移,Redis 与模型配置可用。
|
||||||
|
2. 同一版本注册 Harness Bean graph、切换 `/api/chat` SSE、删除 `/chat_stream` 并更新 bundled frontend。
|
||||||
|
3. smoke/focused tests 验证 metadata/status/content|failure/done、same IDs 和 cancellation;live E2E 延后阶段 7。
|
||||||
|
4. 回滚必须同时回滚 Controller 与 frontend 到阶段 6A commit;数据库 V012 nullable additive 可保留。
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- None。初始数值为可配置默认值,阶段 7 根据 live trace 校准。
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
阶段 6A 已完成内部 Chat Application Use Case,但公开入口仍由 `ChatController` 直接持有旧 `ChatService`、模型、工具、Redis 历史和无界线程池。当前 `/api/chat` 是同步 JSON,`/api/chat_stream` 则在完整答案生成后切片,既绕过新 Harness 释放边界,也让前端维持两套不一致消费者。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 将唯一 `POST /api/chat` 原子切换为 `text/event-stream`,只调用阶段 6A `ChatApplicationUseCase`。
|
||||||
|
- 删除同步 Chat 响应路径和 `/api/chat_stream`,Controller 不再获取 ChatModel、ToolCallbackProvider、旧 Session 历史或 ChatService。
|
||||||
|
- 固定五类 SSE 事件与顺序:`metadata -> status* -> content|failure -> done`;业务 event name 与 payload schema 均稳定。
|
||||||
|
- `content` 最多一次且只承载阶段 6A typed safe content,不做 Token/字符切片;技术失败使用一次 `failure`。
|
||||||
|
- 客户端断开、SSE timeout 或发送失败取消同一个 `ChatRunControl`,内部 Run 记录 CANCELLED,断开后不再发送终态。
|
||||||
|
- 使用 Spring 管理的有界 Chat worker 和 Harness model executor;Controller 不创建线程池。
|
||||||
|
- 新增完整 Harness 生产 Bean 装配,使 Router、三类 executor、Diagnosis Agent、Guards、canonical Tool boundary 和 JPA store 使用同一 Core/ChatModel/ObjectMapper。
|
||||||
|
- 前端删除快速/流式双模式,统一以 fetch streaming 消费 `/api/chat` 的 named SSE events,并保存 metadata 中的 session/run ID。
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `single-react-chat-sse-cutover`: 定义唯一 Chat SSE 入口、五事件状态机、连接取消、生产 Harness 装配和前端消费者迁移。
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- None. 阶段 6A application use case 和 `/api/ai_ops` 对外行为保持不变。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `ChatController` Chat 入口、SSE DTO/adapter、连接生命周期和 focused MVC tests。
|
||||||
|
- Harness 生产配置、集中 limits、受控 executors 和装配启动测试。
|
||||||
|
- `app.js`/`index.html` Chat consumer 和模式控件清理。
|
||||||
|
- SSE schema/order/mutual exclusion/cancel/failure tests,以及阶段 2-6A regression。
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 不修改 `/api/ai_ops` 协议或迁移其旧多 Agent 实现。
|
||||||
|
- 不删除旧 ChatService、Planner/Executor/Verifier/Composer、Hook 或 ThreadLocal;阶段 7 物理清理。
|
||||||
|
- 不实现 Token streaming、断线续传、事件重放、轮询或 WebSocket。
|
||||||
|
- 不运行真实模型/Redis/MySQL live E2E;阶段 7 统一完成。
|
||||||
|
|
||||||
|
## Context Constraints
|
||||||
|
|
||||||
|
- `Diagnosis Agent` 是唯一拥有 Tool loop 的业务 Agent;Controller 和 SSE adapter 不得拥有模型或工具。
|
||||||
|
- SemanticGuard 完成前不得发送 `content`;Fallback 使用 `content + done(FALLBACK)`,技术失败使用 `failure + done(FAILED)`。
|
||||||
|
- metadata 固定第一且一次,done 固定最后且一次;content/failure 互斥。
|
||||||
|
- 这是 L4 破坏性公开协议变更,不保留旧 endpoint 或同步兼容分支。
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- 阶段 6A 组件尚未生产装配,单改 Controller 会导致 Spring 启动失败。
|
||||||
|
- SseEmitter completion/error/timeout 与 worker 完成存在竞态,必须 first-terminal-wins 且断开后禁止继续发送。
|
||||||
|
- 前端解析器当前按单行 data 和旧 `type` 字段兼容,迁移不完整会丢失 metadata、终态或错误。
|
||||||
|
- 有界 executor 饱和时必须 fail closed,不能退化为 Controller 线程执行或无界排队。
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Chat SHALL expose one SSE endpoint
|
||||||
|
The public Chat API SHALL expose only `POST /api/chat` with `text/event-stream`. The synchronous JSON Chat path and `POST /api/chat_stream` MUST be removed without a compatibility branch.
|
||||||
|
|
||||||
|
#### Scenario: Accepted Chat request
|
||||||
|
- **WHEN** a client posts a non-blank Chat query
|
||||||
|
- **THEN** `/api/chat` returns an SSE emitter backed only by `ChatApplicationUseCase`
|
||||||
|
|
||||||
|
#### Scenario: Removed legacy endpoint
|
||||||
|
- **WHEN** a client posts to `/api/chat_stream`
|
||||||
|
- **THEN** no Chat handler is mapped to that endpoint
|
||||||
|
|
||||||
|
#### Scenario: Invalid request
|
||||||
|
- **WHEN** a client posts a blank Chat query
|
||||||
|
- **THEN** the server rejects it before creating a Run or starting an SSE event sequence
|
||||||
|
|
||||||
|
### Requirement: SSE events SHALL follow one strict state machine
|
||||||
|
Every accepted Chat stream SHALL emit named events in the order `metadata -> status* -> content|failure -> done`. Metadata and done MUST occur exactly once, status MAY occur zero or more times, content and failure MUST be mutually exclusive and each MUST occur at most once.
|
||||||
|
|
||||||
|
#### Scenario: Successful diagnosis
|
||||||
|
- **WHEN** a Diagnosis Run safely releases a report
|
||||||
|
- **THEN** the stream emits one metadata event, zero or more safe status events, one content event, and one SUCCESS done event in order
|
||||||
|
|
||||||
|
#### Scenario: Safe fallback
|
||||||
|
- **WHEN** the release boundary returns a Fallback
|
||||||
|
- **THEN** the stream emits one content event containing only the fixed Fallback followed by one FALLBACK done event
|
||||||
|
|
||||||
|
#### Scenario: Technical failure
|
||||||
|
- **WHEN** routing or Harness execution cannot produce safe content
|
||||||
|
- **THEN** the stream emits one stable failure event followed by one FAILED done event and emits no content
|
||||||
|
|
||||||
|
### Requirement: SSE payloads SHALL be typed and safe
|
||||||
|
Metadata SHALL contain only `session_id` and `run_id`; status SHALL contain only a fixed code and safe message; content SHALL contain `content_type` plus the stage 6A typed public payload; failure SHALL contain a stable code and safe message; done SHALL contain only `SUCCESS`, `FALLBACK`, or `FAILED` outcome. The stream MUST NOT expose prompts, thoughts, raw Tool data, complete evidence, internal exceptions, vendor errors, stack traces, unverified Drafts, or internal guard reasons.
|
||||||
|
|
||||||
|
#### Scenario: Metadata identity
|
||||||
|
- **WHEN** the use case starts one Run
|
||||||
|
- **THEN** the metadata IDs exactly match the IDs persisted and returned by that same Run
|
||||||
|
|
||||||
|
#### Scenario: Content release boundary
|
||||||
|
- **WHEN** status events are emitted before SemanticGuard completes
|
||||||
|
- **THEN** no diagnosis content is emitted until `ChatApplicationUseCase` returns released typed content
|
||||||
|
|
||||||
|
#### Scenario: Failure sanitization
|
||||||
|
- **WHEN** an internal exception contains provider or persistence details
|
||||||
|
- **THEN** the failure event contains only the stable application code and public message
|
||||||
|
|
||||||
|
### Requirement: Final content SHALL not be disguised token streaming
|
||||||
|
The Chat stream SHALL send process status while work is running and SHALL release final safe content in one content event. It MUST NOT split a completed answer by character or fixed chunk size.
|
||||||
|
|
||||||
|
#### Scenario: Long final report
|
||||||
|
- **WHEN** a safe final report is larger than the legacy chunk size
|
||||||
|
- **THEN** it is sent as one typed content event rather than multiple content chunks
|
||||||
|
|
||||||
|
### Requirement: Client disconnect SHALL cancel the exact Run
|
||||||
|
SSE completion before terminal release, timeout, error, or send failure SHALL request CLIENT_DISCONNECT cancellation through the `ChatRunControl` belonging to the same metadata IDs. A disconnect that occurs before `onStarted` MUST be remembered and applied when the control becomes available. No event SHALL be sent after disconnection.
|
||||||
|
|
||||||
|
#### Scenario: Disconnect during model work
|
||||||
|
- **WHEN** the client disconnects after metadata while a model call is pending
|
||||||
|
- **THEN** the same Run is cancelled, late content is discarded, and no content/failure/done is attempted
|
||||||
|
|
||||||
|
#### Scenario: Disconnect before Run control publication
|
||||||
|
- **WHEN** a disconnect is observed before the application observer receives `onStarted`
|
||||||
|
- **THEN** the observer cancels that Run immediately when its control is published
|
||||||
|
|
||||||
|
#### Scenario: Normal completion callback
|
||||||
|
- **WHEN** content or failure and done have completed normally
|
||||||
|
- **THEN** the emitter completion callback does not change the already terminal Run outcome
|
||||||
|
|
||||||
|
### Requirement: Controller SHALL remain a bounded protocol adapter
|
||||||
|
The Controller SHALL validate request shape, admit work to a Spring-managed bounded executor, map application observer/results to SSE, and manage connection lifecycle. It MUST NOT select or invoke ChatModel, ToolCallbackProvider, ChatService routing, Session history, Diagnosis Agent, Guards, or Tool logic, and MUST NOT construct an executor.
|
||||||
|
|
||||||
|
#### Scenario: Worker saturation
|
||||||
|
- **WHEN** the bounded Chat worker rejects a request before a Run starts
|
||||||
|
- **THEN** the endpoint returns a stable unavailable HTTP response and does not run work on the request thread
|
||||||
|
|
||||||
|
#### Scenario: Dependency inspection
|
||||||
|
- **WHEN** Chat Controller dependencies and source are inspected
|
||||||
|
- **THEN** only protocol/application collaborators are present for Chat and no model, Tool, old strategy, history, or executor construction remains
|
||||||
|
|
||||||
|
### Requirement: Production SHALL assemble one Harness graph
|
||||||
|
Spring production configuration SHALL assemble one shared Core, ChatModel boundary, canonical store, Tool boundary, three evidence adapters, Diagnosis Agent, EvidenceGuard, repair, SemanticGuard, release use case, three fixed executors, JPA Run store, Router, and Chat Application Use Case. All Run paths SHALL share the same Core and model configuration.
|
||||||
|
|
||||||
|
#### Scenario: Application context wiring
|
||||||
|
- **WHEN** a focused Spring context loads the Harness Chat configuration with controlled dependencies
|
||||||
|
- **THEN** exactly one `ChatApplicationUseCase` graph is created without missing or ambiguous beans
|
||||||
|
|
||||||
|
#### Scenario: MySQL Tool is not configured
|
||||||
|
- **WHEN** no logical MySQL datasource is configured
|
||||||
|
- **THEN** the query_mysql Tool remains bounded and fails closed for unknown data sources without using the application database
|
||||||
|
|
||||||
|
### Requirement: Runtime limits and executors SHALL be centralized and bounded
|
||||||
|
Run budgets, timeouts, byte limits, canonical TTL/capacity, worker sizing and model executor sizing SHALL come from `harness.chat` configuration with positive validated defaults. Worker and model executors SHALL use bounded queues and rejection policies and SHALL be shut down by Spring.
|
||||||
|
|
||||||
|
#### Scenario: Configuration defaults
|
||||||
|
- **WHEN** the production properties load without overrides
|
||||||
|
- **THEN** every Harness limit is positive, canonical limits preserve their invariants, and both executor queues have finite capacity
|
||||||
|
|
||||||
|
#### Scenario: Model executor saturation
|
||||||
|
- **WHEN** the model executor cannot accept another submitted model call
|
||||||
|
- **THEN** the operation fails as a controlled technical failure and does not create an unbounded queue or caller-runs execution
|
||||||
|
|
||||||
|
### Requirement: Bundled frontend SHALL consume only the new Chat SSE contract
|
||||||
|
The bundled frontend SHALL send every Chat message to `/api/chat`, parse complete named SSE frames, store metadata IDs for Trace/feedback, render safe typed content once, surface stable failure, and finish only after a valid done event. It MUST NOT retain quick/stream mode selection, synchronous Chat JSON parsing, `/chat_stream`, legacy message-wrapper fallback, or raw non-JSON content fallback.
|
||||||
|
|
||||||
|
#### Scenario: Frontend success
|
||||||
|
- **WHEN** the browser receives metadata, statuses, one typed content and SUCCESS done
|
||||||
|
- **THEN** it records the exact IDs, updates progress, renders the payload once, and marks the message complete
|
||||||
|
|
||||||
|
#### Scenario: Frontend protocol violation
|
||||||
|
- **WHEN** the browser receives an unknown, duplicate, out-of-order, mutually conflicting, or malformed event
|
||||||
|
- **THEN** it fails closed and does not render the payload as trusted assistant content
|
||||||
|
|
||||||
|
#### Scenario: Frontend request target
|
||||||
|
- **WHEN** static Chat consumer source is inspected
|
||||||
|
- **THEN** it contains one `/chat` streaming request and no `/chat_stream` or synchronous Chat consumer
|
||||||
|
|
||||||
|
### Requirement: AiOps public behavior SHALL remain isolated
|
||||||
|
The `/api/ai_ops` URL, request schema and event behavior SHALL remain unchanged in stage 6B. Any internal model/Tool dependency movement needed to keep Chat Controller protocol-only MUST preserve existing AiOps observable behavior.
|
||||||
|
|
||||||
|
#### Scenario: AiOps regression
|
||||||
|
- **WHEN** existing AiOps Controller and service tests run after Chat cutover
|
||||||
|
- **THEN** existing metadata and analysis behavior remains compatible
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## 1. Production Harness assembly
|
||||||
|
|
||||||
|
- [x] 1.1 Add validated `harness.chat` properties and Spring-managed bounded Chat/model executors with deterministic shutdown and rejection behavior.
|
||||||
|
- [x] 1.2 Assemble canonical Redis store, ToolBoundary, bounded RAG/log/MySQL adapters and HarnessEvidenceTools without using the application database for query_mysql.
|
||||||
|
- [x] 1.3 Assemble shared Core/model boundary, Diagnosis Agent, Guards/release, Router, three fixed executors and one ChatApplicationUseCase graph.
|
||||||
|
- [x] 1.4 Add focused configuration tests for positive defaults, bounded queues, shared dependencies, empty MySQL fail-closed behavior and unambiguous context startup.
|
||||||
|
|
||||||
|
## 2. Unique Chat SSE protocol
|
||||||
|
|
||||||
|
- [x] 2.1 Add immutable five-event payloads and a first-terminal-wins SSE session enforcing metadata -> status* -> content|failure -> done order, safe fields and exact Run cancellation.
|
||||||
|
- [x] 2.2 Replace synchronous `/api/chat` with the bounded-worker SSE path, remove `/api/chat_stream`, old Chat response/chunk/history logic and Chat model/tool/service dependencies.
|
||||||
|
- [x] 2.3 Move legacy AiOps model/Tool acquisition behind AiOpsService while preserving `/api/ai_ops` observable behavior and using a managed executor.
|
||||||
|
- [x] 2.4 Add focused Controller/protocol tests for validation, one endpoint, event order/schema/mutual exclusion, typed success/fallback, stable failure, saturation and disconnect races.
|
||||||
|
|
||||||
|
## 3. Frontend consumer cutover
|
||||||
|
|
||||||
|
- [x] 3.1 Remove quick/stream mode state, selector UI, synchronous Chat JSON path, `/chat_stream` and legacy message-wrapper compatibility.
|
||||||
|
- [x] 3.2 Implement one strict named-event frame parser and typed content renderer that records metadata IDs, shows status, renders content once and requires valid done.
|
||||||
|
- [x] 3.3 Add static frontend contract tests covering the unique request target, removed legacy tokens and required event/render branches.
|
||||||
|
|
||||||
|
## 4. Verification and migration
|
||||||
|
|
||||||
|
- [x] 4.1 Run stage 6B focused tests, relevant stage 2-6A and AiOps regressions, Maven compile and Spring context wiring verification.
|
||||||
|
- [x] 4.2 Validate strict OpenSpec, no `/chat_stream`/sync consumer/controller model/tool dependency, bounded executors, exact SSE safety fields and no unverified/internal content leakage.
|
||||||
|
- [x] 4.3 Record L4 frontend/API migration and paired rollback evidence; leave live model/Redis/MySQL E2E explicitly deferred to stage 7.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# single-react-chat-sse-cutover Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
TBD - created by archiving change single-react-chat-sse-cutover. Update Purpose after archive.
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Chat SHALL expose one SSE endpoint
|
||||||
|
The public Chat API SHALL expose only `POST /api/chat` with `text/event-stream`. The synchronous JSON Chat path and `POST /api/chat_stream` MUST be removed without a compatibility branch.
|
||||||
|
|
||||||
|
#### Scenario: Accepted Chat request
|
||||||
|
- **WHEN** a client posts a non-blank Chat query
|
||||||
|
- **THEN** `/api/chat` returns an SSE emitter backed only by `ChatApplicationUseCase`
|
||||||
|
|
||||||
|
#### Scenario: Removed legacy endpoint
|
||||||
|
- **WHEN** a client posts to `/api/chat_stream`
|
||||||
|
- **THEN** no Chat handler is mapped to that endpoint
|
||||||
|
|
||||||
|
#### Scenario: Invalid request
|
||||||
|
- **WHEN** a client posts a blank Chat query
|
||||||
|
- **THEN** the server rejects it before creating a Run or starting an SSE event sequence
|
||||||
|
|
||||||
|
### Requirement: SSE events SHALL follow one strict state machine
|
||||||
|
Every accepted Chat stream SHALL emit named events in the order `metadata -> status* -> content|failure -> done`. Metadata and done MUST occur exactly once, status MAY occur zero or more times, content and failure MUST be mutually exclusive and each MUST occur at most once.
|
||||||
|
|
||||||
|
#### Scenario: Successful diagnosis
|
||||||
|
- **WHEN** a Diagnosis Run safely releases a report
|
||||||
|
- **THEN** the stream emits one metadata event, zero or more safe status events, one content event, and one SUCCESS done event in order
|
||||||
|
|
||||||
|
#### Scenario: Safe fallback
|
||||||
|
- **WHEN** the release boundary returns a Fallback
|
||||||
|
- **THEN** the stream emits one content event containing only the fixed Fallback followed by one FALLBACK done event
|
||||||
|
|
||||||
|
#### Scenario: Technical failure
|
||||||
|
- **WHEN** routing or Harness execution cannot produce safe content
|
||||||
|
- **THEN** the stream emits one stable failure event followed by one FAILED done event and emits no content
|
||||||
|
|
||||||
|
### Requirement: SSE payloads SHALL be typed and safe
|
||||||
|
Metadata SHALL contain only `session_id` and `run_id`; status SHALL contain only a fixed code and safe message; content SHALL contain `content_type` plus the stage 6A typed public payload; failure SHALL contain a stable code and safe message; done SHALL contain only `SUCCESS`, `FALLBACK`, or `FAILED` outcome. The stream MUST NOT expose prompts, thoughts, raw Tool data, complete evidence, internal exceptions, vendor errors, stack traces, unverified Drafts, or internal guard reasons.
|
||||||
|
|
||||||
|
#### Scenario: Metadata identity
|
||||||
|
- **WHEN** the use case starts one Run
|
||||||
|
- **THEN** the metadata IDs exactly match the IDs persisted and returned by that same Run
|
||||||
|
|
||||||
|
#### Scenario: Content release boundary
|
||||||
|
- **WHEN** status events are emitted before SemanticGuard completes
|
||||||
|
- **THEN** no diagnosis content is emitted until `ChatApplicationUseCase` returns released typed content
|
||||||
|
|
||||||
|
#### Scenario: Failure sanitization
|
||||||
|
- **WHEN** an internal exception contains provider or persistence details
|
||||||
|
- **THEN** the failure event contains only the stable application code and public message
|
||||||
|
|
||||||
|
### Requirement: Final content SHALL not be disguised token streaming
|
||||||
|
The Chat stream SHALL send process status while work is running and SHALL release final safe content in one content event. It MUST NOT split a completed answer by character or fixed chunk size.
|
||||||
|
|
||||||
|
#### Scenario: Long final report
|
||||||
|
- **WHEN** a safe final report is larger than the legacy chunk size
|
||||||
|
- **THEN** it is sent as one typed content event rather than multiple content chunks
|
||||||
|
|
||||||
|
### Requirement: Client disconnect SHALL cancel the exact Run
|
||||||
|
SSE completion before terminal release, timeout, error, or send failure SHALL request CLIENT_DISCONNECT cancellation through the `ChatRunControl` belonging to the same metadata IDs. A disconnect that occurs before `onStarted` MUST be remembered and applied when the control becomes available. No event SHALL be sent after disconnection.
|
||||||
|
|
||||||
|
#### Scenario: Disconnect during model work
|
||||||
|
- **WHEN** the client disconnects after metadata while a model call is pending
|
||||||
|
- **THEN** the same Run is cancelled, late content is discarded, and no content/failure/done is attempted
|
||||||
|
|
||||||
|
#### Scenario: Disconnect before Run control publication
|
||||||
|
- **WHEN** a disconnect is observed before the application observer receives `onStarted`
|
||||||
|
- **THEN** the observer cancels that Run immediately when its control is published
|
||||||
|
|
||||||
|
#### Scenario: Normal completion callback
|
||||||
|
- **WHEN** content or failure and done have completed normally
|
||||||
|
- **THEN** the emitter completion callback does not change the already terminal Run outcome
|
||||||
|
|
||||||
|
### Requirement: Controller SHALL remain a bounded protocol adapter
|
||||||
|
The Controller SHALL validate request shape, admit work to a Spring-managed bounded executor, map application observer/results to SSE, and manage connection lifecycle. It MUST NOT select or invoke ChatModel, ToolCallbackProvider, ChatService routing, Session history, Diagnosis Agent, Guards, or Tool logic, and MUST NOT construct an executor.
|
||||||
|
|
||||||
|
#### Scenario: Worker saturation
|
||||||
|
- **WHEN** the bounded Chat worker rejects a request before a Run starts
|
||||||
|
- **THEN** the endpoint returns a stable unavailable HTTP response and does not run work on the request thread
|
||||||
|
|
||||||
|
#### Scenario: Dependency inspection
|
||||||
|
- **WHEN** Chat Controller dependencies and source are inspected
|
||||||
|
- **THEN** only protocol/application collaborators are present for Chat and no model, Tool, old strategy, history, or executor construction remains
|
||||||
|
|
||||||
|
### Requirement: Production SHALL assemble one Harness graph
|
||||||
|
Spring production configuration SHALL assemble one shared Core, ChatModel boundary, canonical store, Tool boundary, three evidence adapters, Diagnosis Agent, EvidenceGuard, repair, SemanticGuard, release use case, three fixed executors, JPA Run store, Router, and Chat Application Use Case. All Run paths SHALL share the same Core and model configuration.
|
||||||
|
|
||||||
|
#### Scenario: Application context wiring
|
||||||
|
- **WHEN** a focused Spring context loads the Harness Chat configuration with controlled dependencies
|
||||||
|
- **THEN** exactly one `ChatApplicationUseCase` graph is created without missing or ambiguous beans
|
||||||
|
|
||||||
|
#### Scenario: MySQL Tool is not configured
|
||||||
|
- **WHEN** no logical MySQL datasource is configured
|
||||||
|
- **THEN** the query_mysql Tool remains bounded and fails closed for unknown data sources without using the application database
|
||||||
|
|
||||||
|
### Requirement: Runtime limits and executors SHALL be centralized and bounded
|
||||||
|
Run budgets, timeouts, byte limits, canonical TTL/capacity, worker sizing and model executor sizing SHALL come from `harness.chat` configuration with positive validated defaults. Worker and model executors SHALL use bounded queues and rejection policies and SHALL be shut down by Spring.
|
||||||
|
|
||||||
|
#### Scenario: Configuration defaults
|
||||||
|
- **WHEN** the production properties load without overrides
|
||||||
|
- **THEN** every Harness limit is positive, canonical limits preserve their invariants, and both executor queues have finite capacity
|
||||||
|
|
||||||
|
#### Scenario: Model executor saturation
|
||||||
|
- **WHEN** the model executor cannot accept another submitted model call
|
||||||
|
- **THEN** the operation fails as a controlled technical failure and does not create an unbounded queue or caller-runs execution
|
||||||
|
|
||||||
|
### Requirement: Bundled frontend SHALL consume only the new Chat SSE contract
|
||||||
|
The bundled frontend SHALL send every Chat message to `/api/chat`, parse complete named SSE frames, store metadata IDs for Trace/feedback, render safe typed content once, surface stable failure, and finish only after a valid done event. It MUST NOT retain quick/stream mode selection, synchronous Chat JSON parsing, `/chat_stream`, legacy message-wrapper fallback, or raw non-JSON content fallback.
|
||||||
|
|
||||||
|
#### Scenario: Frontend success
|
||||||
|
- **WHEN** the browser receives metadata, statuses, one typed content and SUCCESS done
|
||||||
|
- **THEN** it records the exact IDs, updates progress, renders the payload once, and marks the message complete
|
||||||
|
|
||||||
|
#### Scenario: Frontend protocol violation
|
||||||
|
- **WHEN** the browser receives an unknown, duplicate, out-of-order, mutually conflicting, or malformed event
|
||||||
|
- **THEN** it fails closed and does not render the payload as trusted assistant content
|
||||||
|
|
||||||
|
#### Scenario: Frontend request target
|
||||||
|
- **WHEN** static Chat consumer source is inspected
|
||||||
|
- **THEN** it contains one `/chat` streaming request and no `/chat_stream` or synchronous Chat consumer
|
||||||
|
|
||||||
|
### Requirement: AiOps public behavior SHALL remain isolated
|
||||||
|
The `/api/ai_ops` URL, request schema and event behavior SHALL remain unchanged in stage 6B. Any internal model/Tool dependency movement needed to keep Chat Controller protocol-only MUST preserve existing AiOps observable behavior.
|
||||||
|
|
||||||
|
#### Scenario: AiOps regression
|
||||||
|
- **WHEN** existing AiOps Controller and service tests run after Chat cutover
|
||||||
|
- **THEN** existing metadata and analysis behavior remains compatible
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.superbiz.agent.config;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/** Central limits and bounded executor settings for the production Chat Harness graph. */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@ConfigurationProperties(prefix = "harness.chat")
|
||||||
|
public class ChatHarnessProperties {
|
||||||
|
|
||||||
|
private int workerCorePoolSize = 2;
|
||||||
|
private int workerMaxPoolSize = 8;
|
||||||
|
private int workerQueueCapacity = 64;
|
||||||
|
private int modelCorePoolSize = 4;
|
||||||
|
private int modelMaxPoolSize = 8;
|
||||||
|
private int modelQueueCapacity = 32;
|
||||||
|
private String toolKeyPrefix = "superbiz:harness:tool-call";
|
||||||
|
private Duration runTimeout = Duration.ofMinutes(5);
|
||||||
|
private Duration sseTimeout = Duration.ofMinutes(5);
|
||||||
|
private Duration canonicalTtl = Duration.ofHours(2);
|
||||||
|
private long canonicalMaxRecordBytes = 1_048_576;
|
||||||
|
private long canonicalMaxAgentResultBytes = 65_536;
|
||||||
|
private int maxModelCalls = 24;
|
||||||
|
private int maxToolCalls = 24;
|
||||||
|
private int maxCallsPerTool = 8;
|
||||||
|
private long maxInputTokens = 100_000;
|
||||||
|
private long maxOutputTokens = 100_000;
|
||||||
|
private long maxTotalTokens = 200_000;
|
||||||
|
private long maxRunBytes = 1_000_000;
|
||||||
|
private long diagnosisMaxQueryBytes = 16_384;
|
||||||
|
private long diagnosisMaxPreviousTurnBytes = 16_384;
|
||||||
|
private long diagnosisMaxInputBytes = 49_152;
|
||||||
|
private long diagnosisMaxDraftBytes = 49_152;
|
||||||
|
private long routerMaxInputBytes = 16_384;
|
||||||
|
private long routerMaxOutputBytes = 4_096;
|
||||||
|
private Duration routerAttemptTimeout = Duration.ofSeconds(10);
|
||||||
|
private Duration routerTotalTimeout = Duration.ofSeconds(25);
|
||||||
|
private long systemMaxInputBytes = 16_384;
|
||||||
|
private long systemMaxOutputBytes = 16_384;
|
||||||
|
private Duration systemTimeout = Duration.ofSeconds(30);
|
||||||
|
private long knowledgeMaxModelInputBytes = 32_768;
|
||||||
|
private long knowledgeMaxModelOutputBytes = 24_576;
|
||||||
|
private Duration knowledgeModelTimeout = Duration.ofSeconds(30);
|
||||||
|
private long semanticMaxInputBytes = 100_000;
|
||||||
|
private long semanticMaxOutputBytes = 10_000;
|
||||||
|
private Duration semanticAttemptTimeout = Duration.ofSeconds(20);
|
||||||
|
private Duration semanticTotalTimeout = Duration.ofSeconds(45);
|
||||||
|
private long repairMaxInputBytes = 100_000;
|
||||||
|
private long repairMaxOutputBytes = 48_000;
|
||||||
|
private Duration repairTimeout = Duration.ofSeconds(20);
|
||||||
|
|
||||||
|
public void validate() {
|
||||||
|
positive(workerCorePoolSize, "workerCorePoolSize");
|
||||||
|
positive(workerMaxPoolSize, "workerMaxPoolSize");
|
||||||
|
positive(workerQueueCapacity, "workerQueueCapacity");
|
||||||
|
positive(modelCorePoolSize, "modelCorePoolSize");
|
||||||
|
positive(modelMaxPoolSize, "modelMaxPoolSize");
|
||||||
|
positive(modelQueueCapacity, "modelQueueCapacity");
|
||||||
|
if (workerCorePoolSize > workerMaxPoolSize || modelCorePoolSize > modelMaxPoolSize) {
|
||||||
|
throw new IllegalArgumentException("executor core size must not exceed max size");
|
||||||
|
}
|
||||||
|
positive(canonicalMaxRecordBytes, "canonicalMaxRecordBytes");
|
||||||
|
positive(canonicalMaxAgentResultBytes, "canonicalMaxAgentResultBytes");
|
||||||
|
if (canonicalMaxAgentResultBytes > canonicalMaxRecordBytes) {
|
||||||
|
throw new IllegalArgumentException("canonical agent result exceeds record limit");
|
||||||
|
}
|
||||||
|
positive(maxModelCalls, "maxModelCalls");
|
||||||
|
positive(maxToolCalls, "maxToolCalls");
|
||||||
|
positive(maxCallsPerTool, "maxCallsPerTool");
|
||||||
|
positive(maxInputTokens, "maxInputTokens");
|
||||||
|
positive(maxOutputTokens, "maxOutputTokens");
|
||||||
|
positive(maxTotalTokens, "maxTotalTokens");
|
||||||
|
positive(maxRunBytes, "maxRunBytes");
|
||||||
|
positive(diagnosisMaxQueryBytes, "diagnosisMaxQueryBytes");
|
||||||
|
positive(diagnosisMaxPreviousTurnBytes, "diagnosisMaxPreviousTurnBytes");
|
||||||
|
positive(diagnosisMaxInputBytes, "diagnosisMaxInputBytes");
|
||||||
|
positive(diagnosisMaxDraftBytes, "diagnosisMaxDraftBytes");
|
||||||
|
positive(routerMaxInputBytes, "routerMaxInputBytes");
|
||||||
|
positive(routerMaxOutputBytes, "routerMaxOutputBytes");
|
||||||
|
positive(systemMaxInputBytes, "systemMaxInputBytes");
|
||||||
|
positive(systemMaxOutputBytes, "systemMaxOutputBytes");
|
||||||
|
positive(knowledgeMaxModelInputBytes, "knowledgeMaxModelInputBytes");
|
||||||
|
positive(knowledgeMaxModelOutputBytes, "knowledgeMaxModelOutputBytes");
|
||||||
|
positive(semanticMaxInputBytes, "semanticMaxInputBytes");
|
||||||
|
positive(semanticMaxOutputBytes, "semanticMaxOutputBytes");
|
||||||
|
positive(repairMaxInputBytes, "repairMaxInputBytes");
|
||||||
|
positive(repairMaxOutputBytes, "repairMaxOutputBytes");
|
||||||
|
requirePositive(runTimeout, "runTimeout");
|
||||||
|
requirePositive(sseTimeout, "sseTimeout");
|
||||||
|
requirePositive(canonicalTtl, "canonicalTtl");
|
||||||
|
requirePositive(routerAttemptTimeout, "routerAttemptTimeout");
|
||||||
|
requirePositive(routerTotalTimeout, "routerTotalTimeout");
|
||||||
|
requirePositive(systemTimeout, "systemTimeout");
|
||||||
|
requirePositive(knowledgeModelTimeout, "knowledgeModelTimeout");
|
||||||
|
requirePositive(semanticAttemptTimeout, "semanticAttemptTimeout");
|
||||||
|
requirePositive(semanticTotalTimeout, "semanticTotalTimeout");
|
||||||
|
requirePositive(repairTimeout, "repairTimeout");
|
||||||
|
if (routerTotalTimeout.compareTo(routerAttemptTimeout) < 0
|
||||||
|
|| semanticTotalTimeout.compareTo(semanticAttemptTimeout) < 0) {
|
||||||
|
throw new IllegalArgumentException("total timeout must not be shorter than attempt timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void positive(long value, String name) {
|
||||||
|
if (value <= 0) {
|
||||||
|
throw new IllegalArgumentException(name + " must be positive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requirePositive(Duration value, String name) {
|
||||||
|
if (value == null || value.isZero() || value.isNegative()) {
|
||||||
|
throw new IllegalArgumentException(name + " must be positive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
package com.superbiz.agent.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.superbiz.agent.config.MysqlToolProperties.DataSourceProperties;
|
||||||
|
import com.superbiz.agent.harness.agent.HarnessEvidenceTools;
|
||||||
|
import com.superbiz.agent.harness.agent.DiagnosisAgentFactory;
|
||||||
|
import com.superbiz.agent.harness.agent.DiagnosisAgentLimits;
|
||||||
|
import com.superbiz.agent.harness.agent.DiagnosisAgentUseCase;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationUseCase;
|
||||||
|
import com.superbiz.agent.harness.application.executor.DiagnosisChatExecutor;
|
||||||
|
import com.superbiz.agent.harness.application.executor.KnowledgeQueryExecutor;
|
||||||
|
import com.superbiz.agent.harness.application.executor.KnowledgeQueryLimits;
|
||||||
|
import com.superbiz.agent.harness.application.executor.SingleTurnExecutorLimits;
|
||||||
|
import com.superbiz.agent.harness.application.executor.SystemChatExecutor;
|
||||||
|
import com.superbiz.agent.harness.application.persistence.ChatRunStore;
|
||||||
|
import com.superbiz.agent.harness.application.persistence.PreviousTurnLimits;
|
||||||
|
import com.superbiz.agent.harness.application.persistence.PublishedResultPolicy;
|
||||||
|
import com.superbiz.agent.harness.application.routing.IntentRouter;
|
||||||
|
import com.superbiz.agent.harness.application.routing.IntentRouterLimits;
|
||||||
|
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
|
||||||
|
import com.superbiz.agent.harness.core.RunBudgetLimits;
|
||||||
|
import com.superbiz.agent.harness.guard.evidence.EvidenceGuard;
|
||||||
|
import com.superbiz.agent.harness.guard.semantic.GuardModelCall;
|
||||||
|
import com.superbiz.agent.harness.guard.semantic.SemanticGuard;
|
||||||
|
import com.superbiz.agent.harness.guard.semantic.SemanticGuardLimits;
|
||||||
|
import com.superbiz.agent.harness.release.DiagnosisReleaseUseCase;
|
||||||
|
import com.superbiz.agent.harness.release.EvidenceRepair;
|
||||||
|
import com.superbiz.agent.harness.release.EvidenceRepairLimits;
|
||||||
|
import com.superbiz.agent.harness.release.SafeFallbackFactory;
|
||||||
|
import com.superbiz.agent.harness.retry.HarnessRetryExecutor;
|
||||||
|
import com.superbiz.agent.harness.retry.HarnessRetryPolicies;
|
||||||
|
import com.superbiz.agent.harness.tool.adapter.MysqlToolAdapter;
|
||||||
|
import com.superbiz.agent.harness.tool.adapter.QueryLogsToolAdapter;
|
||||||
|
import com.superbiz.agent.harness.tool.adapter.RagToolAdapter;
|
||||||
|
import com.superbiz.agent.harness.tool.boundary.ToolBoundary;
|
||||||
|
import com.superbiz.agent.harness.tool.mysql.JdbcMysqlReadOnlyExecutor;
|
||||||
|
import com.superbiz.agent.harness.tool.mysql.MysqlDataSourceDefinition;
|
||||||
|
import com.superbiz.agent.harness.tool.mysql.MysqlReadOnlyExecutor;
|
||||||
|
import com.superbiz.agent.harness.tool.mysql.MysqlResultProjector;
|
||||||
|
import com.superbiz.agent.harness.tool.mysql.MysqlSqlValidator;
|
||||||
|
import com.superbiz.agent.harness.tool.projection.QueryLogsResultProjector;
|
||||||
|
import com.superbiz.agent.harness.tool.projection.RagResultProjector;
|
||||||
|
import com.superbiz.agent.harness.tool.projection.ToolProjectionLimits;
|
||||||
|
import com.superbiz.agent.harness.tool.store.CanonicalInvocationLimits;
|
||||||
|
import com.superbiz.agent.harness.tool.store.CanonicalInvocationStore;
|
||||||
|
import com.superbiz.agent.harness.tool.store.RedisCanonicalInvocationStore;
|
||||||
|
import com.superbiz.agent.harness.tool.store.ToolCallKeyFactory;
|
||||||
|
import com.superbiz.agent.harness.application.DiagnosisOperation;
|
||||||
|
import com.superbiz.agent.harness.application.KnowledgeQueryOperation;
|
||||||
|
import com.superbiz.agent.harness.application.SystemChatOperation;
|
||||||
|
import com.superbiz.agent.harness.application.IntentRouting;
|
||||||
|
import com.superbiz.agent.hook.AgentLoggingHook;
|
||||||
|
import com.superbiz.agent.repository.AgentStepRepository;
|
||||||
|
import com.superbiz.agent.tool.LookupKnowledgeTool;
|
||||||
|
import com.superbiz.agent.agent.tool.QueryLogsTools;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(ChatHarnessProperties.class)
|
||||||
|
public class HarnessChatConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Clock harnessClock() {
|
||||||
|
return Clock.systemUTC();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(destroyMethod = "shutdown")
|
||||||
|
@Qualifier("chatWorkerExecutor")
|
||||||
|
public ThreadPoolExecutor chatWorkerExecutor(ChatHarnessProperties properties) {
|
||||||
|
properties.validate();
|
||||||
|
return boundedExecutor(properties.getWorkerCorePoolSize(), properties.getWorkerMaxPoolSize(),
|
||||||
|
properties.getWorkerQueueCapacity(), "chat-worker-");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(destroyMethod = "shutdown")
|
||||||
|
@Qualifier("harnessModelExecutor")
|
||||||
|
public ThreadPoolExecutor harnessModelExecutor(ChatHarnessProperties properties) {
|
||||||
|
properties.validate();
|
||||||
|
return boundedExecutor(properties.getModelCorePoolSize(), properties.getModelMaxPoolSize(),
|
||||||
|
properties.getModelQueueCapacity(), "harness-model-");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DiagnosisHarnessCore diagnosisHarnessCore(ChatHarnessProperties properties, Clock harnessClock) {
|
||||||
|
properties.validate();
|
||||||
|
return new DiagnosisHarnessCore(harnessClock, () -> UUID.randomUUID().toString(),
|
||||||
|
properties.getRunTimeout(),
|
||||||
|
new RunBudgetLimits(properties.getMaxModelCalls(), properties.getMaxToolCalls(),
|
||||||
|
properties.getMaxCallsPerTool(), properties.getMaxInputTokens(),
|
||||||
|
properties.getMaxOutputTokens(), properties.getMaxTotalTokens(), properties.getMaxRunBytes()),
|
||||||
|
HarnessRetryPolicies.strict());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public HarnessRetryExecutor harnessRetryExecutor(DiagnosisHarnessCore core) {
|
||||||
|
return new HarnessRetryExecutor(core);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public GuardModelCall guardModelCall(DiagnosisHarnessCore core, ChatModel chatModel,
|
||||||
|
@Qualifier("harnessModelExecutor") ThreadPoolExecutor executor) {
|
||||||
|
return new GuardModelCall(core, chatModel, executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CanonicalInvocationLimits canonicalInvocationLimits(ChatHarnessProperties properties) {
|
||||||
|
return new CanonicalInvocationLimits(properties.getCanonicalTtl(),
|
||||||
|
properties.getCanonicalMaxRecordBytes(), properties.getCanonicalMaxAgentResultBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ToolCallKeyFactory toolCallKeyFactory(ChatHarnessProperties properties) {
|
||||||
|
return new ToolCallKeyFactory(properties.getToolKeyPrefix());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CanonicalInvocationStore canonicalInvocationStore(
|
||||||
|
RedisTemplate<String, Object> redisTemplate, ObjectMapper objectMapper,
|
||||||
|
CanonicalInvocationLimits limits) {
|
||||||
|
return new RedisCanonicalInvocationStore(redisTemplate, objectMapper, limits);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ToolBoundary toolBoundary(DiagnosisHarnessCore core, ToolCallKeyFactory keyFactory,
|
||||||
|
CanonicalInvocationStore store, ObjectMapper objectMapper, Clock clock) {
|
||||||
|
return new ToolBoundary(core, keyFactory, store, objectMapper, clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ToolProjectionLimits toolProjectionLimits() {
|
||||||
|
return ToolProjectionLimits.defaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RagResultProjector ragResultProjector(ObjectMapper mapper, ToolProjectionLimits limits) {
|
||||||
|
return new RagResultProjector(mapper, limits);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public QueryLogsResultProjector queryLogsResultProjector(ObjectMapper mapper, ToolProjectionLimits limits) {
|
||||||
|
return new QueryLogsResultProjector(mapper, limits);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RagToolAdapter ragToolAdapter(ToolBoundary boundary, ObjectMapper mapper,
|
||||||
|
RagResultProjector projector, LookupKnowledgeTool legacy) {
|
||||||
|
return new RagToolAdapter(boundary, mapper, projector, legacy::lookupKnowledge);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public QueryLogsToolAdapter queryLogsToolAdapter(ToolBoundary boundary, ObjectMapper mapper,
|
||||||
|
QueryLogsResultProjector projector,
|
||||||
|
ObjectProvider<QueryLogsTools> legacy, Clock clock) {
|
||||||
|
return new QueryLogsToolAdapter(boundary, mapper, projector,
|
||||||
|
(region, topic, query, limit) -> {
|
||||||
|
QueryLogsTools tools = legacy.getIfAvailable();
|
||||||
|
if (tools == null) {
|
||||||
|
return "{\"success\":false,\"logs\":[],\"total\":0}";
|
||||||
|
}
|
||||||
|
return tools.queryLogs(region, topic, query, limit);
|
||||||
|
}, clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Map<String, DataSource> harnessMysqlDataSources(MysqlToolProperties properties) {
|
||||||
|
Map<String, DataSource> result = new java.util.LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, DataSourceProperties> entry : properties.getDataSources().entrySet()) {
|
||||||
|
DataSourceProperties source = entry.getValue();
|
||||||
|
if (source.getJdbcUrl() == null || source.getJdbcUrl().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
org.springframework.jdbc.datasource.DriverManagerDataSource dataSource =
|
||||||
|
new org.springframework.jdbc.datasource.DriverManagerDataSource();
|
||||||
|
dataSource.setUrl(source.getJdbcUrl());
|
||||||
|
dataSource.setUsername(source.getUsername());
|
||||||
|
dataSource.setPassword(source.getPassword());
|
||||||
|
result.put(entry.getKey(), dataSource);
|
||||||
|
}
|
||||||
|
return Map.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Map<String, MysqlDataSourceDefinition> harnessMysqlDefinitions(MysqlToolProperties properties) {
|
||||||
|
return properties.definitions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MysqlSqlValidator mysqlSqlValidator(Map<String, MysqlDataSourceDefinition> definitions) {
|
||||||
|
return new MysqlSqlValidator(definitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MysqlReadOnlyExecutor mysqlReadOnlyExecutor(
|
||||||
|
@Qualifier("harnessMysqlDataSources") Map<String, DataSource> dataSources, Clock clock) {
|
||||||
|
return new JdbcMysqlReadOnlyExecutor(dataSources, clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MysqlResultProjector mysqlResultProjector(ObjectMapper mapper) {
|
||||||
|
return new MysqlResultProjector(mapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MysqlToolAdapter mysqlToolAdapter(ToolBoundary boundary, ObjectMapper mapper,
|
||||||
|
MysqlSqlValidator validator, MysqlReadOnlyExecutor executor,
|
||||||
|
MysqlResultProjector projector) {
|
||||||
|
return new MysqlToolAdapter(boundary, mapper, validator, executor, projector);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public HarnessEvidenceTools harnessEvidenceTools(RagToolAdapter rag, QueryLogsToolAdapter logs,
|
||||||
|
MysqlToolAdapter mysql) {
|
||||||
|
return HarnessEvidenceTools.fromAdapters(rag, logs, mysql);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DiagnosisAgentFactory diagnosisAgentFactory(ChatModel chatModel, DiagnosisHarnessCore core,
|
||||||
|
HarnessEvidenceTools tools, ObjectMapper mapper,
|
||||||
|
AgentStepRepository steps) {
|
||||||
|
return new DiagnosisAgentFactory(chatModel, core, tools, mapper,
|
||||||
|
List.of(new AgentLoggingHook(steps, DiagnosisAgentFactory.AGENT_NAME)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DiagnosisAgentUseCase diagnosisAgentUseCase(DiagnosisHarnessCore core,
|
||||||
|
DiagnosisAgentFactory factory,
|
||||||
|
ObjectMapper mapper,
|
||||||
|
ChatHarnessProperties properties) {
|
||||||
|
return new DiagnosisAgentUseCase(core, factory, mapper, new DiagnosisAgentLimits(
|
||||||
|
properties.getDiagnosisMaxQueryBytes(), properties.getDiagnosisMaxPreviousTurnBytes(),
|
||||||
|
properties.getDiagnosisMaxInputBytes(), properties.getDiagnosisMaxDraftBytes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public EvidenceGuard evidenceGuard(CanonicalInvocationStore store, ToolCallKeyFactory keyFactory,
|
||||||
|
ObjectMapper mapper) {
|
||||||
|
return new EvidenceGuard(store, keyFactory, mapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public EvidenceRepair evidenceRepair(DiagnosisHarnessCore core, HarnessRetryExecutor retries,
|
||||||
|
GuardModelCall modelCall, ObjectMapper mapper,
|
||||||
|
ChatHarnessProperties properties) {
|
||||||
|
return new EvidenceRepair(core, retries, modelCall, mapper,
|
||||||
|
new EvidenceRepairLimits(properties.getRepairMaxInputBytes(), properties.getRepairMaxOutputBytes(),
|
||||||
|
properties.getRepairTimeout()),
|
||||||
|
attempt -> { });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SemanticGuard semanticGuard(DiagnosisHarnessCore core, HarnessRetryExecutor retries,
|
||||||
|
GuardModelCall modelCall, ObjectMapper mapper,
|
||||||
|
ChatHarnessProperties properties) {
|
||||||
|
return new SemanticGuard(core, retries, modelCall, mapper,
|
||||||
|
new SemanticGuardLimits(properties.getSemanticMaxInputBytes(), properties.getSemanticMaxOutputBytes(),
|
||||||
|
properties.getSemanticAttemptTimeout(), properties.getSemanticTotalTimeout()),
|
||||||
|
attempt -> { });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DiagnosisReleaseUseCase diagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
|
||||||
|
EvidenceRepair repair,
|
||||||
|
SemanticGuard semanticGuard) {
|
||||||
|
return new DiagnosisReleaseUseCase(evidenceGuard, repair, semanticGuard, new SafeFallbackFactory());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PublishedResultPolicy publishedResultPolicy() {
|
||||||
|
return new PublishedResultPolicy(PreviousTurnLimits.defaults());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public IntentRouting intentRouter(DiagnosisHarnessCore core, HarnessRetryExecutor retries,
|
||||||
|
GuardModelCall modelCall, ObjectMapper mapper,
|
||||||
|
ChatHarnessProperties properties) {
|
||||||
|
return new IntentRouter(core, retries, modelCall, mapper,
|
||||||
|
new IntentRouterLimits(properties.getRouterMaxInputBytes(), properties.getRouterMaxOutputBytes(),
|
||||||
|
properties.getRouterAttemptTimeout(), properties.getRouterTotalTimeout()),
|
||||||
|
attempt -> { });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SystemChatOperation systemChatOperation(DiagnosisHarnessCore core, GuardModelCall modelCall,
|
||||||
|
ObjectMapper mapper, ChatHarnessProperties properties) {
|
||||||
|
return new SystemChatExecutor(core, modelCall,
|
||||||
|
new SingleTurnExecutorLimits(properties.getSystemMaxInputBytes(),
|
||||||
|
properties.getSystemMaxOutputBytes(), properties.getSystemTimeout()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public KnowledgeQueryOperation knowledgeQueryOperation(DiagnosisHarnessCore core,
|
||||||
|
HarnessEvidenceTools tools,
|
||||||
|
GuardModelCall modelCall,
|
||||||
|
ObjectMapper mapper,
|
||||||
|
ChatHarnessProperties properties) {
|
||||||
|
return new KnowledgeQueryExecutor(core, tools, modelCall, mapper, () -> UUID.randomUUID().toString(),
|
||||||
|
new KnowledgeQueryLimits(properties.getKnowledgeMaxModelInputBytes(),
|
||||||
|
properties.getKnowledgeMaxModelOutputBytes(), properties.getKnowledgeModelTimeout()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DiagnosisOperation diagnosisOperation(DiagnosisAgentUseCase agent,
|
||||||
|
DiagnosisReleaseUseCase release,
|
||||||
|
PublishedResultPolicy policy) {
|
||||||
|
return new DiagnosisChatExecutor(agent, release, policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ChatApplicationUseCase chatApplicationUseCase(DiagnosisHarnessCore core,
|
||||||
|
ChatRunStore runStore,
|
||||||
|
IntentRouting router,
|
||||||
|
SystemChatOperation system,
|
||||||
|
KnowledgeQueryOperation knowledge,
|
||||||
|
DiagnosisOperation diagnosis,
|
||||||
|
ObjectMapper mapper) {
|
||||||
|
return new ChatApplicationUseCase(core, () -> UUID.randomUUID().toString(), runStore,
|
||||||
|
router, system, knowledge, diagnosis, mapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ThreadPoolExecutor boundedExecutor(int core, int max, int queue, String prefix) {
|
||||||
|
return new ThreadPoolExecutor(core, max, 60, TimeUnit.SECONDS,
|
||||||
|
new ArrayBlockingQueue<>(queue), runnable -> {
|
||||||
|
Thread thread = new Thread(runnable);
|
||||||
|
thread.setName(prefix + thread.getId());
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
}, new ThreadPoolExecutor.AbortPolicy());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.superbiz.agent.controller;
|
||||||
|
|
||||||
|
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||||
|
import com.superbiz.agent.dto.AIOpsRequest;
|
||||||
|
import com.superbiz.agent.service.AiOpsService;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
|
||||||
|
/** Preserves the legacy AiOps SSE protocol independently from Chat. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
public class AiOpsController {
|
||||||
|
|
||||||
|
private final AiOpsService aiOpsService;
|
||||||
|
private final ThreadPoolExecutor workerExecutor;
|
||||||
|
|
||||||
|
public AiOpsController(AiOpsService aiOpsService,
|
||||||
|
@Qualifier("chatWorkerExecutor") ThreadPoolExecutor workerExecutor) {
|
||||||
|
this.aiOpsService = aiOpsService;
|
||||||
|
this.workerExecutor = workerExecutor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/ai_ops", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public SseEmitter aiOps(@RequestBody(required = false) AIOpsRequest request) {
|
||||||
|
SseEmitter emitter = new SseEmitter(600_000L);
|
||||||
|
String sessionId = aiOpsService.resolveSessionId(request);
|
||||||
|
String runId = aiOpsService.newRunId();
|
||||||
|
|
||||||
|
emitter.onTimeout(emitter::complete);
|
||||||
|
try {
|
||||||
|
workerExecutor.execute(() -> executeAiOps(request, sessionId, runId, emitter));
|
||||||
|
} catch (RejectedExecutionException rejected) {
|
||||||
|
emitter.completeWithError(rejected);
|
||||||
|
}
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeAiOps(AIOpsRequest request, String sessionId, String runId, SseEmitter emitter) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name("message")
|
||||||
|
.data(SseMessage.metadata(sessionId, runId), MediaType.APPLICATION_JSON));
|
||||||
|
emitter.send(SseEmitter.event().name("message")
|
||||||
|
.data(SseMessage.content("正在读取告警并拆解任务...\n"), MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
Optional<OverAllState> state = aiOpsService.executeAiOpsAnalysis(request, sessionId, runId);
|
||||||
|
if (state.isEmpty()) {
|
||||||
|
emitter.send(SseEmitter.event().name("message")
|
||||||
|
.data(SseMessage.error("多 Agent 编排未获取到有效结果"), MediaType.APPLICATION_JSON));
|
||||||
|
emitter.complete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<String> report = aiOpsService.extractFinalReport(state.get());
|
||||||
|
if (report.isPresent()) {
|
||||||
|
aiOpsService.persistFinalReport(sessionId, runId, report.get(), request);
|
||||||
|
emitter.send(SseEmitter.event().name("message")
|
||||||
|
.data(SseMessage.content(report.get()), MediaType.APPLICATION_JSON));
|
||||||
|
} else {
|
||||||
|
emitter.send(SseEmitter.event().name("message")
|
||||||
|
.data(SseMessage.content("多 Agent 流程已完成,但未能生成最终报告。"), MediaType.APPLICATION_JSON));
|
||||||
|
}
|
||||||
|
emitter.send(SseEmitter.event().name("message")
|
||||||
|
.data(SseMessage.done(), MediaType.APPLICATION_JSON));
|
||||||
|
emitter.complete();
|
||||||
|
} catch (Exception exception) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name("message")
|
||||||
|
.data(SseMessage.error("AI Ops 流程失败"), MediaType.APPLICATION_JSON));
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// Client has already gone away.
|
||||||
|
}
|
||||||
|
emitter.completeWithError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class SseMessage {
|
||||||
|
private String type;
|
||||||
|
private String data;
|
||||||
|
private String sessionId;
|
||||||
|
private String runId;
|
||||||
|
|
||||||
|
public static SseMessage content(String data) {
|
||||||
|
SseMessage message = new SseMessage();
|
||||||
|
message.type = "content";
|
||||||
|
message.data = data;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SseMessage metadata(String sessionId, String runId) {
|
||||||
|
SseMessage message = new SseMessage();
|
||||||
|
message.type = "metadata";
|
||||||
|
message.sessionId = sessionId;
|
||||||
|
message.runId = runId;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SseMessage error(String data) {
|
||||||
|
SseMessage message = new SseMessage();
|
||||||
|
message.type = "error";
|
||||||
|
message.data = data;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SseMessage done() {
|
||||||
|
SseMessage message = new SseMessage();
|
||||||
|
message.type = "done";
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,521 +1,90 @@
|
|||||||
package com.superbiz.agent.controller;
|
package com.superbiz.agent.controller;
|
||||||
|
|
||||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.superbiz.agent.config.ChatHarnessProperties;
|
||||||
|
import com.superbiz.agent.controller.sse.ChatSseSession;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationException;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationRequest;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationResult;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationUseCase;
|
||||||
|
import com.superbiz.agent.harness.application.ChatFailureCode;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
import com.superbiz.agent.domain.model.SessionContext;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import com.superbiz.agent.dto.AIOpsRequest;
|
import org.springframework.http.HttpStatus;
|
||||||
import com.superbiz.agent.dto.DiagnosisTraceResponse;
|
|
||||||
import com.superbiz.agent.service.AiOpsService;
|
|
||||||
import com.superbiz.agent.service.ChatService;
|
|
||||||
import com.superbiz.agent.service.DiagnosisTraceService;
|
|
||||||
import com.superbiz.agent.service.session.SessionManager;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.ai.chat.model.ChatModel;
|
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
|
||||||
import org.springframework.ai.tool.ToolCallbackProvider;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
import java.time.LocalDateTime;
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
|
|
||||||
/**
|
/** HTTP and SSE protocol adapter for Chat. */
|
||||||
* 统一 API 控制器
|
|
||||||
* 适配前端接口需求
|
|
||||||
*/
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api")
|
@RequestMapping("/api")
|
||||||
public class ChatController {
|
public class ChatController {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ChatController.class);
|
private final ChatApplicationUseCase chatApplication;
|
||||||
|
private final ThreadPoolExecutor chatWorkerExecutor;
|
||||||
|
private final ChatHarnessProperties harnessProperties;
|
||||||
|
|
||||||
@Autowired
|
public ChatController(ChatApplicationUseCase chatApplication,
|
||||||
private AiOpsService aiOpsService;
|
@Qualifier("chatWorkerExecutor") ThreadPoolExecutor chatWorkerExecutor,
|
||||||
|
ChatHarnessProperties harnessProperties) {
|
||||||
|
this.chatApplication = chatApplication;
|
||||||
|
this.chatWorkerExecutor = chatWorkerExecutor;
|
||||||
|
this.harnessProperties = harnessProperties;
|
||||||
|
}
|
||||||
|
|
||||||
@Autowired
|
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
private ChatService chatService;
|
public ResponseEntity<SseEmitter> chat(@RequestBody ChatRequest request) {
|
||||||
|
if (request == null || request.getQuestion() == null || request.getQuestion().isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
|
||||||
@Autowired
|
SseEmitter emitter = new SseEmitter(harnessProperties.getSseTimeout().toMillis());
|
||||||
private DiagnosisTraceService diagnosisTraceService;
|
ChatSseSession session = new ChatSseSession(emitter);
|
||||||
|
emitter.onTimeout(session::disconnect);
|
||||||
|
emitter.onError(ignored -> session.disconnect());
|
||||||
|
emitter.onCompletion(session::disconnect);
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private SessionManager sessionManager;
|
|
||||||
|
|
||||||
@Autowired(required = false)
|
|
||||||
private ToolCallbackProvider tools;
|
|
||||||
|
|
||||||
private final ExecutorService executor = Executors.newCachedThreadPool();
|
|
||||||
|
|
||||||
// 最大历史消息窗口大小(成对计算:用户消息+AI回复=1对)
|
|
||||||
private static final int MAX_WINDOW_SIZE = 6;
|
|
||||||
|
|
||||||
@Value("${session.ttl-seconds:3600}")
|
|
||||||
private long sessionTtlSeconds;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 普通对话接口(支持工具调用)
|
|
||||||
* 与 /chat_react 逻辑一致,但直接返回完整结果而非流式输出
|
|
||||||
*/
|
|
||||||
@PostMapping("/chat")
|
|
||||||
public ResponseEntity<ApiResponse<ChatResponse>> chat(@RequestBody ChatRequest request) {
|
|
||||||
try {
|
try {
|
||||||
logger.info("收到对话请求 - SessionId: {}, Question: {}", request.getId(), request.getQuestion());
|
chatWorkerExecutor.execute(() -> executeChat(request, session));
|
||||||
|
} catch (RejectedExecutionException rejected) {
|
||||||
// 参数校验
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
|
||||||
if (request.getQuestion() == null || request.getQuestion().trim().isEmpty()) {
|
}
|
||||||
logger.warn("问题内容为空");
|
return ResponseEntity.ok()
|
||||||
return ResponseEntity.ok(ApiResponse.success(ChatResponse.error("问题内容不能为空")));
|
.contentType(MediaType.TEXT_EVENT_STREAM)
|
||||||
|
.body(emitter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取或创建会话
|
private void executeChat(ChatRequest request, ChatSseSession session) {
|
||||||
SessionContext session = getOrCreateSession(request.getId());
|
|
||||||
|
|
||||||
// 获取历史消息
|
|
||||||
List<Map<String, String>> history = session.getMessageHistorySnapshot();
|
|
||||||
logger.info("会话历史消息对数: {}", history.size() / 2);
|
|
||||||
|
|
||||||
// 获取注入的 ChatModel
|
|
||||||
ChatModel chatModel = chatService.getChatModel();
|
|
||||||
|
|
||||||
// 记录可用工具
|
|
||||||
chatService.logAvailableTools();
|
|
||||||
|
|
||||||
ToolCallback[] toolCallbacks = tools != null ? tools.getToolCallbacks() : new ToolCallback[0];
|
|
||||||
|
|
||||||
// 根据问题复杂度自动选择单 Agent 或多 Agent
|
|
||||||
logger.info("开始 ReactAgent 对话(支持自动工具调用)");
|
|
||||||
ChatService.ChatResult result = chatService.executeChatWithStrategy(chatModel, toolCallbacks,
|
|
||||||
request.getQuestion(), history, session.getSessionId());
|
|
||||||
String fullAnswer = result.answer();
|
|
||||||
|
|
||||||
// 更新会话历史
|
|
||||||
session.addChatMessagePair(request.getQuestion(), fullAnswer, MAX_WINDOW_SIZE);
|
|
||||||
sessionManager.updateSession(session);
|
|
||||||
chatService.syncChatSessionMetadata(session.getSessionId(), session.getMessagePairCount());
|
|
||||||
logger.info("已更新会话历史 - SessionId: {}, 当前消息对数: {}",
|
|
||||||
session.getSessionId(), session.getMessagePairCount());
|
|
||||||
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(ChatResponse.success(fullAnswer, result.sessionId(), result.runId())));
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("对话失败", e);
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(ChatResponse.error(e.getMessage())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空会话历史
|
|
||||||
*/
|
|
||||||
@PostMapping("/chat/clear")
|
|
||||||
public ResponseEntity<ApiResponse<String>> clearChatHistory(@RequestBody ClearRequest request) {
|
|
||||||
try {
|
try {
|
||||||
logger.info("收到清空会话历史请求 - SessionId: {}", request.getId());
|
ChatApplicationResult result = chatApplication.execute(
|
||||||
|
new ChatApplicationRequest(request.getQuestion(), request.getId()), session);
|
||||||
if (request.getId() == null || request.getId().isEmpty()) {
|
session.complete(result);
|
||||||
return ResponseEntity.ok(ApiResponse.error("会话ID不能为空"));
|
} catch (ChatApplicationException exception) {
|
||||||
}
|
session.fail(exception);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
Optional<SessionContext> session = sessionManager.getSession(request.getId());
|
session.fail(ChatFailureCode.INTERNAL_FAILURE,
|
||||||
if (session.isPresent()) {
|
"当前暂时无法处理该请求,请稍后重试");
|
||||||
SessionContext context = session.get();
|
|
||||||
context.clearMessageHistory();
|
|
||||||
sessionManager.updateSession(context);
|
|
||||||
return ResponseEntity.ok(ApiResponse.success("会话历史已清空"));
|
|
||||||
} else {
|
|
||||||
return ResponseEntity.ok(ApiResponse.error("会话不存在"));
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("清空会话历史失败", e);
|
|
||||||
return ResponseEntity.ok(ApiResponse.error(e.getMessage()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 对话接口(SSE 流式模式)
|
|
||||||
* 与 /chat 使用同一条 ChatService 策略链路,区别仅在于通过 SSE 分块返回最终答案。
|
|
||||||
*/
|
|
||||||
@PostMapping(value = "/chat_stream", produces = "text/event-stream;charset=UTF-8")
|
|
||||||
public SseEmitter chatStream(@RequestBody ChatRequest request) {
|
|
||||||
SseEmitter emitter = new SseEmitter(300000L); // 5分钟超时
|
|
||||||
|
|
||||||
// 参数校验
|
|
||||||
if (request.getQuestion() == null || request.getQuestion().trim().isEmpty()) {
|
|
||||||
logger.warn("问题内容为空");
|
|
||||||
try {
|
|
||||||
emitter.send(SseEmitter.event().name("message").data(SseMessage.error("问题内容不能为空"), MediaType.APPLICATION_JSON));
|
|
||||||
emitter.complete();
|
|
||||||
} catch (IOException e) {
|
|
||||||
emitter.completeWithError(e);
|
|
||||||
}
|
|
||||||
return emitter;
|
|
||||||
}
|
|
||||||
|
|
||||||
executor.execute(() -> {
|
|
||||||
try {
|
|
||||||
logger.info("收到 ReactAgent 对话请求 - SessionId: {}, Question: {}", request.getId(), request.getQuestion());
|
|
||||||
|
|
||||||
// 获取或创建会话
|
|
||||||
SessionContext session = getOrCreateSession(request.getId());
|
|
||||||
|
|
||||||
// 获取历史消息
|
|
||||||
List<Map<String, String>> history = session.getMessageHistorySnapshot();
|
|
||||||
logger.info("ReactAgent 会话历史消息对数: {}", history.size() / 2);
|
|
||||||
|
|
||||||
// 获取注入的 ChatModel
|
|
||||||
ChatModel chatModel = chatService.getChatModel();
|
|
||||||
|
|
||||||
// 记录可用工具
|
|
||||||
chatService.logAvailableTools();
|
|
||||||
|
|
||||||
ToolCallback[] toolCallbacks = tools != null ? tools.getToolCallbacks() : new ToolCallback[0];
|
|
||||||
|
|
||||||
logger.info("开始统一 ChatService 对话(SSE 分块返回)");
|
|
||||||
ChatService.ChatResult result = chatService.executeChatWithStrategy(chatModel, toolCallbacks,
|
|
||||||
request.getQuestion(), history, session.getSessionId());
|
|
||||||
String fullAnswer = result.answer() == null ? "" : result.answer();
|
|
||||||
logger.info("统一 ChatService 对话完成 - SessionId: {}, 答案长度: {}",
|
|
||||||
result.sessionId(), fullAnswer.length());
|
|
||||||
|
|
||||||
session.addChatMessagePair(request.getQuestion(), fullAnswer, MAX_WINDOW_SIZE);
|
|
||||||
sessionManager.updateSession(session);
|
|
||||||
logger.info("已更新会话历史 - SessionId: {}, 当前消息对数: {}",
|
|
||||||
session.getSessionId(), session.getMessagePairCount());
|
|
||||||
|
|
||||||
sendContentChunks(emitter, fullAnswer);
|
|
||||||
emitter.send(SseEmitter.event()
|
|
||||||
.name("message")
|
|
||||||
.data(SseMessage.done(), MediaType.APPLICATION_JSON));
|
|
||||||
emitter.complete();
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("ReactAgent 对话初始化失败", e);
|
|
||||||
try {
|
|
||||||
emitter.send(SseEmitter.event()
|
|
||||||
.name("message")
|
|
||||||
.data(SseMessage.error(e.getMessage()), MediaType.APPLICATION_JSON));
|
|
||||||
} catch (IOException ex) {
|
|
||||||
logger.error("发送错误消息失败", ex);
|
|
||||||
}
|
|
||||||
emitter.completeWithError(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return emitter;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AI 智能运维接口(SSE 流式模式)- 自动分析告警并生成运维报告
|
|
||||||
* 无需用户输入,自动执行告警分析流程
|
|
||||||
*/
|
|
||||||
@PostMapping(value = "/ai_ops", produces = "text/event-stream;charset=UTF-8")
|
|
||||||
public SseEmitter aiOps(@RequestBody(required = false) AIOpsRequest request) {
|
|
||||||
SseEmitter emitter = new SseEmitter(600000L); // 10分钟超时(告警分析可能较慢)
|
|
||||||
String sessionId = aiOpsService.resolveSessionId(request);
|
|
||||||
String runId = aiOpsService.newRunId();
|
|
||||||
|
|
||||||
executor.execute(() -> {
|
|
||||||
try {
|
|
||||||
logger.info("收到 AI 智能运维请求 - SessionId: {}, RunId: {}, 启动多 Agent 协作流程", sessionId, runId);
|
|
||||||
|
|
||||||
ChatModel chatModel = chatService.getChatModel();
|
|
||||||
|
|
||||||
ToolCallback[] toolCallbacks = tools != null ? tools.getToolCallbacks() : new ToolCallback[0];
|
|
||||||
|
|
||||||
emitter.send(SseEmitter.event().name("message").data(SseMessage.metadata(sessionId, runId), MediaType.APPLICATION_JSON));
|
|
||||||
emitter.send(SseEmitter.event().name("message").data(SseMessage.content("正在读取告警并拆解任务...\n")));
|
|
||||||
|
|
||||||
// 调用 AiOpsService 执行分析流程
|
|
||||||
Optional<OverAllState> overAllStateOptional = aiOpsService.executeAiOpsAnalysis(chatModel, toolCallbacks, request, sessionId, runId);
|
|
||||||
|
|
||||||
if (overAllStateOptional.isEmpty()) {
|
|
||||||
emitter.send(SseEmitter.event().name("message")
|
|
||||||
.data(SseMessage.error("多 Agent 编排未获取到有效结果"), MediaType.APPLICATION_JSON));
|
|
||||||
emitter.complete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OverAllState state = overAllStateOptional.get();
|
|
||||||
logger.info("AI Ops 编排完成,开始提取最终报告...");
|
|
||||||
|
|
||||||
// 提取最终报告
|
|
||||||
Optional<String> finalReportOptional = aiOpsService.extractFinalReport(state);
|
|
||||||
|
|
||||||
// 输出最终报告
|
|
||||||
if (finalReportOptional.isPresent()) {
|
|
||||||
String finalReportText = finalReportOptional.get();
|
|
||||||
logger.info("提取到 Planner 最终报告,长度: {}", finalReportText.length());
|
|
||||||
aiOpsService.persistFinalReport(sessionId, runId, finalReportText, request);
|
|
||||||
|
|
||||||
// 发送分隔线
|
|
||||||
emitter.send(SseEmitter.event().name("message")
|
|
||||||
.data(SseMessage.content("\n\n" + "=".repeat(60) + "\n"), MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
// 发送完整的告警分析报告
|
|
||||||
emitter.send(SseEmitter.event().name("message")
|
|
||||||
.data(SseMessage.content("📋 **告警分析报告**\n\n"), MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
int chunkSize = 50;
|
|
||||||
for (int i = 0; i < finalReportText.length(); i += chunkSize) {
|
|
||||||
int end = Math.min(i + chunkSize, finalReportText.length());
|
|
||||||
String chunk = finalReportText.substring(i, end);
|
|
||||||
|
|
||||||
emitter.send(SseEmitter.event().name("message")
|
|
||||||
.data(SseMessage.content(chunk), MediaType.APPLICATION_JSON));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送结束分隔线
|
|
||||||
emitter.send(SseEmitter.event().name("message")
|
|
||||||
.data(SseMessage.content("\n" + "=".repeat(60) + "\n\n"), MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
logger.info("最终报告已完整输出");
|
|
||||||
} else {
|
|
||||||
logger.warn("未能提取到 Planner 最终报告");
|
|
||||||
emitter.send(SseEmitter.event().name("message")
|
|
||||||
.data(SseMessage.content("⚠️ 多 Agent 流程已完成,但未能生成最终报告。"), MediaType.APPLICATION_JSON));
|
|
||||||
}
|
|
||||||
|
|
||||||
emitter.send(SseEmitter.event().name("message").data(SseMessage.done(), MediaType.APPLICATION_JSON));
|
|
||||||
emitter.complete();
|
|
||||||
logger.info("AI Ops 多 Agent 编排完成");
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("AI Ops 多 Agent 协作失败", e);
|
|
||||||
try {
|
|
||||||
emitter.send(SseEmitter.event().name("message")
|
|
||||||
.data(SseMessage.error("AI Ops 流程失败: " + e.getMessage()), MediaType.APPLICATION_JSON));
|
|
||||||
} catch (IOException ex) {
|
|
||||||
logger.error("发送错误消息失败", ex);
|
|
||||||
}
|
|
||||||
emitter.completeWithError(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return emitter;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取会话信息
|
|
||||||
*/
|
|
||||||
@GetMapping("/chat/session/{sessionId}")
|
|
||||||
public ResponseEntity<ApiResponse<SessionInfoResponse>> getSessionInfo(@PathVariable String sessionId) {
|
|
||||||
try {
|
|
||||||
logger.info("收到获取会话信息请求 - SessionId: {}", sessionId);
|
|
||||||
|
|
||||||
Optional<SessionContext> session = sessionManager.getSession(sessionId);
|
|
||||||
if (session.isPresent()) {
|
|
||||||
SessionContext context = session.get();
|
|
||||||
SessionInfoResponse response = new SessionInfoResponse();
|
|
||||||
response.setSessionId(sessionId);
|
|
||||||
response.setMessagePairCount(context.getMessagePairCount());
|
|
||||||
response.setCreateTime(toEpochMillis(context.getCreatedAt()));
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(response));
|
|
||||||
} else {
|
|
||||||
return ResponseEntity.ok(ApiResponse.error("会话不存在"));
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("获取会话信息失败", e);
|
|
||||||
return ResponseEntity.ok(ApiResponse.error(e.getMessage()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/chat/session/{sessionId}/runs")
|
|
||||||
public ResponseEntity<ApiResponse<List<DiagnosisTraceResponse.RunSummary>>> listSessionRuns(
|
|
||||||
@PathVariable String sessionId) {
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(diagnosisTraceService.listRunSummaries(sessionId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 辅助方法 ====================
|
|
||||||
|
|
||||||
private SessionContext getOrCreateSession(String sessionId) {
|
|
||||||
String resolvedSessionId = (sessionId == null || sessionId.isEmpty())
|
|
||||||
? UUID.randomUUID().toString()
|
|
||||||
: sessionId;
|
|
||||||
return sessionManager.getSession(resolvedSessionId)
|
|
||||||
.orElseGet(() -> {
|
|
||||||
SessionContext context = SessionContext.builder()
|
|
||||||
.sessionId(resolvedSessionId)
|
|
||||||
.status("ACTIVE")
|
|
||||||
.ttl(sessionTtlSeconds)
|
|
||||||
.build();
|
|
||||||
sessionManager.createSession(context, sessionTtlSeconds);
|
|
||||||
return context;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private long toEpochMillis(LocalDateTime time) {
|
|
||||||
if (time == null) {
|
|
||||||
return 0L;
|
|
||||||
}
|
|
||||||
return time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void sendContentChunks(SseEmitter emitter, String content) throws IOException {
|
|
||||||
if (content == null || content.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
int chunkSize = 80;
|
|
||||||
for (int i = 0; i < content.length(); i += chunkSize) {
|
|
||||||
int end = Math.min(i + chunkSize, content.length());
|
|
||||||
emitter.send(SseEmitter.event()
|
|
||||||
.name("message")
|
|
||||||
.data(SseMessage.content(content.substring(i, end)), MediaType.APPLICATION_JSON));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 聊天请求
|
|
||||||
*/
|
|
||||||
@Setter
|
|
||||||
@Getter
|
@Getter
|
||||||
|
@Setter
|
||||||
public static class ChatRequest {
|
public static class ChatRequest {
|
||||||
@com.fasterxml.jackson.annotation.JsonProperty(value = "Id")
|
@JsonProperty("Id")
|
||||||
@com.fasterxml.jackson.annotation.JsonAlias({"id", "ID"})
|
@JsonAlias({"id", "ID"})
|
||||||
private String Id;
|
private String id;
|
||||||
|
@JsonProperty("Question")
|
||||||
@com.fasterxml.jackson.annotation.JsonProperty(value = "Question")
|
@JsonAlias({"question", "QUESTION"})
|
||||||
@com.fasterxml.jackson.annotation.JsonAlias({"question", "QUESTION"})
|
private String question;
|
||||||
private String Question;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空会话请求
|
|
||||||
*/
|
|
||||||
@Setter
|
|
||||||
@Getter
|
|
||||||
public static class ClearRequest {
|
|
||||||
@com.fasterxml.jackson.annotation.JsonProperty(value = "Id")
|
|
||||||
@com.fasterxml.jackson.annotation.JsonAlias({"id", "ID"})
|
|
||||||
private String Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 内部类 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 会话信息响应
|
|
||||||
*/
|
|
||||||
@Setter
|
|
||||||
@Getter
|
|
||||||
public static class SessionInfoResponse {
|
|
||||||
private String sessionId;
|
|
||||||
private int messagePairCount;
|
|
||||||
private long createTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一聊天响应格式
|
|
||||||
* 适用于所有普通返回模式的对话接口
|
|
||||||
*/
|
|
||||||
@Setter
|
|
||||||
@Getter
|
|
||||||
public static class ChatResponse {
|
|
||||||
private boolean success;
|
|
||||||
private String answer;
|
|
||||||
private String errorMessage;
|
|
||||||
private String sessionId;
|
|
||||||
private String runId;
|
|
||||||
|
|
||||||
public static ChatResponse success(String answer, String sessionId, String runId) {
|
|
||||||
ChatResponse response = new ChatResponse();
|
|
||||||
response.setSuccess(true);
|
|
||||||
response.setAnswer(answer);
|
|
||||||
response.setSessionId(sessionId);
|
|
||||||
response.setRunId(runId);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ChatResponse error(String errorMessage) {
|
|
||||||
ChatResponse response = new ChatResponse();
|
|
||||||
response.setSuccess(false);
|
|
||||||
response.setErrorMessage(errorMessage);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一 SSE 流式消息格式
|
|
||||||
* 适用于所有 SSE 流式返回模式的对话接口
|
|
||||||
*/
|
|
||||||
@Setter
|
|
||||||
@Getter
|
|
||||||
public static class SseMessage {
|
|
||||||
private String type; // metadata: 元数据, content: 内容块, error: 错误, done: 完成
|
|
||||||
private String data;
|
|
||||||
private String sessionId;
|
|
||||||
private String runId;
|
|
||||||
|
|
||||||
public static SseMessage content(String data) {
|
|
||||||
SseMessage message = new SseMessage();
|
|
||||||
message.setType("content");
|
|
||||||
message.setData(data);
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static SseMessage session(String sessionId) {
|
|
||||||
SseMessage message = new SseMessage();
|
|
||||||
message.setType("session");
|
|
||||||
message.setData(sessionId);
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static SseMessage metadata(String sessionId, String runId) {
|
|
||||||
SseMessage message = new SseMessage();
|
|
||||||
message.setType("metadata");
|
|
||||||
message.setSessionId(sessionId);
|
|
||||||
message.setRunId(runId);
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static SseMessage error(String errorMessage) {
|
|
||||||
SseMessage message = new SseMessage();
|
|
||||||
message.setType("error");
|
|
||||||
message.setData(errorMessage);
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static SseMessage done() {
|
|
||||||
SseMessage message = new SseMessage();
|
|
||||||
message.setType("done");
|
|
||||||
message.setData(null);
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
public static class ApiResponse<T> {
|
|
||||||
private int code;
|
|
||||||
private String message;
|
|
||||||
private T data;
|
|
||||||
|
|
||||||
public static <T> ApiResponse<T> success(T data) {
|
|
||||||
ApiResponse<T> response = new ApiResponse<>();
|
|
||||||
response.setCode(200);
|
|
||||||
response.setMessage("success");
|
|
||||||
response.setData(data);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <T> ApiResponse<T> error(String message) {
|
|
||||||
ApiResponse<T> response = new ApiResponse<>();
|
|
||||||
response.setCode(500);
|
|
||||||
response.setMessage(message);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.superbiz.agent.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.superbiz.agent.dto.DiagnosisTraceResponse;
|
||||||
|
import com.superbiz.agent.service.DiagnosisTraceService;
|
||||||
|
import com.superbiz.agent.service.session.SessionManager;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/** Legacy Chat session management endpoints, isolated from the Chat execution adapter. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/chat")
|
||||||
|
public class ChatSessionController {
|
||||||
|
|
||||||
|
private final DiagnosisTraceService diagnosisTraceService;
|
||||||
|
private final SessionManager sessionManager;
|
||||||
|
|
||||||
|
public ChatSessionController(DiagnosisTraceService diagnosisTraceService, SessionManager sessionManager) {
|
||||||
|
this.diagnosisTraceService = diagnosisTraceService;
|
||||||
|
this.sessionManager = sessionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/clear")
|
||||||
|
public ResponseEntity<ApiResponse<String>> clearChatHistory(@RequestBody ClearRequest request) {
|
||||||
|
if (request == null || request.getId() == null || request.getId().isBlank()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error("会话ID不能为空"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Optional<com.superbiz.agent.domain.model.SessionContext> session =
|
||||||
|
sessionManager.getSession(request.getId());
|
||||||
|
if (session.isEmpty()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error("会话不存在"));
|
||||||
|
}
|
||||||
|
var context = session.get();
|
||||||
|
context.clearMessageHistory();
|
||||||
|
sessionManager.updateSession(context);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("会话历史已清空"));
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error("会话历史清理失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/session/{sessionId}")
|
||||||
|
public ResponseEntity<ApiResponse<SessionInfoResponse>> getSessionInfo(@PathVariable String sessionId) {
|
||||||
|
try {
|
||||||
|
Optional<com.superbiz.agent.domain.model.SessionContext> session = sessionManager.getSession(sessionId);
|
||||||
|
if (session.isEmpty()) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error("会话不存在"));
|
||||||
|
}
|
||||||
|
var context = session.get();
|
||||||
|
SessionInfoResponse response = new SessionInfoResponse();
|
||||||
|
response.setSessionId(sessionId);
|
||||||
|
response.setMessagePairCount(context.getMessagePairCount());
|
||||||
|
response.setCreateTime(toEpochMillis(context.getCreatedAt()));
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error("会话读取失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/session/{sessionId}/runs")
|
||||||
|
public ResponseEntity<ApiResponse<List<DiagnosisTraceResponse.RunSummary>>> listSessionRuns(
|
||||||
|
@PathVariable String sessionId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(diagnosisTraceService.listRunSummaries(sessionId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long toEpochMillis(LocalDateTime time) {
|
||||||
|
return time == null ? 0L : time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class ClearRequest {
|
||||||
|
@JsonProperty("Id")
|
||||||
|
@JsonAlias({"id", "ID"})
|
||||||
|
private String id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class SessionInfoResponse {
|
||||||
|
private String sessionId;
|
||||||
|
private int messagePairCount;
|
||||||
|
private long createTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class ApiResponse<T> {
|
||||||
|
private int code;
|
||||||
|
private String message;
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> success(T data) {
|
||||||
|
ApiResponse<T> response = new ApiResponse<>();
|
||||||
|
response.code = 200;
|
||||||
|
response.message = "success";
|
||||||
|
response.data = data;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> error(String message) {
|
||||||
|
ApiResponse<T> response = new ApiResponse<>();
|
||||||
|
response.code = 500;
|
||||||
|
response.message = message;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.superbiz.agent.controller.sse;
|
||||||
|
|
||||||
|
final class ChatSseDisconnectedException extends RuntimeException {
|
||||||
|
|
||||||
|
ChatSseDisconnectedException(Throwable cause) {
|
||||||
|
super("Chat SSE connection is no longer writable", cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.superbiz.agent.controller.sse;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationContent;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationStatus;
|
||||||
|
import com.superbiz.agent.harness.application.ChatContentType;
|
||||||
|
import com.superbiz.agent.harness.application.ChatFailureCode;
|
||||||
|
import com.superbiz.agent.harness.contract.ReleaseOutcome;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public interface ChatSseEvent {
|
||||||
|
|
||||||
|
record Metadata(
|
||||||
|
@JsonProperty("session_id") String sessionId,
|
||||||
|
@JsonProperty("run_id") String runId) implements ChatSseEvent {
|
||||||
|
public Metadata {
|
||||||
|
requireText(sessionId, "sessionId");
|
||||||
|
requireText(runId, "runId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record Status(String code, String message) implements ChatSseEvent {
|
||||||
|
public Status {
|
||||||
|
requireText(code, "code");
|
||||||
|
requireText(message, "message");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Status from(ChatApplicationStatus status) {
|
||||||
|
Objects.requireNonNull(status, "status must not be null");
|
||||||
|
return new Status(status.name(), status.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record Content(
|
||||||
|
@JsonProperty("content_type") ChatContentType contentType,
|
||||||
|
@JsonProperty("payload") ChatApplicationContent payload) implements ChatSseEvent {
|
||||||
|
public Content {
|
||||||
|
Objects.requireNonNull(contentType, "contentType must not be null");
|
||||||
|
Objects.requireNonNull(payload, "payload must not be null");
|
||||||
|
if (contentType != payload.contentType()) {
|
||||||
|
throw new IllegalArgumentException("contentType does not match payload");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record Failure(ChatFailureCode code, String message) implements ChatSseEvent {
|
||||||
|
public Failure {
|
||||||
|
Objects.requireNonNull(code, "code must not be null");
|
||||||
|
requireText(message, "message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record Done(ReleaseOutcome outcome) implements ChatSseEvent {
|
||||||
|
public Done {
|
||||||
|
Objects.requireNonNull(outcome, "outcome must not be null");
|
||||||
|
if (outcome == ReleaseOutcome.CANCELLED) {
|
||||||
|
throw new IllegalArgumentException("CANCELLED is not a public SSE outcome");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireText(String value, String name) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException(name + " must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package com.superbiz.agent.controller.sse;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationException;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationObserver;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationResult;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationStatus;
|
||||||
|
import com.superbiz.agent.harness.application.ChatFailureCode;
|
||||||
|
import com.superbiz.agent.harness.application.ChatRunControl;
|
||||||
|
import com.superbiz.agent.harness.contract.ReleaseOutcome;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public final class ChatSseSession implements ChatApplicationObserver {
|
||||||
|
|
||||||
|
private enum State {
|
||||||
|
NEW,
|
||||||
|
OPEN,
|
||||||
|
TERMINAL,
|
||||||
|
DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ChatSseSink sink;
|
||||||
|
private State state = State.NEW;
|
||||||
|
private ChatRunControl runControl;
|
||||||
|
|
||||||
|
public ChatSseSession(SseEmitter emitter) {
|
||||||
|
this(new SseEmitterChatSink(emitter));
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatSseSession(ChatSseSink sink) {
|
||||||
|
this.sink = Objects.requireNonNull(sink, "sink must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void onStarted(ChatRunControl control) {
|
||||||
|
Objects.requireNonNull(control, "runControl must not be null");
|
||||||
|
if (runControl != null) {
|
||||||
|
throw new IllegalStateException("Run control was already published");
|
||||||
|
}
|
||||||
|
runControl = control;
|
||||||
|
if (state == State.DISCONNECTED) {
|
||||||
|
control.cancelClientDisconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requireState(State.NEW);
|
||||||
|
send("metadata", new ChatSseEvent.Metadata(control.sessionId(), control.runId()));
|
||||||
|
state = State.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void onStatus(ChatApplicationStatus status) {
|
||||||
|
if (state == State.DISCONNECTED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requireState(State.OPEN);
|
||||||
|
send("status", ChatSseEvent.Status.from(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean complete(ChatApplicationResult result) {
|
||||||
|
Objects.requireNonNull(result, "result must not be null");
|
||||||
|
if (state == State.DISCONNECTED || state == State.TERMINAL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
requireState(State.OPEN);
|
||||||
|
requireMatchingRun(result);
|
||||||
|
send("content", new ChatSseEvent.Content(result.contentType(), result.content()));
|
||||||
|
send("done", new ChatSseEvent.Done(result.outcome()));
|
||||||
|
state = State.TERMINAL;
|
||||||
|
sink.complete();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean fail(ChatApplicationException failure) {
|
||||||
|
Objects.requireNonNull(failure, "failure must not be null");
|
||||||
|
return fail(failure.code(), failure.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean fail(ChatFailureCode code, String message) {
|
||||||
|
if (state == State.DISCONNECTED || state == State.TERMINAL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (state == State.NEW) {
|
||||||
|
state = State.TERMINAL;
|
||||||
|
sink.complete();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
requireState(State.OPEN);
|
||||||
|
send("failure", new ChatSseEvent.Failure(code, message));
|
||||||
|
send("done", new ChatSseEvent.Done(ReleaseOutcome.FAILED));
|
||||||
|
state = State.TERMINAL;
|
||||||
|
sink.complete();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean disconnect() {
|
||||||
|
if (state == State.DISCONNECTED || state == State.TERMINAL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state = State.DISCONNECTED;
|
||||||
|
return runControl != null && runControl.cancelClientDisconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean terminal() {
|
||||||
|
return state == State.TERMINAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireMatchingRun(ChatApplicationResult result) {
|
||||||
|
if (runControl == null
|
||||||
|
|| !runControl.sessionId().equals(result.sessionId())
|
||||||
|
|| !runControl.runId().equals(result.runId())) {
|
||||||
|
disconnect();
|
||||||
|
throw new IllegalStateException("Application result does not match SSE Run metadata");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void send(String name, ChatSseEvent payload) {
|
||||||
|
try {
|
||||||
|
sink.send(name, payload);
|
||||||
|
} catch (IOException | RuntimeException exception) {
|
||||||
|
disconnect();
|
||||||
|
throw new ChatSseDisconnectedException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireState(State expected) {
|
||||||
|
if (state != expected) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Invalid Chat SSE state: expected " + expected + " but was " + state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.superbiz.agent.controller.sse;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
interface ChatSseSink {
|
||||||
|
|
||||||
|
void send(String name, Object payload) throws IOException;
|
||||||
|
|
||||||
|
void complete();
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.superbiz.agent.controller.sse;
|
||||||
|
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
final class SseEmitterChatSink implements ChatSseSink {
|
||||||
|
|
||||||
|
private final SseEmitter emitter;
|
||||||
|
|
||||||
|
SseEmitterChatSink(SseEmitter emitter) {
|
||||||
|
this.emitter = Objects.requireNonNull(emitter, "emitter must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void send(String name, Object payload) throws IOException {
|
||||||
|
emitter.send(SseEmitter.event()
|
||||||
|
.name(name)
|
||||||
|
.data(payload, MediaType.APPLICATION_JSON));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void complete() {
|
||||||
|
emitter.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -32,7 +32,6 @@ public class JpaChatRunStore implements ChatRunStore {
|
|||||||
private final ObjectReader publishedReader;
|
private final ObjectReader publishedReader;
|
||||||
private final PublishedResultPolicy publishedPolicy;
|
private final PublishedResultPolicy publishedPolicy;
|
||||||
|
|
||||||
@Autowired
|
|
||||||
public JpaChatRunStore(ChatSessionRepository chatSessions,
|
public JpaChatRunStore(ChatSessionRepository chatSessions,
|
||||||
DiagnosisRunRepository runs,
|
DiagnosisRunRepository runs,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
@@ -40,6 +39,7 @@ public class JpaChatRunStore implements ChatRunStore {
|
|||||||
new PublishedResultPolicy(PreviousTurnLimits.defaults()));
|
new PublishedResultPolicy(PreviousTurnLimits.defaults()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
public JpaChatRunStore(ChatSessionRepository chatSessions,
|
public JpaChatRunStore(ChatSessionRepository chatSessions,
|
||||||
DiagnosisRunRepository runs,
|
DiagnosisRunRepository runs,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
import org.springframework.ai.tool.ToolCallbackProvider;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import com.superbiz.agent.config.AiOpsPromptProperties;
|
import com.superbiz.agent.config.AiOpsPromptProperties;
|
||||||
@@ -47,6 +48,12 @@ public class AiOpsService {
|
|||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(AiOpsService.class);
|
private static final Logger logger = LoggerFactory.getLogger(AiOpsService.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ChatModel chatModel;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private ToolCallbackProvider toolCallbackProvider;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private DateTimeTools dateTimeTools;
|
private DateTimeTools dateTimeTools;
|
||||||
|
|
||||||
@@ -157,6 +164,15 @@ public class AiOpsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Internal dependency-owning overload used by the protocol-only Controller. */
|
||||||
|
public Optional<OverAllState> executeAiOpsAnalysis(AIOpsRequest request,
|
||||||
|
String sessionId,
|
||||||
|
String runId) throws GraphRunnerException {
|
||||||
|
ToolCallback[] callbacks = toolCallbackProvider == null
|
||||||
|
? new ToolCallback[0] : toolCallbackProvider.getToolCallbacks();
|
||||||
|
return executeAiOpsAnalysis(chatModel, callbacks, request, sessionId, runId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从多 Agent 执行状态中提取最终报告文本。
|
* 从多 Agent 执行状态中提取最终报告文本。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -204,5 +204,15 @@ logging:
|
|||||||
# Agent-facing MySQL Tool uses independent logical datasources only.
|
# Agent-facing MySQL Tool uses independent logical datasources only.
|
||||||
# Production entries are supplied by a dedicated profile and Secret injection.
|
# Production entries are supplied by a dedicated profile and Secret injection.
|
||||||
harness:
|
harness:
|
||||||
|
chat:
|
||||||
|
worker-core-pool-size: 2
|
||||||
|
worker-max-pool-size: 8
|
||||||
|
worker-queue-capacity: 64
|
||||||
|
model-core-pool-size: 4
|
||||||
|
model-max-pool-size: 8
|
||||||
|
model-queue-capacity: 32
|
||||||
|
run-timeout: 5m
|
||||||
|
sse-timeout: 5m
|
||||||
|
canonical-ttl: 2h
|
||||||
mysql-tools:
|
mysql-tools:
|
||||||
data-sources: {}
|
data-sources: {}
|
||||||
|
|||||||
+110
-285
@@ -2,7 +2,6 @@
|
|||||||
class SuperBizAgentApp {
|
class SuperBizAgentApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.apiBaseUrl = 'http://localhost:9900/api';
|
this.apiBaseUrl = 'http://localhost:9900/api';
|
||||||
this.currentMode = 'quick'; // 'quick' 或 'stream'
|
|
||||||
this.sessionId = this.generateSessionId();
|
this.sessionId = this.generateSessionId();
|
||||||
this.isStreaming = false;
|
this.isStreaming = false;
|
||||||
this.currentChatHistory = []; // 当前对话的消息历史
|
this.currentChatHistory = []; // 当前对话的消息历史
|
||||||
@@ -108,9 +107,6 @@ class SuperBizAgentApp {
|
|||||||
this.toolsBtn = document.getElementById('toolsBtn');
|
this.toolsBtn = document.getElementById('toolsBtn');
|
||||||
this.toolsMenu = document.getElementById('toolsMenu');
|
this.toolsMenu = document.getElementById('toolsMenu');
|
||||||
this.uploadFileItem = document.getElementById('uploadFileItem');
|
this.uploadFileItem = document.getElementById('uploadFileItem');
|
||||||
this.modeSelectorBtn = document.getElementById('modeSelectorBtn');
|
|
||||||
this.modeDropdown = document.getElementById('modeDropdown');
|
|
||||||
this.currentModeText = document.getElementById('currentModeText');
|
|
||||||
this.fileInput = document.getElementById('fileInput');
|
this.fileInput = document.getElementById('fileInput');
|
||||||
|
|
||||||
// 聊天区域元素
|
// 聊天区域元素
|
||||||
@@ -137,32 +133,6 @@ class SuperBizAgentApp {
|
|||||||
this.aiOpsSidebarBtn.addEventListener('click', () => this.triggerAIOps());
|
this.aiOpsSidebarBtn.addEventListener('click', () => this.triggerAIOps());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模式选择下拉菜单
|
|
||||||
if (this.modeSelectorBtn) {
|
|
||||||
this.modeSelectorBtn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.toggleModeDropdown();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下拉菜单项点击
|
|
||||||
const dropdownItems = document.querySelectorAll('.dropdown-item');
|
|
||||||
dropdownItems.forEach(item => {
|
|
||||||
item.addEventListener('click', (e) => {
|
|
||||||
const mode = item.getAttribute('data-mode');
|
|
||||||
this.selectMode(mode);
|
|
||||||
this.closeModeDropdown();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 点击外部关闭下拉菜单
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!this.modeSelectorBtn.contains(e.target) &&
|
|
||||||
!this.modeDropdown.contains(e.target)) {
|
|
||||||
this.closeModeDropdown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 发送消息
|
// 发送消息
|
||||||
if (this.sendButton) {
|
if (this.sendButton) {
|
||||||
this.sendButton.addEventListener('click', () => this.sendMessage());
|
this.sendButton.addEventListener('click', () => this.sendMessage());
|
||||||
@@ -271,8 +241,6 @@ class SuperBizAgentApp {
|
|||||||
this.sessionId = this.generateSessionId();
|
this.sessionId = this.generateSessionId();
|
||||||
this.updateTraceWorkbenchLink();
|
this.updateTraceWorkbenchLink();
|
||||||
|
|
||||||
// 重置模式为快速
|
|
||||||
this.currentMode = 'quick';
|
|
||||||
this.updateUI();
|
this.updateUI();
|
||||||
|
|
||||||
// 重新设置居中样式(确保对话框居中显示)
|
// 重新设置居中样式(确保对话框居中显示)
|
||||||
@@ -552,8 +520,10 @@ class SuperBizAgentApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = sseMessage.data && typeof sseMessage.data === 'object' ? sseMessage.data : {};
|
const payload = sseMessage.data && typeof sseMessage.data === 'object' ? sseMessage.data : {};
|
||||||
const sessionId = sseMessage.sessionId || payload.sessionId;
|
const sessionId = sseMessage.session_id || sseMessage.sessionId
|
||||||
const runId = sseMessage.runId || payload.runId;
|
|| payload.session_id || payload.sessionId;
|
||||||
|
const runId = sseMessage.run_id || sseMessage.runId
|
||||||
|
|| payload.run_id || payload.runId;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -562,66 +532,8 @@ class SuperBizAgentApp {
|
|||||||
this.rememberTraceTarget(sessionId, runId || null);
|
this.rememberTraceTarget(sessionId, runId || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换模式下拉菜单
|
|
||||||
toggleModeDropdown() {
|
|
||||||
if (this.modeSelectorBtn && this.modeDropdown) {
|
|
||||||
const wrapper = this.modeSelectorBtn.closest('.mode-selector-wrapper');
|
|
||||||
if (wrapper) {
|
|
||||||
wrapper.classList.toggle('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭模式下拉菜单
|
|
||||||
closeModeDropdown() {
|
|
||||||
if (this.modeSelectorBtn && this.modeDropdown) {
|
|
||||||
const wrapper = this.modeSelectorBtn.closest('.mode-selector-wrapper');
|
|
||||||
if (wrapper) {
|
|
||||||
wrapper.classList.remove('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择模式
|
|
||||||
selectMode(mode) {
|
|
||||||
if (this.isStreaming) {
|
|
||||||
this.showNotification('请等待当前对话完成后再切换模式', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentMode = mode;
|
|
||||||
this.updateUI();
|
|
||||||
|
|
||||||
const modeNames = {
|
|
||||||
'quick': '快速',
|
|
||||||
'stream': '流式'
|
|
||||||
};
|
|
||||||
|
|
||||||
this.showNotification(`已切换到${modeNames[mode]}模式`, 'info');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新UI
|
// 更新UI
|
||||||
updateUI() {
|
updateUI() {
|
||||||
// 更新模式选择器显示
|
|
||||||
if (this.currentModeText) {
|
|
||||||
const modeNames = {
|
|
||||||
'quick': '快速',
|
|
||||||
'stream': '流式'
|
|
||||||
};
|
|
||||||
this.currentModeText.textContent = modeNames[this.currentMode] || '快速';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新下拉菜单选中状态
|
|
||||||
const dropdownItems = document.querySelectorAll('.dropdown-item');
|
|
||||||
dropdownItems.forEach(item => {
|
|
||||||
const mode = item.getAttribute('data-mode');
|
|
||||||
if (mode === this.currentMode) {
|
|
||||||
item.classList.add('active');
|
|
||||||
} else {
|
|
||||||
item.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 更新发送按钮状态
|
// 更新发送按钮状态
|
||||||
if (this.sendButton) {
|
if (this.sendButton) {
|
||||||
this.sendButton.disabled = this.isStreaming;
|
this.sendButton.disabled = this.isStreaming;
|
||||||
@@ -669,11 +581,7 @@ class SuperBizAgentApp {
|
|||||||
this.updateUI();
|
this.updateUI();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.currentMode === 'quick') {
|
await this.sendChatMessage(message);
|
||||||
await this.sendQuickMessage(message);
|
|
||||||
} else if (this.currentMode === 'stream') {
|
|
||||||
await this.sendStreamMessage(message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('发送消息失败:', error);
|
console.error('发送消息失败:', error);
|
||||||
this.addMessage('assistant', '抱歉,发送消息时出现错误:' + error.message);
|
this.addMessage('assistant', '抱歉,发送消息时出现错误:' + error.message);
|
||||||
@@ -689,214 +597,131 @@ class SuperBizAgentApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送快速消息(普通对话)
|
async sendChatMessage(message) {
|
||||||
async sendQuickMessage(message) {
|
const loadingMessage = this.addLoadingMessage('正在处理...');
|
||||||
// 添加等待提示消息
|
const assistantMessageElement = this.addMessage('assistant', '', true);
|
||||||
const loadingMessage = this.addLoadingMessage('正在思考...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.apiBaseUrl}/chat`, {
|
const response = await fetch(`${this.apiBaseUrl}/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify({ Id: this.sessionId, Question: message })
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
Id: this.sessionId,
|
|
||||||
Question: message
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP错误: ${response.status}`);
|
throw new Error(`HTTP错误: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const state = { metadata: false, content: false, failure: false, done: false };
|
||||||
console.log('[sendQuickMessage] 响应数据:', JSON.stringify(data));
|
|
||||||
|
|
||||||
// 移除等待提示消息
|
|
||||||
if (loadingMessage && loadingMessage.parentNode) {
|
|
||||||
loadingMessage.parentNode.removeChild(loadingMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统一响应格式:检查 data.code 或 data.message 判断请求是否成功
|
|
||||||
if (data.code === 200 || data.message === 'success') {
|
|
||||||
// data.data 是 ChatResponse 对象
|
|
||||||
const chatResponse = data.data;
|
|
||||||
|
|
||||||
if (chatResponse && chatResponse.success) {
|
|
||||||
// 保存后端返回的 sessionId/runId,用于 feedback 提交和 Trace 精确定位
|
|
||||||
if (chatResponse.sessionId) {
|
|
||||||
this.lastSessionId = chatResponse.sessionId;
|
|
||||||
this.lastRunId = chatResponse.runId || '';
|
|
||||||
this.rememberTraceTarget(chatResponse.sessionId, chatResponse.runId || null);
|
|
||||||
}
|
|
||||||
// 成功:添加实际响应消息(即使 answer 为空也显示)
|
|
||||||
const answer = chatResponse.answer || '(无回复内容)';
|
|
||||||
this.addMessage('assistant', answer);
|
|
||||||
} else if (chatResponse && chatResponse.errorMessage) {
|
|
||||||
// 业务错误
|
|
||||||
throw new Error(chatResponse.errorMessage);
|
|
||||||
} else {
|
|
||||||
// 兜底:尝试显示任何可用内容
|
|
||||||
const fallbackAnswer = chatResponse?.answer || chatResponse?.errorMessage || '服务返回了空内容';
|
|
||||||
this.addMessage('assistant', fallbackAnswer);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// HTTP 成功但业务失败
|
|
||||||
throw new Error(data.message || '请求失败');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 出错时也要移除等待提示消息
|
|
||||||
if (loadingMessage && loadingMessage.parentNode) {
|
|
||||||
loadingMessage.parentNode.removeChild(loadingMessage);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送流式消息
|
|
||||||
async sendStreamMessage(message) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.apiBaseUrl}/chat_stream`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
Id: this.sessionId,
|
|
||||||
Question: message
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP错误: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建助手消息元素
|
|
||||||
const assistantMessageElement = this.addMessage('assistant', '', true);
|
|
||||||
let fullResponse = '';
|
let fullResponse = '';
|
||||||
|
|
||||||
// 处理流式响应
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
let currentEvent = '';
|
|
||||||
|
const renderContent = (event) => {
|
||||||
|
if (state.content || state.failure || !state.metadata
|
||||||
|
|| !event.content_type || !event.payload) {
|
||||||
|
throw new Error('非法 content 事件');
|
||||||
|
}
|
||||||
|
state.content = true;
|
||||||
|
fullResponse = this.renderChatPayload(event.content_type, event.payload);
|
||||||
|
const contentElement = assistantMessageElement.querySelector('.message-content');
|
||||||
|
contentElement.innerHTML = this.renderMarkdown(fullResponse);
|
||||||
|
this.highlightCodeBlocks(contentElement);
|
||||||
|
this.scrollToBottom();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEvent = (frame) => {
|
||||||
|
const lines = frame.split(/\r?\n/);
|
||||||
|
const eventName = lines.find(line => line.startsWith('event:'))?.slice(6).trim();
|
||||||
|
const dataLines = lines.filter(line => line.startsWith('data:'))
|
||||||
|
.map(line => line.slice(5).trim());
|
||||||
|
if (!eventName || dataLines.length === 0) {
|
||||||
|
throw new Error('非法 SSE frame');
|
||||||
|
}
|
||||||
|
const event = JSON.parse(dataLines.join('\n'));
|
||||||
|
switch (eventName) {
|
||||||
|
case 'metadata':
|
||||||
|
if (state.metadata || state.content || state.failure || state.done
|
||||||
|
|| !event.session_id || !event.run_id) {
|
||||||
|
throw new Error('非法 metadata 事件');
|
||||||
|
}
|
||||||
|
state.metadata = true;
|
||||||
|
this.rememberRunMetadata(event);
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
if (!state.metadata || state.content || state.failure || state.done
|
||||||
|
|| !event.code || !event.message) {
|
||||||
|
throw new Error('非法 status 事件');
|
||||||
|
}
|
||||||
|
const statusText = loadingMessage?.querySelector('.loading-message-content span');
|
||||||
|
if (statusText) statusText.textContent = event.message;
|
||||||
|
break;
|
||||||
|
case 'content':
|
||||||
|
renderContent(event);
|
||||||
|
break;
|
||||||
|
case 'failure':
|
||||||
|
if (state.failure || state.content || state.done || !state.metadata
|
||||||
|
|| !event.code || !event.message) {
|
||||||
|
throw new Error('非法 failure 事件');
|
||||||
|
}
|
||||||
|
state.failure = true;
|
||||||
|
fullResponse = event.message;
|
||||||
|
assistantMessageElement.querySelector('.message-content').textContent = event.message;
|
||||||
|
break;
|
||||||
|
case 'done':
|
||||||
|
if (state.done || !state.metadata || (!state.content && !state.failure)
|
||||||
|
|| !['SUCCESS', 'FALLBACK', 'FAILED'].includes(event.outcome)) {
|
||||||
|
throw new Error('非法 done 事件');
|
||||||
|
}
|
||||||
|
state.done = true;
|
||||||
|
if (event.outcome === 'FAILED') {
|
||||||
|
if (!state.failure) throw new Error('FAILED 缺少 failure');
|
||||||
|
} else if (!state.content) {
|
||||||
|
throw new Error('成功终态缺少 content');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`未知 SSE 事件: ${eventName}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
if (done) {
|
|
||||||
// 流结束,使用统一的处理方法
|
|
||||||
this.handleStreamComplete(assistantMessageElement, fullResponse);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解码数据并添加到缓冲区
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const frames = buffer.split(/\r?\n\r?\n/);
|
||||||
// 按行分割处理
|
buffer = frames.pop() || '';
|
||||||
const lines = buffer.split('\n');
|
frames.filter(Boolean).forEach(handleEvent);
|
||||||
// 保留最后一行(可能不完整)
|
}
|
||||||
buffer = lines.pop() || '';
|
buffer += decoder.decode();
|
||||||
|
if (buffer.trim()) handleEvent(buffer);
|
||||||
for (const line of lines) {
|
if (!state.done) throw new Error('SSE 缺少 done 终态');
|
||||||
if (line.trim() === '') continue;
|
|
||||||
|
|
||||||
console.log('[SSE调试] 收到行:', line);
|
|
||||||
|
|
||||||
// 解析SSE格式
|
|
||||||
if (line.startsWith('id:')) {
|
|
||||||
console.log('[SSE调试] 解析到ID');
|
|
||||||
continue;
|
|
||||||
} else if (line.startsWith('event:')) {
|
|
||||||
// 兼容 "event:message" 和 "event: message" 两种格式
|
|
||||||
currentEvent = line.substring(6).trim();
|
|
||||||
console.log('[SSE调试] 解析到事件类型:', currentEvent);
|
|
||||||
// 注意:后端统一使用 "message" 事件名,真正的类型在 data 的 JSON 中
|
|
||||||
continue;
|
|
||||||
} else if (line.startsWith('data:')) {
|
|
||||||
// 兼容 "data:xxx" 和 "data: xxx" 两种格式
|
|
||||||
const rawData = line.substring(5).trim();
|
|
||||||
console.log('[SSE调试] 解析到数据, currentEvent:', currentEvent, ', rawData:', rawData);
|
|
||||||
|
|
||||||
// 兼容旧格式 [DONE] 标记
|
|
||||||
if (rawData === '[DONE]') {
|
|
||||||
// 流结束标记,将内容转换为Markdown渲染
|
|
||||||
this.handleStreamComplete(assistantMessageElement, fullResponse);
|
this.handleStreamComplete(assistantMessageElement, fullResponse);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理 SSE 数据
|
|
||||||
try {
|
|
||||||
// 尝试解析为 SseMessage 格式的 JSON
|
|
||||||
const sseMessage = JSON.parse(rawData);
|
|
||||||
console.log('[SSE调试] 解析JSON成功:', sseMessage);
|
|
||||||
|
|
||||||
if (sseMessage && typeof sseMessage.type === 'string') {
|
|
||||||
if (sseMessage.type === 'metadata') {
|
|
||||||
this.rememberRunMetadata(sseMessage);
|
|
||||||
} else if (sseMessage.type === 'content') {
|
|
||||||
const content = sseMessage.data || '';
|
|
||||||
fullResponse += content;
|
|
||||||
console.log('[SSE调试] 添加内容:', content);
|
|
||||||
|
|
||||||
// 实时渲染 Markdown
|
|
||||||
if (assistantMessageElement) {
|
|
||||||
const messageContent = assistantMessageElement.querySelector('.message-content');
|
|
||||||
messageContent.innerHTML = this.renderMarkdown(fullResponse);
|
|
||||||
// 高亮代码块
|
|
||||||
this.highlightCodeBlocks(messageContent);
|
|
||||||
this.scrollToBottom();
|
|
||||||
}
|
|
||||||
} else if (sseMessage.type === 'done') {
|
|
||||||
console.log('[SSE调试] 收到done标记,流结束');
|
|
||||||
this.handleStreamComplete(assistantMessageElement, fullResponse);
|
|
||||||
return;
|
|
||||||
} else if (sseMessage.type === 'error') {
|
|
||||||
console.error('[SSE调试] 收到错误:', sseMessage.data);
|
|
||||||
if (assistantMessageElement) {
|
|
||||||
const messageContent = assistantMessageElement.querySelector('.message-content');
|
|
||||||
messageContent.innerHTML = this.renderMarkdown('错误: ' + (sseMessage.data || '未知错误'));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 不是标准 SseMessage 格式,尝试兼容处理
|
|
||||||
console.log('[SSE调试] 非标准格式,尝试兼容处理');
|
|
||||||
fullResponse += rawData;
|
|
||||||
if (assistantMessageElement) {
|
|
||||||
const messageContent = assistantMessageElement.querySelector('.message-content');
|
|
||||||
messageContent.innerHTML = this.renderMarkdown(fullResponse);
|
|
||||||
this.highlightCodeBlocks(messageContent);
|
|
||||||
this.scrollToBottom();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// JSON 解析失败,尝试兼容旧格式
|
|
||||||
console.log('[SSE调试] JSON解析失败,使用兼容模式:', e.message);
|
|
||||||
if (rawData === '') {
|
|
||||||
fullResponse += '\n';
|
|
||||||
} else {
|
|
||||||
fullResponse += rawData;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (assistantMessageElement) {
|
|
||||||
const messageContent = assistantMessageElement.querySelector('.message-content');
|
|
||||||
messageContent.innerHTML = this.renderMarkdown(fullResponse);
|
|
||||||
this.highlightCodeBlocks(messageContent);
|
|
||||||
this.scrollToBottom();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
|
if (loadingMessage && loadingMessage.parentNode) loadingMessage.parentNode.removeChild(loadingMessage);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderChatPayload(contentType, payload) {
|
||||||
|
if (contentType === 'SYSTEM_CHAT') return payload.answer || '';
|
||||||
|
if (contentType === 'KNOWLEDGE_ANSWER') {
|
||||||
|
const references = (payload.references || []).map(ref => `- ${ref.title || ref.document_id}`).join('\n');
|
||||||
|
return [payload.answer || '', references ? `\n\n参考资料:\n${references}` : ''].join('');
|
||||||
|
}
|
||||||
|
if (contentType === 'SAFE_FALLBACK') {
|
||||||
|
return payload.fallback?.message || '当前证据不足,无法确认根因';
|
||||||
|
}
|
||||||
|
if (contentType === 'DIAGNOSIS_REPORT') {
|
||||||
|
const report = payload.report || {};
|
||||||
|
const sections = [];
|
||||||
|
if (report.conclusion?.text) sections.push(`## 结论\n${report.conclusion.text}`);
|
||||||
|
if (report.analysis?.length) sections.push(`## 分析\n${report.analysis.map(item => `- ${item.text}`).join('\n')}`);
|
||||||
|
if (report.action_plan?.length) sections.push(`## 行动计划\n${report.action_plan.map(item => `- ${item.action}`).join('\n')}`);
|
||||||
|
if (report.recommendations?.length) sections.push(`## 建议\n${report.recommendations.map(item => `- ${item.text}`).join('\n')}`);
|
||||||
|
if (report.limitations?.scope) sections.push(`## 限制\n${report.limitations.scope}`);
|
||||||
|
return sections.join('\n\n');
|
||||||
|
}
|
||||||
|
throw new Error(`未知 content_type: ${contentType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加消息到聊天界面
|
// 添加消息到聊天界面
|
||||||
|
|||||||
@@ -95,30 +95,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="right-actions">
|
<div class="right-actions">
|
||||||
<div class="mode-selector-wrapper">
|
|
||||||
<button class="mode-selector-btn" id="modeSelectorBtn">
|
|
||||||
<span id="currentModeText">快速</span>
|
|
||||||
<svg class="dropdown-arrow" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="mode-dropdown" id="modeDropdown">
|
|
||||||
<div class="dropdown-header">选择对话方式</div>
|
|
||||||
<div class="dropdown-item active" data-mode="quick">
|
|
||||||
<div class="dropdown-item-main">
|
|
||||||
<span>快速</span>
|
|
||||||
<span class="badge-new">新</span>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-item-sub">快速对话</div>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-item" data-mode="stream">
|
|
||||||
<div class="dropdown-item-main">
|
|
||||||
<span>流式</span>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-item-sub">流式对话</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="send-btn-circle" id="sendButton" title="发送">
|
<button class="send-btn-circle" id="sendButton" title="发送">
|
||||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M22 2L11 13M22 2L15 22L11 13M22 2L2 9L11 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M22 2L11 13M22 2L15 22L11 13M22 2L2 9L11 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
|||||||
@@ -735,108 +735,6 @@ body {
|
|||||||
color: #9aa0a6;
|
color: #9aa0a6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-selector-wrapper {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-selector-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 0;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #5f6368;
|
|
||||||
font-weight: 400;
|
|
||||||
transition: color 0.2s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-selector-btn:hover {
|
|
||||||
color: #202124;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-arrow {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-selector-wrapper.active .dropdown-arrow {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 8px);
|
|
||||||
right: 0;
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid #dadce0;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 10px rgba(60, 64, 67, 0.15);
|
|
||||||
min-width: 200px;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transform: translateY(8px);
|
|
||||||
transition: all 0.2s;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-selector-wrapper.active .mode-dropdown {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-header {
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #5f6368;
|
|
||||||
text-transform: uppercase;
|
|
||||||
border-bottom: 1px solid #dadce0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
padding: 12px 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:hover {
|
|
||||||
background: #f1f3f4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item.active {
|
|
||||||
background: rgba(26, 115, 232, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item-main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #202124;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item-sub {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #5f6368;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-new {
|
|
||||||
background: #1a73e8;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-btn-circle {
|
.send-btn-circle {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -1146,22 +1044,6 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-selector-wrapper {
|
|
||||||
order: -1;
|
|
||||||
width: 100%;
|
|
||||||
margin-left: 0;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-selector-btn {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-dropdown {
|
|
||||||
width: 100%;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 反馈栏 */
|
/* 反馈栏 */
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.superbiz.agent.config;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
|
||||||
|
import com.superbiz.agent.harness.application.persistence.ChatRunStore;
|
||||||
|
import com.superbiz.agent.harness.tool.contract.MysqlToolRequest;
|
||||||
|
import com.superbiz.agent.harness.tool.mysql.MysqlSqlValidator;
|
||||||
|
import com.superbiz.agent.harness.tool.mysql.MysqlSecurityException;
|
||||||
|
import com.superbiz.agent.tool.LookupKnowledgeTool;
|
||||||
|
import com.superbiz.agent.tool.RetrievedDocTracker;
|
||||||
|
import com.superbiz.agent.service.KnowledgeContextPacker;
|
||||||
|
import com.superbiz.agent.service.KnowledgeDocumentRetriever;
|
||||||
|
import com.superbiz.agent.service.KnowledgeEvidencePostProcessor;
|
||||||
|
import com.superbiz.agent.service.KnowledgeQueryTransformer;
|
||||||
|
import com.superbiz.agent.service.LookupResultAssembler;
|
||||||
|
import com.superbiz.agent.service.ToolInvocationRecorder;
|
||||||
|
import com.superbiz.agent.repository.AgentStepRepository;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
class HarnessChatConfigurationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultsArePositiveAndExecutorsAreBounded() {
|
||||||
|
ChatHarnessProperties properties = new ChatHarnessProperties();
|
||||||
|
properties.validate();
|
||||||
|
HarnessChatConfiguration configuration = new HarnessChatConfiguration();
|
||||||
|
|
||||||
|
ThreadPoolExecutor worker = configuration.chatWorkerExecutor(properties);
|
||||||
|
ThreadPoolExecutor model = configuration.harnessModelExecutor(properties);
|
||||||
|
try {
|
||||||
|
assertTrue(worker.getQueue().remainingCapacity() > 0);
|
||||||
|
assertTrue(model.getQueue().remainingCapacity() > 0);
|
||||||
|
assertEquals(properties.getWorkerMaxPoolSize(), worker.getMaximumPoolSize());
|
||||||
|
assertEquals(properties.getModelMaxPoolSize(), model.getMaximumPoolSize());
|
||||||
|
} finally {
|
||||||
|
worker.shutdownNow();
|
||||||
|
model.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyMysqlConfigurationFailsClosedWithoutApplicationDatasource() {
|
||||||
|
MysqlToolProperties properties = new MysqlToolProperties();
|
||||||
|
HarnessChatConfiguration configuration = new HarnessChatConfiguration();
|
||||||
|
assertTrue(configuration.harnessMysqlDataSources(properties).isEmpty());
|
||||||
|
Map<String, com.superbiz.agent.harness.tool.mysql.MysqlDataSourceDefinition> definitions =
|
||||||
|
configuration.harnessMysqlDefinitions(properties);
|
||||||
|
MysqlSqlValidator validator = configuration.mysqlSqlValidator(definitions);
|
||||||
|
|
||||||
|
assertThrows(MysqlSecurityException.class, () -> validator.validate(
|
||||||
|
new MysqlToolRequest("application", "SELECT id FROM users", java.util.List.of())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void harnessConfigurationBuildsOneApplicationGraphWithControlledDependencies() {
|
||||||
|
new ApplicationContextRunner()
|
||||||
|
.withUserConfiguration(HarnessChatConfiguration.class)
|
||||||
|
.withBean(ObjectMapper.class, ObjectMapper::new)
|
||||||
|
.withBean(ChatModel.class, () -> mock(ChatModel.class))
|
||||||
|
.withBean(RedisTemplate.class, () -> mock(RedisTemplate.class))
|
||||||
|
.withBean(LookupKnowledgeTool.class, () -> mock(LookupKnowledgeTool.class))
|
||||||
|
.withBean(KnowledgeQueryTransformer.class, () -> mock(KnowledgeQueryTransformer.class))
|
||||||
|
.withBean(KnowledgeDocumentRetriever.class, () -> mock(KnowledgeDocumentRetriever.class))
|
||||||
|
.withBean(KnowledgeEvidencePostProcessor.class, () -> mock(KnowledgeEvidencePostProcessor.class))
|
||||||
|
.withBean(KnowledgeContextPacker.class, () -> mock(KnowledgeContextPacker.class))
|
||||||
|
.withBean(LookupResultAssembler.class, () -> mock(LookupResultAssembler.class))
|
||||||
|
.withBean(ToolInvocationRecorder.class, () -> mock(ToolInvocationRecorder.class))
|
||||||
|
.withBean(RetrievedDocTracker.class, () -> mock(RetrievedDocTracker.class))
|
||||||
|
.withBean(AgentStepRepository.class, () -> mock(AgentStepRepository.class))
|
||||||
|
.withBean(ChatRunStore.class, () -> mock(ChatRunStore.class))
|
||||||
|
.withBean(MysqlToolProperties.class, MysqlToolProperties::new)
|
||||||
|
.run(context -> {
|
||||||
|
assertTrue(context.isRunning());
|
||||||
|
assertEquals(1, context.getBeansOfType(
|
||||||
|
com.superbiz.agent.harness.application.ChatApplicationUseCase.class).size());
|
||||||
|
assertTrue(context.containsBean("harnessModelExecutor"));
|
||||||
|
assertTrue(context.containsBean("chatWorkerExecutor"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.superbiz.agent.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class AiOpsControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void metadataMessageCarriesSessionAndRunId() {
|
||||||
|
AiOpsController.SseMessage message = AiOpsController.SseMessage.metadata("session-1", "run-1");
|
||||||
|
|
||||||
|
assertEquals("metadata", message.getType());
|
||||||
|
assertEquals("session-1", message.getSessionId());
|
||||||
|
assertEquals("run-1", message.getRunId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
package com.superbiz.agent.controller;
|
package com.superbiz.agent.controller;
|
||||||
|
|
||||||
import com.superbiz.agent.service.ChatService;
|
import com.superbiz.agent.config.ChatHarnessProperties;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationUseCase;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.verifyNoInteractions;
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
|
||||||
@@ -14,28 +21,59 @@ class ChatControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void blankChatRequestReturnsErrorBeforeCreatingRun() {
|
void blankChatRequestReturnsErrorBeforeCreatingRun() {
|
||||||
ChatController controller = new ChatController();
|
ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
||||||
ChatService chatService = mock(ChatService.class);
|
1, 1, 1, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1));
|
||||||
ReflectionTestUtils.setField(controller, "chatService", chatService);
|
ChatApplicationUseCase application = mock(ChatApplicationUseCase.class);
|
||||||
|
ChatController controller = new ChatController(
|
||||||
|
application, executor, new ChatHarnessProperties());
|
||||||
|
|
||||||
ChatController.ChatRequest request = new ChatController.ChatRequest();
|
ChatController.ChatRequest request = new ChatController.ChatRequest();
|
||||||
request.setId("invalid-chat-session");
|
request.setId("invalid-chat-session");
|
||||||
request.setQuestion(" ");
|
request.setQuestion(" ");
|
||||||
|
|
||||||
ResponseEntity<ChatController.ApiResponse<ChatController.ChatResponse>> response = controller.chat(request);
|
ResponseEntity<?> response = controller.chat(request);
|
||||||
|
|
||||||
ChatController.ChatResponse body = response.getBody().getData();
|
assertEquals(400, response.getStatusCode().value());
|
||||||
assertFalse(body.isSuccess());
|
verifyNoInteractions(application);
|
||||||
assertEquals("问题内容不能为空", body.getErrorMessage());
|
executor.shutdownNow();
|
||||||
verifyNoInteractions(chatService);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aiOpsMetadataMessageCarriesSessionAndRunId() {
|
void controllerOwnsOnlyChatProtocolAndApplicationDependencies() {
|
||||||
ChatController.SseMessage message = ChatController.SseMessage.metadata("session-1", "run-1");
|
Set<Class<?>> dependencyTypes = Arrays.stream(ChatController.class.getDeclaredFields())
|
||||||
|
.map(Field::getType)
|
||||||
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
|
|
||||||
assertEquals("metadata", message.getType());
|
assertEquals(Set.of(ChatApplicationUseCase.class, ThreadPoolExecutor.class, ChatHarnessProperties.class),
|
||||||
assertEquals("session-1", message.getSessionId());
|
dependencyTypes);
|
||||||
assertEquals("run-1", message.getRunId());
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saturatedWorkerReturnsUnavailableBeforeCreatingRun() throws Exception {
|
||||||
|
ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
||||||
|
1, 1, 1, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1));
|
||||||
|
CountDownLatch release = new CountDownLatch(1);
|
||||||
|
executor.execute(() -> await(release));
|
||||||
|
executor.execute(() -> await(release));
|
||||||
|
ChatApplicationUseCase application = mock(ChatApplicationUseCase.class);
|
||||||
|
ChatController controller = new ChatController(
|
||||||
|
application, executor, new ChatHarnessProperties());
|
||||||
|
ChatController.ChatRequest request = new ChatController.ChatRequest();
|
||||||
|
request.setQuestion("hello");
|
||||||
|
|
||||||
|
ResponseEntity<?> response = controller.chat(request);
|
||||||
|
|
||||||
|
assertEquals(503, response.getStatusCode().value());
|
||||||
|
verifyNoInteractions(application);
|
||||||
|
release.countDown();
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void await(CountDownLatch release) {
|
||||||
|
try {
|
||||||
|
release.await(2, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.superbiz.agent.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class FrontendChatSseContractTest {
|
||||||
|
|
||||||
|
private static final Path STATIC_ROOT = Path.of("src", "main", "resources", "static");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bundledChatUsesOnlyTheNewSseConsumer() throws IOException {
|
||||||
|
String app = Files.readString(STATIC_ROOT.resolve("app.js"), StandardCharsets.UTF_8);
|
||||||
|
String html = Files.readString(STATIC_ROOT.resolve("index.html"), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
assertTrue(app.contains("sendChatMessage"));
|
||||||
|
assertTrue(app.contains("/chat`"));
|
||||||
|
assertTrue(app.contains("case 'metadata'"));
|
||||||
|
assertTrue(app.contains("case 'status'"));
|
||||||
|
assertTrue(app.contains("case 'content'"));
|
||||||
|
assertTrue(app.contains("case 'failure'"));
|
||||||
|
assertTrue(app.contains("case 'done'"));
|
||||||
|
assertFalse(app.contains("/chat_stream"));
|
||||||
|
assertFalse(app.contains("sendQuickMessage"));
|
||||||
|
assertFalse(app.contains("sendStreamMessage"));
|
||||||
|
assertFalse(app.contains("currentMode"));
|
||||||
|
assertFalse(html.contains("modeSelectorBtn"));
|
||||||
|
assertFalse(html.contains("modeDropdown"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.superbiz.agent.controller.sse;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationResult;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationException;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationStatus;
|
||||||
|
import com.superbiz.agent.harness.application.ChatContentType;
|
||||||
|
import com.superbiz.agent.harness.application.ChatFailureCode;
|
||||||
|
import com.superbiz.agent.harness.application.ChatRunControl;
|
||||||
|
import com.superbiz.agent.harness.application.SystemChatContent;
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
import com.superbiz.agent.harness.contract.ReleaseOutcome;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class ChatSseSessionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void successEmitsStrictNamedEventSequenceWithTypedContent() {
|
||||||
|
RecordingSink sink = new RecordingSink();
|
||||||
|
ChatSseSession session = new ChatSseSession(sink);
|
||||||
|
RecordingRunControl control = new RecordingRunControl();
|
||||||
|
|
||||||
|
session.onStarted(control);
|
||||||
|
session.onStatus(ChatApplicationStatus.ROUTING);
|
||||||
|
session.complete(new ChatApplicationResult(
|
||||||
|
"session-1", "run-1", IntentType.SYSTEM_CHAT, ReleaseOutcome.SUCCESS,
|
||||||
|
ChatContentType.SYSTEM_CHAT, new SystemChatContent("hello")));
|
||||||
|
|
||||||
|
assertEquals(List.of("metadata", "status", "content", "done"), sink.names());
|
||||||
|
assertEquals(new ChatSseEvent.Metadata("session-1", "run-1"), sink.events.get(0).payload());
|
||||||
|
assertEquals(new ChatSseEvent.Content(
|
||||||
|
ChatContentType.SYSTEM_CHAT, new SystemChatContent("hello")),
|
||||||
|
sink.events.get(2).payload());
|
||||||
|
assertEquals(new ChatSseEvent.Done(ReleaseOutcome.SUCCESS), sink.events.get(3).payload());
|
||||||
|
assertEquals(1, sink.completions);
|
||||||
|
assertFalse(control.cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void technicalFailureEmitsSafeFailureAndFailedDoneWithoutContent() {
|
||||||
|
RecordingSink sink = new RecordingSink();
|
||||||
|
ChatSseSession session = new ChatSseSession(sink);
|
||||||
|
session.onStarted(new RecordingRunControl());
|
||||||
|
|
||||||
|
session.fail(new ChatApplicationException(
|
||||||
|
ChatFailureCode.ROUTING_UNAVAILABLE,
|
||||||
|
"当前暂时无法处理该请求,请稍后重试",
|
||||||
|
new IllegalStateException("provider-secret-detail")));
|
||||||
|
|
||||||
|
assertEquals(List.of("metadata", "failure", "done"), sink.names());
|
||||||
|
assertEquals(new ChatSseEvent.Failure(
|
||||||
|
ChatFailureCode.ROUTING_UNAVAILABLE,
|
||||||
|
"当前暂时无法处理该请求,请稍后重试"), sink.events.get(1).payload());
|
||||||
|
assertEquals(new ChatSseEvent.Done(ReleaseOutcome.FAILED), sink.events.get(2).payload());
|
||||||
|
assertFalse(sink.events.toString().contains("provider-secret-detail"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disconnectBeforeRunStartCancelsControlWhenPublishedWithoutSendingEvents() {
|
||||||
|
RecordingSink sink = new RecordingSink();
|
||||||
|
ChatSseSession session = new ChatSseSession(sink);
|
||||||
|
RecordingRunControl control = new RecordingRunControl();
|
||||||
|
|
||||||
|
assertFalse(session.disconnect());
|
||||||
|
session.onStarted(control);
|
||||||
|
|
||||||
|
assertTrue(control.cancelled);
|
||||||
|
assertTrue(sink.events.isEmpty());
|
||||||
|
assertEquals(0, sink.completions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusSendFailureCancelsThePublishedRunAndBlocksLateTerminalEvents() {
|
||||||
|
RecordingSink sink = new RecordingSink();
|
||||||
|
sink.failOn = "status";
|
||||||
|
ChatSseSession session = new ChatSseSession(sink);
|
||||||
|
RecordingRunControl control = new RecordingRunControl();
|
||||||
|
session.onStarted(control);
|
||||||
|
|
||||||
|
assertThrows(ChatSseDisconnectedException.class,
|
||||||
|
() -> session.onStatus(ChatApplicationStatus.ROUTING));
|
||||||
|
|
||||||
|
assertTrue(control.cancelled);
|
||||||
|
assertFalse(session.complete(new ChatApplicationResult(
|
||||||
|
"session-1", "run-1", IntentType.SYSTEM_CHAT, ReleaseOutcome.SUCCESS,
|
||||||
|
ChatContentType.SYSTEM_CHAT, new SystemChatContent("late"))));
|
||||||
|
assertEquals(List.of("metadata"), sink.names());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RecordingSink implements ChatSseSink {
|
||||||
|
private final List<Event> events = new ArrayList<>();
|
||||||
|
private int completions;
|
||||||
|
private String failOn;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void send(String name, Object payload) throws IOException {
|
||||||
|
if (name.equals(failOn)) {
|
||||||
|
throw new IOException("closed");
|
||||||
|
}
|
||||||
|
events.add(new Event(name, payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void complete() {
|
||||||
|
completions++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> names() {
|
||||||
|
return events.stream().map(Event::name).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RecordingRunControl implements ChatRunControl {
|
||||||
|
private boolean cancelled;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String sessionId() {
|
||||||
|
return "session-1";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String runId() {
|
||||||
|
return "run-1";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean cancelClientDisconnect() {
|
||||||
|
cancelled = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Event(String name, Object payload) {
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user