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" 而不是粘一段没头没尾的片段。