From 2f69d084830b0c67520e91a76821c0fa37ba5b36 Mon Sep 17 00:00:00 2001 From: Wenyi Tang Date: Thu, 27 Aug 2026 13:03:59 +0800 Subject: [PATCH 1/3] =?UTF-8?q?[dev]=20=E5=AF=B9=E8=AF=9D=E8=AF=A6?= =?UTF-8?q?=E6=83=85=EF=BC=9A=E5=93=8D=E5=BA=94/=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E4=BD=93=E4=B8=8D=E5=86=8D=E6=88=AA=E6=96=AD=EF=BC=8C=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=8F=AF=E8=AF=BB=E5=8C=96=20Claude=20SSE=20=E4=B8=8E?= =?UTF-8?q?=E6=88=AA=E6=96=AD=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:移除 256KiB 采集上限,请求/响应体全量入库(MySQL 列已为 LONGTEXT) - 前端:新增 format-body,Claude SSE 流重组为等价非流式消息(text/thinking/signature/tool_use), 其他 SSE 解析为数组,截断 JSON 重新缩进分行展示 - 测试:vitest 9 例覆盖格式化各分支;capture writer 增加大 body 不截断回归测试 Signed-off-by: AGENTS (opencode) --- service/chat_log_capture.go | 33 +- service/chat_log_capture_test.go | 36 +-- service/chat_log_persist.go | 12 +- .../components/chat-log-detail-sheet.tsx | 13 +- .../lib/__tests__/format-body.test.ts | 173 ++++++++++ web/src/features/chat-logs/lib/format-body.ts | 301 ++++++++++++++++++ 6 files changed, 502 insertions(+), 66 deletions(-) create mode 100644 web/src/features/chat-logs/lib/__tests__/format-body.test.ts create mode 100644 web/src/features/chat-logs/lib/format-body.ts diff --git a/service/chat_log_capture.go b/service/chat_log_capture.go index bec77f5ed99e..57e2c0d843f2 100644 --- a/service/chat_log_capture.go +++ b/service/chat_log_capture.go @@ -9,24 +9,20 @@ import ( type chatLogCaptureWriter struct { gin.ResponseWriter - buffer bytes.Buffer - mu sync.Mutex - maxBytes int - truncated bool + buffer bytes.Buffer + mu sync.Mutex } -func wrapWithChatLogCapture(c *gin.Context, original gin.ResponseWriter, maxBytes int) gin.ResponseWriter { - if maxBytes <= 0 { - return original - } +func wrapWithChatLogCapture(c *gin.Context, original gin.ResponseWriter) gin.ResponseWriter { w := &chatLogCaptureWriter{ ResponseWriter: original, - maxBytes: maxBytes, } c.Writer = w return w } +// ponytail: response is buffered fully in memory per request; chat-log is +// opt-in per token, add a cap again if this ever becomes a memory problem. func (w *chatLogCaptureWriter) Write(data []byte) (int, error) { n, err := w.ResponseWriter.Write(data) if err != nil { @@ -34,25 +30,12 @@ func (w *chatLogCaptureWriter) Write(data []byte) (int, error) { } w.mu.Lock() defer w.mu.Unlock() - if w.truncated { - return n, nil - } - remaining := w.maxBytes - w.buffer.Len() - if remaining <= 0 { - w.truncated = true - return n, nil - } - if len(data) <= remaining { - w.buffer.Write(data) - } else { - w.buffer.Write(data[:remaining]) - w.truncated = true - } + w.buffer.Write(data) return n, nil } -func (w *chatLogCaptureWriter) capturedBytes() (string, bool) { +func (w *chatLogCaptureWriter) capturedBytes() string { w.mu.Lock() defer w.mu.Unlock() - return w.buffer.String(), w.truncated + return w.buffer.String() } diff --git a/service/chat_log_capture_test.go b/service/chat_log_capture_test.go index 17efaeac2f45..26bb95530033 100644 --- a/service/chat_log_capture_test.go +++ b/service/chat_log_capture_test.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "net/http/httptest" "testing" @@ -18,52 +19,43 @@ func newCaptureTestContext() (*gin.Context, *httptest.ResponseRecorder) { func TestChatLogCaptureWriter_BuffersBytes(t *testing.T) { c, rec := newCaptureTestContext() - cap := wrapWithChatLogCapture(c, c.Writer, 1024).(*chatLogCaptureWriter) + cap := wrapWithChatLogCapture(c, c.Writer).(*chatLogCaptureWriter) n, err := cap.Write([]byte(`{"hello":"world"}`)) require.NoError(t, err) assert.Equal(t, 17, n) - got, truncated := cap.capturedBytes() - assert.False(t, truncated) + got := cap.capturedBytes() assert.Equal(t, `{"hello":"world"}`, got) assert.Equal(t, `{"hello":"world"}`, rec.Body.String(), "underlying writer must still receive data") } -func TestChatLogCaptureWriter_TruncatesAtCap(t *testing.T) { - c, _ := newCaptureTestContext() - cap := wrapWithChatLogCapture(c, c.Writer, 4).(*chatLogCaptureWriter) +func TestChatLogCaptureWriter_NoCapOnLargeBodies(t *testing.T) { + c, rec := newCaptureTestContext() + cap := wrapWithChatLogCapture(c, c.Writer).(*chatLogCaptureWriter) - n, err := cap.Write([]byte(`1234567890`)) + big := bytes.Repeat([]byte("x"), 512*1024) + n, err := cap.Write(big) require.NoError(t, err) - assert.Equal(t, 10, n, "all bytes written to underlying writer") + assert.Equal(t, len(big), n, "all bytes written to underlying writer") - got, truncated := cap.capturedBytes() - require.True(t, truncated) - assert.Equal(t, `1234`, got) -} - -func TestChatLogCaptureWriter_NoBufferWhenMaxZero(t *testing.T) { - c, _ := newCaptureTestContext() - w := wrapWithChatLogCapture(c, c.Writer, 0) - _, ok := w.(*chatLogCaptureWriter) - assert.False(t, ok, "maxBytes<=0 returns the original writer unwrapped") + assert.Equal(t, len(big), len(cap.capturedBytes()), "capture must not truncate") + assert.Equal(t, len(big), rec.Body.Len()) } func TestChatLogCaptureWriter_MultiWriteAccumulates(t *testing.T) { c, _ := newCaptureTestContext() - cap := wrapWithChatLogCapture(c, c.Writer, 100).(*chatLogCaptureWriter) + cap := wrapWithChatLogCapture(c, c.Writer).(*chatLogCaptureWriter) _, _ = cap.Write([]byte(`abc`)) _, _ = cap.Write([]byte(`def`)) - got, truncated := cap.capturedBytes() - assert.False(t, truncated) + got := cap.capturedBytes() assert.Equal(t, `abcdef`, got) } func TestChatLogCaptureWriter_PreservesStatusCode(t *testing.T) { c, rec := newCaptureTestContext() - cap := wrapWithChatLogCapture(c, c.Writer, 100).(*chatLogCaptureWriter) + cap := wrapWithChatLogCapture(c, c.Writer).(*chatLogCaptureWriter) cap.WriteHeader(207) _, _ = cap.Write([]byte(`x`)) assert.Equal(t, 207, rec.Code, "embedded ResponseWriter must pass status through") diff --git a/service/chat_log_persist.go b/service/chat_log_persist.go index 8b594715e4bc..5fe38cbea096 100644 --- a/service/chat_log_persist.go +++ b/service/chat_log_persist.go @@ -10,8 +10,6 @@ import ( "github.com/gin-gonic/gin" ) -const defaultChatLogMaxBodyBytes = 262144 - type ChatLogRecorder struct { cw *chatLogCaptureWriter requestBody string @@ -21,7 +19,7 @@ func MaybeInstallChatLogCapture(c *gin.Context) *ChatLogRecorder { if !model.ChatLogDBEnabled() { return nil } - wrapped := wrapWithChatLogCapture(c, c.Writer, defaultChatLogMaxBodyBytes) + wrapped := wrapWithChatLogCapture(c, c.Writer) cw, ok := wrapped.(*chatLogCaptureWriter) if !ok { return nil @@ -33,10 +31,6 @@ func (r *ChatLogRecorder) SetRequestBody(body []byte) { if r == nil || len(body) == 0 { return } - if len(body) > defaultChatLogMaxBodyBytes { - r.requestBody = string(body[:defaultChatLogMaxBodyBytes]) - return - } r.requestBody = string(body) } @@ -56,7 +50,7 @@ func (r *ChatLogRecorder) Persist(c *gin.Context) { requestBody := r.requestBody statusCode := cw.ResponseWriter.Status() gopool.Go(func() { - respBody, truncated := cw.capturedBytes() + respBody := cw.capturedBytes() useTime := 0 if !startTime.IsZero() { useTime = int(time.Since(startTime).Seconds()) @@ -65,7 +59,7 @@ func (r *ChatLogRecorder) Persist(c *gin.Context) { TokenId: tokenId, UserId: userId, ChannelId: channelId, ModelName: modelName, RequestId: requestId, RequestBody: requestBody, ResponseBody: respBody, - IsStream: isStream, Truncated: truncated, + IsStream: isStream, Truncated: false, StatusCode: statusCode, UseTime: useTime, } diff --git a/web/src/features/chat-logs/components/chat-log-detail-sheet.tsx b/web/src/features/chat-logs/components/chat-log-detail-sheet.tsx index 2ed22d42c3e2..41a8e4a6e74f 100644 --- a/web/src/features/chat-logs/components/chat-log-detail-sheet.tsx +++ b/web/src/features/chat-logs/components/chat-log-detail-sheet.tsx @@ -33,14 +33,7 @@ import { import { formatTimestampToDate } from '@/lib/format' import { chatLogsQueryKeys, getChatLogDetail } from '../api' - -function tryFormat(body: string): string { - try { - return JSON.stringify(JSON.parse(body), null, 2) - } catch { - return body - } -} +import { formatChatLogBody } from '../lib/format-body' export interface ChatLogDetailSheetProps { open: boolean @@ -119,7 +112,7 @@ export function ChatLogDetailSheet(props: ChatLogDetailSheetProps) { {t('Request Body')} . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { describe, expect, test } from 'vitest' + +import { formatChatLogBody } from '../format-body' + +const claudeSse = [ + 'event: message_start', + 'data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"glm-5.3","content":[],"usage":{"input_tokens":10,"output_tokens":0}}}', + '', + 'event: ping', + 'data: {"type":"ping"}', + '', + 'event: content_block_start', + 'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}', + '', + 'event: content_block_delta', + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"continue"}}', + '', + 'event: content_block_stop', + 'data: {"type":"content_block_stop","index":0}', + '', + 'event: message_delta', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":3}}', + '', + 'event: message_stop', + 'data: {"type":"message_stop"}', + '', +].join('\n') + +const claudeSseFullBlocks = [ + 'event: message_start', + 'data: {"type":"message_start","message":{"id":"msg_2","type":"message","role":"assistant","model":"glm-5.3","content":[],"usage":{"input_tokens":5,"output_tokens":0}}}', + '', + 'event: content_block_start', + 'data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}', + '', + 'event: content_block_delta', + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"let me think"}}', + '', + 'event: content_block_delta', + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig123"}}', + '', + 'event: content_block_stop', + 'data: {"type":"content_block_stop","index":0}', + '', + 'event: content_block_start', + 'data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{}}}', + '', + 'event: content_block_delta', + 'data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\\"city\\":"}}', + '', + 'event: content_block_delta', + 'data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" \\"Beijing\\"}"}}', + '', + 'event: content_block_stop', + 'data: {"type":"content_block_stop","index":1}', + '', + 'event: message_delta', + 'data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":50}}', + '', + 'event: message_stop', + 'data: {"type":"message_stop"}', + '', +].join('\n') + +const openAiSse = [ + 'data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hi"}}]}', + '', + 'data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" there"}}]}', + '', + 'data: [DONE]', + '', +].join('\n') + +describe('formatChatLogBody', () => { + test('pretty-prints valid JSON bodies', () => { + const out = formatChatLogBody('{"a": 1, "b":[1, 2]}') + expect(JSON.parse(out)).toEqual({ a: 1, b: [1, 2] }) + expect(out).toContain('\n') + }) + + test('returns empty body unchanged', () => { + expect(formatChatLogBody('')).toBe('') + }) + + test('reconstructs a Claude SSE stream into a single message', () => { + const out = formatChatLogBody(claudeSse) + const msg = JSON.parse(out) + expect(msg).toMatchObject({ + id: 'msg_1', + type: 'message', + role: 'assistant', + model: 'glm-5.3', + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'continue' }], + usage: { input_tokens: 10, output_tokens: 3 }, + }) + }) + + test('reassembles Claude thinking, signature and tool_use json deltas', () => { + const out = formatChatLogBody(claudeSseFullBlocks) + const msg = JSON.parse(out) + expect(msg.stop_reason).toBe('tool_use') + expect(msg.content[0]).toEqual({ + type: 'thinking', + thinking: 'let me think', + signature: 'sig123', + }) + expect(msg.content[1]).toEqual({ + type: 'tool_use', + id: 'toolu_1', + name: 'get_weather', + input: { city: 'Beijing' }, + }) + }) + + test('tolerates a Claude stream truncated mid-event', () => { + const truncated = + 'event: message_start\n' + + 'data: {"type":"message_start","message":{"id":"msg_t","role":"assistant","model":"m","content":[]}}\n\n' + + 'event: content_block_start\n' + + 'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n' + + 'event: content_block_delta\n' + + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial answ' + const msg = JSON.parse(formatChatLogBody(truncated)) + expect(msg.id).toBe('msg_t') + expect(msg.content[0].text).toBe('partial answ') + }) + + test('pretty-prints non-Claude SSE chunks as an array', () => { + const out = formatChatLogBody(openAiSse) + const chunks = JSON.parse(out) + expect(Array.isArray(chunks)).toBe(true) + expect(chunks).toHaveLength(2) + expect(chunks[0].choices[0].delta.content).toBe('Hi') + }) + + test('re-indents truncated JSON instead of returning one huge line', () => { + const truncated = '{"model":"glm-5.3","messages":[{"role":"user","content":[{"type":"text","text":"a very long body that got cut of' + const out = formatChatLogBody(truncated) + expect(out.split('\n').length).toBeGreaterThan(3) + expect(out).toContain('"model": "glm-5.3"') + }) + + test('re-indent never breaks string contents containing braces', () => { + const truncated = '{"text":"keep {this} [intact]","n":1' + const out = formatChatLogBody(truncated) + expect(out).toContain('"keep {this} [intact]"') + }) + + test('returns non-JSON plain text unchanged', () => { + expect(formatChatLogBody('upstream said: bad gateway')).toBe( + 'upstream said: bad gateway' + ) + }) +}) diff --git a/web/src/features/chat-logs/lib/format-body.ts b/web/src/features/chat-logs/lib/format-body.ts new file mode 100644 index 000000000000..34d944abd9c7 --- /dev/null +++ b/web/src/features/chat-logs/lib/format-body.ts @@ -0,0 +1,301 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +type SseChunk = Record + +interface ClaudeBlock { + type: string + text?: string + thinking?: string + signature?: string + id?: string + name?: string + input?: unknown + inputRaw?: string +} + +interface ClaudeMessage { + id?: string + type: string + role?: string + model?: string + content: ClaudeBlock[] + stop_reason?: string + stop_sequence?: string | null + usage?: Record +} + +/** + * Close a JSON object whose capture was cut mid-text (close open string, + * dangling key/comma, open brackets) so the last partial chunk stays readable. + */ +function repairTruncatedJson(text: string): SseChunk | null { + const stack: string[] = [] + let inString = false + let escaped = false + for (const ch of text) { + if (inString) { + if (escaped) { + escaped = false + } else if (ch === '\\') { + escaped = true + } else if (ch === '"') { + inString = false + } + } else if (ch === '"') { + inString = true + } else if (ch === '{' || ch === '[') { + stack.push(ch) + } else if (ch === '}' || ch === ']') { + stack.pop() + } + } + let repaired = inString ? `${text}"` : text + const trimmedEnd = repaired.trimEnd() + if (trimmedEnd.endsWith(':')) { + repaired = `${trimmedEnd}null` + } else if (trimmedEnd.endsWith(',')) { + repaired = trimmedEnd.slice(0, -1) + } + for (let i = stack.length - 1; i >= 0; i--) { + repaired += stack[i] === '{' ? '}' : ']' + } + try { + const parsed: unknown = JSON.parse(repaired) + return isRecord(parsed) ? parsed : null + } catch { + return null + } +} + +/** + * Parse `data:` payload lines of an SSE capture. Unparseable lines (e.g. the + * capture was truncated mid-event) and `[DONE]` markers are skipped. + */ +function parseSseData(body: string): SseChunk[] { + const chunks: SseChunk[] = [] + for (const line of body.split('\n')) { + const trimmed = line.trim() + if (!trimmed.startsWith('data:')) { + continue + } + const payload = trimmed.slice(5).trim() + if (!payload || payload === '[DONE]') { + continue + } + try { + const parsed: unknown = JSON.parse(payload) + if (isRecord(parsed)) { + chunks.push(parsed) + } + } catch { + const repaired = repairTruncatedJson(payload) + if (repaired) { + chunks.push(repaired) + } + } + } + return chunks +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function str(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +/** + * Reassemble Claude streaming events into the equivalent non-stream message. + * Returns null when the chunks are not a Claude event stream. + */ +function reassembleClaudeMessage(chunks: SseChunk[]): ClaudeMessage | null { + if (!chunks.some((c) => c.type === 'message_start')) { + return null + } + let message: ClaudeMessage | null = null + const blocks = new Map() + for (const chunk of chunks) { + switch (chunk.type) { + case 'message_start': { + const src = isRecord(chunk.message) ? chunk.message : {} + message = { + id: str(src.id), + type: 'message', + role: str(src.role), + model: str(src.model), + content: [], + usage: isRecord(src.usage) ? { ...src.usage } : undefined, + } + break + } + case 'content_block_start': { + const src = isRecord(chunk.content_block) ? chunk.content_block : {} + blocks.set(Number(chunk.index) || 0, { + type: str(src.type) ?? 'text', + text: str(src.text), + thinking: str(src.thinking), + signature: str(src.signature), + id: str(src.id), + name: str(src.name), + inputRaw: '', + }) + break + } + case 'content_block_delta': { + const block = blocks.get(Number(chunk.index) || 0) + const delta = isRecord(chunk.delta) ? chunk.delta : {} + if (!block) { + break + } + if (delta.type === 'text_delta' && typeof delta.text === 'string') { + block.text = (block.text ?? '') + delta.text + } else if ( + delta.type === 'thinking_delta' && + typeof delta.thinking === 'string' + ) { + block.thinking = (block.thinking ?? '') + delta.thinking + } else if ( + delta.type === 'signature_delta' && + typeof delta.signature === 'string' + ) { + block.signature = (block.signature ?? '') + delta.signature + } else if ( + delta.type === 'input_json_delta' && + typeof delta.partial_json === 'string' + ) { + block.inputRaw = (block.inputRaw ?? '') + delta.partial_json + } + break + } + case 'message_delta': { + if (!message) { + break + } + const delta = isRecord(chunk.delta) ? chunk.delta : {} + message.stop_reason = str(delta.stop_reason) + message.stop_sequence = + delta.stop_sequence === null ? null : str(delta.stop_sequence) + if (isRecord(chunk.usage)) { + message.usage = { ...message.usage, ...chunk.usage } + } + break + } + default: + break + } + } + if (!message) { + return null + } + const finalizeBlock = (block: ClaudeBlock): ClaudeBlock => { + if (block.type === 'tool_use') { + const raw = block.inputRaw ?? '' + delete block.inputRaw + try { + block.input = raw ? JSON.parse(raw) : {} + } catch { + block.input = raw // truncated mid-JSON; keep the raw fragment + } + } else { + delete block.inputRaw + } + return block + } + message.content = [...blocks.entries()] + .sort(([a], [b]) => a - b) + .map(([, block]) => finalizeBlock(block)) + return message +} + +/** + * Line-break and indent a JSON-like body that failed to parse (capture was + * truncated mid-JSON). Whitespace outside strings is re-emitted by structure; + * string contents are kept verbatim so truncation never loses text. + */ +function reindentJsonLike(text: string): string { + const out: string[] = [] + let indent = 0 + let inString = false + let escaped = false + const newline = () => `\n${' '.repeat(indent)}` + for (const ch of text) { + if (inString) { + out.push(ch) + if (escaped) { + escaped = false + } else if (ch === '\\') { + escaped = true + } else if (ch === '"') { + inString = false + } + continue + } + if (ch === '"') { + inString = true + out.push(ch) + } else if (ch === '{' || ch === '[') { + indent += 1 + out.push(ch, newline()) + } else if (ch === '}' || ch === ']') { + indent = Math.max(0, indent - 1) + out.push(newline(), ch) + } else if (ch === ',') { + out.push(ch, newline()) + } else if (ch === ':') { + out.push(ch, ' ') + } else if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r') { + out.push(ch) + } + } + return out.join('').trim() +} + +/** + * Format a captured chat-log request/response body for display: + * - valid JSON is pretty-printed + * - Claude SSE streams are reassembled into a single non-stream message + * - other SSE streams are shown as an array of parsed chunks + * - truncated JSON is re-indented instead of shown as one huge line + */ +export function formatChatLogBody(body: string): string { + const trimmed = body.trim() + if (!trimmed) { + return body + } + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + return JSON.stringify(JSON.parse(trimmed), null, 2) + } catch { + return reindentJsonLike(trimmed) + } + } + if (/^data:/m.test(trimmed)) { + const chunks = parseSseData(trimmed) + const claude = chunks.length > 0 ? reassembleClaudeMessage(chunks) : null + if (claude) { + return JSON.stringify(claude, null, 2) + } + if (chunks.length > 0) { + return JSON.stringify(chunks, null, 2) + } + } + return body +} From e943bc9ca6d27af5f8fe697af294a248dc32da28 Mon Sep 17 00:00:00 2001 From: Wenyi Tang Date: Tue, 1 Sep 2026 13:40:29 +0800 Subject: [PATCH 2/3] =?UTF-8?q?[dev]=20=E5=AF=B9=E8=AF=9D=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E4=BC=9A=E8=AF=9D=E8=BF=BD=E8=B8=AA=EF=BC=9A=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E5=93=88=E5=B8=8C=E9=93=BE=E5=A2=9E=E9=87=8F=E5=AD=98?= =?UTF-8?q?=E5=82=A8=20+=20=E4=BC=9A=E8=AF=9D=E7=BB=B4=E5=BA=A6=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 会话识别:请求消息原始字节构建 SHA256 前缀哈希链,token 作用域内链值匹配即同一会话, 只存增量消息(new_messages),响应仍全量存储;compact/编辑重发自然开新会话 - 数据模型:chat_sessions(链头/轮次/消息数/system) + chat_turns(增量+响应); master 迁移 AutoMigrate 两表并 DROP 旧 chat_logs;MySQL system 保留字列反引号处理 - 提取:OpenAI chat/Claude/Gemini 规范化候选链,其余格式单轮会话;同会话并行写竞争按 v1 接受 - 管理 API:GET /api/chat_logs/sessions[/:id];列表不含消息体 - 前端:会话列表 + 转录时间线,跳过每轮回显的 assistant/model 消息,响应经 formatChatLogBody 重组展示(并入此前 OpenAI SSE 分块合并为 chat.completion) - 测试:Go service/model/controller 四包 + 前端 vitest 17 例;i18n 7 语言 Signed-off-by: AGENTS (opencode) --- .gitignore | 1 + controller/chat_log.go | 51 ++--- controller/chat_log_test.go | 148 ++++++++++---- controller/relay.go | 2 +- model/chat_log.go | 112 ++++++++--- model/chat_log_test.go | 85 +++++--- model/main.go | 11 +- model/task_cas_test.go | 6 +- router/chat-log-router.go | 4 +- service/channel_affinity_usage_cache_test.go | 17 +- service/chat_log_persist.go | 95 +++++++-- service/chat_log_persist_test.go | 80 ++++++-- service/chat_log_session.go | 64 ++++++ service/chat_log_session_test.go | 58 ++++++ service/task_billing_test.go | 6 +- web/src/features/chat-logs/api.ts | 30 +-- .../components/chat-log-detail-sheet.tsx | 156 --------------- ...logs-table.tsx => chat-sessions-table.tsx} | 132 ++++++------- .../components/session-detail-sheet.tsx | 182 ++++++++++++++++++ web/src/features/chat-logs/index.tsx | 4 +- .../lib/__tests__/format-body.test.ts | 61 +++++- .../lib/__tests__/transcript.test.ts | 118 ++++++++++++ web/src/features/chat-logs/lib/format-body.ts | 130 +++++++++++++ web/src/features/chat-logs/lib/transcript.ts | 60 ++++++ web/src/features/chat-logs/types.ts | 39 ++-- web/src/i18n/locales/en.json | 8 +- web/src/i18n/locales/fr.json | 8 +- web/src/i18n/locales/ja.json | 8 +- web/src/i18n/locales/ru.json | 8 +- web/src/i18n/locales/vi.json | 8 +- web/src/i18n/locales/zh-TW.json | 8 +- web/src/i18n/locales/zh.json | 8 +- .../routes/_authenticated/chat-logs/index.tsx | 1 + 33 files changed, 1273 insertions(+), 436 deletions(-) create mode 100644 service/chat_log_session.go create mode 100644 service/chat_log_session_test.go delete mode 100644 web/src/features/chat-logs/components/chat-log-detail-sheet.tsx rename web/src/features/chat-logs/components/{chat-logs-table.tsx => chat-sessions-table.tsx} (72%) create mode 100644 web/src/features/chat-logs/components/session-detail-sheet.tsx create mode 100644 web/src/features/chat-logs/lib/__tests__/transcript.test.ts create mode 100644 web/src/features/chat-logs/lib/transcript.ts diff --git a/.gitignore b/.gitignore index dc328dd6c80c..0f2451586ea9 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ service/relayconvert/chat_responses_live_local_test.go service/openaicompat/chat_responses_live_local_test.go go.work go.work.sum +.superpowers/ diff --git a/controller/chat_log.go b/controller/chat_log.go index 9fe978132c69..14e70c654f16 100644 --- a/controller/chat_log.go +++ b/controller/chat_log.go @@ -8,48 +8,44 @@ import ( "github.com/gin-gonic/gin" ) -func AdminGetChatLogs(c *gin.Context) { +func AdminGetChatSessions(c *gin.Context) { if !model.ChatLogDBEnabled() { c.JSON(http.StatusOK, gin.H{"success": false, "message": "对话详情库未配置"}) return } tokenId, _ := strconv.Atoi(c.Query("token_id")) userId, _ := strconv.Atoi(c.Query("user_id")) - channelId, _ := strconv.Atoi(c.Query("channel_id")) modelName := c.Query("model_name") page, _ := strconv.Atoi(c.Query("page")) pageSize, _ := strconv.Atoi(c.Query("page_size")) - logs, total, err := model.SearchChatLogs(tokenId, userId, channelId, modelName, "", page, pageSize) + sessions, total, err := model.SearchChatSessions(tokenId, userId, modelName, page, pageSize) if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) return } - type chatLogMeta struct { - Id int `json:"id"` - TokenId int `json:"token_id"` - UserId int `json:"user_id"` - ChannelId int `json:"channel_id"` - ModelName string `json:"model_name"` - RequestId string `json:"request_id"` - IsStream bool `json:"is_stream"` - Truncated bool `json:"truncated"` - StatusCode int `json:"status_code"` - UseTime int `json:"use_time"` - CreatedAt int64 `json:"created_at"` + type chatSessionMeta struct { + Id int `json:"id"` + TokenId int `json:"token_id"` + UserId int `json:"user_id"` + ModelName string `json:"model_name"` + TurnCount int `json:"turn_count"` + MessageCount int `json:"message_count"` + CreatedAt int64 `json:"created_at"` + LastActiveAt int64 `json:"last_active_at"` } - out := make([]chatLogMeta, 0, len(logs)) - for _, l := range logs { - out = append(out, chatLogMeta{ - Id: l.Id, TokenId: l.TokenId, UserId: l.UserId, ChannelId: l.ChannelId, - ModelName: l.ModelName, RequestId: l.RequestId, IsStream: l.IsStream, - Truncated: l.Truncated, StatusCode: l.StatusCode, UseTime: l.UseTime, CreatedAt: l.CreatedAt, + out := make([]chatSessionMeta, 0, len(sessions)) + for _, s := range sessions { + out = append(out, chatSessionMeta{ + Id: s.Id, TokenId: s.TokenId, UserId: s.UserId, ModelName: s.ModelName, + TurnCount: s.TurnCount, MessageCount: s.MessageCount, + CreatedAt: s.CreatedAt, LastActiveAt: s.LastActiveAt, }) } c.JSON(http.StatusOK, gin.H{"success": true, "data": out, "total": total}) } -func AdminGetChatLogDetail(c *gin.Context) { +func AdminGetChatSessionDetail(c *gin.Context) { if !model.ChatLogDBEnabled() { c.JSON(http.StatusOK, gin.H{"success": false, "message": "对话详情库未配置"}) return @@ -59,10 +55,15 @@ func AdminGetChatLogDetail(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效 ID"}) return } - cl, err := model.GetChatLogById(id) + session, err := model.GetChatSessionById(id) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "记录不存在"}) + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "会话不存在"}) return } - c.JSON(http.StatusOK, gin.H{"success": true, "data": cl}) + turns, err := model.GetChatTurnsBySessionId(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"session": session, "turns": turns}}) } diff --git a/controller/chat_log_test.go b/controller/chat_log_test.go index 658cdc7d9ab1..64a2f80df849 100644 --- a/controller/chat_log_test.go +++ b/controller/chat_log_test.go @@ -1,6 +1,7 @@ package controller import ( + "fmt" "net/http" "net/http/httptest" "strconv" @@ -16,49 +17,130 @@ import ( func setupChatLogTestDB(t *testing.T) { t.Helper() db := setupTokenControllerTestDB(t) - require.NoError(t, db.AutoMigrate(&model.ChatLog{})) + require.NoError(t, db.AutoMigrate(&model.ChatSession{}, &model.ChatTurn{})) model.CHATLOG_DB = db common.SetChatLogDatabaseType(common.DatabaseTypeSQLite) t.Cleanup(func() { model.CHATLOG_DB = nil }) } -func TestAdminGetChatLogs_ListAndDetail(t *testing.T) { +func serveChatLog(t *testing.T, route string, handler gin.HandlerFunc, url string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + r := gin.New() + r.GET(route, handler) + r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil)) + return rec +} + +func TestAdminGetChatSessions_ListMetaShape(t *testing.T) { setupChatLogTestDB(t) - cl := &model.ChatLog{ - TokenId: 1, UserId: 1, ChannelId: 5, ModelName: "gpt-4", - RequestId: "req-x", RequestBody: `{"q":1}`, ResponseBody: `{"a":2}`, + s := &model.ChatSession{ + TokenId: 1, UserId: 1, ModelName: "gpt-4", System: "secret-system", + TurnCount: 2, MessageCount: 4, PrefixHash: "h1", } - require.NoError(t, cl.Insert()) + require.NoError(t, s.Insert()) + + rec := serveChatLog(t, "/chat_logs/sessions", AdminGetChatSessions, "/chat_logs/sessions?page=1&page_size=10") + require.Equal(t, http.StatusOK, rec.Code) - authMW := func(role int) gin.HandlerFunc { - return func(c *gin.Context) { c.Set("role", role); c.Next() } + var resp struct { + Success bool `json:"success"` + Total int64 `json:"total"` + Data []map[string]any `json:"data"` } + require.NoError(t, common.Unmarshal(rec.Body.Bytes(), &resp)) + require.True(t, resp.Success) + require.Equal(t, int64(1), resp.Total) + require.Len(t, resp.Data, 1) - // List - rec := httptest.NewRecorder() - r := gin.New() - r.GET("/chat_logs", authMW(common.RoleAdminUser), AdminGetChatLogs) - r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/chat_logs?page=1&page_size=10", nil)) + meta := resp.Data[0] + for _, key := range []string{"id", "token_id", "user_id", "model_name", "turn_count", "message_count", "created_at", "last_active_at"} { + assert.Contains(t, meta, key) + } + for _, key := range []string{"system", "prefix_hash", "new_messages", "response_body"} { + assert.NotContains(t, meta, key) + } + assert.Equal(t, "gpt-4", meta["model_name"]) + assert.Equal(t, float64(2), meta["turn_count"]) +} + +func TestAdminGetChatSessions_FiltersAndPaging(t *testing.T) { + setupChatLogTestDB(t) + for i, m := range []string{"gpt-4", "gpt-4", "claude-3"} { + s := &model.ChatSession{TokenId: i + 1, UserId: 1, ModelName: m, PrefixHash: fmt.Sprintf("h%d", i)} + require.NoError(t, s.Insert()) + } + + list := func(query string) (int64, []map[string]any) { + rec := serveChatLog(t, "/chat_logs/sessions", AdminGetChatSessions, "/chat_logs/sessions"+query) + require.Equal(t, http.StatusOK, rec.Code) + var resp struct { + Success bool `json:"success"` + Total int64 `json:"total"` + Data []map[string]any `json:"data"` + } + require.NoError(t, common.Unmarshal(rec.Body.Bytes(), &resp)) + require.True(t, resp.Success) + return resp.Total, resp.Data + } + + total, data := list("?page=1&page_size=2") + assert.Equal(t, int64(3), total) + assert.Len(t, data, 2) + + total, data = list("?page=1&page_size=10&model_name=gpt-4") + assert.Equal(t, int64(2), total) + require.Len(t, data, 2) + assert.Equal(t, "gpt-4", data[0]["model_name"]) + assert.Equal(t, "gpt-4", data[1]["model_name"]) + + total, data = list("?page=1&page_size=10&token_id=3") + assert.Equal(t, int64(1), total) + require.Len(t, data, 1) + assert.Equal(t, float64(3), data[0]["token_id"]) + + total, data = list("?page=1&page_size=10&model_name=claude-3&token_id=3") + assert.Equal(t, int64(1), total) + assert.Len(t, data, 1) + + total, _ = list("?page=1&page_size=10&model_name=none") + assert.Equal(t, int64(0), total) +} + +func TestAdminGetChatSessionDetail_TurnsOrdered(t *testing.T) { + setupChatLogTestDB(t) + s := &model.ChatSession{TokenId: 7, UserId: 1, ModelName: "gpt-4", PrefixHash: "h7"} + require.NoError(t, s.Insert()) + turn2 := &model.ChatTurn{SessionId: s.Id, TurnIndex: 2, RequestId: "r2", ModelName: "gpt-4", NewMessages: `[{"role":"assistant"}]`, ResponseBody: `{"b":2}`} + require.NoError(t, turn2.Insert()) + turn1 := &model.ChatTurn{SessionId: s.Id, TurnIndex: 1, RequestId: "r1", ModelName: "gpt-4", NewMessages: `[{"role":"user"}]`, ResponseBody: `{"a":1}`} + require.NoError(t, turn1.Insert()) + + rec := serveChatLog(t, "/chat_logs/sessions/:id", AdminGetChatSessionDetail, "/chat_logs/sessions/"+strconv.Itoa(s.Id)) require.Equal(t, http.StatusOK, rec.Code) - resp := struct { - Success bool `json:"success"` - Total int64 `json:"total"` - }{} + + var resp struct { + Success bool `json:"success"` + Data struct { + Session *model.ChatSession `json:"session"` + Turns []*model.ChatTurn `json:"turns"` + } `json:"data"` + } require.NoError(t, common.Unmarshal(rec.Body.Bytes(), &resp)) - assert.True(t, resp.Success) - assert.Equal(t, int64(1), resp.Total) - - // Detail - rec2 := httptest.NewRecorder() - r2 := gin.New() - r2.GET("/chat_logs/:id", authMW(common.RoleAdminUser), AdminGetChatLogDetail) - r2.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/chat_logs/"+strconv.Itoa(cl.Id), nil)) - require.Equal(t, http.StatusOK, rec2.Code) - detail := struct { - Success bool `json:"success"` - Data *model.ChatLog `json:"data"` - }{} - require.NoError(t, common.Unmarshal(rec2.Body.Bytes(), &detail)) - require.True(t, detail.Success) - assert.Equal(t, `{"q":1}`, detail.Data.RequestBody) + require.True(t, resp.Success) + require.NotNil(t, resp.Data.Session) + assert.Equal(t, s.Id, resp.Data.Session.Id) + require.Len(t, resp.Data.Turns, 2) + assert.Equal(t, 1, resp.Data.Turns[0].TurnIndex) + assert.Equal(t, "r1", resp.Data.Turns[0].RequestId) + assert.Equal(t, `{"a":1}`, resp.Data.Turns[0].ResponseBody) + assert.Equal(t, 2, resp.Data.Turns[1].TurnIndex) + assert.Equal(t, "r2", resp.Data.Turns[1].RequestId) + + // nonexistent id -> 404 + rec404 := serveChatLog(t, "/chat_logs/sessions/:id", AdminGetChatSessionDetail, "/chat_logs/sessions/99999") + assert.Equal(t, http.StatusNotFound, rec404.Code) + // invalid id -> 400 + rec400 := serveChatLog(t, "/chat_logs/sessions/:id", AdminGetChatSessionDetail, "/chat_logs/sessions/abc") + assert.Equal(t, http.StatusBadRequest, rec400.Code) } diff --git a/controller/relay.go b/controller/relay.go index c59daaf3fad0..bad4bff6ba69 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -128,7 +128,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { var chatLogRecorder *service.ChatLogRecorder if tok, terr := model.GetTokenByKey(relayInfo.TokenKey, false); terr == nil && tok.ChatLogEnabled { - chatLogRecorder = service.MaybeInstallChatLogCapture(c) + chatLogRecorder = service.MaybeInstallChatLogCapture(c, relayFormat) } needSensitiveCheck := setting.ShouldCheckPromptSensitive() diff --git a/model/chat_log.go b/model/chat_log.go index 091e4dee71b9..1f308757a980 100644 --- a/model/chat_log.go +++ b/model/chat_log.go @@ -4,71 +4,123 @@ import ( "github.com/QuantumNous/new-api/common" ) -type ChatLog struct { +type ChatSession struct { Id int `json:"id" gorm:"primaryKey"` - TokenId int `json:"token_id" gorm:"index"` + TokenId int `json:"token_id" gorm:"index;uniqueIndex:idx_chat_sessions_token_prefix"` UserId int `json:"user_id" gorm:"index"` - ChannelId int `json:"channel_id" gorm:"index"` - ModelName string `json:"model_name" gorm:"type:varchar(128);index"` + ModelName string `json:"model_name" gorm:"type:varchar(128)"` + System string `json:"system" gorm:"type:text"` + TurnCount int `json:"turn_count"` + MessageCount int `json:"message_count"` + PrefixHash string `json:"prefix_hash" gorm:"type:varchar(64);uniqueIndex:idx_chat_sessions_token_prefix"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + LastActiveAt int64 `json:"last_active_at" gorm:"bigint;index"` +} + +func (ChatSession) TableName() string { + return "chat_sessions" +} + +func ChatLogDBEnabled() bool { + return CHATLOG_DB != nil +} + +func (s *ChatSession) Insert() error { + now := common.GetTimestamp() + if s.CreatedAt == 0 { + s.CreatedAt = now + } + if s.LastActiveAt == 0 { + s.LastActiveAt = now + } + return CHATLOG_DB.Create(s).Error +} + +type ChatTurn struct { + Id int `json:"id" gorm:"primaryKey"` + SessionId int `json:"session_id" gorm:"index"` + TurnIndex int `json:"turn_index"` RequestId string `json:"request_id" gorm:"type:varchar(64);index"` - RequestBody string `json:"request_body" gorm:"type:text"` - ResponseBody string `json:"response_body" gorm:"type:text"` - IsStream bool `json:"is_stream"` - Truncated bool `json:"truncated"` + ModelName string `json:"model_name" gorm:"type:varchar(128)"` + ChannelId int `json:"channel_id" gorm:"index"` StatusCode int `json:"status_code" gorm:"default:0"` UseTime int `json:"use_time" gorm:"default:0"` + IsStream bool `json:"is_stream"` + NewMessages string `json:"new_messages" gorm:"type:text"` + ResponseBody string `json:"response_body" gorm:"type:text"` CreatedAt int64 `json:"created_at" gorm:"bigint;index"` } -func (ChatLog) TableName() string { - return "chat_logs" +func (ChatTurn) TableName() string { + return "chat_turns" } -func ChatLogDBEnabled() bool { - return CHATLOG_DB != nil +func (t *ChatTurn) Insert() error { + if t.CreatedAt == 0 { + t.CreatedAt = common.GetTimestamp() + } + return CHATLOG_DB.Create(t).Error } -func (cl *ChatLog) Insert() error { - cl.CreatedAt = common.GetTimestamp() - return CHATLOG_DB.Create(cl).Error +func GetChatTurnsBySessionId(sessionId int) ([]*ChatTurn, error) { + var turns []*ChatTurn + err := CHATLOG_DB.Where("session_id = ?", sessionId).Order("turn_index asc").Find(&turns).Error + return turns, err } -func GetChatLogById(id int) (*ChatLog, error) { - var cl ChatLog - err := CHATLOG_DB.First(&cl, "id = ?", id).Error - return &cl, err +func FindChatSessionByPrefixHashes(tokenId int, hashes []string) (*ChatSession, error) { + var s ChatSession + err := CHATLOG_DB.Where("token_id = ? AND prefix_hash IN ?", tokenId, hashes). + Order("message_count desc").First(&s).Error + if err != nil { + return nil, err + } + return &s, nil +} + +func (s *ChatSession) Advance(modelName string, at int64) error { + s.TurnCount++ + s.LastActiveAt = at + s.ModelName = modelName + return CHATLOG_DB.Model(s).Updates(map[string]any{ + "turn_count": s.TurnCount, + "message_count": s.MessageCount, + "prefix_hash": s.PrefixHash, + "last_active_at": s.LastActiveAt, + "model_name": s.ModelName, + }).Error } -func SearchChatLogs(tokenId, userId, channelId int, modelName, requestId string, page, pageSize int) ([]*ChatLog, int64, error) { +func SearchChatSessions(tokenId, userId int, modelName string, page, pageSize int) ([]*ChatSession, int64, error) { if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } - q := CHATLOG_DB.Model(&ChatLog{}) + q := CHATLOG_DB.Model(&ChatSession{}) if tokenId > 0 { q = q.Where("token_id = ?", tokenId) } if userId > 0 { q = q.Where("user_id = ?", userId) } - if channelId > 0 { - q = q.Where("channel_id = ?", channelId) - } if modelName != "" { q = q.Where("model_name = ?", modelName) } - if requestId != "" { - q = q.Where("request_id = ?", requestId) - } var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, err } - var logs []*ChatLog - if err := q.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs).Error; err != nil { + var sessions []*ChatSession + if err := q.Order("last_active_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil { return nil, 0, err } - return logs, total, nil + return sessions, total, nil +} + +func GetChatSessionById(id int) (*ChatSession, error) { + var s ChatSession + err := CHATLOG_DB.First(&s, "id = ?", id).Error + return &s, err } diff --git a/model/chat_log_test.go b/model/chat_log_test.go index a98ae142f06b..4704711cc542 100644 --- a/model/chat_log_test.go +++ b/model/chat_log_test.go @@ -7,55 +7,90 @@ import ( "github.com/stretchr/testify/require" ) -func TestChatLog_CreateAndQuery(t *testing.T) { +func TestChatSession_CreateAndQuery(t *testing.T) { truncateTables(t) if !ChatLogDBEnabled() { t.Skip("CHATLOG_DB not configured") } - cl := &ChatLog{ - TokenId: 1, UserId: 1, ChannelId: 5, - ModelName: "gpt-4", RequestId: "req-1", - RequestBody: `{"messages":[]}`, ResponseBody: `{"choices":[]}`, - IsStream: true, StatusCode: 200, UseTime: 2, - } - require.NoError(t, cl.Insert()) + s := &ChatSession{TokenId: 1, UserId: 2, ModelName: "glm-5.3", PrefixHash: "a", MessageCount: 3, System: `"sys"`} + require.NoError(t, s.Insert()) + require.NoError(t, (&ChatTurn{SessionId: s.Id, TurnIndex: 0, RequestId: "r1", NewMessages: `[{"role":"user","content":"hi"}]`, ResponseBody: `{}`}).Insert()) + require.NoError(t, (&ChatTurn{SessionId: s.Id, TurnIndex: 1, RequestId: "r2", NewMessages: `[{"role":"user","content":"again"}]`, ResponseBody: `{}`}).Insert()) - got, err := GetChatLogById(cl.Id) + got, err := GetChatSessionById(s.Id) require.NoError(t, err) - assert.Equal(t, "gpt-4", got.ModelName) - assert.Equal(t, `{"messages":[]}`, got.RequestBody) + assert.Equal(t, 3, got.MessageCount) - list, total, err := SearchChatLogs(1, 0, 0, "", "", 1, 10) + turns, err := GetChatTurnsBySessionId(s.Id) require.NoError(t, err) - assert.Equal(t, int64(1), total) - require.Len(t, list, 1) - assert.Equal(t, cl.Id, list[0].Id) + require.Len(t, turns, 2) + assert.Equal(t, 0, turns[0].TurnIndex) + assert.Equal(t, 1, turns[1].TurnIndex) } -func TestChatLog_SearchFilters(t *testing.T) { +func TestFindChatSessionByPrefixHashes_PrefersLongestMatch(t *testing.T) { truncateTables(t) if !ChatLogDBEnabled() { t.Skip("CHATLOG_DB not configured") } - require.NoError(t, (&ChatLog{TokenId: 1, UserId: 1, ChannelId: 5, ModelName: "gpt-4", RequestId: "a", RequestBody: "{}", ResponseBody: "{}"}).Insert()) - require.NoError(t, (&ChatLog{TokenId: 1, UserId: 2, ChannelId: 6, ModelName: "claude", RequestId: "b", RequestBody: "{}", ResponseBody: "{}"}).Insert()) - require.NoError(t, (&ChatLog{TokenId: 2, UserId: 1, ChannelId: 5, ModelName: "gpt-4", RequestId: "c", RequestBody: "{}", ResponseBody: "{}"}).Insert()) + require.NoError(t, (&ChatSession{TokenId: 1, PrefixHash: "h2", MessageCount: 2}).Insert()) + longer := &ChatSession{TokenId: 1, PrefixHash: "h5", MessageCount: 5} + require.NoError(t, longer.Insert()) + require.NoError(t, (&ChatSession{TokenId: 2, PrefixHash: "h5", MessageCount: 9}).Insert()) // other token - list, total, err := SearchChatLogs(1, 0, 0, "", "", 1, 10) + got, err := FindChatSessionByPrefixHashes(1, []string{"h2", "h5", "h9"}) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, 5, got.MessageCount) +} + +func TestChatSession_Advance(t *testing.T) { + truncateTables(t) + if !ChatLogDBEnabled() { + t.Skip("CHATLOG_DB not configured") + } + s := &ChatSession{TokenId: 1, PrefixHash: "h2", MessageCount: 2} + require.NoError(t, s.Insert()) + s.MessageCount = 4 + s.PrefixHash = "h4" + require.NoError(t, s.Advance("glm-5.3", 123)) + got, _ := GetChatSessionById(s.Id) + assert.Equal(t, 4, got.MessageCount) + assert.Equal(t, "h4", got.PrefixHash) + assert.Equal(t, 1, got.TurnCount) + assert.Equal(t, "glm-5.3", got.ModelName) + assert.Equal(t, int64(123), got.LastActiveAt) +} + +func TestSearchChatSessions(t *testing.T) { + truncateTables(t) + if !ChatLogDBEnabled() { + t.Skip("CHATLOG_DB not configured") + } + require.NoError(t, (&ChatSession{TokenId: 1, UserId: 1, ModelName: "gpt-4", PrefixHash: "p1", MessageCount: 1, CreatedAt: 100, LastActiveAt: 100}).Insert()) + require.NoError(t, (&ChatSession{TokenId: 1, UserId: 2, ModelName: "claude", PrefixHash: "p2", MessageCount: 2, CreatedAt: 200, LastActiveAt: 200}).Insert()) + require.NoError(t, (&ChatSession{TokenId: 2, UserId: 1, ModelName: "gpt-4", PrefixHash: "p3", MessageCount: 3, CreatedAt: 300, LastActiveAt: 300}).Insert()) + + list, total, err := SearchChatSessions(1, 0, "", 1, 10) require.NoError(t, err) assert.Equal(t, int64(2), total) assert.Len(t, list, 2) - list, total, err = SearchChatLogs(0, 0, 0, "gpt-4", "", 1, 10) + list, total, err = SearchChatSessions(0, 1, "", 1, 10) require.NoError(t, err) assert.Equal(t, int64(2), total) - list, total, err = SearchChatLogs(0, 0, 0, "", "b", 1, 10) + list, total, err = SearchChatSessions(0, 0, "gpt-4", 1, 10) require.NoError(t, err) - assert.Equal(t, int64(1), total) - assert.Equal(t, "claude", list[0].ModelName) + assert.Equal(t, int64(2), total) + + list, total, err = SearchChatSessions(0, 0, "", 1, 1) + require.NoError(t, err) + assert.Equal(t, int64(3), total) + require.Len(t, list, 1) + assert.Equal(t, int64(300), list[0].LastActiveAt, "ordered by last_active_at desc") - list, total, err = SearchChatLogs(0, 0, 0, "", "", 2, 10) + list, total, err = SearchChatSessions(0, 0, "", 2, 10) require.NoError(t, err) assert.Equal(t, int64(3), total) assert.Empty(t, list, "page 2 of 3 items is empty") diff --git a/model/main.go b/model/main.go index cc43e37d9196..a89be40f4264 100644 --- a/model/main.go +++ b/model/main.go @@ -441,18 +441,21 @@ func InitChatLogDB() (err error) { } func migrateChatLogDB(dbType common.DatabaseType) error { - if err := CHATLOG_DB.AutoMigrate(&ChatLog{}); err != nil { + if err := CHATLOG_DB.AutoMigrate(&ChatSession{}, &ChatTurn{}); err != nil { return err } if dbType == common.DatabaseTypeMySQL { - if err := CHATLOG_DB.Exec("ALTER TABLE chat_logs MODIFY COLUMN request_body LONGTEXT").Error; err != nil { + if err := CHATLOG_DB.Exec("ALTER TABLE chat_turns MODIFY COLUMN new_messages LONGTEXT").Error; err != nil { return err } - if err := CHATLOG_DB.Exec("ALTER TABLE chat_logs MODIFY COLUMN response_body LONGTEXT").Error; err != nil { + if err := CHATLOG_DB.Exec("ALTER TABLE chat_turns MODIFY COLUMN response_body LONGTEXT").Error; err != nil { + return err + } + if err := CHATLOG_DB.Exec("ALTER TABLE chat_sessions MODIFY COLUMN `system` LONGTEXT").Error; err != nil { return err } } - return nil + return CHATLOG_DB.Exec("DROP TABLE IF EXISTS chat_logs").Error } func migrateClickHouseLogDB() error { diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 11d3fbcda47f..bcb586f69397 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -60,7 +60,8 @@ func TestMain(m *testing.M) { &SystemTask{}, &SystemTaskLock{}, &TokenChannelQuota{}, - &ChatLog{}, + &ChatSession{}, + &ChatTurn{}, ); err != nil { panic("failed to migrate: " + err.Error()) } @@ -94,7 +95,8 @@ func truncateTables(t *testing.T) { DB.Exec("DELETE FROM system_task_locks") DB.Exec("DELETE FROM system_tasks") DB.Exec("DELETE FROM token_channel_quotas") - DB.Exec("DELETE FROM chat_logs") + DB.Exec("DELETE FROM chat_turns") + DB.Exec("DELETE FROM chat_sessions") }) } diff --git a/router/chat-log-router.go b/router/chat-log-router.go index 9231c514e588..2f72574e58e1 100644 --- a/router/chat-log-router.go +++ b/router/chat-log-router.go @@ -9,6 +9,6 @@ import ( func registerChatLogRoutes(apiRouter *gin.RouterGroup) { chatLogRoute := apiRouter.Group("/chat_logs") chatLogRoute.Use(middleware.AdminAuth()) - chatLogRoute.GET("/", controller.AdminGetChatLogs) - chatLogRoute.GET("/:id", controller.AdminGetChatLogDetail) + chatLogRoute.GET("/sessions", controller.AdminGetChatSessions) + chatLogRoute.GET("/sessions/:id", controller.AdminGetChatSessionDetail) } diff --git a/service/channel_affinity_usage_cache_test.go b/service/channel_affinity_usage_cache_test.go index 876297b21c05..9afc877ab28d 100644 --- a/service/channel_affinity_usage_cache_test.go +++ b/service/channel_affinity_usage_cache_test.go @@ -3,6 +3,7 @@ package service import ( "fmt" "net/http/httptest" + "sync/atomic" "testing" "time" @@ -12,6 +13,10 @@ import ( "github.com/stretchr/testify/require" ) +// ponytail: UnixNano alone collides on Windows' coarse clock and the stats +// bucket is global, so keys need a process-unique counter suffix. +var channelAffinityTestSeq atomic.Int64 + func buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP string) *gin.Context { rec := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(rec) @@ -26,9 +31,9 @@ func buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP string) } func TestObserveChannelAffinityUsageCacheByRelayFormat_ClaudeMode(t *testing.T) { - ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano()) + ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano()) + fmt.Sprintf("_%d", channelAffinityTestSeq.Add(1)) usingGroup := "default" - keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano()) + keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano()) + fmt.Sprintf("_%d", channelAffinityTestSeq.Add(1)) ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP) usage := &dto.Usage{ @@ -53,9 +58,9 @@ func TestObserveChannelAffinityUsageCacheByRelayFormat_ClaudeMode(t *testing.T) } func TestObserveChannelAffinityUsageCacheByRelayFormat_MixedMode(t *testing.T) { - ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano()) + ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano()) + fmt.Sprintf("_%d", channelAffinityTestSeq.Add(1)) usingGroup := "default" - keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano()) + keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano()) + fmt.Sprintf("_%d", channelAffinityTestSeq.Add(1)) ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP) openAIUsage := &dto.Usage{ @@ -83,9 +88,9 @@ func TestObserveChannelAffinityUsageCacheByRelayFormat_MixedMode(t *testing.T) { } func TestObserveChannelAffinityUsageCacheByRelayFormat_UnsupportedModeKeepsEmpty(t *testing.T) { - ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano()) + ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano()) + fmt.Sprintf("_%d", channelAffinityTestSeq.Add(1)) usingGroup := "default" - keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano()) + keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano()) + fmt.Sprintf("_%d", channelAffinityTestSeq.Add(1)) ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP) usage := &dto.Usage{ diff --git a/service/chat_log_persist.go b/service/chat_log_persist.go index 5fe38cbea096..ea0b017804e5 100644 --- a/service/chat_log_persist.go +++ b/service/chat_log_persist.go @@ -1,21 +1,29 @@ package service import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strconv" "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relaykit/types" "github.com/bytedance/gopkg/util/gopool" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) type ChatLogRecorder struct { cw *chatLogCaptureWriter requestBody string + format types.RelayFormat } -func MaybeInstallChatLogCapture(c *gin.Context) *ChatLogRecorder { +func MaybeInstallChatLogCapture(c *gin.Context, format types.RelayFormat) *ChatLogRecorder { if !model.ChatLogDBEnabled() { return nil } @@ -24,7 +32,7 @@ func MaybeInstallChatLogCapture(c *gin.Context) *ChatLogRecorder { if !ok { return nil } - return &ChatLogRecorder{cw: cw} + return &ChatLogRecorder{cw: cw, format: format} } func (r *ChatLogRecorder) SetRequestBody(body []byte) { @@ -48,6 +56,7 @@ func (r *ChatLogRecorder) Persist(c *gin.Context) { cw := r.cw requestBody := r.requestBody + format := string(r.format) statusCode := cw.ResponseWriter.Status() gopool.Go(func() { respBody := cw.capturedBytes() @@ -55,16 +64,80 @@ func (r *ChatLogRecorder) Persist(c *gin.Context) { if !startTime.IsZero() { useTime = int(time.Since(startTime).Seconds()) } - cl := &model.ChatLog{ - TokenId: tokenId, UserId: userId, ChannelId: channelId, - ModelName: modelName, RequestId: requestId, - RequestBody: requestBody, ResponseBody: respBody, - IsStream: isStream, Truncated: false, - StatusCode: statusCode, - UseTime: useTime, + now := common.GetTimestamp() + session, delta := resolveChatLogSession(tokenId, userId, format, requestId, requestBody) + if session == nil { + return } - if err := cl.Insert(); err != nil { - common.SysError("failed to insert chat log: " + err.Error()) + newMessages := "[]" + if len(delta) > 0 { + b, err := common.Marshal(delta) + if err != nil { + common.SysError("chat log: marshal delta failed: " + err.Error()) + return + } + newMessages = string(b) + } + turn := &model.ChatTurn{ + SessionId: session.Id, TurnIndex: session.TurnCount, + RequestId: requestId, ModelName: modelName, ChannelId: channelId, + StatusCode: statusCode, UseTime: useTime, IsStream: isStream, + NewMessages: newMessages, ResponseBody: respBody, + } + if err := turn.Insert(); err != nil { + common.SysError("chat log: insert turn failed: " + err.Error()) + return + } + if err := session.Advance(modelName, now); err != nil { + common.SysError("chat log: advance session failed: " + err.Error()) } }) } + +// resolveChatLogSession finds or creates the session a request body belongs to. +// The returned session carries the post-turn MessageCount/PrefixHash so the +// caller's Advance persists exactly those values. +func resolveChatLogSession(tokenId, userId int, format, requestId, requestBody string) (*model.ChatSession, []json.RawMessage) { + chain := extractChatLogChain([]byte(requestBody)) + if chain == nil { + sum := sha256.Sum256([]byte(strconv.Itoa(tokenId) + "|" + format + "|" + requestId)) + session := &model.ChatSession{ + TokenId: tokenId, UserId: userId, + PrefixHash: hex.EncodeToString(sum[:]), + } + if err := session.Insert(); err != nil { + common.SysError("chat log: insert standalone session failed: " + err.Error()) + return nil, nil + } + return session, nil + } + hashes := computeChatLogChainHashes(tokenId, format, chain.Elements) + existing, err := model.FindChatSessionByPrefixHashes(tokenId, hashes) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + common.SysError("chat log: find session failed: " + err.Error()) + } + if err == nil { + matchMsgIdx := existing.MessageCount + if chain.SystemRaw != nil { + matchMsgIdx-- + } + delta := []json.RawMessage(nil) + if matchMsgIdx > 0 { + delta = chain.Messages[matchMsgIdx:] + } + existing.MessageCount = len(chain.Elements) + existing.PrefixHash = hashes[len(hashes)-1] + return existing, delta + } + session := &model.ChatSession{ + TokenId: tokenId, UserId: userId, + System: string(chain.SystemRaw), + MessageCount: len(chain.Elements), + PrefixHash: hashes[len(hashes)-1], + } + if err := session.Insert(); err != nil { + common.SysError("chat log: insert session failed: " + err.Error()) + return nil, nil + } + return session, chain.Messages +} diff --git a/service/chat_log_persist_test.go b/service/chat_log_persist_test.go index f38ed5ff34b2..3a83ee9e0d31 100644 --- a/service/chat_log_persist_test.go +++ b/service/chat_log_persist_test.go @@ -7,6 +7,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relaykit/types" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,7 +21,7 @@ func TestMaybeInstallChatLogCapture_DisabledReturnsNil(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest("POST", "/", nil) - assert.Nil(t, MaybeInstallChatLogCapture(c)) + assert.Nil(t, MaybeInstallChatLogCapture(c, types.RelayFormatClaude)) } func TestMaybeInstallChatLogCapture_EnabledWhenDBOn(t *testing.T) { @@ -31,36 +32,75 @@ func TestMaybeInstallChatLogCapture_EnabledWhenDBOn(t *testing.T) { if !model.ChatLogDBEnabled() { t.Skip("CHATLOG_DB not configured") } - rec := MaybeInstallChatLogCapture(c) + rec := MaybeInstallChatLogCapture(c, types.RelayFormatClaude) require.NotNil(t, rec) _, ok := c.Writer.(*chatLogCaptureWriter) assert.True(t, ok, "c.Writer should be wrapped") } -func TestChatLogRecorder_PersistInsertsRow(t *testing.T) { +func TestChatLogRecorder_PersistChainsTurns(t *testing.T) { truncate(t) if !model.ChatLogDBEnabled() { t.Skip("CHATLOG_DB not configured") } - gin.SetMode(gin.TestMode) - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest("POST", "/chat/completions", nil) - c.Set("token_id", 7) - c.Set("id", 3) - c.Set("channel_id", 11) - c.Set("original_model", "gpt-4") - c.Set(common.RequestIdKey, "req-persist") - c.Set("is_stream", true) + tokenId := int(time.Now().UnixNano() % 1e6) + turn1Body := `{"system":"You are helpful.","messages":[{"role":"user","content":"hi"}]}` + turn2Body := `{"system":"You are helpful.","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello"},{"role":"user","content":"bye"}]}` - recorder := MaybeInstallChatLogCapture(c) - require.NotNil(t, recorder) - recorder.SetRequestBody([]byte(`{"messages":[]}`)) - _, _ = c.Writer.Write([]byte(`{"choices":[]}`)) - recorder.Persist(c) + newTurnCtx := func(requestId string) *gin.Context { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest("POST", "/v1/messages", nil) + c.Set("token_id", tokenId) + c.Set("id", 3) + c.Set("channel_id", 11) + c.Set("original_model", "claude-3-5-sonnet") + c.Set(common.RequestIdKey, requestId) + c.Set("is_stream", false) + return c + } + persistTurn := func(requestId, body string) { + c := newTurnCtx(requestId) + recorder := MaybeInstallChatLogCapture(c, types.RelayFormatClaude) + require.NotNil(t, recorder) + recorder.SetRequestBody([]byte(body)) + _, err := c.Writer.Write([]byte(`{"type":"message"}`)) + require.NoError(t, err) + recorder.Persist(c) + } + + persistTurn("req-turn-1", turn1Body) require.Eventually(t, func() bool { - logs, total, _ := model.SearchChatLogs(7, 0, 0, "", "", 1, 10) - return total == 1 && len(logs) == 1 && logs[0].ResponseBody == `{"choices":[]}` && logs[0].RequestBody == `{"messages":[]}` + sessions, total, err := model.SearchChatSessions(tokenId, 0, "", 1, 10) + return err == nil && total == 1 && len(sessions) == 1 && + sessions[0].TurnCount == 1 && sessions[0].MessageCount == 2 }, 2*time.Second, 20*time.Millisecond) + + persistTurn("req-turn-2", turn2Body) + require.Eventually(t, func() bool { + sessions, total, err := model.SearchChatSessions(tokenId, 0, "", 1, 10) + return err == nil && total == 1 && len(sessions) == 1 && + sessions[0].TurnCount == 2 && sessions[0].MessageCount == 4 + }, 2*time.Second, 20*time.Millisecond) + + sessions, total, err := model.SearchChatSessions(tokenId, 0, "", 1, 10) + require.NoError(t, err) + require.Equal(t, int64(1), total) + require.Len(t, sessions, 1) + s := sessions[0] + assert.Equal(t, 2, s.TurnCount) + assert.Equal(t, 4, s.MessageCount) + assert.Equal(t, `"You are helpful."`, s.System) + + turns, err := model.GetChatTurnsBySessionId(s.Id) + require.NoError(t, err) + require.Len(t, turns, 2) + assert.Equal(t, 0, turns[0].TurnIndex) + assert.JSONEq(t, `[{"role":"user","content":"hi"}]`, turns[0].NewMessages) + assert.Equal(t, `{"type":"message"}`, turns[0].ResponseBody) + assert.Equal(t, 1, turns[1].TurnIndex) + assert.JSONEq(t, `[{"role":"assistant","content":"hello"},{"role":"user","content":"bye"}]`, turns[1].NewMessages) + assert.Equal(t, "req-turn-2", turns[1].RequestId) } diff --git a/service/chat_log_session.go b/service/chat_log_session.go new file mode 100644 index 000000000000..e9137f1319c6 --- /dev/null +++ b/service/chat_log_session.go @@ -0,0 +1,64 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strconv" + + "github.com/QuantumNous/new-api/common" +) + +type chatLogChain struct { + SystemRaw json.RawMessage // claude "system" / gemini "systemInstruction" field, nil if absent + Elements []json.RawMessage // chain elements: [system?, messages...] + Messages []json.RawMessage // Elements without the leading system element (delta storage) +} + +// Returns nil chain when the body has no recognizable message list. +func extractChatLogChain(body []byte) *chatLogChain { + var probe struct { + System json.RawMessage `json:"system"` + SystemInstruction json.RawMessage `json:"systemInstruction"` + Messages []json.RawMessage `json:"messages"` + Contents []json.RawMessage `json:"contents"` + } + if err := common.Unmarshal(body, &probe); err != nil { + return nil + } + chain := &chatLogChain{} + msgs := probe.Messages + if probe.System != nil { + chain.SystemRaw = probe.System + } else if probe.Contents != nil { + if probe.SystemInstruction != nil { + chain.SystemRaw = probe.SystemInstruction + } + msgs = probe.Contents + } + if len(msgs) == 0 { + return nil + } + if chain.SystemRaw != nil { + chain.Elements = append(chain.Elements, chain.SystemRaw) + } + chain.Messages = msgs + chain.Elements = append(chain.Elements, msgs...) + return chain +} + +// h(-1) = SHA256("|"); h(i) = SHA256(h(i-1) || SHA256(elem_i)). +// Returns hex hashes h_0..h_{len(elements)-1}. +func computeChatLogChainHashes(tokenId int, format string, elements []json.RawMessage) []string { + seed := sha256.Sum256([]byte(strconv.Itoa(tokenId) + "|" + format)) + prev := seed[:] + hashes := make([]string, 0, len(elements)) + for _, elem := range elements { + elemHash := sha256.Sum256(elem) + combined := append(append(make([]byte, 0, len(prev)+sha256.Size), prev...), elemHash[:]...) + next := sha256.Sum256(combined) + hashes = append(hashes, hex.EncodeToString(next[:])) + prev = next[:] + } + return hashes +} diff --git a/service/chat_log_session_test.go b/service/chat_log_session_test.go new file mode 100644 index 000000000000..1561de47f882 --- /dev/null +++ b/service/chat_log_session_test.go @@ -0,0 +1,58 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractChatLogChain_OpenAI(t *testing.T) { + c := extractChatLogChain([]byte(`{"model":"gpt","messages":[{"role":"user","content":"hi"}]}`)) + require.NotNil(t, c) + require.Nil(t, c.SystemRaw) + require.Len(t, c.Elements, 1) + require.Len(t, c.Messages, 1) +} + +func TestExtractChatLogChain_ClaudeSystemString(t *testing.T) { + c := extractChatLogChain([]byte(`{"system":"be brief","messages":[{"role":"user","content":"hi"}]}`)) + require.NotNil(t, c) + require.JSONEq(t, `"be brief"`, string(c.SystemRaw)) + require.Len(t, c.Elements, 2) // system + 1 message + require.Len(t, c.Messages, 1) // messages without the system element +} + +func TestExtractChatLogChain_ClaudeSystemArray(t *testing.T) { + c := extractChatLogChain([]byte(`{"system":[{"type":"text","text":"a"}],"messages":[{"role":"user","content":"hi"}]}`)) + require.Len(t, c.Elements, 2) +} + +func TestExtractChatLogChain_Gemini(t *testing.T) { + c := extractChatLogChain([]byte(`{"systemInstruction":{"role":"user","parts":[{"text":"sys"}]},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + require.NotNil(t, c) + require.Len(t, c.Elements, 2) +} + +func TestExtractChatLogChain_UnknownShape(t *testing.T) { + require.Nil(t, extractChatLogChain([]byte(`{"input": "x"}`))) + require.Nil(t, extractChatLogChain([]byte(`not json`))) +} + +func TestComputeChatLogChainHashes_PrefixProperty(t *testing.T) { + elems := []json.RawMessage{[]byte(`{"role":"user","content":"a"}`), []byte(`{"role":"assistant","content":"b"}`)} + h2 := computeChatLogChainHashes(7, "claude", elems) + elems = append(elems, json.RawMessage(`{"role":"user","content":"c"}`)) + h3 := computeChatLogChainHashes(7, "claude", elems) + require.Len(t, h2, 2) + require.Len(t, h3, 3) + assert.Equal(t, h2, h3[:2], "extending the conversation must not change earlier prefix hashes") + assert.NotEqual(t, computeChatLogChainHashes(8, "claude", elems[:1])[0], h3[0], "seed differs by token") +} + +func TestComputeChatLogChainHashes_Deterministic(t *testing.T) { + elems := []json.RawMessage{[]byte(`{"role":"user","content":"a"}`), []byte(`{"role":"assistant","content":"b"}`)} + assert.Equal(t, computeChatLogChainHashes(7, "claude", elems), computeChatLogChainHashes(7, "claude", elems)) + assert.NotEqual(t, computeChatLogChainHashes(7, "openai", elems[:1])[0], computeChatLogChainHashes(7, "claude", elems[:1])[0], "seed differs by format") +} diff --git a/service/task_billing_test.go b/service/task_billing_test.go index f51b9d538a12..623b25be494e 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -54,7 +54,8 @@ func TestMain(m *testing.M) { &model.UserSubscription{}, &model.SystemTask{}, &model.SystemTaskLock{}, - &model.ChatLog{}, + &model.ChatSession{}, + &model.ChatTurn{}, ); err != nil { panic("failed to migrate: " + err.Error()) } @@ -80,7 +81,8 @@ func truncate(t *testing.T) { model.DB.Exec("DELETE FROM user_subscriptions") model.DB.Exec("DELETE FROM system_task_locks") model.DB.Exec("DELETE FROM system_tasks") - model.DB.Exec("DELETE FROM chat_logs") + model.DB.Exec("DELETE FROM chat_turns") + model.DB.Exec("DELETE FROM chat_sessions") }) } diff --git a/web/src/features/chat-logs/api.ts b/web/src/features/chat-logs/api.ts index 6d04bfa447ed..2ad85ef98be0 100644 --- a/web/src/features/chat-logs/api.ts +++ b/web/src/features/chat-logs/api.ts @@ -7,7 +7,7 @@ published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of +but WITHOUT ANY WARRANTY; without even implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. @@ -18,25 +18,29 @@ For commercial licensing, please contact support@quantumnous.com */ import { api } from '@/lib/api' -import type { DetailResponse, GetChatLogsParams, ListResponse } from './types' +import type { + GetChatSessionsParams, + SessionDetailResponse, + SessionListResponse, +} from './types' -export async function getChatLogs( - params: GetChatLogsParams -): Promise { - const res = await api.get('/api/chat_logs', { params }) +export async function getChatSessions( + params: GetChatSessionsParams +): Promise { + const res = await api.get('/api/chat_logs/sessions', { params }) return res.data } -export async function getChatLogDetail( +export async function getChatSessionDetail( id: number -): Promise { - const res = await api.get(`/api/chat_logs/${id}`) +): Promise { + const res = await api.get(`/api/chat_logs/sessions/${id}`) return res.data } -export const chatLogsQueryKeys = { - all: ['chat-logs'] as const, +export const chatSessionsQueryKeys = { + all: ['chat-sessions'] as const, list: (params: Record) => - [...chatLogsQueryKeys.all, 'list', params] as const, - detail: (id: number) => [...chatLogsQueryKeys.all, 'detail', id] as const, + [...chatSessionsQueryKeys.all, 'list', params] as const, + detail: (id: number) => [...chatSessionsQueryKeys.all, 'detail', id] as const, } diff --git a/web/src/features/chat-logs/components/chat-log-detail-sheet.tsx b/web/src/features/chat-logs/components/chat-log-detail-sheet.tsx deleted file mode 100644 index 41a8e4a6e74f..000000000000 --- a/web/src/features/chat-logs/components/chat-log-detail-sheet.tsx +++ /dev/null @@ -1,156 +0,0 @@ -/* -Copyright (C) 2023-2026 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ -import { useQuery } from '@tanstack/react-query' -import { Loader2 } from 'lucide-react' -import { useTranslation } from 'react-i18next' - -import { CodeBlock } from '@/components/ai-elements/code-block' -import { sideDrawerContentClassName } from '@/components/drawer-layout' -import { Badge } from '@/components/ui/badge' -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from '@/components/ui/sheet' -import { formatTimestampToDate } from '@/lib/format' - -import { chatLogsQueryKeys, getChatLogDetail } from '../api' -import { formatChatLogBody } from '../lib/format-body' - -export interface ChatLogDetailSheetProps { - open: boolean - onOpenChange: (open: boolean) => void - id: number | null -} - -function MetaItem({ label, value }: { label: string; value: React.ReactNode }) { - return ( -
- {label} - {value} -
- ) -} - -export function ChatLogDetailSheet(props: ChatLogDetailSheetProps) { - const { t } = useTranslation() - - const query = useQuery({ - queryKey: chatLogsQueryKeys.detail(props.id ?? 0), - queryFn: () => getChatLogDetail(props.id as number), - enabled: props.open && props.id != null, - }) - - const record = query.data?.data - const unavailable = !query.isLoading && (query.isError || !query.data?.success) - - let body: React.ReactNode = null - if (query.isLoading) { - body = ( -
- - {t('Loading...')} -
- ) - } else if (unavailable) { - body = ( -

- {query.data?.message || t('No data')} -

- ) - } else if (record) { - body = ( -
-
- - - - - - - - -
- {t('Stream')} - - {record.is_stream ? t('Yes') : t('No')} - -
- {record.truncated ? ( -
- - {t('Truncated')} - - {t('Truncated')} -
- ) : null} -
- -
- - {t('Request Body')} - - -
- -
- - {t('Response Body')} - - -
-
- ) - } - - return ( - - - - {t('Chat Logs')} - - {t( - 'Conversation detail is only available when the token has chat-log enabled and the standalone database is configured.' - )} - - - -
{body}
-
-
- ) -} diff --git a/web/src/features/chat-logs/components/chat-logs-table.tsx b/web/src/features/chat-logs/components/chat-sessions-table.tsx similarity index 72% rename from web/src/features/chat-logs/components/chat-logs-table.tsx rename to web/src/features/chat-logs/components/chat-sessions-table.tsx index 1a72abeb848f..ba6c134e0cf9 100644 --- a/web/src/features/chat-logs/components/chat-logs-table.tsx +++ b/web/src/features/chat-logs/components/chat-sessions-table.tsx @@ -7,7 +7,7 @@ published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of +but WITHOUT ANY WARRANTY; without even implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. @@ -30,17 +30,16 @@ import { } from '@/components/data-table' import { useTableUrlState } from '@/hooks/use-table-url-state' import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { formatTimestampToDate } from '@/lib/format' -import { chatLogsQueryKeys, getChatLogs } from '../api' -import type { ChatLogMeta } from '../types' -import { ChatLogDetailSheet } from './chat-log-detail-sheet' +import { chatSessionsQueryKeys, getChatSessions } from '../api' +import type { ChatSessionMeta } from '../types' +import { SessionDetailSheet } from './session-detail-sheet' const route = getRouteApi('/_authenticated/chat-logs/') -export function ChatLogsTable() { +export function ChatSessionsTable() { const { t } = useTranslation() const [detailId, setDetailId] = useState(null) const [detailOpen, setDetailOpen] = useState(false) @@ -65,6 +64,14 @@ export function ChatLogsTable() { deserialize: (value) => typeof value === 'number' ? String(value) : '', }, + { + columnId: 'user_id', + searchKey: 'user_id', + type: 'string', + serialize: (value) => (value ? Number(value) : undefined), + deserialize: (value) => + typeof value === 'number' ? String(value) : '', + }, { columnId: 'model_name', searchKey: 'model_name', type: 'string' }, ], }) @@ -82,6 +89,19 @@ export function ChatLogsTable() { onColumnFiltersChange, }) + const { + value: userIdFilter, + inputValue: userIdInput, + onChange: onUserIdChange, + onCompositionStart: onUserIdCompositionStart, + onCompositionEnd: onUserIdCompositionEnd, + resetInput: resetUserIdInput, + } = useDebouncedColumnFilter({ + columnFilters, + columnId: 'user_id', + onColumnFiltersChange, + }) + const { value: modelNameFilter, inputValue: modelNameInput, @@ -100,15 +120,8 @@ export function ChatLogsTable() { setDetailOpen(true) } - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ - { - accessorKey: 'created_at', - header: t('Created At'), - cell: ({ row }) => - formatTimestampToDate(row.getValue('created_at') as number), - size: 170, - }, { accessorKey: 'model_name', header: t('Model'), @@ -127,51 +140,33 @@ export function ChatLogsTable() { size: 90, }, { - accessorKey: 'user_id', - header: t('User ID'), + accessorKey: 'turn_count', + header: t('Turns'), meta: { mobileHidden: true }, - cell: ({ row }) => row.getValue('user_id') as number, + cell: ({ row }) => row.getValue('turn_count') as number, size: 90, }, { - accessorKey: 'channel_id', - header: t('Channel ID'), + accessorKey: 'message_count', + header: t('Messages'), meta: { mobileHidden: true }, - cell: ({ row }) => row.getValue('channel_id') as number, + cell: ({ row }) => row.getValue('message_count') as number, size: 110, }, { - accessorKey: 'is_stream', - header: t('Stream'), - meta: { mobileHidden: true }, - cell: ({ row }) => { - const stream = row.getValue('is_stream') as boolean - return ( - - {stream ? t('Yes') : t('No')} - - ) - }, - size: 90, - }, - { - accessorKey: 'truncated', - header: t('Truncated'), + accessorKey: 'created_at', + header: t('Created At'), meta: { mobileHidden: true }, cell: ({ row }) => - (row.getValue('truncated') as boolean) ? ( - {t('Truncated')} - ) : ( - - - ), - size: 110, + formatTimestampToDate(row.getValue('created_at') as number), + size: 170, }, { - accessorKey: 'status_code', - header: t('Status Code'), - meta: { mobileHidden: true }, - cell: ({ row }) => row.getValue('status_code') as number, - size: 110, + accessorKey: 'last_active_at', + header: t('Last Active'), + cell: ({ row }) => + formatTimestampToDate(row.getValue('last_active_at') as number), + size: 170, }, { id: 'actions', @@ -195,28 +190,25 @@ export function ChatLogsTable() { [t] ) + const queryParams = { + token_id: tokenIdFilter ? Number(tokenIdFilter) : undefined, + user_id: userIdFilter ? Number(userIdFilter) : undefined, + model_name: modelNameFilter || undefined, + page: pagination.pageIndex + 1, + page_size: pagination.pageSize, + } + const { data, isLoading, isFetching } = useQuery({ - queryKey: chatLogsQueryKeys.list({ - token_id: tokenIdFilter ? Number(tokenIdFilter) : undefined, - model_name: modelNameFilter || undefined, - page: pagination.pageIndex + 1, - page_size: pagination.pageSize, - }), - queryFn: () => - getChatLogs({ - token_id: tokenIdFilter ? Number(tokenIdFilter) : undefined, - model_name: modelNameFilter || undefined, - page: pagination.pageIndex + 1, - page_size: pagination.pageSize, - }), + queryKey: chatSessionsQueryKeys.list(queryParams), + queryFn: () => getChatSessions(queryParams), placeholderData: (prev) => prev, }) - const logs = data?.data ?? [] + const sessions = data?.data ?? [] const totalCount = data?.total ?? 0 const { table } = useDataTable({ - data: logs, + data: sessions, columns, totalCount, columnFilters, @@ -238,7 +230,7 @@ export function ChatLogsTable() { isFetching={isFetching} emptyTitle={t('No Chat Logs Found')} emptyDescription={t('No conversation records available.')} - skeletonKeyPrefix='chat-log-skeleton' + skeletonKeyPrefix='chat-session-skeleton' toolbarProps={{ customSearch: null, additionalSearch: ( @@ -252,6 +244,15 @@ export function ChatLogsTable() { onCompositionEnd={onTokenIdCompositionEnd} className='w-full sm:w-[140px]' /> + ), hasAdditionalFilters: - !!tokenIdFilter || !!modelNameFilter, + !!tokenIdFilter || !!userIdFilter || !!modelNameFilter, onReset: () => { resetTokenIdInput() + resetUserIdInput() resetModelNameInput() }, hideViewOptions: true, }} /> - . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useQuery } from '@tanstack/react-query' +import { Loader2 } from 'lucide-react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +import { CodeBlock } from '@/components/ai-elements/code-block' +import { sideDrawerContentClassName } from '@/components/drawer-layout' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' +import { formatTimestampToDate } from '@/lib/format' + +import { chatSessionsQueryKeys, getChatSessionDetail } from '../api' +import { buildTranscript } from '../lib/transcript' + +export interface SessionDetailSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + id: number | null +} + +function MetaItem({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ) +} + +interface KeyedCode { + key: string + code: string +} + +/** + * Messages are arbitrary JSON without ids; derive stable unique keys from + * content (with an occurrence suffix for exact duplicates). + */ +function toKeyedCodes(messages: unknown[]): KeyedCode[] { + const seen = new Map() + return messages.map((message) => { + const code = JSON.stringify(message, null, 2) + const n = seen.get(code) ?? 0 + seen.set(code, n + 1) + return { key: n === 0 ? code : `${code}#${n}`, code } + }) +} + +export function SessionDetailSheet(props: SessionDetailSheetProps) { + const { t } = useTranslation() + + const query = useQuery({ + queryKey: chatSessionsQueryKeys.detail(props.id ?? 0), + queryFn: () => getChatSessionDetail(props.id as number), + enabled: props.open && props.id != null, + }) + + const record = query.data?.data + const turns = useMemo( + () => (record ? buildTranscript(record.turns) : []), + [record] + ) + const unavailable = !query.isLoading && (query.isError || !query.data?.success) + + let body: React.ReactNode = null + if (query.isLoading) { + body = ( +
+ + {t('Loading...')} +
+ ) + } else if (unavailable) { + body = ( +

+ {query.data?.message || t('No data')} +

+ ) + } else if (record) { + body = ( +
+
+ {record.session.model_name}} + /> + + + + + + +
+ + {turns.length === 0 ? ( +

+ {t('No data')} +

+ ) : ( +
+ {turns.map((view) => ( +
+
+ + {t('Turn {{index}}', { index: view.turn.turn_index + 1 })} + + + {formatTimestampToDate(view.turn.created_at)} + +
+ {toKeyedCodes(view.displayMessages).map((item) => ( + + ))} + +
+ ))} +
+ )} +
+ ) + } + + return ( + + + + {t('Transcript')} + + {t( + 'Conversation detail is only available when the token has chat-log enabled and the standalone database is configured.' + )} + + + +
{body}
+
+
+ ) +} diff --git a/web/src/features/chat-logs/index.tsx b/web/src/features/chat-logs/index.tsx index 63e9e1687264..27bb39409ad8 100644 --- a/web/src/features/chat-logs/index.tsx +++ b/web/src/features/chat-logs/index.tsx @@ -20,7 +20,7 @@ import { useTranslation } from 'react-i18next' import { SectionPageLayout } from '@/components/layout' -import { ChatLogsTable } from './components/chat-logs-table' +import { ChatSessionsTable } from './components/chat-sessions-table' export function ChatLogs() { const { t } = useTranslation() @@ -31,7 +31,7 @@ export function ChatLogs() { {t('Chat Logs')} - + ) diff --git a/web/src/features/chat-logs/lib/__tests__/format-body.test.ts b/web/src/features/chat-logs/lib/__tests__/format-body.test.ts index 2a2da78eeb1f..1f027a2f2d6f 100644 --- a/web/src/features/chat-logs/lib/__tests__/format-body.test.ts +++ b/web/src/features/chat-logs/lib/__tests__/format-body.test.ts @@ -81,14 +81,38 @@ const claudeSseFullBlocks = [ ].join('\n') const openAiSse = [ - 'data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hi"}}]}', + 'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},"finish_reason":null}]}', '', - 'data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" there"}}]}', + 'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"gpt-4","choices":[{"index":0,"delta":{"content":" there"},"finish_reason":null}]}', + '', + 'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}', + '', + 'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"gpt-4","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":2}}', + '', + 'data: [DONE]', + '', +].join('\n') + +const openAiToolCallSse = [ + 'data: {"id":"c2","object":"chat.completion.chunk","model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}', + '', + 'data: {"id":"c2","object":"chat.completion.chunk","model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"city\\":"}}]},"finish_reason":null}]}', + '', + 'data: {"id":"c2","object":"chat.completion.chunk","model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" \\"Beijing\\"}"}}]},"finish_reason":null}]}', + '', + 'data: {"id":"c2","object":"chat.completion.chunk","model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}', '', 'data: [DONE]', '', ].join('\n') +const genericSse = [ + 'data: {"type":"status","progress":1}', + '', + 'data: {"type":"status","progress":2}', + '', +].join('\n') + describe('formatChatLogBody', () => { test('pretty-prints valid JSON bodies', () => { const out = formatChatLogBody('{"a": 1, "b":[1, 2]}') @@ -144,12 +168,41 @@ describe('formatChatLogBody', () => { expect(msg.content[0].text).toBe('partial answ') }) - test('pretty-prints non-Claude SSE chunks as an array', () => { + test('merges OpenAI stream chunks into a chat.completion', () => { const out = formatChatLogBody(openAiSse) + const msg = JSON.parse(out) + expect(msg).toMatchObject({ + id: 'c1', + object: 'chat.completion', + model: 'gpt-4', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hi there' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 2 }, + }) + }) + + test('merges OpenAI tool_call argument deltas', () => { + const out = formatChatLogBody(openAiToolCallSse) + const msg = JSON.parse(out) + expect(msg.choices[0].finish_reason).toBe('tool_calls') + expect(msg.choices[0].message.tool_calls[0]).toEqual({ + id: 'call_1', + type: 'function', + function: { name: 'get_weather', arguments: '{"city": "Beijing"}' }, + }) + }) + + test('falls back to an array for non-chat SSE payloads', () => { + const out = formatChatLogBody(genericSse) const chunks = JSON.parse(out) expect(Array.isArray(chunks)).toBe(true) expect(chunks).toHaveLength(2) - expect(chunks[0].choices[0].delta.content).toBe('Hi') + expect(chunks[1].progress).toBe(2) }) test('re-indents truncated JSON instead of returning one huge line', () => { diff --git a/web/src/features/chat-logs/lib/__tests__/transcript.test.ts b/web/src/features/chat-logs/lib/__tests__/transcript.test.ts new file mode 100644 index 000000000000..756fce410f4a --- /dev/null +++ b/web/src/features/chat-logs/lib/__tests__/transcript.test.ts @@ -0,0 +1,118 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { describe, expect, test } from 'vitest' + +import type { ChatTurn } from '../../types' +import { buildTranscript } from '../transcript' + +function makeTurn(overrides: Partial = {}): ChatTurn { + return { + id: 1, + session_id: 1, + turn_index: 0, + request_id: 'req-1', + model_name: 'gpt-4', + channel_id: 1, + status_code: 200, + use_time: 1, + is_stream: false, + new_messages: '[]', + response_body: '{}', + created_at: 1700000000, + ...overrides, + } +} + +describe('buildTranscript', () => { + test('drops leading assistant echo messages and stops at first non-assistant', () => { + const turn = makeTurn({ + new_messages: JSON.stringify([ + { role: 'assistant', content: 'previous response' }, + { role: 'assistant', content: 'another echo' }, + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'kept: after user' }, + ]), + }) + + const [view] = buildTranscript([turn]) + + expect(view?.displayMessages).toEqual([ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'kept: after user' }, + ]) + }) + + test('drops leading model (Gemini) echo message and stops at first non-echo', () => { + const turn = makeTurn({ + new_messages: JSON.stringify([ + { role: 'model', content: 'gemini echo of previous response' }, + { role: 'user', content: 'hello' }, + ]), + }) + + const [view] = buildTranscript([turn]) + + expect(view?.displayMessages).toEqual([{ role: 'user', content: 'hello' }]) + }) + + test('keeps assistant message that appears after a user message', () => { + const turn = makeTurn({ + new_messages: JSON.stringify([ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'tool echo mid-conversation' }, + { role: 'user', content: 'next' }, + ]), + }) + + const [view] = buildTranscript([turn]) + + expect(view?.displayMessages).toHaveLength(3) + }) + + test('returns empty displayMessages when new_messages is malformed JSON', () => { + const turn = makeTurn({ new_messages: '{"broken":' }) + + const [view] = buildTranscript([turn]) + + expect(view?.displayMessages).toEqual([]) + }) + + test('passes response_body through formatChatLogBody', () => { + const turn = makeTurn({ response_body: '{"role":"assistant","content":"hi"}' }) + + const [view] = buildTranscript([turn]) + + expect(view?.responseText).toBe( + JSON.stringify( + { role: 'assistant', content: 'hi' }, + null, + 2 + ) + ) + }) + + test('returns one TurnView per turn preserving order', () => { + const views = buildTranscript([ + makeTurn({ id: 1, turn_index: 0 }), + makeTurn({ id: 2, turn_index: 1 }), + ]) + + expect(views.map((v) => v.turn.id)).toEqual([1, 2]) + }) +}) diff --git a/web/src/features/chat-logs/lib/format-body.ts b/web/src/features/chat-logs/lib/format-body.ts index 34d944abd9c7..7f4c7631f144 100644 --- a/web/src/features/chat-logs/lib/format-body.ts +++ b/web/src/features/chat-logs/lib/format-body.ts @@ -225,6 +225,132 @@ function reassembleClaudeMessage(chunks: SseChunk[]): ClaudeMessage | null { return message } +interface OpenAiToolCall { + id?: string + type?: string + fnName: string + fnArgs: string +} + +interface OpenAiChoice { + index: number + role: string + content?: string + reasoning?: string + reasoningKey?: string + toolCalls: Map + finishReason?: string +} + +/** + * Merge chat.completion.chunk stream events into the equivalent + * non-stream chat.completion. Returns null for non-OpenAI payloads. + */ +function reassembleOpenAiChunks(chunks: SseChunk[]): SseChunk | null { + if (!chunks.some((c) => c.object === 'chat.completion.chunk')) { + return null + } + const message: SseChunk = { object: 'chat.completion' } + const choices = new Map() + const choiceOf = (index: number): OpenAiChoice => { + let choice = choices.get(index) + if (!choice) { + choice = { index, role: 'assistant', toolCalls: new Map() } + choices.set(index, choice) + } + return choice + } + for (const chunk of chunks) { + for (const key of ['id', 'created', 'model', 'system_fingerprint']) { + if (message[key] === undefined && chunk[key] !== undefined) { + message[key] = chunk[key] + } + } + if (isRecord(chunk.usage) && Object.keys(chunk.usage).length > 0) { + message.usage = { ...chunk.usage } + } + if (!Array.isArray(chunk.choices)) { + continue + } + for (const raw of chunk.choices) { + if (!isRecord(raw)) { + continue + } + const choice = choiceOf(Number(raw.index) || 0) + if (typeof raw.finish_reason === 'string') { + choice.finishReason = raw.finish_reason + } + const delta = isRecord(raw.delta) ? raw.delta : {} + if (typeof delta.role === 'string') { + choice.role = delta.role + } + if (typeof delta.content === 'string') { + choice.content = (choice.content ?? '') + delta.content + } + for (const key of ['reasoning_content', 'reasoning']) { + const value = delta[key] + if (typeof value === 'string' && value) { + choice.reasoningKey = key + choice.reasoning = (choice.reasoning ?? '') + value + } + } + if (!Array.isArray(delta.tool_calls)) { + continue + } + for (const rawCall of delta.tool_calls) { + if (!isRecord(rawCall)) { + continue + } + const idx = Number(rawCall.index) || 0 + let call = choice.toolCalls.get(idx) + if (!call) { + call = { fnName: '', fnArgs: '' } + choice.toolCalls.set(idx, call) + } + if (typeof rawCall.id === 'string') { + call.id = rawCall.id + } + if (typeof rawCall.type === 'string') { + call.type = rawCall.type + } + const fn = isRecord(rawCall.function) ? rawCall.function : {} + if (typeof fn.name === 'string') { + call.fnName += fn.name + } + if (typeof fn.arguments === 'string') { + call.fnArgs += fn.arguments + } + } + } + } + message.choices = [...choices.entries()] + .sort(([a], [b]) => a - b) + .map(([, choice]) => { + const msg: SseChunk = { role: choice.role } + if (choice.content !== undefined) { + msg.content = choice.content + } + if (choice.reasoning !== undefined) { + msg[choice.reasoningKey ?? 'reasoning_content'] = choice.reasoning + } + if (choice.toolCalls.size > 0) { + msg.tool_calls = [...choice.toolCalls.entries()] + .sort(([a], [b]) => a - b) + .map(([, call]) => ({ + id: call.id, + type: call.type, + function: { name: call.fnName, arguments: call.fnArgs }, + })) + } + return { + index: choice.index, + message: msg, + finish_reason: choice.finishReason, + } + }) + return message +} + /** * Line-break and indent a JSON-like body that failed to parse (capture was * truncated mid-JSON). Whitespace outside strings is re-emitted by structure; @@ -293,6 +419,10 @@ export function formatChatLogBody(body: string): string { if (claude) { return JSON.stringify(claude, null, 2) } + const openai = chunks.length > 0 ? reassembleOpenAiChunks(chunks) : null + if (openai) { + return JSON.stringify(openai, null, 2) + } if (chunks.length > 0) { return JSON.stringify(chunks, null, 2) } diff --git a/web/src/features/chat-logs/lib/transcript.ts b/web/src/features/chat-logs/lib/transcript.ts new file mode 100644 index 000000000000..576520dda820 --- /dev/null +++ b/web/src/features/chat-logs/lib/transcript.ts @@ -0,0 +1,60 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { ChatTurn } from '../types' +import { formatChatLogBody } from './format-body' + +export interface TurnView { + turn: ChatTurn + displayMessages: unknown[] + responseText: string +} + +function parseMessages(raw: string): unknown[] { + try { + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function dropLeadingAssistantEchoes(messages: unknown[]): unknown[] { + let first = 0 + while (first < messages.length) { + const role = (messages[first] as { role?: string } | undefined)?.role + if (role !== 'assistant' && role !== 'model') { + break + } + first++ + } + return messages.slice(first) +} + +/** + * Build the display transcript for a session: each turn keeps its new + * messages (minus the leading assistant echoes of the previous response) + * and its formatted response body. + */ +export function buildTranscript(turns: ChatTurn[]): TurnView[] { + return turns.map((turn) => ({ + turn, + displayMessages: dropLeadingAssistantEchoes(parseMessages(turn.new_messages)), + responseText: formatChatLogBody(turn.response_body), + })) +} diff --git a/web/src/features/chat-logs/types.ts b/web/src/features/chat-logs/types.ts index f5e2c026d40c..3bde03613141 100644 --- a/web/src/features/chat-logs/types.ts +++ b/web/src/features/chat-logs/types.ts @@ -7,7 +7,7 @@ published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of +but WITHOUT ANY WARRANTY; without even implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. @@ -16,43 +16,54 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -export interface ChatLogMeta { +export interface ChatSessionMeta { id: number token_id: number user_id: number - channel_id: number model_name: string + turn_count: number + message_count: number + created_at: number + last_active_at: number +} + +export interface ChatTurn { + id: number + session_id: number + turn_index: number request_id: string - is_stream: boolean - truncated: boolean + model_name: string + channel_id: number status_code: number use_time: number + is_stream: boolean + new_messages: string + response_body: string created_at: number } -export interface ChatLogFull extends ChatLogMeta { - request_body: string - response_body: string +export interface SessionDetail { + session: ChatSessionMeta + turns: ChatTurn[] } -export interface GetChatLogsParams { +export interface GetChatSessionsParams { page?: number page_size?: number token_id?: number user_id?: number - channel_id?: number model_name?: string } -export interface ListResponse { +export interface SessionListResponse { success: boolean message?: string - data?: ChatLogMeta[] + data?: ChatSessionMeta[] total: number } -export interface DetailResponse { +export interface SessionDetailResponse { success: boolean message?: string - data?: ChatLogFull + data?: SessionDetail } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 177b68a2c178..acb6828bc84d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2501,6 +2501,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "Language preferences sync across your signed-in devices and affect API error messages.", "Last 24h usage": "Last 24h usage", "Last 30 days uptime": "Last 30 days uptime", + "Last Active": "Last Active", "Last active {{time}} · Expires {{expires}}": "Last active {{time}} · Expires {{expires}}", "Last check time": "Last check time", "Last detected addable models": "Last detected addable models", @@ -2707,6 +2708,7 @@ "Merchant ID is required": "Merchant ID is required", "Merge into Other": "Merge into Other", "Message Priority": "Message Priority", + "Messages": "Messages", "Metadata": "Metadata", "min downtime": "min downtime", "Min Top-up": "Min Top-up", @@ -3887,7 +3889,6 @@ "Report an issue": "Report an issue", "request": "request", "Request": "Request", - "Request Body": "Request Body", "Request Body Disk Cache": "Request Body Disk Cache", "Request Body Field": "Request Body Field", "Request Body Memory Cache": "Request Body Memory Cache", @@ -3976,7 +3977,6 @@ "Resources": "Resources", "Responding...": "Responding...", "Response": "Response", - "Response Body": "Response Body", "Response Time": "Response Time", "Response time: {{duration}}": "Response time: {{duration}}", "Responses API Version": "Responses API Version", @@ -4890,6 +4890,7 @@ "Tracked apps": "Tracked apps", "Tracks current account base limits and additional metered usage on Codex upstream.": "Tracks current account base limits and additional metered usage on Codex upstream.", "Trading insights, accounting, advisory": "Trading insights, accounting, advisory", + "Transcript": "Transcript", "Transfer": "Transfer", "Transfer Amount": "Transfer Amount", "Transfer failed": "Transfer failed", @@ -4907,7 +4908,6 @@ "Trim Space": "Trim Space", "Trim Suffix": "Trim Suffix", "Truncate embeddings to this many dimensions": "Truncate embeddings to this many dimensions", - "Truncated": "Truncated", "Trusted": "Trusted", "Try adjusting your search": "Try adjusting your search", "Try adjusting your search to locate a missing model.": "Try adjusting your search to locate a missing model.", @@ -4920,6 +4920,8 @@ "TTL (seconds)": "TTL (seconds)", "Tuesday": "Tuesday", "Tune selection priority, testing, status handling, and request overrides.": "Tune selection priority, testing, status handling, and request overrides.", + "Turn {{index}}": "Turn {{index}}", + "Turns": "Turns", "Turnstile is enabled but site key is empty.": "Turnstile is enabled but site key is empty.", "Tutoring, learning aids, assessment": "Tutoring, learning aids, assessment", "Two-factor Authentication": "Two-factor Authentication", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index f650e1e1c679..9b16835a7bc5 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2501,6 +2501,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "Les préférences de langue se synchronisent sur vos appareils connectés et affectent les messages d'erreur de l'API.", "Last 24h usage": "Utilisation 24h", "Last 30 days uptime": "Disponibilité 30 derniers jours", + "Last Active": "Dernière activité", "Last active {{time}} · Expires {{expires}}": "Dernière activité {{time}} · Expire le {{expires}}", "Last check time": "Dernière vérification", "Last detected addable models": "Derniers modèles ajoutables détectés", @@ -2707,6 +2708,7 @@ "Merchant ID is required": "L'ID marchand est requis", "Merge into Other": "Fusionner dans Autres", "Message Priority": "Priorité du message", + "Messages": "Messages", "Metadata": "Métadonnées", "min downtime": "min d'interruption", "Min Top-up": "Recharge min.", @@ -3887,7 +3889,6 @@ "Report an issue": "Signaler un problème", "request": "requête", "Request": "Requête", - "Request Body": "Corps de la requête", "Request Body Disk Cache": "Cache disque du corps de requête", "Request Body Field": "Champ du corps de requête", "Request Body Memory Cache": "Cache mémoire du corps de requête", @@ -3976,7 +3977,6 @@ "Resources": "Ressources", "Responding...": "Réponse en cours...", "Response": "Réponse", - "Response Body": "Corps de la réponse", "Response Time": "Temps de réponse", "Response time: {{duration}}": "Temps de réponse : {{duration}}", "Responses API Version": "Version de l'API des réponses", @@ -4890,6 +4890,7 @@ "Tracked apps": "Applications suivies", "Tracks current account base limits and additional metered usage on Codex upstream.": "Affiche les limites de base et l’utilisation supplémentaire (metered) du compte auprès de Codex en amont.", "Trading insights, accounting, advisory": "Analyses de marché, comptabilité, conseil", + "Transcript": "Transcription", "Transfer": "Transférer", "Transfer Amount": "Montant du transfert", "Transfer failed": "Transfert échoué", @@ -4907,7 +4908,6 @@ "Trim Space": "Supprimer les espaces", "Trim Suffix": "Supprimer le suffixe", "Truncate embeddings to this many dimensions": "Tronquer les vecteurs à autant de dimensions", - "Truncated": "Tronqué", "Trusted": "Fiable", "Try adjusting your search": "Essayez d'ajuster votre recherche", "Try adjusting your search to locate a missing model.": "Essayez d'ajuster votre recherche pour localiser un modèle manquant.", @@ -4920,6 +4920,8 @@ "TTL (seconds)": "TTL (secondes)", "Tuesday": "Mardi", "Tune selection priority, testing, status handling, and request overrides.": "Ajustez la priorité de sélection, les tests, la gestion des statuts et les surcharges de requête.", + "Turn {{index}}": "Tour {{index}}", + "Turns": "Tours", "Turnstile is enabled but site key is empty.": "Turnstile est activé mais la clé du site est vide.", "Tutoring, learning aids, assessment": "Tutorat, aides à l'apprentissage, évaluation", "Two-factor Authentication": "Authentification à deux facteurs", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 2c6fdf6df8df..7d035ed28463 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -2501,6 +2501,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "言語設定はログイン中のすべてのデバイスで同期され、API のエラーメッセージ言語にも反映されます。", "Last 24h usage": "直近24時間の使用量", "Last 30 days uptime": "直近 30 日の稼働率", + "Last Active": "最終アクティブ", "Last active {{time}} · Expires {{expires}}": "最終利用 {{time}} · 有効期限 {{expires}}", "Last check time": "最終チェック時刻", "Last detected addable models": "最後に検出された追加可能モデル", @@ -2707,6 +2708,7 @@ "Merchant ID is required": "マーチャント ID は必須です", "Merge into Other": "その他にまとめる", "Message Priority": "メッセージの優先度", + "Messages": "メッセージ", "Metadata": "メタデータ", "min downtime": "分のダウンタイム", "Min Top-up": "最低チャージ額", @@ -3887,7 +3889,6 @@ "Report an issue": "問題を報告", "request": "リクエスト", "Request": "リクエスト", - "Request Body": "リクエスト本文", "Request Body Disk Cache": "リクエストボディのディスクキャッシュ", "Request Body Field": "リクエストボディフィールド", "Request Body Memory Cache": "リクエストボディのメモリキャッシュ", @@ -3976,7 +3977,6 @@ "Resources": "リソース", "Responding...": "応答中...", "Response": "レスポンス", - "Response Body": "レスポンス本文", "Response Time": "応答時間", "Response time: {{duration}}": "応答時間: {{duration}}", "Responses API Version": "応答APIバージョン", @@ -4890,6 +4890,7 @@ "Tracked apps": "追跡中のアプリ", "Tracks current account base limits and additional metered usage on Codex upstream.": "Codex 上でのアカウント基礎枠と追加従量の利用量を表示します。", "Trading insights, accounting, advisory": "トレーディング分析・会計・アドバイザリー", + "Transcript": "トランスクリプト", "Transfer": "振替", "Transfer Amount": "振替金額", "Transfer failed": "転送に失敗しました", @@ -4907,7 +4908,6 @@ "Trim Space": "空白削除", "Trim Suffix": "サフィックス削除", "Truncate embeddings to this many dimensions": "指定した次元数にベクトルを切り詰めます", - "Truncated": "切り詰め", "Trusted": "信頼済み", "Try adjusting your search": "検索条件を調整してみてください", "Try adjusting your search to locate a missing model.": "見つからないモデルを見つけるには、検索を調整してみてください。", @@ -4920,6 +4920,8 @@ "TTL (seconds)": "TTL(秒)", "Tuesday": "火曜日", "Tune selection priority, testing, status handling, and request overrides.": "選択優先度、テスト、ステータス処理、リクエスト上書きを調整します。", + "Turn {{index}}": "ターン {{index}}", + "Turns": "ターン", "Turnstile is enabled but site key is empty.": "Turnstile が有効ですが、サイトキーが空です。", "Tutoring, learning aids, assessment": "個別指導・学習補助・評価", "Two-factor Authentication": "2要素認証", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 03663429aff0..b99d536903f7 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2501,6 +2501,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "Языковые настройки синхронизируются на всех ваших устройствах после входа и влияют на язык сообщений об ошибках API.", "Last 24h usage": "Расход за 24ч", "Last 30 days uptime": "Доступность за 30 дней", + "Last Active": "Последняя активность", "Last active {{time}} · Expires {{expires}}": "Последняя активность: {{time}} · Истекает: {{expires}}", "Last check time": "Время последней проверки", "Last detected addable models": "Последние обнаруженные модели для добавления", @@ -2707,6 +2708,7 @@ "Merchant ID is required": "Требуется ID мерчанта", "Merge into Other": "Объединить в «Другое»", "Message Priority": "Приоритет сообщения", + "Messages": "Сообщения", "Metadata": "Метаданные", "min downtime": "мин простоя", "Min Top-up": "Мин. пополнение", @@ -3887,7 +3889,6 @@ "Report an issue": "Сообщить о проблеме", "request": "запрос", "Request": "Запрос", - "Request Body": "Тело запроса", "Request Body Disk Cache": "Дисковый кэш тела запроса", "Request Body Field": "Поле тела запроса", "Request Body Memory Cache": "Кэш памяти тела запроса", @@ -3976,7 +3977,6 @@ "Resources": "Ресурсы", "Responding...": "Отвечаем...", "Response": "Ответ", - "Response Body": "Тело ответа", "Response Time": "Время ответа", "Response time: {{duration}}": "Время ответа: {{duration}}", "Responses API Version": "Версия API ответов", @@ -4890,6 +4890,7 @@ "Tracked apps": "Отслеживаемые приложения", "Tracks current account base limits and additional metered usage on Codex upstream.": "Отслеживает базовые лимиты и дополнительное потребление (metered) аккаунта на стороне Codex.", "Trading insights, accounting, advisory": "Торговые инсайты, учёт, консалтинг", + "Transcript": "Стенограмма", "Transfer": "Перевод", "Transfer Amount": "Сумма перевода", "Transfer failed": "Перевод не удался", @@ -4907,7 +4908,6 @@ "Trim Space": "Обрезать пробелы", "Trim Suffix": "Обрезать суффикс", "Truncate embeddings to this many dimensions": "Усечь эмбеддинги до указанного числа измерений", - "Truncated": "Обрезано", "Trusted": "Доверенный", "Try adjusting your search": "Попробуйте изменить условия поиска", "Try adjusting your search to locate a missing model.": "Попробуйте изменить параметры поиска, чтобы найти отсутствующую модель.", @@ -4920,6 +4920,8 @@ "TTL (seconds)": "TTL (секунды)", "Tuesday": "Вторник", "Tune selection priority, testing, status handling, and request overrides.": "Настройте приоритет выбора, тестирование, обработку статусов и переопределения запросов.", + "Turn {{index}}": "Ход {{index}}", + "Turns": "Ходы", "Turnstile is enabled but site key is empty.": "Turnstile включён, но ключ сайта пуст.", "Tutoring, learning aids, assessment": "Репетиторство, учебные материалы, оценка", "Two-factor Authentication": "Двухфакторная аутентификация", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index bc92044d4eec..c4c520e62ccb 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -2501,6 +2501,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "Tùy chọn ngôn ngữ sẽ đồng bộ trên các thiết bị đã đăng nhập và ảnh hưởng đến ngôn ngữ thông báo lỗi API.", "Last 24h usage": "Sử dụng 24h qua", "Last 30 days uptime": "Uptime 30 ngày qua", + "Last Active": "Hoạt động cuối", "Last active {{time}} · Expires {{expires}}": "Hoạt động gần nhất {{time}} · Hết hạn {{expires}}", "Last check time": "Thời gian kiểm tra gần nhất", "Last detected addable models": "Mô hình có thể thêm được phát hiện gần nhất", @@ -2707,6 +2708,7 @@ "Merchant ID is required": "Bắt buộc nhập Merchant ID", "Merge into Other": "Gộp vào Khác", "Message Priority": "Ưu tiên tin nhắn", + "Messages": "Tin nhắn", "Metadata": "Siêu dữ liệu", "min downtime": "phút gián đoạn", "Min Top-up": "Nạp tối thiểu", @@ -3887,7 +3889,6 @@ "Report an issue": "Báo cáo sự cố", "request": "yêu cầu", "Request": "Yêu cầu", - "Request Body": "Nội dung yêu cầu", "Request Body Disk Cache": "Bộ nhớ đệm đĩa nội dung yêu cầu", "Request Body Field": "Trường thân yêu cầu", "Request Body Memory Cache": "Bộ nhớ đệm RAM nội dung yêu cầu", @@ -3976,7 +3977,6 @@ "Resources": "Tài nguyên", "Responding...": "Đang phản hồi...", "Response": "Phản hồi", - "Response Body": "Nội dung phản hồi", "Response Time": "Thời gian phản hồi", "Response time: {{duration}}": "Thời gian phản hồi: {{duration}}", "Responses API Version": "Phiên bản API Phản hồi", @@ -4890,6 +4890,7 @@ "Tracked apps": "Ứng dụng được theo dõi", "Tracks current account base limits and additional metered usage on Codex upstream.": "Theo dõi hạn cơ bản và mức dùng tính phí bổ sung của tài khoản ở phía upstream Codex.", "Trading insights, accounting, advisory": "Phân tích giao dịch, kế toán, tư vấn", + "Transcript": "Bản ghi hội thoại", "Transfer": "Chuyển", "Transfer Amount": "Số tiền chuyển khoản", "Transfer failed": "Chuyển thất bại", @@ -4907,7 +4908,6 @@ "Trim Space": "Cắt khoảng trắng", "Trim Suffix": "Cắt hậu tố", "Truncate embeddings to this many dimensions": "Cắt embedding xuống số chiều này", - "Truncated": "Đã cắt bớt", "Trusted": "Đáng tin cậy", "Try adjusting your search": "Hãy thử điều chỉnh tìm kiếm", "Try adjusting your search to locate a missing model.": "Hãy thử điều chỉnh tìm kiếm của bạn để định vị một mô hình bị thiếu.", @@ -4920,6 +4920,8 @@ "TTL (seconds)": "TTL (giây)", "Tuesday": "Thứ ba", "Tune selection priority, testing, status handling, and request overrides.": "Tinh chỉnh ưu tiên chọn, kiểm thử, xử lý trạng thái và ghi đè yêu cầu.", + "Turn {{index}}": "Lượt {{index}}", + "Turns": "Lượt", "Turnstile is enabled but site key is empty.": "Turnstile đã được bật nhưng khóa trang web trống.", "Tutoring, learning aids, assessment": "Gia sư, hỗ trợ học tập, đánh giá", "Two-factor Authentication": "Xác thực hai yếu tố", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 8ea314500790..fe4bb552a318 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -2501,6 +2501,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "語言偏好會同步到您登入的所有設備,並影響 API 錯誤訊息語言。", "Last 24h usage": "近 24 小時消耗", "Last 30 days uptime": "近 30 天可用率", + "Last Active": "最後活躍", "Last active {{time}} · Expires {{expires}}": "最後活動於 {{time}} · 到期時間 {{expires}}", "Last check time": "上次檢測時間", "Last detected addable models": "上次檢測到可加入模型", @@ -2707,6 +2708,7 @@ "Merchant ID is required": "商戶 ID 為必填項", "Merge into Other": "合併為其他", "Message Priority": "訊息優先級", + "Messages": "訊息", "Metadata": "元資訊", "min downtime": "分鐘停機", "Min Top-up": "最低儲值", @@ -3887,7 +3889,6 @@ "Report an issue": "回報問題", "request": "請求", "Request": "請求", - "Request Body": "Request Body", "Request Body Disk Cache": "請求體磁碟緩存", "Request Body Field": "請求體欄位", "Request Body Memory Cache": "請求體記憶體緩存", @@ -3976,7 +3977,6 @@ "Resources": "資源", "Responding...": "正在回覆...", "Response": "回應", - "Response Body": "Response Body", "Response Time": "回應時間", "Response time: {{duration}}": "回應時間:{{duration}}", "Responses API Version": "回應 API 版本", @@ -4890,6 +4890,7 @@ "Tracked apps": "已追蹤的套用", "Tracks current account base limits and additional metered usage on Codex upstream.": "追蹤目前賬號在 Codex 上游的基礎限額與附加收費用量。", "Trading insights, accounting, advisory": "交易洞察、記賬與財務建議", + "Transcript": "對話記錄", "Transfer": "轉移", "Transfer Amount": "轉移金額", "Transfer failed": "轉賬失敗", @@ -4907,7 +4908,6 @@ "Trim Space": "去掉空白", "Trim Suffix": "裁剪後綴", "Truncate embeddings to this many dimensions": "將向量截斷到指定維度", - "Truncated": "Truncated", "Trusted": "受信任", "Try adjusting your search": "請嘗試調整搜尋條件", "Try adjusting your search to locate a missing model.": "嘗試調整您的搜尋以找到缺失的模型。", @@ -4920,6 +4920,8 @@ "TTL (seconds)": "TTL(秒)", "Tuesday": "星期二", "Tune selection priority, testing, status handling, and request overrides.": "調整選擇優先級、測試、狀態處理和請求覆蓋。", + "Turn {{index}}": "第 {{index}} 輪", + "Turns": "輪次", "Turnstile is enabled but site key is empty.": "Turnstile 已啟用但站點金鑰為空。", "Tutoring, learning aids, assessment": "輔導、學習輔助與測評", "Two-factor Authentication": "雙重身份驗證", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 77ae593b0e00..7a30c4e54fe1 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2501,6 +2501,7 @@ "Language preferences sync across your signed-in devices and affect API error messages.": "语言偏好会同步到您登录的所有设备,并影响 API 错误消息语言。", "Last 24h usage": "近 24 小时消耗", "Last 30 days uptime": "近 30 天可用率", + "Last Active": "最后活跃", "Last active {{time}} · Expires {{expires}}": "最后活跃于 {{time}} · 到期时间 {{expires}}", "Last check time": "上次检测时间", "Last detected addable models": "上次检测到可加入模型", @@ -2707,6 +2708,7 @@ "Merchant ID is required": "商户 ID 为必填项", "Merge into Other": "合并为其他", "Message Priority": "消息优先级", + "Messages": "消息", "Metadata": "元信息", "min downtime": "分钟停机", "Min Top-up": "最低充值", @@ -3887,7 +3889,6 @@ "Report an issue": "反馈问题", "request": "请求", "Request": "请求", - "Request Body": "请求体", "Request Body Disk Cache": "请求体磁盘缓存", "Request Body Field": "请求体字段", "Request Body Memory Cache": "请求体内存缓存", @@ -3976,7 +3977,6 @@ "Resources": "资源", "Responding...": "正在回复...", "Response": "响应", - "Response Body": "响应体", "Response Time": "响应时间", "Response time: {{duration}}": "响应时间:{{duration}}", "Responses API Version": "响应 API 版本", @@ -4890,6 +4890,7 @@ "Tracked apps": "已跟踪的应用", "Tracks current account base limits and additional metered usage on Codex upstream.": "跟踪当前账号在 Codex 上游的基础限额与附加计费用量。", "Trading insights, accounting, advisory": "交易洞察、记账与财务建议", + "Transcript": "对话记录", "Transfer": "转移", "Transfer Amount": "转移金额", "Transfer failed": "转账失败", @@ -4907,7 +4908,6 @@ "Trim Space": "去掉空白", "Trim Suffix": "裁剪后缀", "Truncate embeddings to this many dimensions": "将向量截断到指定维度", - "Truncated": "已截断", "Trusted": "受信任", "Try adjusting your search": "请尝试调整搜索条件", "Try adjusting your search to locate a missing model.": "尝试调整您的搜索以找到缺失的模型。", @@ -4920,6 +4920,8 @@ "TTL (seconds)": "TTL(秒)", "Tuesday": "周二", "Tune selection priority, testing, status handling, and request overrides.": "调整选择优先级、测试、状态处理和请求覆盖。", + "Turn {{index}}": "第 {{index}} 轮", + "Turns": "轮次", "Turnstile is enabled but site key is empty.": "Turnstile 已启用但站点密钥为空。", "Tutoring, learning aids, assessment": "辅导、学习辅助与测评", "Two-factor Authentication": "双重身份验证", diff --git a/web/src/routes/_authenticated/chat-logs/index.tsx b/web/src/routes/_authenticated/chat-logs/index.tsx index 98c303f8de5c..b22fec051688 100644 --- a/web/src/routes/_authenticated/chat-logs/index.tsx +++ b/web/src/routes/_authenticated/chat-logs/index.tsx @@ -26,6 +26,7 @@ import { useAuthStore } from '@/stores/auth-store' const chatLogsSearchSchema = z.object({ page: z.number().optional().catch(1), token_id: z.number().optional().catch(undefined), + user_id: z.number().optional().catch(undefined), model_name: z.string().optional().catch(''), }) From edacf34720f8e37d6a426c324c00ce4abc4d4e11 Mon Sep 17 00:00:00 2001 From: Wenyi Tang Date: Tue, 1 Sep 2026 14:22:07 +0800 Subject: [PATCH 3/3] =?UTF-8?q?[fix]=20=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E4=BB=96=E4=BA=BA=20API=20Key=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=EF=BC=9A=E6=96=B0=E5=A2=9E=20admin=20key=20=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E7=AB=AF=E7=82=B9=E5=B9=B6=E6=8E=A5=E5=85=A5=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20adminMode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: AGENTS --- controller/audit.go | 1 + controller/token.go | 40 ++++++++++++++++ controller/token_test.go | 29 ++++++++++++ model/token.go | 8 ++-- router/api-router.go | 2 + web/src/features/keys/api.ts | 46 +++++++++++++++---- .../keys/components/api-keys-provider.tsx | 14 ++++-- 7 files changed, 122 insertions(+), 18 deletions(-) diff --git a/controller/audit.go b/controller/audit.go index d6974b900806..7874a9279109 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -36,6 +36,7 @@ var auditContentTemplates = map[string]string{ "channel.delete_batch": "Batch deleted ${count} channels", "channel.delete_disabled": "Deleted all disabled channels (${count})", "channel.key_view": "Viewed channel key ${name} (ID: ${id})", + "token.key_view": "Viewed token key ${name} (ID: ${id})", "channel.tag_disable": "Disabled channels with tag ${tag}", "channel.tag_enable": "Enabled channels with tag ${tag}", "channel.tag_edit": "Edited channels with tag ${tag}", diff --git a/controller/token.go b/controller/token.go index de8c6082d58d..5957c6fde3d4 100644 --- a/controller/token.go +++ b/controller/token.go @@ -516,6 +516,46 @@ func GetTokenKeysBatch(c *gin.Context) { common.ApiSuccess(c, gin.H{"keys": keysMap}) } +func AdminGetTokenKey(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + token, err := model.GetTokenById(id) + if err != nil { + common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed) + return + } + recordManageAudit(c, "token.key_view", map[string]interface{}{ + "id": id, + "name": token.Name, + }) + common.ApiSuccess(c, gin.H{"key": token.GetFullKey()}) +} + +func AdminGetTokenKeysBatch(c *gin.Context) { + tokenBatch := TokenBatch{} + if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + if len(tokenBatch.Ids) > 100 { + common.ApiErrorI18n(c, i18n.MsgBatchTooMany, map[string]any{"Max": 100}) + return + } + tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, 0) + if err != nil { + common.ApiError(c, err) + return + } + keysMap := make(map[int]string) + for _, t := range tokens { + keysMap[t.Id] = t.GetFullKey() + } + common.ApiSuccess(c, gin.H{"keys": keysMap}) +} + func GetTokenChannelQuotas(c *gin.Context) { tokenId, err := strconv.Atoi(c.Param("id")) if err != nil { diff --git a/controller/token_test.go b/controller/token_test.go index 9cca168ab100..5ec9d9b2cedf 100644 --- a/controller/token_test.go +++ b/controller/token_test.go @@ -578,3 +578,32 @@ func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) { t.Fatalf("unauthorized key response leaked raw token key: %s", unauthorizedRecorder.Body.String()) } } + +func TestAdminGetTokenKeyReturnsOtherUsersFullKey(t *testing.T) { + db := setupTokenControllerTestDB(t) + token := seedToken(t, db, 1, "admin-viewed-token", "adminView312token456") + + adminCtx, adminRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/admin/"+strconv.Itoa(token.Id)+"/key", nil, 99) + adminCtx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}} + AdminGetTokenKey(adminCtx) + + adminResponse := decodeAPIResponse(t, adminRecorder) + if !adminResponse.Success { + t.Fatalf("expected admin key fetch to succeed, got message: %s", adminResponse.Message) + } + var keyData tokenKeyResponse + if err := common.Unmarshal(adminResponse.Data, &keyData); err != nil { + t.Fatalf("failed to decode token key response: %v", err) + } + if keyData.Key != token.GetFullKey() { + t.Fatalf("expected full key %q, got %q", token.GetFullKey(), keyData.Key) + } + + missingCtx, missingRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/admin/9999/key", nil, 99) + missingCtx.Params = gin.Params{{Key: "id", Value: "9999"}} + AdminGetTokenKey(missingCtx) + missingResponse := decodeAPIResponse(t, missingRecorder) + if missingResponse.Success { + t.Fatalf("expected missing token fetch to fail") + } +} diff --git a/model/token.go b/model/token.go index 5d2011eb3167..c66e8ead73b4 100644 --- a/model/token.go +++ b/model/token.go @@ -502,9 +502,11 @@ func BatchDeleteTokens(ids []int, userId int) (int, error) { func GetTokenKeysByIds(ids []int, userId int) ([]Token, error) { var tokens []Token - err := DB.Select("id", commonKeyCol). - Where("user_id = ? AND id IN (?)", userId, ids). - Find(&tokens).Error + q := DB.Select("id", commonKeyCol).Where("id IN (?)", ids) + if userId != 0 { + q = q.Where("user_id = ?", userId) + } + err := q.Find(&tokens).Error return tokens, err } diff --git a/router/api-router.go b/router/api-router.go index d14aeb1b30ba..c91c3d1b3098 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -261,6 +261,8 @@ func SetApiRouter(router *gin.Engine) { adminTokenRoute.PUT("/", controller.AdminUpdateToken) adminTokenRoute.DELETE("/:id/", controller.AdminDeleteToken) adminTokenRoute.POST("/batch", controller.AdminDeleteTokenBatch) + adminTokenRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.AdminGetTokenKey) + adminTokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.AdminGetTokenKeysBatch) } usageRoute := apiRouter.Group("/usage") diff --git a/web/src/features/keys/api.ts b/web/src/features/keys/api.ts index 519d89a8fe60..39cfbb00a4e9 100644 --- a/web/src/features/keys/api.ts +++ b/web/src/features/keys/api.ts @@ -117,17 +117,43 @@ export async function fetchTokenKey( const res = await api.post(`/api/token/${id}/key`) return res.data } - // Batch fetch real (unmasked) keys for multiple tokens -export async function fetchTokenKeysBatch(ids: number[]): Promise<{ - success: boolean - message?: string - data?: { keys: Record } -}> { - const res = await api.post('/api/token/batch/keys', { ids }) - return res.data -} - + export async function fetchTokenKeysBatch(ids: number[]): Promise<{ + success: boolean + message?: string + data?: { + keys: Record + } + }> { + const res = await api.post('/api/token/batch/keys', { ids }) + return res.data + } + + // Admin fetch the real (unmasked) key for any user's token by ID + export async function fetchTokenKeyAdmin( + id: number + ): Promise<{ + success: boolean + message?: string + data?: { + key: string + } + }> { + const res = await api.post(`/api/token/admin/${id}/key`) + return res.data + } + + // Admin batch fetch real (unmasked) keys for any users' tokens + export async function fetchTokenKeysBatchAdmin(ids: number[]): Promise<{ + success: boolean + message?: string + data?: { + keys: Record + } + }> { + const res = await api.post('/api/token/admin/batch/keys', { ids }) + return res.data + } // ============================================================================ // Admin API Key Management (cross-user, no plaintext key access) // ============================================================================ diff --git a/web/src/features/keys/components/api-keys-provider.tsx b/web/src/features/keys/components/api-keys-provider.tsx index e1bc1c4c6f6b..082d4d560336 100644 --- a/web/src/features/keys/components/api-keys-provider.tsx +++ b/web/src/features/keys/components/api-keys-provider.tsx @@ -22,7 +22,7 @@ import { toast } from 'sonner' import useDialogState from '@/hooks/use-dialog' -import { fetchTokenKey, fetchTokenKeysBatch } from '../api' +import { fetchTokenKey, fetchTokenKeyAdmin, fetchTokenKeysBatch, fetchTokenKeysBatchAdmin } from '../api' import { ERROR_MESSAGES } from '../constants' import type { ApiKey, ApiKeysDialogType } from '../types' @@ -89,7 +89,9 @@ export function ApiKeysProvider({ const request = (async () => { setLoadingKeys((prev) => ({ ...prev, [id]: true })) try { - const res = await fetchTokenKey(id) + const res = adminMode + ? await fetchTokenKeyAdmin(id) + : await fetchTokenKey(id) if (res.success && res.data?.key) { const fullKey = `sk-${res.data.key}` setResolvedKeys((prev) => ({ ...prev, [id]: fullKey })) @@ -113,7 +115,7 @@ export function ApiKeysProvider({ pendingRequests.current[id] = request return request }, - [resolvedKeys, t] + [resolvedKeys, t, adminMode] ) const resolveRealKeysBatch = useCallback( @@ -130,7 +132,9 @@ export function ApiKeysProvider({ } try { - const res = await fetchTokenKeysBatch(uncachedIds) + const res = adminMode + ? await fetchTokenKeysBatchAdmin(uncachedIds) + : await fetchTokenKeysBatch(uncachedIds) if (res.success && res.data?.keys) { const newKeys: Record = {} for (const [idStr, key] of Object.entries(res.data.keys)) { @@ -159,7 +163,7 @@ export function ApiKeysProvider({ } } }, - [resolvedKeys, t] + [resolvedKeys, t, adminMode] ) return (