diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java
index 20a9b7d9..9fbe6786 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java
@@ -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.
+ *
+ *
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;
}
diff --git a/mateclaw-server/src/main/resources/docs/en/api.md b/mateclaw-server/src/main/resources/docs/en/api.md
index 576e8082..53e0bb30 100644
--- a/mateclaw-server/src/main/resources/docs/en/api.md
+++ b/mateclaw-server/src/main/resources/docs/en/api.md
@@ -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).
diff --git a/mateclaw-server/src/main/resources/docs/en/architecture.md b/mateclaw-server/src/main/resources/docs/en/architecture.md
index 126fbcae..594fa940 100644
--- a/mateclaw-server/src/main/resources/docs/en/architecture.md
+++ b/mateclaw-server/src/main/resources/docs/en/architecture.md
@@ -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
diff --git a/mateclaw-server/src/main/resources/docs/en/channels.md b/mateclaw-server/src/main/resources/docs/en/channels.md
index 41cebae6..317d2b92 100644
--- a/mateclaw-server/src/main/resources/docs/en/channels.md
+++ b/mateclaw-server/src/main/resources/docs/en/channels.md
@@ -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).
diff --git a/mateclaw-server/src/main/resources/docs/en/chat.md b/mateclaw-server/src/main/resources/docs/en/chat.md
index 0bd0b343..4c23db3c 100644
--- a/mateclaw-server/src/main/resources/docs/en/chat.md
+++ b/mateclaw-server/src/main/resources/docs/en/chat.md
@@ -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 |
diff --git a/mateclaw-server/src/main/resources/docs/en/faq.md b/mateclaw-server/src/main/resources/docs/en/faq.md
index 1f09792f..524bf628 100644
--- a/mateclaw-server/src/main/resources/docs/en/faq.md
+++ b/mateclaw-server/src/main/resources/docs/en/faq.md
@@ -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 " \
- "http://localhost:18088/api/v1/chat/1/stream?conversationId=1"
+curl -N -X POST 'http://localhost:18088/api/v1/chat/stream' \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -H "Accept: text/event-stream" \
+ -d '{"agentId":1, "message":"test", "conversationId":"1"}'
```
---
diff --git a/mateclaw-server/src/main/resources/docs/en/wiki.md b/mateclaw-server/src/main/resources/docs/en/wiki.md
index 683024ce..18601792 100644
--- a/mateclaw-server/src/main/resources/docs/en/wiki.md
+++ b/mateclaw-server/src/main/resources/docs/en/wiki.md
@@ -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 ``
+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.
diff --git a/mateclaw-server/src/main/resources/docs/zh/api.md b/mateclaw-server/src/main/resources/docs/zh/api.md
index 1ce7b953..3553ee4c 100644
--- a/mateclaw-server/src/main/resources/docs/zh/api.md
+++ b/mateclaw-server/src/main/resources/docs/zh/api.md
@@ -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 流式(POST,agentId 在 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) 里。
diff --git a/mateclaw-server/src/main/resources/docs/zh/architecture.md b/mateclaw-server/src/main/resources/docs/zh/architecture.md
index 75bfc597..8a7b0a9c 100644
--- a/mateclaw-server/src/main/resources/docs/zh/architecture.md
+++ b/mateclaw-server/src/main/resources/docs/zh/architecture.md
@@ -179,7 +179,7 @@ mateclaw/
## 数据流 —— 单次回合
```
-1. POST /api/v1/chat/{agentId}/message
+1. POST /api/v1/chat?agentId={id} (或 POST /api/v1/chat/stream,agentId 在 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
diff --git a/mateclaw-server/src/main/resources/docs/zh/channels.md b/mateclaw-server/src/main/resources/docs/zh/channels.md
index accf2aea..1d8d8779 100644
--- a/mateclaw-server/src/main/resources/docs/zh/channels.md
+++ b/mateclaw-server/src/main/resources/docs/zh/channels.md
@@ -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) 里。
diff --git a/mateclaw-server/src/main/resources/docs/zh/chat.md b/mateclaw-server/src/main/resources/docs/zh/chat.md
index 8eefbdda..b6a0d26d 100644
--- a/mateclaw-server/src/main/resources/docs/zh/chat.md
+++ b/mateclaw-server/src/main/resources/docs/zh/chat.md
@@ -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 事件类型
| 事件 | 含义 |
diff --git a/mateclaw-server/src/main/resources/docs/zh/faq.md b/mateclaw-server/src/main/resources/docs/zh/faq.md
index 205c82aa..68091fa1 100644
--- a/mateclaw-server/src/main/resources/docs/zh/faq.md
+++ b/mateclaw-server/src/main/resources/docs/zh/faq.md
@@ -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 " \
- "http://localhost:18088/api/v1/chat/1/stream?conversationId=1"
+curl -N -X POST 'http://localhost:18088/api/v1/chat/stream' \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -H "Accept: text/event-stream" \
+ -d '{"agentId":1, "message":"测试", "conversationId":"1"}'
```
---
diff --git a/mateclaw-server/src/main/resources/docs/zh/wiki.md b/mateclaw-server/src/main/resources/docs/zh/wiki.md
index 0498b38a..5312d06e 100644
--- a/mateclaw-server/src/main/resources/docs/zh/wiki.md
+++ b/mateclaw-server/src/main/resources/docs/zh/wiki.md
@@ -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` | 启动 job(job-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` 的 ``。点一下:
+
+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-first,batch-create existing/planned 二分 |
+| 4 | 删除 / 重命名级联清理,audit log,feature 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" 而不是粘一段没头没尾的片段。