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}`),