From 4f160b6ffb0e2fa367a8f5969a5081ac3c89fc6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Thu, 18 Jun 2026 19:53:31 +0800 Subject: [PATCH] fix(ui): URL-encode conversationId in path segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a webchat visitorId + sessionId pair exceeds the conversation_id column width, WebChatController#deriveConversationId folds the variable part into a SHA-256 hash prefixed with `#`: webchat::# That `#` is the URL fragment delimiter. Every URL the admin console builds by interpolating the conversationId into a path — message list, status, rename, pin, model, delete, goals/by-conversation, chat/stop, chat/pending-approvals — gets truncated at the `#` before reaching the server. Symptom: opening one of these conversations in the console surfaces as 405 (GET landing on @DeleteMapping("/{conversationId}")) and 403 (owner check on the truncated id). Add an `encId` helper (encodeURIComponent) and apply it to every conversationId path segment. The server's @PathVariable decoder already handles the percent-encoded form transparently, so this is purely a client-side fix that recovers every existing hashed-id row in addition to any future ones. Issue: #372 --- mateclaw-ui/src/api/index.ts | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 210d0a1e..c0f9f445 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -9,6 +9,17 @@ import type { GrantScope, } from '@/types' +/** + * URL-encode a conversation id before interpolating it into a path. Some ids + * contain characters that the browser interprets as URL structural — notably + * `#`, which webchat emits as a hash marker when the visitorId+sessionId pair + * exceeds the conversation_id column width (see WebChatController#deriveConversationId: + * `webchat::#`). Without encoding, everything after the + * `#` is treated as a fragment and never reaches the server, producing 405 on + * `@DeleteMapping` fallbacks and 403 from the owner check. + */ +const encId = (id: string) => encodeURIComponent(id) + // Axios 实例 export const http = axios.create({ baseURL: '/api/v1', @@ -156,9 +167,9 @@ export const chatApi = { }) }, stop: (conversationId: string) => - http.post<{ stopped: boolean }>(`/chat/${conversationId}/stop`), + http.post<{ stopped: boolean }>(`/chat/${encId(conversationId)}/stop`), getPendingApprovals: (conversationId: string) => - http.get(`/chat/${conversationId}/pending-approvals`), + http.get(`/chat/${encId(conversationId)}/pending-approvals`), } // ==================== Conversation ==================== @@ -172,17 +183,17 @@ export const conversationApi = { page: (params: { page?: number; size?: number; keyword?: string }) => http.get('/conversations/page', { params }), listMessages: (conversationId: string, params?: { beforeId?: number; limit?: number }) => - http.get(`/conversations/${conversationId}/messages`, { params }), + http.get(`/conversations/${encId(conversationId)}/messages`, { params }), getStatus: (conversationId: string) => - http.get(`/conversations/${conversationId}/status`), + http.get(`/conversations/${encId(conversationId)}/status`), delete: (conversationId: string) => - http.delete(`/conversations/${conversationId}`), + http.delete(`/conversations/${encId(conversationId)}`), clearMessages: (conversationId: string) => - http.delete(`/conversations/${conversationId}/messages`), + http.delete(`/conversations/${encId(conversationId)}/messages`), rename: (conversationId: string, title: string) => - http.put(`/conversations/${conversationId}/title`, { title }), + http.put(`/conversations/${encId(conversationId)}/title`, { title }), setPinned: (conversationId: string, pinned: boolean) => - http.put(`/conversations/${conversationId}/pin`, { pinned }), + http.put(`/conversations/${encId(conversationId)}/pin`, { pinned }), /** * Pin this conversation to a specific (provider, model). Closes issue * #183 — lets the admin UI switch model for IM-channel conversations @@ -190,7 +201,7 @@ export const conversationApi = { * not just for the Web channel. Both params required and non-empty. */ setModel: (conversationId: string, modelProvider: string, modelName: string) => - http.put(`/conversations/${conversationId}/model`, { modelProvider, modelName }), + http.put(`/conversations/${encId(conversationId)}/model`, { modelProvider, modelName }), batchDelete: (conversationIds: string[]) => http.post('/conversations/batch-delete', { conversationIds }), } @@ -1377,7 +1388,7 @@ export const goalApi = { }) => http.post('/goals', data), findActive: (conversationId: string) => - http.get(`/goals/by-conversation/${conversationId}`), + http.get(`/goals/by-conversation/${encId(conversationId)}`), get: (id: string) => http.get(`/goals/${id}`),