fix(feishu): align recent-file cache id with router so attachments resolve

This commit is contained in:
matevip 2026-05-29 10:08:01 +08:00
parent 55982c490a
commit f8088088b5
13 changed files with 351 additions and 63 deletions

View File

@ -32,7 +32,6 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@ -934,7 +933,14 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|| "audio".equals(messageType) || "media".equals(messageType);
// Compute conversationId once used as cache key for both write (cacheRecentFile)
// and read (injectRecentFiles), and as the directory name under data/chat-uploads/.
String conversationId = buildConversationId(chatId, senderOpenId, isGroup);
// It MUST equal the id ChannelMessageRouter derives for this chat: the routed
// ChannelMessage carries chatId = (isGroup ? shortSuffix : null), so the router
// resolves it to feishu:{shortSuffix} for groups and feishu:{senderId} for DMs.
// ChatUploadResolver locates attachments under data/chat-uploads/{that id}/, and the
// prompt only exposes the file name (not its path) to the model so if this id does
// not match, ReadFileTool / DocumentExtractTool cannot find the cached file.
String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup);
String conversationId = buildConversationId(shortSuffix, senderOpenId, isGroup);
if (isFileMessage) {
cacheRecentFile(messageId, messageType, contentStr, conversationId);
@ -999,9 +1005,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
textContent = injectRecentFiles(conversationId, contentParts, textContent);
}
// 生成短会话后缀
String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup);
// shortSuffix already computed above (kept consistent with conversationId).
ChannelMessage channelMessage = ChannelMessage.builder()
.messageId(messageId)
.channelType(CHANNEL_TYPE)
@ -1400,13 +1404,16 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
/**
* Compute the conversationId that {@link ChannelMessageRouter} would
* derive from the same chat/sender fields, so we can save inbound
* files to the matching {@code data/chat-uploads/} directory.
* derive for this chat, so we can save inbound files to the matching
* {@code data/chat-uploads/} directory.
*
* <p>The router derives the id from the routed {@link ChannelMessage},
* whose {@code chatId} is {@code (isGroup ? shortSuffix : null)} and whose
* {@code senderId} is the full open id. Mirror that exactly:
* {@code groups feishu:{shortSuffix}}, {@code DMs feishu:{senderOpenId}}.
*/
private String buildConversationId(String chatId, String senderOpenId, boolean isGroup) {
// Mirror ChannelMessageRouter#buildConversationId:
// groups feishu:{chatId}, DMs feishu:{full senderOpenId}
String identifier = chatId != null ? chatId : senderOpenId;
private String buildConversationId(String shortSuffix, String senderOpenId, boolean isGroup) {
String identifier = isGroup ? shortSuffix : senderOpenId;
return identifier != null ? CHANNEL_TYPE + ":" + identifier : null;
}

View File

@ -55,26 +55,36 @@ Response:
## Chat
```
POST /api/v1/chat/{agentId}/message # Send a message
GET /api/v1/chat/{agentId}/stream?conversationId= # SSE streaming
POST /api/v1/chat/{conversationId}/stop # Stop an in-flight stream
GET /api/v1/chat/{conversationId}/pending-approvals # List waiting approvals
POST /api/v1/chat?agentId={id} # Send a message (sync; agentId is a query param)
POST /api/v1/chat/stream # SSE streaming (POST; agentId in the JSON body)
POST /api/v1/chat/{conversationId}/stop # Stop an in-flight stream
POST /api/v1/chat/{conversationId}/interrupt # Interrupt the agent loop
POST /api/v1/chat/upload # Upload a chat attachment (multipart/form-data)
GET /api/v1/chat/files/{conversationId}/{storedName} # Read an uploaded attachment
GET /api/v1/chat/{conversationId}/pending-approvals # List waiting approvals
```
**Send message:**
```bash
curl -X POST http://localhost:18088/api/v1/chat/1/message \
curl -X POST 'http://localhost:18088/api/v1/chat?agentId=1' \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content":"Hello, what can you do?", "conversationId":"conv-abc123"}'
-d '{"message":"Hello, what can you do?", "conversationId":"conv-abc123"}'
```
Request body fields: `message` (required), `conversationId` (optional, defaults to `default`), `contentParts` (optional structured content parts for attachments).
**SSE stream example:**
The SSE endpoint is **POST with a JSON body** — browser-native `EventSource` only supports GET, so integrators should use `fetch()` and read the response stream (see the frontend's `composables/chat/useChat.ts`).
```bash
curl -N http://localhost:18088/api/v1/chat/1/stream?conversationId=conv-abc123 \
-H "Authorization: Bearer YOUR_TOKEN"
curl -N -X POST 'http://localhost:18088/api/v1/chat/stream' \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"agentId":1, "message":"Hello", "conversationId":"conv-abc123"}'
```
Event types and schema are documented in [Chat & Messaging](./chat).

View File

@ -179,7 +179,7 @@ The graph (both ReAct and Plan-Execute) now runs a `GoalEvaluationNode` after `F
## Data flow — a single turn
```
1. POST /api/v1/chat/{agentId}/message
1. POST /api/v1/chat?agentId={id} (or POST /api/v1/chat/stream with agentId in the body)
2. ChatController.sendMessage()
@ -301,7 +301,7 @@ Why: Spring MVC + SSE is sufficient for streaming LLM responses to the frontend.
Streaming flow:
1. Client opens `GET /api/v1/chat/{agentId}/stream` with `Accept: text/event-stream`
1. Client `POST /api/v1/chat/stream` with `agentId` / `message` / `conversationId` in the JSON body and `Accept: text/event-stream` in the headers
2. Controller returns `SseEmitter`
3. Agent graph runs on a worker thread; node execution emits events to `GraphEventPublisher`
4. Events serialize into SSE format and write to the emitter

View File

@ -103,7 +103,11 @@ All credentials encrypted at rest. One agent can have many channels; different c
Built in. No setup, no credentials. Uses Server-Sent Events for real-time streaming.
```
GET /api/v1/chat/{agentId}/stream
POST /api/v1/chat/stream
Content-Type: application/json
Accept: text/event-stream
{"agentId": 1, "message": "...", "conversationId": "..."}
```
Event format documented in [Chat & Messaging](./chat).

View File

@ -119,7 +119,7 @@ This is the thirty-second version. The ninety-second version is in [Agents](./ag
You type
POST /api/v1/chat/{agentId}/message ← or SSE for streaming
POST /api/v1/chat?agentId={id} ← or SSE for streaming (POST /api/v1/chat/stream)
Conversation Manager ← load/create conversation, append user message
@ -270,31 +270,49 @@ Go deeper in [Channels](./channels).
### Send a message
```bash
curl -X POST http://localhost:18088/api/v1/chat/1/message \
curl -X POST 'http://localhost:18088/api/v1/chat?agentId=1' \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"content": "What is the current time in Tokyo?",
"message": "What is the current time in Tokyo?",
"conversationId": "conv-abc123"
}'
```
Omit `conversationId` to start a new conversation.
Omit `conversationId` to start a new conversation. `agentId` is a query parameter, **not** a path segment.
### SSE streaming
```javascript
const eventSource = new EventSource(
'/api/v1/chat/1/stream?conversationId=conv-abc123',
{ headers: { 'Authorization': 'Bearer YOUR_JWT_TOKEN' } }
);
The SSE endpoint is `POST /api/v1/chat/stream` with `agentId` in the JSON body. Browser-native `EventSource` only supports GET, so integrators should use `fetch()` and read the response stream:
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
// handle segment
};
```javascript
const resp = await fetch('/api/v1/chat/stream', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_JWT_TOKEN',
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
body: JSON.stringify({
agentId: 1,
message: 'What is the current time in Tokyo?',
conversationId: 'conv-abc123',
}),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// Split on SSE `\n\n` event boundaries and dispatch segments
}
```
See `mateclaw-ui/src/composables/chat/useChat.ts` for a full client implementation.
### SSE event types
| Event | Meaning |

View File

@ -381,8 +381,11 @@ mvn spring-boot:run -Dspring-boot.run.arguments="--logging.level.vip.mate=DEBUG"
Browser DevTools → Network → filter `EventStream`. Or:
```bash
curl -N -H "Authorization: Bearer <token>" \
"http://localhost:18088/api/v1/chat/1/stream?conversationId=1"
curl -N -X POST 'http://localhost:18088/api/v1/chat/stream' \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"agentId":1, "message":"test", "conversationId":"1"}'
```
---

View File

@ -342,6 +342,135 @@ Edit when the AI got it wrong. Your edits survive the next ingest — `locked` t
---
## Wikilinks and broken-link care
Cross-page references via `[[slug]]` are the connective tissue of a
long-lived knowledge asset. RFC 55 turns this layer from "writing
`[[Title]]` looked fine until you clicked and got a 404" into **lint on
write, cascade on delete, broken links visible everywhere**.
### Wikilink syntax
Exactly one contract is honoured:
- `[[slug]]` — visible label defaults to the target page's title
- `[[slug|display text]]` — explicit label, the slug is still the
navigation target
The slug must reference an existing page. The LLM page-generation
prompts give the model a slug-first index (`- [[slug]] — Title — Summary`),
forbid inventing slugs that aren't in the index, and explicitly warn
that older `[[Page Title]]` form will be flagged as a dead link by the
lint.
Case-insensitive: `[[STATEGRAPH]]` and `[[stategraph]]` both resolve
via lowercased exact match against `page.slug`.
### In-transaction lint: `outgoing_links` + `broken_links`
Every page save (manual edit, AI generation, merge, cascade rewrite)
runs in one transaction:
1. Extract every `[[...]]` from the body (skipping fenced and inline
code blocks)
2. Write `mate_wiki_page.outgoing_links` (deduped, lowercased string
array)
3. Diff against the KB's active slug set (archived pages excluded)
to produce `broken_links`
4. Stamp `broken_links_scanned_at`
You see which `[[...]]` are dead the moment the page saves — no
batch scan required. Code blocks and inline `` `[[...]]` `` snippets
are preserved verbatim and never enter `outgoing_links` (so a page
that teaches wikilink syntax doesn't accidentally lint itself).
### KB-wide broken-link scan
Each KB shows a banner at the top of the workspace. Click "Scan dead
links" to start a job:
| Method | Path | What it does |
|---|---|---|
| `POST /api/v1/wiki/knowledge-bases/{kbId}/lint/broken-links` | Starts a job (async, job-based). Returns `{jobId, status, startedAt}`. Idempotent — repeat POSTs while a job is in flight return the same id |
| `GET .../lint/broken-links` | Returns the latest completed scan as a per-page aggregate |
| `GET .../lint/broken-links/jobs/{jobId}` | Status check for a specific job |
The aggregate carries `pageId / slug / title / brokenRefs` for each
affected page. The banner distinguishes "scanned X pages, no broken
links" from "found N broken links in M pages". Clicking "view" opens
a panel listing each broken ref with a jump-to-source-page action.
Performance: 100-page KB scans in well under a second; POST submit
latency under 200ms.
### Cascade delete and rename
**Delete a page**: every other page that linked to it gets its
`[[deleted-slug]]` rewritten to plain text (using the snapshot title
as the visible word). Aliased `[[deleted-slug|some alias]]` collapses
to just the alias. Referrers' `outgoing_links` and `broken_links` are
recomputed in the same transaction.
**Rename a page**: `POST /api/v1/wiki/knowledge-bases/{kbId}/pages/{slug}/rename`
with `{"newSlug":"new"}`. In one transaction:
- The page's own slug is updated
- Every referrer's `[[oldSlug]]` becomes `[[newSlug]]`, and
`[[oldSlug|alias]]` becomes `[[newSlug|alias]]` (alias preserved
byte-for-byte)
- Referrers' `outgoing_links` is updated
Rejected: empty slug, slug equal to the current slug, slug already
owned by another page in the same KB, target page is protected
(system / locked). Case-only renames (`foo → FOO`) are allowed and
behave the same on H2 and MySQL.
Each delete / rename writes an audit row to `mate_audit_event` with
`action=wiki.page.delete` or `wiki.page.rename`. `detailJson` carries
an `affectedPageIds` list so the cascade impact is queryable after
the fact.
Emergency kill-switch: set `mate.wiki.cascade-delete-enabled=false`
to revert to the legacy row-only delete (the rewrite is bypassed,
referrer wikilinks dangle). Default-on is the intended steady state.
### Click-through from chat
When the chat renders an agent reply, `[[slug]]` and `[[slug|alias]]`
tokens in the content become `<a class="wiki-link" data-wiki-title=...>`
anchors. Clicking one:
1. The app-level global click delegator catches the click
2. Calls `GET /api/v1/wiki/pages/lookup?title=X&slug=X` — searches
every KB visible to the user (slug match first, title fallback)
3. 1 hit → `router.push` into the wiki view, auto-selects the KB,
auto-opens the page
4. 0 hits → toast "未找到匹配的 wiki 页面X"
5. >1 hits → picker offering to open the first match
No more navigating to the wiki view, finding the KB, finding the
page — clicking a `[[link]]` in chat gets you there directly. The
lookup is strict case-insensitive exact (no canonical fuzzing), so
if the LLM wrote a slug that doesn't exist you see the toast rather
than getting silently redirected to a similarly-named page.
### Phase roadmap (all phases landed)
| Phase | Key changes |
|---|---|
| 1 | Frontend slug-first DOM postprocess; dangerous-char guard; full `pages/refs` index decoupled from raw-material filter |
| 2 | V129 migration adds `broken_links` and `broken_links_scanned_at`; save-path writes them in the same transaction; KB-wide async lint job + banner |
| 3 | All 9 wiki prompt templates unified on `[[slug]]` contract; existing-pages index reformatted slug-first; batch-create splits existing pages from same-batch planned pages |
| 4 | Cascade delete and rename rewrite referrers in-transaction; audit log; feature flag |
| 5 | Analyze stage emits a `related_pages` slug whitelist (validated server-side); enrich applier skips code blocks and gates on the whitelist |
Full design + live verification: `rfcs/202605/55-wiki-link-resolution-overhaul.md`
and `mateclaw-server/src/test/resources/e2e/wiki-link-overhaul-verification.md`
(6 e2e passes, 50+ live assertions, 3 bugs caught and fixed during the
test).
---
## Search, source tracing, and semantic retrieval
- **Semantic search** — ask "what did we decide about auth?" and get the decision, not pages containing "auth". Chunk-level embeddings with cosine retrieval — it understands what you mean. Hits now include `pageNumber` and `section`, so the agent can quote "page 12, Setup / Linux" instead of a free-floating snippet.

View File

@ -55,26 +55,36 @@ curl -X POST http://localhost:18088/api/v1/auth/login \
## 聊天
```
POST /api/v1/chat/{agentId}/message # 发送消息
GET /api/v1/chat/{agentId}/stream?conversationId= # SSE 流式
POST /api/v1/chat/{conversationId}/stop # 停止进行中的流
GET /api/v1/chat/{conversationId}/pending-approvals # 列出等待的审批
POST /api/v1/chat?agentId={id} # 发送消息同步agentId 是 query 参数)
POST /api/v1/chat/stream # SSE 流式POSTagentId 在 body 里)
POST /api/v1/chat/{conversationId}/stop # 停止进行中的流
POST /api/v1/chat/{conversationId}/interrupt # 中断 Agent 循环
POST /api/v1/chat/upload # 上传聊天附件multipart/form-data
GET /api/v1/chat/files/{conversationId}/{storedName} # 读取已上传附件
GET /api/v1/chat/{conversationId}/pending-approvals # 列出等待的审批
```
**发送消息:**
```bash
curl -X POST http://localhost:18088/api/v1/chat/1/message \
curl -X POST 'http://localhost:18088/api/v1/chat?agentId=1' \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content":"你好,你能做什么?", "conversationId":"conv-abc123"}'
-d '{"message":"你好,你能做什么?", "conversationId":"conv-abc123"}'
```
请求体字段:`message`(必填)、`conversationId`(可选,省略则用 `default`)、`contentParts`(可选,结构化内容片段,附件场景使用)。
**SSE 流式示例:**
SSE 端点是 **POST + 请求体**,浏览器原生 `EventSource` 不支持 POST集成时请用 `fetch()` 读流(参考前端 `composables/chat/useChat.ts`)。
```bash
curl -N http://localhost:18088/api/v1/chat/1/stream?conversationId=conv-abc123 \
-H "Authorization: Bearer YOUR_TOKEN"
curl -N -X POST 'http://localhost:18088/api/v1/chat/stream' \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"agentId":1, "message":"你好", "conversationId":"conv-abc123"}'
```
事件类型和 schema 在 [聊天与消息](./chat) 里。

View File

@ -179,7 +179,7 @@ mateclaw/
## 数据流 —— 单次回合
```
1. POST /api/v1/chat/{agentId}/message
1. POST /api/v1/chat?agentId={id} (或 POST /api/v1/chat/streamagentId 在 body 里)
2. ChatController.sendMessage()
@ -301,7 +301,7 @@ MateClaw 用 **Spring MVC**,不是 Spring WebFlux。**WebFlux 在依赖图里
流式流程:
1. 客户端打开 `GET /api/v1/chat/{agentId}/stream`,带 `Accept: text/event-stream`
1. 客户端 `POST /api/v1/chat/stream`body 里带 `agentId` / `message` / `conversationId`,请求头加 `Accept: text/event-stream`
2. Controller 返回 `SseEmitter`
3. Agent 图在工作线程上运行;节点执行把事件发给 `GraphEventPublisher`
4. 事件序列化成 SSE 格式写进 emitter

View File

@ -103,7 +103,11 @@ v1.4.0 把飞书做成了"一等公民"渠道——交互卡片、流式卡片
内置。没有外部配置,没有凭证。用 Server-Sent Events 做实时流式。
```
GET /api/v1/chat/{agentId}/stream
POST /api/v1/chat/stream
Content-Type: application/json
Accept: text/event-stream
{"agentId": 1, "message": "...", "conversationId": "..."}
```
事件格式在 [聊天与消息](./chat) 里。

View File

@ -119,7 +119,7 @@ ChatConsole 不只是你自己聊天的地方。它是一个**运营控制台**
你输入
POST /api/v1/chat/{agentId}/message ← 或走 SSE 流式
POST /api/v1/chat?agentId={id} ← 或走 SSE 流式POST /api/v1/chat/stream
Conversation Manager ← 加载/创建会话,追加用户消息
@ -270,31 +270,49 @@ Segment 的结构是渐进展示的底层。它也让**数据库成为单一事
### 发送消息
```bash
curl -X POST http://localhost:18088/api/v1/chat/1/message \
curl -X POST 'http://localhost:18088/api/v1/chat?agentId=1' \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"content": "东京现在几点?",
"message": "东京现在几点?",
"conversationId": "conv-abc123"
}'
```
省略 `conversationId` 就会开一个新会话。
省略 `conversationId` 就会开一个新会话。`agentId` 是 query 参数,**不是**路径段。
### SSE 流式
```javascript
const eventSource = new EventSource(
'/api/v1/chat/1/stream?conversationId=conv-abc123',
{ headers: { 'Authorization': 'Bearer YOUR_JWT_TOKEN' } }
);
SSE 端点是 `POST /api/v1/chat/stream`,请求体里带 `agentId`。浏览器原生 `EventSource` 只支持 GET所以集成时用 `fetch()` 读流:
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
// 处理 segment
};
```javascript
const resp = await fetch('/api/v1/chat/stream', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_JWT_TOKEN',
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
body: JSON.stringify({
agentId: 1,
message: '东京现在几点?',
conversationId: 'conv-abc123',
}),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// 按 SSE 协议拆 `\n\n` 边界,逐事件处理 segment
}
```
完整客户端实现可以参考 `mateclaw-ui/src/composables/chat/useChat.ts`
### SSE 事件类型
| 事件 | 含义 |

View File

@ -381,8 +381,11 @@ mvn spring-boot:run -Dspring-boot.run.arguments="--logging.level.vip.mate=DEBUG"
浏览器 DevTools → Network → 筛选 `EventStream`。或:
```bash
curl -N -H "Authorization: Bearer <token>" \
"http://localhost:18088/api/v1/chat/1/stream?conversationId=1"
curl -N -X POST 'http://localhost:18088/api/v1/chat/stream' \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"agentId":1, "message":"测试", "conversationId":"1"}'
```
---

View File

@ -342,6 +342,88 @@ AI 写错了就改。你的修改在下一次入库时会被保留——`locked`
---
## Wikilink 与死链治理
页面之间用 `[[slug]]` 写跨页引用,是 Wiki 这种长寿命知识资产的核心粘合剂。RFC 55 把这一层从 "[[Title]] 写起来好像也行、点了 404 才发现" 改成 **写入即校验、删除自动清理、死链显式可见**
### Wikilink 语法
只承认一种契约:
- `[[slug]]` —— 显示文本默认用目标页的 title
- `[[slug|显示文本]]` —— 自定义显示文本slug 仍是跳转目标
slug 必须是真实存在页面的 slug。LLM 生成内容时索引里给的就是 slug-first 列表(`- [[slug]] — Title — Summary`prompt 显式禁止发明索引外的 slug并明示 `[[页面标题]]` / `[[Title]]` 这种早期写法会被识别为死链。
跨大小写命中:`[[STATEGRAPH]]` 和 `[[stategraph]]` 一视同仁,都按 lowercased exact match 匹配 slug。
### 同事务校验:`outgoing_links` + `broken_links`
每次页面保存手工编辑、AI 生成、合并、级联重写)的**同一个事务**内:
1. 从正文里抽出所有 `[[...]]`(跳过 fenced 代码块、inline 代码)
2. 写 `mate_wiki_page.outgoing_links`去重、lowercased 字符串数组)
3. 拿当前 KB 的活跃 slug 集合(不含 archived做差集 → 写 `broken_links`
4. 写 `broken_links_scanned_at` 时间戳
效果:写完页面**立刻**就知道哪些 `[[...]]` 是死链,不需要等扫描。代码块和反引号里的 `[[...]]` 是讲解 wiki 语法的示例,被严格保留为字面,不进入 outgoing。
### KB 级死链 lint
进入任一 KB顶部 banner 会显示当前死链状态。按"扫描死链"启动一次全 KB job
| Method | Path | 说明 |
|---|---|---|
| `POST /api/v1/wiki/knowledge-bases/{kbId}/lint/broken-links` | 启动 jobjob-based 异步),返回 `{jobId, status, startedAt}`;同 KB 已有 running job 时幂等返回 |
| `GET .../lint/broken-links` | 拉最近一次 completed 扫描的聚合结果 |
| `GET .../lint/broken-links/jobs/{jobId}` | 查单次 job 状态 |
聚合结果按页列出,每条带 `pageId / slug / title / brokenRefs`。前端 banner 把"已扫描 X 页,无死链"和"发现 N 条死链分布在 M 页"区分显示,点"查看"打开详情面板,可一键跳到出错的源页面去手工修。
job 执行时间100 页 KB 通常 1 秒以内POST 入队 < 200ms
### 删除 / 重命名的级联清理
**删页面**时,所有引用方的 `[[deleted-slug]]` 会在同一事务里被改写成纯文本,保留快照标题作为可读文字。带别名的 `[[deleted-slug|alias]]` 直接降级为 `alias`。引用方的 `outgoing_links` / `broken_links` 跟着重算。
**重命名页面**`POST /api/v1/wiki/knowledge-bases/{kbId}/pages/{slug}/rename` body `{"newSlug":"new"}`。同一事务里:
- 自身 slug 更新为新值
- 所有引用方的 `[[oldSlug]]` 改写成 `[[newSlug]]``[[oldSlug|alias]]` 改写成 `[[newSlug|alias]]`alias 字节一致保留)
- 引用方的 `outgoing_links` 同步更新
不接受空 slug、不接受和自身相同的 slug、不接受和**别的**页面冲突的 slug保护页system / locked拒改。case-only rename`foo → FOO`)允许,跨 H2 与 MySQL 行为一致。
每次 delete / rename 写一条 `mate_audit_event`action `wiki.page.delete` / `wiki.page.rename``detailJson` 里带 `affectedPageIds` 列表,方便事后追溯影响面。
紧急 kill-switch`mate.wiki.cascade-delete-enabled=false` 关闭级联,回到只删自身行的旧行为;正常状态下不需要开启。
### Chat 里点 wikilink 直接跳
Chat 渲染 agent 回复时content 里的 `[[slug]]` / `[[slug|alias]]` 会渲染成带 `data-wiki-title``<a class="wiki-link">`。点一下:
1. App 级全局 click 委托抓到 click
2. 调 `GET /api/v1/wiki/pages/lookup?title=X&slug=X` —— 在用户可见的所有 KB 里搜slug 命中优先title fallback
3. 1 hit → `router.push` 进 wiki 视图、自动选 KB、自动打开页面
4. 0 hit → toast "未找到匹配的 wiki 页面X"
5. 多 hit → picker 让用户挑
不再需要先去 wiki 视图、再找 KB、再找页面——chat 里看到的引用直接跳。lookup 严格 case-insensitive exact不做 canonical 模糊,所以 LLM 写错 slug 会通过 toast 让你看到,而不是悄悄跳到一个"看起来像的"页面。
### Phase 路线图(每个 phase 都已 land
| Phase | 主要变更 |
|---|---|
| 1 | 前端渲染层 slug-first DOM postprocess + 危险字符 guard + 全量 `pages/refs` |
| 2 | V129 迁移 `broken_links` / `broken_links_scanned_at`save 同事务写KB 级 lint job + UI banner |
| 3 | 9 份 wiki prompt 统一 `[[slug]]` 契约,索引格式 slug-firstbatch-create existing/planned 二分 |
| 4 | 删除 / 重命名级联清理audit logfeature flag |
| 5 | analyze 阶段输出 slug 白名单 `related_pages`服务端二次校验enrich applier 跳代码块 + slug 白名单 gate |
完整设计 + 实测见 `rfcs/202605/55-wiki-link-resolution-overhaul.md` + `mateclaw-server/src/test/resources/e2e/wiki-link-overhaul-verification.md`6 个 e2e pass section、50+ 条 live 断言、3 个测试中发现并修复的 bug 完整记录)。
---
## 搜索、来源追溯、语义检索
- **语义搜索**——问"我们关于 auth 决定了什么?",直接返回那个决策,不是一堆包含"auth"的页面。chunk 级嵌入 + cosine 检索,**理解你问的是什么意思**。命中现在自带 `pageNumber``section`agent 可以引用 "page 12, Setup / Linux" 而不是粘一段没头没尾的片段。