Skip to content

Commit f16820e

Browse files
zeweihanclaude
andauthored
feat(ai): WPS 前后端契约命名双轨迁移——doc_*/editor_command 新名与 wps_* 旧名并行一个发布周期 (#192)
Phase 3 遗留项落地(docs/AI_ARCHITECTURE.md §编辑器前后端契约): 后端双发(新名在前、旧名在后,顺序即契约): - SSE 流式写入:doc_stream_data + wps_stream_data(AgentOrchestrator) - client_action 命令:tool editor_command + wps_command,同 requestId; 同步打开 action doc_open_file_sync ↔ 旧名 wps_open_file_sync 映射(EditorBridgeService) - client_action 打开/刷新:doc_open_file / doc_reload_file + wps_*(sendDualNamedAction) - 结果回调路由:/api/ai/agent/editor-result 新路由,/wps-result 保留别名(EditorResultController) 前端双听 + 去重闩锁(project-overview handleClientAction): - 一旦见到任一新名即判定新后端(SSE 单连接有序),此后丢弃所有旧名事件, 新旧后端×新旧前端四种搭配下每条指令都恰好执行一次 - sendEditorResult 改发新路由 /editor-result 前端零散 WPS 遗留清理: - VariablePanel getWps prop → getEditor(绑定点/局部变量/注释同步) - ChatInterface wps-tip-*/wps-link-text CSS 类 → doc-tip-*/doc-link-text 文档:AI_ARCHITECTURE.md 契约表改双轨状态并勾掉 Phase 3 待办(留摘旧名项); ai_agent_dev.md §2.2 事件字典改为实际契约(移除已不存在的 wps_write)。 明确不动(存量数据,另立迁移方案):ProjectFile.wpsFileId、wps-files/ 存储前缀、 write_docx 的 wps_file_id 键、FeatureNotConfiguredException "wps" 标识。 验收:backend mvn test 全绿(JDK 21);npm run check:emits 过; Electron 真机三链路实测通过——AI 打开文档(后端 Sent doc_open_file + 编辑器拉流)、 查找替换修订(双发各 1 次/去重后执行 1 次/新路由 200/success=true)、 流式写入新文档(产物标记恰出现一次,无双写); 双 e2e:test:lowa-e2e 33/33,test:app-e2e 29 步全过 0 异常。 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 394a3e3 commit f16820e

15 files changed

Lines changed: 154 additions & 105 deletions

File tree

backend/src/main/java/com/checkba/controller/ai/EditorResultController.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
/**
1010
* 编辑器操作结果接收控制器
1111
*
12-
* 接收前端执行编辑器操作后的结果回调(路由 /wps-result 为前后端契约,保持旧名)
12+
* 接收前端执行编辑器操作后的结果回调(双轨迁移:新路由 /editor-result,
13+
* 旧路由 /wps-result 保留别名供旧前端使用,一个发布周期后摘除)
1314
*/
1415
@RestController
1516
@RequestMapping("/api/ai/agent")
@@ -24,7 +25,7 @@ public class EditorResultController {
2425
*
2526
* 前端执行完编辑器操作后,调用此接口返回结果
2627
*/
27-
@PostMapping("/wps-result")
28+
@PostMapping({"/editor-result", "/wps-result"})
2829
public EditorResultResponse receiveEditorResult(@RequestBody EditorResultPayload payload) {
2930
log.info("Received editor result: requestId={}, success={}", payload.getRequestId(), payload.isSuccess());
3031

backend/src/main/java/com/checkba/service/ai/AgentOrchestrator.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,11 @@ private void runLoop(StreamingChatLanguageModel model,
368368
}
369369
});
370370

371-
// 编辑器实时流式写入拦截(事件名沿用 wps_stream_data,前后端契约)
371+
// 编辑器实时流式写入拦截(双轨迁移:新名 doc_stream_data 必须先于旧名 wps_stream_data 发出,
372+
// 前端以"先见新名"判定新后端并丢弃旧名去重;一个发布周期后摘旧名,见 docs/AI_ARCHITECTURE.md Phase 3)
372373
handler.setOnEditorStream(token -> {
373374
if (editorBridgeService.isStreamingMode(conversationId)) {
375+
sseEmitterService.send(conversationId, "doc_stream_data", java.util.Map.of("content", token));
374376
sseEmitterService.send(conversationId, "wps_stream_data", java.util.Map.of("content", token));
375377
}
376378
});

backend/src/main/java/com/checkba/service/ai/AgentStreamHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public void onNext(String token) {
6666
}
6767
}
6868

69-
// ==================== Editor Stream Filtering Logic(过滤后实时写入编辑器文档;SSE 事件名 wps_stream_data 为前后端契约保留) ====================
69+
// ==================== Editor Stream Filtering Logic(过滤后实时写入编辑器文档;SSE 事件名双轨 doc_stream_data/wps_stream_data,见 AgentOrchestrator) ====================
7070

7171
// Buffer for editor stream parser to handle split tags
7272
private final StringBuilder editorStreamBuffer = new StringBuilder();

backend/src/main/java/com/checkba/service/ai/EditorBridgeService.java

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@
2424
* 工作流程:
2525
* 1. Agent 调用文档编辑工具 -> DocumentEditTools 调用 executeEditorCommand
2626
* 2. EditorBridgeService 生成 requestId,发送 SSE 事件,创建 CompletableFuture
27-
* 3. 前端执行操作后调用 /api/ai/agent/wps-result 返回结果
27+
* 3. 前端执行操作后调用 /api/ai/agent/editor-result 返回结果(旧路由 /wps-result 保留别名)
2828
* 4. EditorResultController 调用 completeEditorAction 解锁 CompletableFuture
2929
* 5. executeEditorCommand 获取结果并返回给 DocumentEditTools
3030
*
3131
* 历史沿革:原名 WpsActionService(WPS WebOffice 时代)。SSE 事件与路由中的
32-
* wps_* 字符串是前后端契约(见 docs/ai_agent_dev.md §2.2),保持旧名不变。
32+
* wps_* 字符串是前后端契约(见 docs/ai_agent_dev.md §2.2),当前处于双轨迁移期:
33+
* 每条指令按"新名在前、旧名在后"各发一份(doc_* 与 editor_command + wps_*),前端凭
34+
* "先见新名"判定新后端并丢弃旧名去重;兼容一个发布周期后摘旧名(AI_ARCHITECTURE.md Phase 3)。
3335
*/
3436
@Service
3537
@RequiredArgsConstructor
@@ -98,19 +100,17 @@ public void sendOpenFileAction(ProjectFile file) {
98100
}
99101

100102
try {
101-
String payload = objectMapper.writeValueAsString(Map.of(
102-
"action", "wps_open_file",
103+
Map<String, Object> fields = Map.of(
103104
"fileId", file.getId(),
104105
"fileName", file.getName(),
105106
"fileType", file.getFileType(),
106107
"wpsFileId", file.getWpsFileId() != null ? file.getWpsFileId() : "",
107108
"trackRevisions", true,
108109
"userName", "AI Workdeck"
109-
));
110-
111-
sseEmitterService.send(conversationId, "client_action", payload);
112-
log.info("Sent wps_open_file action for file: {} (id={})", file.getName(), file.getId());
113-
110+
);
111+
sendDualNamedAction("doc_open_file", "wps_open_file", conversationId, fields);
112+
log.info("Sent doc_open_file action for file: {} (id={})", file.getName(), file.getId());
113+
114114
} catch (Exception e) {
115115
log.error("Failed to send open file action", e);
116116
}
@@ -128,22 +128,33 @@ public void sendReloadFileAction(ProjectFile file) {
128128
}
129129

130130
try {
131-
String payload = objectMapper.writeValueAsString(Map.of(
132-
"action", "wps_reload_file",
131+
Map<String, Object> fields = Map.of(
133132
"fileId", file.getId(),
134133
"fileName", file.getName(),
135134
"fileType", file.getFileType(),
136135
"wpsFileId", file.getWpsFileId() != null ? file.getWpsFileId() : ""
137-
));
138-
139-
sseEmitterService.send(conversationId, "client_action", payload);
140-
log.info("Sent wps_reload_file action for file: {} (id={})", file.getName(), file.getId());
141-
136+
);
137+
sendDualNamedAction("doc_reload_file", "wps_reload_file", conversationId, fields);
138+
log.info("Sent doc_reload_file action for file: {} (id={})", file.getName(), file.getId());
139+
142140
} catch (Exception e) {
143141
log.error("Failed to send reload file action", e);
144142
}
145143
}
146144

145+
/**
146+
* 双轨迁移期的单向 client_action 发送:同一份载荷按"新名在前、旧名在后"各发一次。
147+
* 顺序是契约的一部分——前端凭"先见新名"判定新后端并丢弃随后的旧名事件去重。
148+
*/
149+
private void sendDualNamedAction(String newAction, String legacyAction,
150+
String conversationId, Map<String, Object> fields) throws Exception {
151+
java.util.Map<String, Object> payloadMap = new java.util.HashMap<>(fields);
152+
payloadMap.put("action", newAction);
153+
sseEmitterService.send(conversationId, "client_action", objectMapper.writeValueAsString(payloadMap));
154+
payloadMap.put("action", legacyAction);
155+
sseEmitterService.send(conversationId, "client_action", objectMapper.writeValueAsString(payloadMap));
156+
}
157+
147158
/**
148159
* 发送刷新文件树的 SSE 事件到前端
149160
*/
@@ -210,16 +221,20 @@ public String executeEditorCommand(String action, Map<String, Object> params) {
210221
pendingRequests.put(requestId, future);
211222

212223
try {
213-
// 构建并发送 SSE 事件
214-
String payload = objectMapper.writeValueAsString(Map.of(
215-
"tool", "wps_command",
216-
"action", action,
217-
"params", params != null ? params : Map.of(),
218-
"requestId", requestId,
219-
"conversationId", conversationId
220-
));
221-
222-
sseEmitterService.send(conversationId, "client_action", payload);
224+
// 构建并发送 SSE 事件(双轨:新名 editor_command 在前、旧名 wps_command 在后,
225+
// requestId 相同;action 中仅 doc_open_file_sync 有旧名 wps_open_file_sync 需映射)
226+
String legacyAction = "doc_open_file_sync".equals(action) ? "wps_open_file_sync" : action;
227+
java.util.Map<String, Object> payloadMap = new java.util.HashMap<>();
228+
payloadMap.put("action", action);
229+
payloadMap.put("params", params != null ? params : Map.of());
230+
payloadMap.put("requestId", requestId);
231+
payloadMap.put("conversationId", conversationId);
232+
233+
payloadMap.put("tool", "editor_command");
234+
sseEmitterService.send(conversationId, "client_action", objectMapper.writeValueAsString(payloadMap));
235+
payloadMap.put("tool", "wps_command");
236+
payloadMap.put("action", legacyAction);
237+
sseEmitterService.send(conversationId, "client_action", objectMapper.writeValueAsString(payloadMap));
223238
log.info("Sent editor command: action={}, requestId={}", action, requestId);
224239

225240
// 等待前端执行结果

backend/src/main/java/com/checkba/service/ai/tools/DocumentEditTools.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ public String doc_start_stream(
189189
}
190190

191191
// 2. 同步打开文件 (Wait for Ready)
192-
String resultJson = editorBridgeService.executeEditorCommand("wps_open_file_sync", java.util.Map.of(
192+
String resultJson = editorBridgeService.executeEditorCommand("doc_open_file_sync", java.util.Map.of(
193193
"fileId", file.getId(),
194194
"fileName", file.getName(),
195195
"fileType", file.getFileType(),

docs/AI_ARCHITECTURE.md

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,30 @@ LLM 面的 30 个文档编辑工具 `wps_*` 更名为 `doc_*`(宿主类 `WpsTo
5858
(系统提示中只有 `doc_*` 名),无静默失败。
5959
- 数据库历史消息中的旧工具名只影响展示(displayName 兜底"工具执行"),不做数据迁移。
6060

61-
### 编辑器前后端契约(Phase 2.5 保持旧名
61+
### 编辑器前后端契约(双轨迁移期
6262

63-
以下 `wps_*` 字符串是前后端契约(事件字典见 `docs/ai_agent_dev.md` §2.2),受不变式 4 约束,
64-
Phase 2.5 未改动,命名迁移列入 Phase 3:
63+
以下字符串是前后端契约(事件字典见 `docs/ai_agent_dev.md` §2.2),受不变式 4 约束。
64+
命名迁移已进入**双轨期**:后端对每条指令按"**新名在前、旧名在后**"各发一份(SSE 单连接
65+
有序),前端凭"先见新名"判定新后端并丢弃旧名事件去重(`project-overview.vue`
66+
`handleClientAction` 闩锁);旧前端不认识新名、只执行旧名——两个方向各恰好执行一次。
67+
兼容一个发布周期后摘旧名:
68+
69+
| 契约 | 新名(主) | 旧名(双轨别名) |
70+
|-----|-----------|----------------|
71+
| SSE 流式写入事件 | `doc_stream_data` | `wps_stream_data` |
72+
| SSE client_action 命令载荷 | `tool: "editor_command"`(内含 action 字典)| `tool: "wps_command"` |
73+
| SSE client_action 打开/刷新 | `action: "doc_open_file"` / `"doc_reload_file"` | `wps_open_file` / `wps_reload_file` |
74+
| 同步打开命令 | `doc_open_file_sync` | `wps_open_file_sync` |
75+
| 结果回调路由 | `POST /api/ai/agent/editor-result` | `/wps-result`(路由别名,新前端已改发新路由)|
76+
77+
以下为**存量数据契约**,不在本次迁移范围(需另立数据迁移方案):
6578

6679
| 契约 | 现名 |
6780
|-----|------|
68-
| SSE 流式写入事件 | `wps_stream_data` |
69-
| SSE client_action 命令载荷 | `tool: "wps_command"`(内含 action 字典)|
70-
| SSE client_action 打开/刷新 | `action: "wps_open_file"` / `"wps_reload_file"` |
71-
| 同步打开命令 | `wps_open_file_sync` |
72-
| 结果回调路由 | `POST /api/ai/agent/wps-result` |
7381
| write_docx 输出 JSON 键 | `wps_file_id`(前端有字符串匹配逻辑)|
74-
| 文件实体字段 | `ProjectFile.wpsFileId` |
82+
| 文件实体字段 | `ProjectFile.wpsFileId`(含数据库列与 API JSON 字段)|
83+
| 存储路径前缀 | `wps-files/`(application.yml / application-prod.yml)|
84+
| 功能未配置标识 | `FeatureNotConfiguredException``"wps"` |
7585

7686
### Skill 体系(Phase 3B)
7787

@@ -171,15 +181,17 @@ AgentOrchestrator 仅 3 行挂载点(字段 + activateForTurn + visibleTools
171181
docs/SKILL_SPEC.md);插件可携带 Skill(PLUGIN_SPEC v2.1);
172182
内置 listing-pathway;前端插件广场 Skill 区块。
173183

174-
**Phase 3 剩余项(仅此两项)**
175-
- [ ] **SSE 事件名双轨迁移**`wps_stream_data``doc_stream_data`
184+
**Phase 3 剩余项**
185+
- [x] **SSE 事件名双轨迁移(双轨已落地)**`wps_stream_data``doc_stream_data`
176186
`client_action.tool: wps_command``editor_command`
177187
`wps_open_file(_sync)` / `wps_reload_file``doc_*`、路由 `/wps-result`
178-
`/editor-result`。方案:后端双发(新旧事件并行)、前端双听,观察一个发布周期后
179-
摘旧名;**合并前必须在 Electron 桌面端真机实测文档打开、查找替换、流式写入三条链路**
180-
随迁移一并清理:前端零散 WPS 遗留(`VariablePanel.getWps` prop、
181-
`ChatInterface.vue` `wps-tip-*` CSS 类、`ProjectFile.wpsFileId` 字段语义梳理);
182-
`wps_*` 工具名别名已按门槛于 0.7.9 后移除(PR#189,见 §3「工具命名与别名」)。
188+
`/editor-result`。已落地:后端双发(新名在前、旧名在后)、前端双听 + 旧名去重闩锁
189+
(见 §3「编辑器前后端契约」);随迁移已清理前端零散 WPS 遗留
190+
`VariablePanel.getWps``getEditor` prop、`ChatInterface.vue` `wps-tip-*`
191+
`doc-tip-*` CSS 类);`wps_*` 工具名别名已按门槛于 0.7.9 后移除(PR#189)。
192+
- [ ] **双轨摘旧名**:兼容一个发布周期后,摘除后端旧名双发、前端旧名分支与
193+
`/wps-result` 路由别名;`ProjectFile.wpsFileId` / `wps_file_id` / `wps-files/`
194+
前缀等存量数据契约需另立数据迁移方案。
183195
- [ ] **插件进程级运行时沙箱**:v2 权限校验是分发层的诚实声明模型,插件代码仍与宿主同进程;
184196
真正强制 file/network 隔离需进程级沙箱(独立进程 + IPC 或 SecurityManager 替代方案)。
185197

docs/ai_agent_dev.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,16 @@ v1.8 新增 **"文件化产物生命周期 (File-based Artifact Lifecycle)"**:
1717
| `text_delta` | `{ content }` | Markdown 增量内容 |
1818
| `step_update` | `{ bubbleId, stepId, status: "loading|done" }` | 更新步骤进度 |
1919
| `artifact` | `{ operation: "create|update|resolve", id, type, status?, data?, meta? }` | 产物操作 |
20-
| `client_action` | `{ tool: "wps_write", action, content, options?: { track_changes: boolean } }` | WPS 本地写入指令 |
20+
| `client_action`(命令) | `{ tool: "editor_command", action, params, requestId, conversationId }` | 编辑器命令(前端执行后 POST `/api/ai/agent/editor-result` 回传结果);同步打开为 `action: "doc_open_file_sync"` |
21+
| `client_action`(打开/刷新) | `{ action: "doc_open_file" \| "doc_reload_file", fileId, fileName, fileType, wpsFileId, ... }` | 单向打开/刷新编辑器文档 |
22+
| `doc_stream_data` | `{ content }` | 编辑器实时流式写入增量(doc_start_stream 激活后) |
2123
| `bubble_end` | `{ bubbleId, status: "finished" }` | 气泡结束 |
2224

25+
> **双轨迁移期(AI_ARCHITECTURE.md Phase 3)**:以上编辑器契约的旧名
26+
> `wps_stream_data` / `tool: "wps_command"` / `wps_open_file(_sync)` / `wps_reload_file` /
27+
> 路由 `/wps-result` 仍由后端按"新名在前、旧名在后"双发(路由为别名保留),前端凭
28+
> "先见新名"丢弃旧名去重;兼容一个发布周期后摘旧名。
29+
2330
### 2.3 事件扩充说明
2431
- **`artifact` - 命名与存储规则**:
2532
- 前端收到 `artifact: create` 后,将在项目根目录的 `AI助手文件夹/` 下创建对应的 `.md` 文件。

frontend/src/components/ChatInterface.vue

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,11 @@
139139
<view class="awd-dialog-body">
140140
<view class="rollback-warning-content">
141141
<text class="warning-text">此操作将删除该消息以及之后的所有对话记录,且无法恢复。</text>
142-
<view class="wps-tip-box">
143-
<text class="wps-tip-icon">💡</text>
144-
<view class="wps-tip-text">
142+
<view class="doc-tip-box">
143+
<text class="doc-tip-icon">💡</text>
144+
<view class="doc-tip-text">
145145
<text>如果助手在后续对话中修改了文档(Word/PPT),文件内容不会自动回退。</text>
146-
<text class="wps-link-text">AI 的文档修改以修订痕迹记录,可在编辑器中「拒绝修订」恢复原文。</text>
146+
<text class="doc-link-text">AI 的文档修改以修订痕迹记录,可在编辑器中「拒绝修订」恢复原文。</text>
147147
</view>
148148
</view>
149149
<view class="rollback-preview">
@@ -3475,7 +3475,7 @@ export default {
34753475
display: block;
34763476
}
34773477
3478-
.wps-tip-box {
3478+
.doc-tip-box {
34793479
background-color: #f0f9ff;
34803480
border: 1px solid #bae6fd;
34813481
border-radius: 6px;
@@ -3485,19 +3485,19 @@ export default {
34853485
margin-bottom: 16px;
34863486
}
34873487
3488-
.wps-tip-icon {
3488+
.doc-tip-icon {
34893489
font-size: 18px;
34903490
margin-right: 10px;
34913491
}
34923492
3493-
.wps-tip-text {
3493+
.doc-tip-text {
34943494
font-size: 13px;
34953495
color: #0369a1;
34963496
display: flex;
34973497
flex-direction: column;
34983498
}
34993499
3500-
.wps-link-text {
3500+
.doc-link-text {
35013501
font-weight: 500;
35023502
margin-top: 2px;
35033503
}

0 commit comments

Comments
 (0)