diff --git a/.gitignore b/.gitignore index 6f5b334..1fedba8 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ CLAUDE.local.md # Local tool bootstrap cache .tmp/ + +# Chat history +data/ diff --git a/API.en.md b/API.en.md index 1d6fe6c..c055d33 100644 --- a/API.en.md +++ b/API.en.md @@ -31,7 +31,7 @@ Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Depl | Base URL | `http://localhost:5001` or your deployment domain | | Default Content-Type | `application/json` | | Health probes | `GET /healthz`, `GET /readyz` | -| CORS | Enabled (`Access-Control-Allow-Origin: *`, allows `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Vercel-Protection-Bypass`) | +| CORS | Enabled (`Access-Control-Allow-Origin: *`, allows `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`) | ### 3.0 Adapter-Layer Notes @@ -156,6 +156,10 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key= | GET | `/admin/export` | Admin | Export config JSON/Base64 | | GET | `/admin/dev/captures` | Admin | Read local packet-capture entries | | DELETE | `/admin/dev/captures` | Admin | Clear local packet-capture entries | +| GET | `/admin/chat-history` | Admin | Read server-side conversation history | +| DELETE | `/admin/chat-history` | Admin | Clear server-side conversation history | +| DELETE | `/admin/chat-history/{id}` | Admin | Delete one server-side conversation entry | +| PUT | `/admin/chat-history/settings` | Admin | Update conversation history retention limit | | GET | `/admin/version` | Admin | Check current version and latest Release | --- diff --git a/API.md b/API.md index 1f9bcf5..016b113 100644 --- a/API.md +++ b/API.md @@ -31,7 +31,7 @@ | Base URL | `http://localhost:5001` 或你的部署域名 | | 默认 Content-Type | `application/json` | | 健康检查 | `GET /healthz`、`GET /readyz` | -| CORS | 已启用(`Access-Control-Allow-Origin: *`,允许 `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Vercel-Protection-Bypass`) | +| CORS | 已启用(`Access-Control-Allow-Origin: *`,允许 `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`) | ### 3.0 接口适配层说明 @@ -156,6 +156,10 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` | GET | `/admin/export` | Admin | 导出配置 JSON/Base64 | | GET | `/admin/dev/captures` | Admin | 查看本地抓包记录 | | DELETE | `/admin/dev/captures` | Admin | 清空本地抓包记录 | +| GET | `/admin/chat-history` | Admin | 查看服务器端对话记录 | +| DELETE | `/admin/chat-history` | Admin | 清空服务器端对话记录 | +| DELETE | `/admin/chat-history/{id}` | Admin | 删除单条服务器端对话记录 | +| PUT | `/admin/chat-history/settings` | Admin | 更新对话记录保留条数 | | GET | `/admin/version` | Admin | 查询当前版本与最新 Release | --- diff --git a/README.MD b/README.MD index b72c364..4d99c83 100644 --- a/README.MD +++ b/README.MD @@ -94,7 +94,7 @@ flowchart LR | DeepSeek PoW | 纯 Go 高性能实现(DeepSeekHashV1),毫秒级响应 | | Tool Calling | 防泄漏处理:非代码块高置信特征识别、`delta.tool_calls` 早发、结构化增量输出 | | Admin API | 配置管理、运行时设置热更新、代理管理、账号测试 / 批量测试、会话清理、导入导出、Vercel 同步、版本检查 | -| WebUI 管理台 | `/admin` 单页应用(中英文双语、深色模式) | +| WebUI 管理台 | `/admin` 单页应用(中英文双语、深色模式,支持查看服务器端对话记录) | | 运维探针 | `GET /healthz`(存活)、`GET /readyz`(就绪) | ## 平台兼容矩阵 @@ -344,6 +344,7 @@ go run ./cmd/ds2api | `DS2API_JWT_EXPIRE_HOURS` | Admin JWT 过期小时数 | `24` | | `DS2API_CONFIG_PATH` | 配置文件路径 | `config.json` | | `DS2API_CONFIG_JSON` | 直接注入配置(JSON 或 Base64) | — | +| `DS2API_CHAT_HISTORY_PATH` | 服务器端对话记录文件路径 | `data/chat_history.json` | | `DS2API_ENV_WRITEBACK` | 环境变量模式下自动写回配置文件并切换文件模式(`1/true/yes/on`) | 关闭 | | `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 | `static/admin` | | `DS2API_AUTO_BUILD_WEBUI` | 启动时自动构建 WebUI | 本地开启,Vercel 关闭 | diff --git a/README.en.md b/README.en.md index d930b4e..61c4c90 100644 --- a/README.en.md +++ b/README.en.md @@ -92,7 +92,7 @@ For the full module-by-module architecture and directory responsibilities, see [ | DeepSeek PoW | Pure Go high-performance solver (DeepSeekHashV1), ms-level response | | Tool Calling | Anti-leak handling: non-code-block feature match, early `delta.tool_calls`, structured incremental output | | Admin API | Config management, runtime settings hot-reload, proxy management, account testing/batch test, session cleanup, import/export, Vercel sync, version check | -| WebUI Admin Panel | SPA at `/admin` (bilingual Chinese/English, dark mode) | +| WebUI Admin Panel | SPA at `/admin` (bilingual Chinese/English, dark mode, with server-side conversation history) | | Health Probes | `GET /healthz` (liveness), `GET /readyz` (readiness) | ## Platform Compatibility Matrix @@ -342,6 +342,7 @@ The server actually binds to `0.0.0.0:5001`, so devices on the same LAN can usua | `DS2API_JWT_EXPIRE_HOURS` | Admin JWT TTL in hours | `24` | | `DS2API_CONFIG_PATH` | Config file path | `config.json` | | `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) | — | +| `DS2API_CHAT_HISTORY_PATH` | Server-side conversation history file path | `data/chat_history.json` | | `DS2API_ENV_WRITEBACK` | Auto-write env-backed config to file and transition to file mode (`1/true/yes/on`) | Disabled | | `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir | `static/admin` | | `DS2API_AUTO_BUILD_WEBUI` | Auto-build WebUI on startup | Enabled locally, disabled on Vercel | diff --git a/internal/adapter/openai/chat_history.go b/internal/adapter/openai/chat_history.go new file mode 100644 index 0000000..b6435de --- /dev/null +++ b/internal/adapter/openai/chat_history.go @@ -0,0 +1,268 @@ +package openai + +import ( + "net/http" + "strings" + "time" + + "ds2api/internal/auth" + "ds2api/internal/chathistory" + "ds2api/internal/config" + openaifmt "ds2api/internal/format/openai" + "ds2api/internal/prompt" + "ds2api/internal/util" +) + +const adminWebUISourceHeader = "X-Ds2-Source" +const adminWebUISourceValue = "admin-webui-api-tester" + +type chatHistorySession struct { + store *chathistory.Store + entryID string + startedAt time.Time + lastPersist time.Time + finalPrompt string + startParams chathistory.StartParams + disabled bool +} + +func startChatHistory(store *chathistory.Store, r *http.Request, a *auth.RequestAuth, stdReq util.StandardRequest) *chatHistorySession { + if store == nil || r == nil || a == nil { + return nil + } + if !store.Enabled() { + return nil + } + if !shouldCaptureChatHistory(r) { + return nil + } + entry, err := store.Start(chathistory.StartParams{ + CallerID: strings.TrimSpace(a.CallerID), + AccountID: strings.TrimSpace(a.AccountID), + Model: strings.TrimSpace(stdReq.ResponseModel), + Stream: stdReq.Stream, + UserInput: extractSingleUserInput(stdReq.Messages), + Messages: extractAllMessages(stdReq.Messages), + FinalPrompt: stdReq.FinalPrompt, + }) + if err != nil { + config.Logger.Warn("[chat_history] start failed", "error", err) + return nil + } + startParams := chathistory.StartParams{ + CallerID: strings.TrimSpace(a.CallerID), + AccountID: strings.TrimSpace(a.AccountID), + Model: strings.TrimSpace(stdReq.ResponseModel), + Stream: stdReq.Stream, + UserInput: extractSingleUserInput(stdReq.Messages), + Messages: extractAllMessages(stdReq.Messages), + FinalPrompt: stdReq.FinalPrompt, + } + return &chatHistorySession{ + store: store, + entryID: entry.ID, + startedAt: time.Now(), + lastPersist: time.Now(), + finalPrompt: stdReq.FinalPrompt, + startParams: startParams, + } +} + +func shouldCaptureChatHistory(r *http.Request) bool { + if r == nil { + return false + } + if isVercelStreamPrepareRequest(r) || isVercelStreamReleaseRequest(r) { + return false + } + return strings.TrimSpace(r.Header.Get(adminWebUISourceHeader)) != adminWebUISourceValue +} + +func extractSingleUserInput(messages []any) string { + for i := len(messages) - 1; i >= 0; i-- { + msg, ok := messages[i].(map[string]any) + if !ok { + continue + } + role := strings.ToLower(strings.TrimSpace(asString(msg["role"]))) + if role != "user" { + continue + } + if normalized := strings.TrimSpace(prompt.NormalizeContent(msg["content"])); normalized != "" { + return normalized + } + } + return "" +} + +func extractAllMessages(messages []any) []chathistory.Message { + out := make([]chathistory.Message, 0, len(messages)) + for _, raw := range messages { + msg, ok := raw.(map[string]any) + if !ok { + continue + } + role := strings.ToLower(strings.TrimSpace(asString(msg["role"]))) + content := strings.TrimSpace(prompt.NormalizeContent(msg["content"])) + if role == "" || content == "" { + continue + } + out = append(out, chathistory.Message{ + Role: role, + Content: content, + }) + } + return out +} + +func (s *chatHistorySession) progress(thinking, content string) { + if s == nil || s.store == nil || s.disabled { + return + } + now := time.Now() + if now.Sub(s.lastPersist) < 250*time.Millisecond { + return + } + s.lastPersist = now + if _, err := s.store.Update(s.entryID, chathistory.UpdateParams{ + Status: "streaming", + ReasoningContent: thinking, + Content: content, + StatusCode: http.StatusOK, + ElapsedMs: time.Since(s.startedAt).Milliseconds(), + }); err != nil { + if !s.retryMissingEntry() { + s.disableOnMissing(err) + return + } + _, retryErr := s.store.Update(s.entryID, chathistory.UpdateParams{ + Status: "streaming", + ReasoningContent: thinking, + Content: content, + StatusCode: http.StatusOK, + ElapsedMs: time.Since(s.startedAt).Milliseconds(), + }) + s.disableOnMissing(retryErr) + } +} + +func (s *chatHistorySession) success(statusCode int, thinking, content, finishReason string, usage map[string]any) { + if s == nil || s.store == nil || s.disabled { + return + } + if _, err := s.store.Update(s.entryID, chathistory.UpdateParams{ + Status: "success", + ReasoningContent: thinking, + Content: content, + StatusCode: statusCode, + ElapsedMs: time.Since(s.startedAt).Milliseconds(), + FinishReason: finishReason, + Usage: usage, + Completed: true, + }); err != nil { + if !s.retryMissingEntry() { + s.disableOnMissing(err) + return + } + _, retryErr := s.store.Update(s.entryID, chathistory.UpdateParams{ + Status: "success", + ReasoningContent: thinking, + Content: content, + StatusCode: statusCode, + ElapsedMs: time.Since(s.startedAt).Milliseconds(), + FinishReason: finishReason, + Usage: usage, + Completed: true, + }) + s.disableOnMissing(retryErr) + } +} + +func (s *chatHistorySession) error(statusCode int, message, finishReason, thinking, content string) { + if s == nil || s.store == nil || s.disabled { + return + } + if _, err := s.store.Update(s.entryID, chathistory.UpdateParams{ + Status: "error", + ReasoningContent: thinking, + Content: content, + Error: message, + StatusCode: statusCode, + ElapsedMs: time.Since(s.startedAt).Milliseconds(), + FinishReason: finishReason, + Completed: true, + }); err != nil { + if !s.retryMissingEntry() { + s.disableOnMissing(err) + return + } + _, retryErr := s.store.Update(s.entryID, chathistory.UpdateParams{ + Status: "error", + ReasoningContent: thinking, + Content: content, + Error: message, + StatusCode: statusCode, + ElapsedMs: time.Since(s.startedAt).Milliseconds(), + FinishReason: finishReason, + Completed: true, + }) + s.disableOnMissing(retryErr) + } +} + +func (s *chatHistorySession) stopped(thinking, content, finishReason string) { + if s == nil || s.store == nil || s.disabled { + return + } + if _, err := s.store.Update(s.entryID, chathistory.UpdateParams{ + Status: "stopped", + ReasoningContent: thinking, + Content: content, + StatusCode: http.StatusOK, + ElapsedMs: time.Since(s.startedAt).Milliseconds(), + FinishReason: finishReason, + Usage: openaifmt.BuildChatUsage(s.finalPrompt, thinking, content), + Completed: true, + }); err != nil { + if !s.retryMissingEntry() { + s.disableOnMissing(err) + return + } + _, retryErr := s.store.Update(s.entryID, chathistory.UpdateParams{ + Status: "stopped", + ReasoningContent: thinking, + Content: content, + StatusCode: http.StatusOK, + ElapsedMs: time.Since(s.startedAt).Milliseconds(), + FinishReason: finishReason, + Usage: openaifmt.BuildChatUsage(s.finalPrompt, thinking, content), + Completed: true, + }) + s.disableOnMissing(retryErr) + } +} + +func (s *chatHistorySession) retryMissingEntry() bool { + if s == nil || s.store == nil || s.disabled { + return false + } + entry, err := s.store.Start(s.startParams) + if err != nil { + s.disableOnMissing(err) + return false + } + s.entryID = entry.ID + return true +} + +func (s *chatHistorySession) disableOnMissing(err error) { + if err == nil || s == nil { + return + } + if strings.Contains(strings.ToLower(err.Error()), "not found") { + s.disabled = true + return + } + config.Logger.Warn("[chat_history] update disabled", "error", err) + s.disabled = true +} diff --git a/internal/adapter/openai/chat_history_test.go b/internal/adapter/openai/chat_history_test.go new file mode 100644 index 0000000..4500e1c --- /dev/null +++ b/internal/adapter/openai/chat_history_test.go @@ -0,0 +1,174 @@ +package openai + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "ds2api/internal/chathistory" +) + +func newTestChatHistoryStore(t *testing.T) *chathistory.Store { + t.Helper() + store := chathistory.New(filepath.Join(t.TempDir(), "chat_history.json")) + if err := store.Err(); err != nil { + t.Fatalf("chat history store unavailable: %v", err) + } + return store +} + +func TestChatCompletionsNonStreamPersistsHistory(t *testing.T) { + historyStore := newTestChatHistoryStore(t) + h := &Handler{ + Store: mockOpenAIConfig{wideInput: true}, + Auth: streamStatusAuthStub{}, + DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello world"}`, `data: [DONE]`)}, + ChatHistory: historyStore, + } + + reqBody := `{"model":"deepseek-chat","messages":[{"role":"system","content":"be precise"},{"role":"user","content":"hi there"},{"role":"assistant","content":"previous answer"}],"stream":false}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ChatCompletions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + snapshot, err := historyStore.Snapshot() + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if len(snapshot.Items) != 1 { + t.Fatalf("expected one history item, got %d", len(snapshot.Items)) + } + item := snapshot.Items[0] + if item.Status != "success" || item.UserInput != "hi there" { + t.Fatalf("unexpected persisted history summary: %#v", item) + } + full, err := historyStore.Get(item.ID) + if err != nil { + t.Fatalf("expected detail item, got %v", err) + } + if full.Content != "hello world" { + t.Fatalf("expected detail content persisted, got %#v", full) + } + if len(full.Messages) != 3 { + t.Fatalf("expected all request messages persisted, got %#v", full.Messages) + } + if full.FinalPrompt == "" { + t.Fatalf("expected final prompt to be persisted") + } + if item.CallerID != "caller:test" { + t.Fatalf("expected caller hash persisted in summary, got %#v", item.CallerID) + } +} + +func TestHandleStreamContextCancelledMarksHistoryStopped(t *testing.T) { + historyStore := newTestChatHistoryStore(t) + entry, err := historyStore.Start(chathistory.StartParams{ + CallerID: "caller:test", + Model: "deepseek-chat", + Stream: true, + UserInput: "hello", + }) + if err != nil { + t.Fatalf("start history failed: %v", err) + } + session := &chatHistorySession{ + store: historyStore, + entryID: entry.ID, + startedAt: time.Now(), + lastPersist: time.Now(), + finalPrompt: "hello", + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + h := &Handler{} + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx) + rec := httptest.NewRecorder() + resp := makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello"}`, `data: [DONE]`) + + h.handleStream(rec, req, resp, "cid-stop", "deepseek-chat", "prompt", false, false, nil, session) + + snapshot, err := historyStore.Snapshot() + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if len(snapshot.Items) != 1 { + t.Fatalf("expected one history item, got %d", len(snapshot.Items)) + } + full, err := historyStore.Get(snapshot.Items[0].ID) + if err != nil { + t.Fatalf("get detail failed: %v", err) + } + if full.Status != "stopped" { + t.Fatalf("expected stopped status, got %#v", full) + } +} + +func TestChatCompletionsSkipsAdminWebUISource(t *testing.T) { + historyStore := newTestChatHistoryStore(t) + h := &Handler{ + Store: mockOpenAIConfig{wideInput: true}, + Auth: streamStatusAuthStub{}, + DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello world"}`, `data: [DONE]`)}, + ChatHistory: historyStore, + } + + reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":"hi there"}],"stream":false}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + req.Header.Set(adminWebUISourceHeader, adminWebUISourceValue) + rec := httptest.NewRecorder() + h.ChatCompletions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + snapshot, err := historyStore.Snapshot() + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if len(snapshot.Items) != 0 { + t.Fatalf("expected admin webui source to be skipped, got %#v", snapshot.Items) + } +} + +func TestChatCompletionsSkipsHistoryWhenDisabled(t *testing.T) { + historyStore := newTestChatHistoryStore(t) + if _, err := historyStore.SetLimit(chathistory.DisabledLimit); err != nil { + t.Fatalf("disable history store failed: %v", err) + } + h := &Handler{ + Store: mockOpenAIConfig{wideInput: true}, + Auth: streamStatusAuthStub{}, + DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"hello world"}`, `data: [DONE]`)}, + ChatHistory: historyStore, + } + + reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":"hi there"}],"stream":false}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ChatCompletions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + snapshot, err := historyStore.Snapshot() + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if len(snapshot.Items) != 0 { + t.Fatalf("expected disabled history to stay empty, got %#v", snapshot.Items) + } +} diff --git a/internal/adapter/openai/chat_stream_runtime.go b/internal/adapter/openai/chat_stream_runtime.go index a5ff195..176dca4 100644 --- a/internal/adapter/openai/chat_stream_runtime.go +++ b/internal/adapter/openai/chat_stream_runtime.go @@ -37,6 +37,14 @@ type chatStreamRuntime struct { streamToolNames map[int]string thinking strings.Builder text strings.Builder + + finalThinking string + finalText string + finalFinishReason string + finalUsage map[string]any + finalErrorStatus int + finalErrorMessage string + finalErrorCode string } func newChatStreamRuntime( @@ -99,6 +107,9 @@ func (s *chatStreamRuntime) sendDone() { } func (s *chatStreamRuntime) sendFailedChunk(status int, message, code string) { + s.finalErrorStatus = status + s.finalErrorMessage = message + s.finalErrorCode = code s.sendChunk(map[string]any{ "status_code": status, "error": map[string]any{ @@ -114,6 +125,8 @@ func (s *chatStreamRuntime) sendFailedChunk(status int, message, code string) { func (s *chatStreamRuntime) finalize(finishReason string) { finalThinking := s.thinking.String() finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) + s.finalThinking = finalThinking + s.finalText = finalText detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, s.toolNames) if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted { finishReason = "tool_calls" @@ -197,6 +210,8 @@ func (s *chatStreamRuntime) finalize(finishReason string) { return } usage := openaifmt.BuildChatUsage(s.finalPrompt, finalThinking, finalText) + s.finalFinishReason = finishReason + s.finalUsage = usage s.sendChunk(openaifmt.BuildChatStreamChunk( s.completionID, s.created, diff --git a/internal/adapter/openai/handler_chat.go b/internal/adapter/openai/handler_chat.go index e2c5f3c..ed9d2c7 100644 --- a/internal/adapter/openai/handler_chat.go +++ b/internal/adapter/openai/handler_chat.go @@ -63,32 +63,45 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { writeOpenAIError(w, http.StatusBadRequest, err.Error()) return } + historySession := startChatHistory(h.ChatHistory, r, a, stdReq) sessionID, err = h.DS.CreateSession(r.Context(), a, 3) if err != nil { if a.UseConfigToken { + if historySession != nil { + historySession.error(http.StatusUnauthorized, "Account token is invalid. Please re-login the account in admin.", "error", "", "") + } writeOpenAIError(w, http.StatusUnauthorized, "Account token is invalid. Please re-login the account in admin.") } else { + if historySession != nil { + historySession.error(http.StatusUnauthorized, "Invalid token. If this should be a DS2API key, add it to config.keys first.", "error", "", "") + } writeOpenAIError(w, http.StatusUnauthorized, "Invalid token. If this should be a DS2API key, add it to config.keys first.") } return } pow, err := h.DS.GetPow(r.Context(), a, 3) if err != nil { + if historySession != nil { + historySession.error(http.StatusUnauthorized, "Failed to get PoW (invalid token or unknown error).", "error", "", "") + } writeOpenAIError(w, http.StatusUnauthorized, "Failed to get PoW (invalid token or unknown error).") return } payload := stdReq.CompletionPayload(sessionID) resp, err := h.DS.CallCompletion(r.Context(), a, payload, pow, 3) if err != nil { + if historySession != nil { + historySession.error(http.StatusInternalServerError, "Failed to get completion.", "error", "", "") + } writeOpenAIError(w, http.StatusInternalServerError, "Failed to get completion.") return } if stdReq.Stream { - h.handleStream(w, r, resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames) + h.handleStream(w, r, resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, historySession) return } - h.handleNonStream(w, r.Context(), resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames) + h.handleNonStream(w, resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, historySession) } func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAuth, sessionID string) { @@ -124,14 +137,16 @@ func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAu } } -func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string) { +func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, historySession *chatHistorySession) { if resp.StatusCode != http.StatusOK { defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(resp.Body) + if historySession != nil { + historySession.error(resp.StatusCode, string(body), "error", "", "") + } writeOpenAIError(w, resp.StatusCode, string(body)) return } - _ = ctx result := sse.CollectStream(resp, thinkingEnabled, true) stripReferenceMarkers := h.compatStripReferenceMarkers() @@ -140,17 +155,34 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, re if searchEnabled { finalText = replaceCitationMarkersWithLinks(finalText, result.CitationLinks) } - if writeUpstreamEmptyOutputError(w, finalText, result.ContentFilter) { + if shouldWriteUpstreamEmptyOutputError(finalText, result.ContentFilter) { + status, message, code := upstreamEmptyOutputDetail(result.ContentFilter, finalText, finalThinking) + if historySession != nil { + historySession.error(status, message, code, finalThinking, finalText) + } + writeUpstreamEmptyOutputError(w, finalText, result.ContentFilter) return } respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames) + finishReason := "stop" + if choices, ok := respBody["choices"].([]map[string]any); ok && len(choices) > 0 { + if fr, _ := choices[0]["finish_reason"].(string); strings.TrimSpace(fr) != "" { + finishReason = fr + } + } + if historySession != nil { + historySession.success(http.StatusOK, finalThinking, finalText, finishReason, openaifmt.BuildChatUsage(finalPrompt, finalThinking, finalText)) + } writeJSON(w, http.StatusOK, respBody) } -func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string) { +func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, historySession *chatHistorySession) { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) + if historySession != nil { + historySession.error(resp.StatusCode, string(body), "error", "", "") + } writeOpenAIError(w, resp.StatusCode, string(body)) return } @@ -201,13 +233,32 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt OnKeepAlive: func() { streamRuntime.sendKeepAlive() }, - OnParsed: streamRuntime.onParsed, + OnParsed: func(parsed sse.LineResult) streamengine.ParsedDecision { + decision := streamRuntime.onParsed(parsed) + if historySession != nil { + historySession.progress(streamRuntime.thinking.String(), streamRuntime.text.String()) + } + return decision + }, OnFinalize: func(reason streamengine.StopReason, _ error) { if string(reason) == "content_filter" { streamRuntime.finalize("content_filter") + } else { + streamRuntime.finalize("stop") + } + if historySession == nil { return } - streamRuntime.finalize("stop") + if streamRuntime.finalErrorMessage != "" { + historySession.error(streamRuntime.finalErrorStatus, streamRuntime.finalErrorMessage, streamRuntime.finalErrorCode, streamRuntime.thinking.String(), streamRuntime.text.String()) + return + } + historySession.success(http.StatusOK, streamRuntime.finalThinking, streamRuntime.finalText, streamRuntime.finalFinishReason, streamRuntime.finalUsage) + }, + OnContextDone: func() { + if historySession != nil { + historySession.stopped(streamRuntime.thinking.String(), streamRuntime.text.String(), string(streamengine.StopReasonContextCancelled)) + } }, }) } diff --git a/internal/adapter/openai/handler_routes.go b/internal/adapter/openai/handler_routes.go index 5e48953..a08be15 100644 --- a/internal/adapter/openai/handler_routes.go +++ b/internal/adapter/openai/handler_routes.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" "ds2api/internal/auth" + "ds2api/internal/chathistory" "ds2api/internal/config" "ds2api/internal/util" ) @@ -25,9 +26,10 @@ const ( var writeJSON = util.WriteJSON type Handler struct { - Store ConfigReader - Auth AuthResolver - DS DeepSeekCaller + Store ConfigReader + Auth AuthResolver + DS DeepSeekCaller + ChatHistory *chathistory.Store leaseMu sync.Mutex streamLeases map[string]streamLease diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/adapter/openai/handler_toolcall_test.go index a274d5b..d168fca 100644 --- a/internal/adapter/openai/handler_toolcall_test.go +++ b/internal/adapter/openai/handler_toolcall_test.go @@ -1,7 +1,6 @@ package openai import ( - "context" "encoding/json" "io" "net/http" @@ -94,7 +93,7 @@ func TestHandleNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T) { ) rec := httptest.NewRecorder() - h.handleNonStream(rec, context.Background(), resp, "cid-empty", "deepseek-chat", "prompt", false, false, nil) + h.handleNonStream(rec, resp, "cid-empty", "deepseek-chat", "prompt", false, false, nil, nil) if rec.Code != http.StatusTooManyRequests { t.Fatalf("expected status 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String()) } @@ -113,7 +112,7 @@ func TestHandleNonStreamReturnsContentFilterErrorWhenUpstreamFilteredWithoutOutp ) rec := httptest.NewRecorder() - h.handleNonStream(rec, context.Background(), resp, "cid-empty-filtered", "deepseek-chat", "prompt", false, false, nil) + h.handleNonStream(rec, resp, "cid-empty-filtered", "deepseek-chat", "prompt", false, false, nil, nil) if rec.Code != http.StatusBadRequest { t.Fatalf("expected status 400 for filtered upstream output, got %d body=%s", rec.Code, rec.Body.String()) } @@ -132,7 +131,7 @@ func TestHandleNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testing.T) { ) rec := httptest.NewRecorder() - h.handleNonStream(rec, context.Background(), resp, "cid-thinking-only", "deepseek-reasoner", "prompt", true, false, nil) + h.handleNonStream(rec, resp, "cid-thinking-only", "deepseek-reasoner", "prompt", true, false, nil, nil) if rec.Code != http.StatusTooManyRequests { t.Fatalf("expected status 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String()) } @@ -153,7 +152,7 @@ func TestHandleStreamToolsPlainTextStreamsBeforeFinish(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) - h.handleStream(rec, req, resp, "cid6", "deepseek-chat", "prompt", false, false, []string{"search"}) + h.handleStream(rec, req, resp, "cid6", "deepseek-chat", "prompt", false, false, []string{"search"}, nil) frames, done := parseSSEDataFrames(t, rec.Body.String()) if !done { @@ -190,7 +189,7 @@ func TestHandleStreamIncompleteCapturedToolJSONFlushesAsTextOnFinalize(t *testin rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) - h.handleStream(rec, req, resp, "cid10", "deepseek-chat", "prompt", false, false, []string{"search"}) + h.handleStream(rec, req, resp, "cid10", "deepseek-chat", "prompt", false, false, []string{"search"}, nil) frames, done := parseSSEDataFrames(t, rec.Body.String()) if !done { diff --git a/internal/adapter/openai/upstream_empty.go b/internal/adapter/openai/upstream_empty.go index 9c21adc..8b3d07f 100644 --- a/internal/adapter/openai/upstream_empty.go +++ b/internal/adapter/openai/upstream_empty.go @@ -2,14 +2,26 @@ package openai import "net/http" +func shouldWriteUpstreamEmptyOutputError(text string, contentFilter bool) bool { + return text == "" +} + +func upstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int, string, string) { + _ = text + if contentFilter { + return http.StatusBadRequest, "Upstream content filtered the response and returned no output.", "content_filter" + } + if thinking != "" { + return http.StatusTooManyRequests, "Upstream model returned reasoning without visible output.", "upstream_empty_output" + } + return http.StatusTooManyRequests, "Upstream model returned empty output.", "upstream_empty_output" +} + func writeUpstreamEmptyOutputError(w http.ResponseWriter, text string, contentFilter bool) bool { - if text != "" { + if !shouldWriteUpstreamEmptyOutputError(text, contentFilter) { return false } - if contentFilter { - writeOpenAIErrorWithCode(w, http.StatusBadRequest, "Upstream content filtered the response and returned no output.", "content_filter") - return true - } - writeOpenAIErrorWithCode(w, http.StatusTooManyRequests, "Upstream model returned empty output.", "upstream_empty_output") + status, message, code := upstreamEmptyOutputDetail(contentFilter, text, "") + writeOpenAIErrorWithCode(w, status, message, code) return true } diff --git a/internal/admin/handler.go b/internal/admin/handler.go index bed3894..8d271ea 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -2,13 +2,16 @@ package admin import ( "github.com/go-chi/chi/v5" + + "ds2api/internal/chathistory" ) type Handler struct { - Store ConfigStore - Pool PoolController - DS DeepSeekCaller - OpenAI OpenAIChatCaller + Store ConfigStore + Pool PoolController + DS DeepSeekCaller + OpenAI OpenAIChatCaller + ChatHistory *chathistory.Store } func RegisterRoutes(r chi.Router, h *Handler) { @@ -50,6 +53,11 @@ func RegisterRoutes(r chi.Router, h *Handler) { pr.Get("/export", h.exportConfig) pr.Get("/dev/captures", h.getDevCaptures) pr.Delete("/dev/captures", h.clearDevCaptures) + pr.Get("/chat-history", h.getChatHistory) + pr.Get("/chat-history/{id}", h.getChatHistoryItem) + pr.Delete("/chat-history", h.clearChatHistory) + pr.Delete("/chat-history/{id}", h.deleteChatHistoryItem) + pr.Put("/chat-history/settings", h.updateChatHistorySettings) pr.Get("/version", h.getVersion) }) } diff --git a/internal/admin/handler_chat_history.go b/internal/admin/handler_chat_history.go new file mode 100644 index 0000000..2eb61e6 --- /dev/null +++ b/internal/admin/handler_chat_history.go @@ -0,0 +1,134 @@ +package admin + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/chathistory" +) + +func (h *Handler) getChatHistory(w http.ResponseWriter, r *http.Request) { + store := h.ChatHistory + if store == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"}) + return + } + snapshot, err := store.Snapshot() + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{ + "detail": err.Error(), + "path": store.Path(), + }) + return + } + etag := chathistory.ListETag(snapshot.Revision) + w.Header().Set("ETag", etag) + w.Header().Set("Cache-Control", "no-cache") + if strings.TrimSpace(r.Header.Get("If-None-Match")) == etag { + w.WriteHeader(http.StatusNotModified) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "version": snapshot.Version, + "limit": snapshot.Limit, + "revision": snapshot.Revision, + "items": snapshot.Items, + "path": store.Path(), + }) +} + +func (h *Handler) getChatHistoryItem(w http.ResponseWriter, r *http.Request) { + store := h.ChatHistory + if store == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"}) + return + } + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "history id is required"}) + return + } + item, err := store.Get(id) + if err != nil { + status := http.StatusInternalServerError + if strings.Contains(strings.ToLower(err.Error()), "not found") { + status = http.StatusNotFound + } + writeJSON(w, status, map[string]any{"detail": err.Error()}) + return + } + etag := chathistory.DetailETag(item.ID, item.Revision) + w.Header().Set("ETag", etag) + w.Header().Set("Cache-Control", "no-cache") + if strings.TrimSpace(r.Header.Get("If-None-Match")) == etag { + w.WriteHeader(http.StatusNotModified) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "item": item, + }) +} + +func (h *Handler) clearChatHistory(w http.ResponseWriter, _ *http.Request) { + store := h.ChatHistory + if store == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"}) + return + } + if err := store.Clear(); err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": err.Error(), "path": store.Path()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"success": true}) +} + +func (h *Handler) deleteChatHistoryItem(w http.ResponseWriter, r *http.Request) { + store := h.ChatHistory + if store == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"}) + return + } + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "history id is required"}) + return + } + if err := store.Delete(id); err != nil { + status := http.StatusInternalServerError + if strings.Contains(strings.ToLower(err.Error()), "not found") { + status = http.StatusNotFound + } + writeJSON(w, status, map[string]any{"detail": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"success": true}) +} + +func (h *Handler) updateChatHistorySettings(w http.ResponseWriter, r *http.Request) { + store := h.ChatHistory + if store == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"}) + return + } + var body struct { + Limit int `json:"limit"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"}) + return + } + snapshot, err := store.SetLimit(body.Limit) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "success": true, + "limit": snapshot.Limit, + "revision": snapshot.Revision, + "items": snapshot.Items, + }) +} diff --git a/internal/admin/handler_chat_history_test.go b/internal/admin/handler_chat_history_test.go new file mode 100644 index 0000000..ca61110 --- /dev/null +++ b/internal/admin/handler_chat_history_test.go @@ -0,0 +1,176 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/chathistory" + "ds2api/internal/config" +) + +func newChatHistoryAdminHarness(t *testing.T) (*Handler, *chathistory.Store) { + t.Helper() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil { + t.Fatalf("write config failed: %v", err) + } + t.Setenv("DS2API_CONFIG_PATH", configPath) + t.Setenv("DS2API_ADMIN_KEY", "admin") + t.Setenv("DS2API_CONFIG_JSON", "") + store, err := config.LoadStoreWithError() + if err != nil { + t.Fatalf("load config store failed: %v", err) + } + historyStore := chathistory.New(filepath.Join(dir, "chat_history.json")) + return &Handler{Store: store, ChatHistory: historyStore}, historyStore +} + +func TestGetChatHistoryAndUpdateSettings(t *testing.T) { + h, historyStore := newChatHistoryAdminHarness(t) + entry, err := historyStore.Start(chathistory.StartParams{ + CallerID: "caller:test", + AccountID: "user@example.com", + Model: "deepseek-chat", + UserInput: "hello", + }) + if err != nil { + t.Fatalf("start history failed: %v", err) + } + if _, err := historyStore.Update(entry.ID, chathistory.UpdateParams{ + Status: "success", + Content: "world", + Completed: true, + }); err != nil { + t.Fatalf("update history failed: %v", err) + } + + r := chi.NewRouter() + RegisterRoutes(r, h) + + req := httptest.NewRequest(http.MethodGet, "/chat-history", nil) + req.Header.Set("Authorization", "Bearer admin") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode payload failed: %v", err) + } + items, _ := payload["items"].([]any) + if len(items) != 1 { + t.Fatalf("expected one history item, got %#v", payload) + } + if rec.Header().Get("ETag") == "" { + t.Fatalf("expected list etag header") + } + + notModifiedReq := httptest.NewRequest(http.MethodGet, "/chat-history", nil) + notModifiedReq.Header.Set("Authorization", "Bearer admin") + notModifiedReq.Header.Set("If-None-Match", rec.Header().Get("ETag")) + notModifiedRec := httptest.NewRecorder() + r.ServeHTTP(notModifiedRec, notModifiedReq) + if notModifiedRec.Code != http.StatusNotModified { + t.Fatalf("expected 304, got %d body=%s", notModifiedRec.Code, notModifiedRec.Body.String()) + } + + itemReq := httptest.NewRequest(http.MethodGet, "/chat-history/"+entry.ID, nil) + itemReq.Header.Set("Authorization", "Bearer admin") + itemRec := httptest.NewRecorder() + r.ServeHTTP(itemRec, itemReq) + if itemRec.Code != http.StatusOK { + t.Fatalf("expected item 200, got %d body=%s", itemRec.Code, itemRec.Body.String()) + } + if itemRec.Header().Get("ETag") == "" { + t.Fatalf("expected detail etag header") + } + + updateReq := httptest.NewRequest(http.MethodPut, "/chat-history/settings", bytes.NewReader([]byte(`{"limit":10}`))) + updateReq.Header.Set("Authorization", "Bearer admin") + updateRec := httptest.NewRecorder() + r.ServeHTTP(updateRec, updateReq) + if updateRec.Code != http.StatusOK { + t.Fatalf("expected 200 from settings update, got %d body=%s", updateRec.Code, updateRec.Body.String()) + } + snapshot, err := historyStore.Snapshot() + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if snapshot.Limit != 10 { + t.Fatalf("expected limit=10, got %d", snapshot.Limit) + } + + disableReq := httptest.NewRequest(http.MethodPut, "/chat-history/settings", bytes.NewReader([]byte(`{"limit":0}`))) + disableReq.Header.Set("Authorization", "Bearer admin") + disableRec := httptest.NewRecorder() + r.ServeHTTP(disableRec, disableReq) + if disableRec.Code != http.StatusOK { + t.Fatalf("expected 200 from disable update, got %d body=%s", disableRec.Code, disableRec.Body.String()) + } + snapshot, err = historyStore.Snapshot() + if err != nil { + t.Fatalf("snapshot after disable failed: %v", err) + } + if snapshot.Limit != chathistory.DisabledLimit { + t.Fatalf("expected limit=0, got %d", snapshot.Limit) + } + if len(snapshot.Items) != 1 { + t.Fatalf("expected history preserved when disabled, got %d", len(snapshot.Items)) + } +} + +func TestDeleteAndClearChatHistory(t *testing.T) { + h, historyStore := newChatHistoryAdminHarness(t) + entryA, err := historyStore.Start(chathistory.StartParams{UserInput: "a"}) + if err != nil { + t.Fatalf("start A failed: %v", err) + } + if _, err := historyStore.Start(chathistory.StartParams{UserInput: "b"}); err != nil { + t.Fatalf("start B failed: %v", err) + } + + r := chi.NewRouter() + RegisterRoutes(r, h) + + deleteReq := httptest.NewRequest(http.MethodDelete, "/chat-history/"+entryA.ID, nil) + deleteReq.Header.Set("Authorization", "Bearer admin") + deleteRec := httptest.NewRecorder() + r.ServeHTTP(deleteRec, deleteReq) + if deleteRec.Code != http.StatusOK { + t.Fatalf("expected delete 200, got %d body=%s", deleteRec.Code, deleteRec.Body.String()) + } + + snapshot, err := historyStore.Snapshot() + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if len(snapshot.Items) != 1 { + t.Fatalf("expected one item after delete, got %d", len(snapshot.Items)) + } + + clearReq := httptest.NewRequest(http.MethodDelete, "/chat-history", nil) + clearReq.Header.Set("Authorization", "Bearer admin") + clearRec := httptest.NewRecorder() + r.ServeHTTP(clearRec, clearReq) + if clearRec.Code != http.StatusOK { + t.Fatalf("expected clear 200, got %d body=%s", clearRec.Code, clearRec.Body.String()) + } + + snapshot, err = historyStore.Snapshot() + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if len(snapshot.Items) != 0 { + t.Fatalf("expected empty items after clear, got %d", len(snapshot.Items)) + } +} diff --git a/internal/chathistory/store.go b/internal/chathistory/store.go new file mode 100644 index 0000000..716b953 --- /dev/null +++ b/internal/chathistory/store.go @@ -0,0 +1,711 @@ +package chathistory + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +const ( + FileVersion = 2 + DisabledLimit = 0 + DefaultLimit = 20 + MaxLimit = 50 + defaultPreviewAt = 160 +) + +var allowedLimits = map[int]struct{}{ + DisabledLimit: {}, + 10: {}, + 20: {}, + 50: {}, +} + +var ErrDisabled = errors.New("chat history disabled") + +type Entry struct { + ID string `json:"id"` + Revision int64 `json:"revision"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + CompletedAt int64 `json:"completed_at,omitempty"` + Status string `json:"status"` + CallerID string `json:"caller_id,omitempty"` + AccountID string `json:"account_id,omitempty"` + Model string `json:"model,omitempty"` + Stream bool `json:"stream"` + UserInput string `json:"user_input,omitempty"` + Messages []Message `json:"messages,omitempty"` + FinalPrompt string `json:"final_prompt,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + Content string `json:"content,omitempty"` + Error string `json:"error,omitempty"` + StatusCode int `json:"status_code,omitempty"` + ElapsedMs int64 `json:"elapsed_ms,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` + Usage map[string]any `json:"usage,omitempty"` +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type SummaryEntry struct { + ID string `json:"id"` + Revision int64 `json:"revision"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + CompletedAt int64 `json:"completed_at,omitempty"` + Status string `json:"status"` + CallerID string `json:"caller_id,omitempty"` + AccountID string `json:"account_id,omitempty"` + Model string `json:"model,omitempty"` + Stream bool `json:"stream"` + UserInput string `json:"user_input,omitempty"` + Preview string `json:"preview,omitempty"` + StatusCode int `json:"status_code,omitempty"` + ElapsedMs int64 `json:"elapsed_ms,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` + DetailRevision int64 `json:"detail_revision"` +} + +type File struct { + Version int `json:"version"` + Limit int `json:"limit"` + Revision int64 `json:"revision"` + Items []SummaryEntry `json:"items"` +} + +type StartParams struct { + CallerID string + AccountID string + Model string + Stream bool + UserInput string + Messages []Message + FinalPrompt string +} + +type UpdateParams struct { + Status string + ReasoningContent string + Content string + Error string + StatusCode int + ElapsedMs int64 + FinishReason string + Usage map[string]any + Completed bool +} + +type detailEnvelope struct { + Version int `json:"version"` + Item Entry `json:"item"` +} + +type legacyFile struct { + Version int `json:"version"` + Limit int `json:"limit"` + Items []Entry `json:"items"` +} + +type Store struct { + mu sync.Mutex + path string + detailDir string + state File + details map[string]Entry + err error +} + +func New(path string) *Store { + s := &Store{ + path: strings.TrimSpace(path), + detailDir: strings.TrimSpace(path) + ".d", + state: File{ + Version: FileVersion, + Limit: DefaultLimit, + Revision: 0, + Items: []SummaryEntry{}, + }, + details: map[string]Entry{}, + } + s.mu.Lock() + defer s.mu.Unlock() + s.err = s.loadLocked() + return s +} + +func (s *Store) Path() string { + if s == nil { + return "" + } + return s.path +} + +func (s *Store) DetailDir() string { + if s == nil { + return "" + } + return s.detailDir +} + +func (s *Store) Err() error { + if s == nil { + return errors.New("chat history store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + return s.err +} + +func (s *Store) Snapshot() (File, error) { + if s == nil { + return File{}, errors.New("chat history store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return File{}, s.err + } + return cloneFile(s.state), nil +} + +func (s *Store) Enabled() bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return false + } + return s.state.Limit != DisabledLimit +} + +func (s *Store) Get(id string) (Entry, error) { + if s == nil { + return Entry{}, errors.New("chat history store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return Entry{}, s.err + } + item, ok := s.details[strings.TrimSpace(id)] + if !ok { + return Entry{}, errors.New("chat history entry not found") + } + return cloneEntry(item), nil +} + +func (s *Store) Start(params StartParams) (Entry, error) { + if s == nil { + return Entry{}, errors.New("chat history store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return Entry{}, s.err + } + if s.state.Limit == DisabledLimit { + return Entry{}, ErrDisabled + } + now := time.Now().UnixMilli() + revision := s.nextRevisionLocked() + entry := Entry{ + ID: "chat_" + strings.ReplaceAll(uuid.NewString(), "-", ""), + Revision: revision, + CreatedAt: now, + UpdatedAt: now, + Status: "streaming", + CallerID: strings.TrimSpace(params.CallerID), + AccountID: strings.TrimSpace(params.AccountID), + Model: strings.TrimSpace(params.Model), + Stream: params.Stream, + UserInput: strings.TrimSpace(params.UserInput), + Messages: cloneMessages(params.Messages), + FinalPrompt: strings.TrimSpace(params.FinalPrompt), + } + s.details[entry.ID] = entry + s.rebuildIndexLocked() + if err := s.saveLocked(); err != nil { + return Entry{}, err + } + return cloneEntry(entry), nil +} + +func (s *Store) Update(id string, params UpdateParams) (Entry, error) { + if s == nil { + return Entry{}, errors.New("chat history store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return Entry{}, s.err + } + target := strings.TrimSpace(id) + if target == "" { + return Entry{}, errors.New("history id is required") + } + item, ok := s.details[target] + if !ok { + return Entry{}, errors.New("chat history entry not found") + } + now := time.Now().UnixMilli() + item.Revision = s.nextRevisionLocked() + item.UpdatedAt = now + if params.Status != "" { + item.Status = params.Status + } + item.ReasoningContent = params.ReasoningContent + item.Content = params.Content + item.Error = strings.TrimSpace(params.Error) + item.StatusCode = params.StatusCode + item.ElapsedMs = params.ElapsedMs + item.FinishReason = strings.TrimSpace(params.FinishReason) + if params.Usage != nil { + item.Usage = cloneMap(params.Usage) + } + if params.Completed { + item.CompletedAt = now + } + s.details[target] = item + s.rebuildIndexLocked() + if err := s.saveLocked(); err != nil { + return Entry{}, err + } + return cloneEntry(item), nil +} + +func (s *Store) Delete(id string) error { + if s == nil { + return errors.New("chat history store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return s.err + } + target := strings.TrimSpace(id) + if target == "" { + return errors.New("history id is required") + } + if _, ok := s.details[target]; !ok { + return errors.New("chat history entry not found") + } + delete(s.details, target) + s.nextRevisionLocked() + s.rebuildIndexLocked() + if err := s.saveLocked(); err != nil { + return err + } + return nil +} + +func (s *Store) Clear() error { + if s == nil { + return errors.New("chat history store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return s.err + } + s.details = map[string]Entry{} + s.nextRevisionLocked() + s.rebuildIndexLocked() + if err := s.saveLocked(); err != nil { + return err + } + return nil +} + +func (s *Store) SetLimit(limit int) (File, error) { + if s == nil { + return File{}, errors.New("chat history store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return File{}, s.err + } + if !isAllowedLimit(limit) { + return File{}, fmt.Errorf("unsupported chat history limit: %d", limit) + } + s.state.Limit = limit + s.nextRevisionLocked() + s.rebuildIndexLocked() + if err := s.saveLocked(); err != nil { + return File{}, err + } + return cloneFile(s.state), nil +} + +func (s *Store) loadLocked() error { + if strings.TrimSpace(s.path) == "" { + return errors.New("chat history path is required") + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil && filepath.Dir(s.path) != "." { + return fmt.Errorf("create chat history dir: %w", err) + } + if err := os.MkdirAll(s.detailDir, 0o755); err != nil { + return fmt.Errorf("create chat history detail dir: %w", err) + } + + raw, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return s.saveLocked() + } + return fmt.Errorf("read chat history index: %w", err) + } + + legacy, legacyOK, legacyErr := parseLegacy(raw) + if legacyErr != nil { + return legacyErr + } + if legacyOK && !hasDetailFiles(s.detailDir) { + s.loadLegacyLocked(legacy) + return s.saveLocked() + } + + var state File + if err := json.Unmarshal(raw, &state); err != nil { + return fmt.Errorf("decode chat history index: %w", err) + } + if state.Version == 0 { + state.Version = FileVersion + } + if !isAllowedLimit(state.Limit) { + state.Limit = DefaultLimit + } + s.state = cloneFile(state) + s.details = map[string]Entry{} + for _, item := range state.Items { + detail, err := readDetailFile(filepath.Join(s.detailDir, item.ID+".json")) + if err != nil { + return err + } + s.details[item.ID] = detail + } + s.rebuildIndexLocked() + return s.saveLocked() +} + +func (s *Store) loadLegacyLocked(legacy legacyFile) { + s.state.Version = FileVersion + s.state.Limit = legacy.Limit + if !isAllowedLimit(s.state.Limit) { + s.state.Limit = DefaultLimit + } + s.details = map[string]Entry{} + maxRevision := int64(0) + for _, item := range legacy.Items { + if strings.TrimSpace(item.ID) == "" { + continue + } + item.Messages = cloneMessages(item.Messages) + if item.Revision == 0 { + if item.UpdatedAt > 0 { + item.Revision = item.UpdatedAt + } else { + item.Revision = time.Now().UnixNano() + } + } + if item.Revision > maxRevision { + maxRevision = item.Revision + } + s.details[item.ID] = item + } + s.state.Revision = maxRevision + s.rebuildIndexLocked() +} + +func (s *Store) saveLocked() error { + s.state.Version = FileVersion + if !isAllowedLimit(s.state.Limit) { + s.state.Limit = DefaultLimit + } + s.rebuildIndexLocked() + + if err := os.MkdirAll(s.detailDir, 0o755); err != nil { + s.err = err + return err + } + activeFiles := make(map[string]struct{}, len(s.details)) + for id, item := range s.details { + path := filepath.Join(s.detailDir, id+".json") + activeFiles[path] = struct{}{} + payload, err := json.MarshalIndent(detailEnvelope{ + Version: FileVersion, + Item: item, + }, "", " ") + if err != nil { + s.err = err + return err + } + if err := writeFileAtomic(path, append(payload, '\n')); err != nil { + s.err = err + return err + } + } + if err := cleanupDetailDir(s.detailDir, activeFiles); err != nil { + s.err = err + return err + } + + payload, err := json.MarshalIndent(s.state, "", " ") + if err != nil { + s.err = err + return err + } + if err := writeFileAtomic(s.path, append(payload, '\n')); err != nil { + s.err = err + return err + } + s.err = nil + return nil +} + +func (s *Store) rebuildIndexLocked() { + summaries := make([]SummaryEntry, 0, len(s.details)) + for _, item := range s.details { + summaries = append(summaries, summaryFromEntry(item)) + } + sort.Slice(summaries, func(i, j int) bool { + if summaries[i].UpdatedAt == summaries[j].UpdatedAt { + return summaries[i].CreatedAt > summaries[j].CreatedAt + } + return summaries[i].UpdatedAt > summaries[j].UpdatedAt + }) + if s.state.Limit < DisabledLimit || !isAllowedLimit(s.state.Limit) { + s.state.Limit = DefaultLimit + } + if s.state.Limit == DisabledLimit { + s.state.Items = summaries + return + } + if len(summaries) > s.state.Limit { + keep := make(map[string]struct{}, s.state.Limit) + for _, item := range summaries[:s.state.Limit] { + keep[item.ID] = struct{}{} + } + for id := range s.details { + if _, ok := keep[id]; !ok { + delete(s.details, id) + } + } + summaries = summaries[:s.state.Limit] + } + s.state.Items = summaries +} + +func (s *Store) nextRevisionLocked() int64 { + next := time.Now().UnixNano() + if next <= s.state.Revision { + next = s.state.Revision + 1 + } + s.state.Revision = next + return next +} + +func summaryFromEntry(item Entry) SummaryEntry { + return SummaryEntry{ + ID: item.ID, + Revision: item.Revision, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + CompletedAt: item.CompletedAt, + Status: item.Status, + CallerID: item.CallerID, + AccountID: item.AccountID, + Model: item.Model, + Stream: item.Stream, + UserInput: item.UserInput, + Preview: buildPreview(item), + StatusCode: item.StatusCode, + ElapsedMs: item.ElapsedMs, + FinishReason: item.FinishReason, + DetailRevision: item.Revision, + } +} + +func buildPreview(item Entry) string { + candidate := strings.TrimSpace(item.Content) + if candidate == "" { + candidate = strings.TrimSpace(item.ReasoningContent) + } + if candidate == "" { + candidate = strings.TrimSpace(item.Error) + } + if candidate == "" { + candidate = strings.TrimSpace(item.UserInput) + } + if len(candidate) > defaultPreviewAt { + return candidate[:defaultPreviewAt] + "..." + } + return candidate +} + +func readDetailFile(path string) (Entry, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Entry{}, fmt.Errorf("read chat history detail: %w", err) + } + var env detailEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return Entry{}, fmt.Errorf("decode chat history detail: %w", err) + } + return cloneEntry(env.Item), nil +} + +func hasDetailFiles(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + if strings.HasSuffix(strings.ToLower(entry.Name()), ".json") { + return true + } + } + return false +} + +func parseLegacy(raw []byte) (legacyFile, bool, error) { + var legacy legacyFile + if err := json.Unmarshal(raw, &legacy); err != nil { + return legacyFile{}, false, nil + } + if len(legacy.Items) == 0 { + return legacy, false, nil + } + for _, item := range legacy.Items { + if item.Content != "" || item.ReasoningContent != "" || item.FinalPrompt != "" || len(item.Messages) > 0 { + return legacy, true, nil + } + } + return legacy, false, nil +} + +func cleanupDetailDir(dir string, active map[string]struct{}) error { + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("list chat history detail dir: %w", err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + path := filepath.Join(dir, entry.Name()) + if _, ok := active[path]; ok { + continue + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove stale chat history detail: %w", err) + } + } + return nil +} + +func writeFileAtomic(path string, body []byte) error { + dir := filepath.Dir(path) + if dir == "" { + dir = "." + } + if dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create chat history dir: %w", err) + } + } + tmpFile, err := os.CreateTemp(dir, ".chat-history-*.tmp") + if err != nil { + return fmt.Errorf("create temp chat history: %w", err) + } + tmpPath := tmpFile.Name() + cleanup := func() { + _ = os.Remove(tmpPath) + } + if _, err := tmpFile.Write(body); err != nil { + _ = tmpFile.Close() + cleanup() + return fmt.Errorf("write temp chat history: %w", err) + } + if err := tmpFile.Sync(); err != nil { + _ = tmpFile.Close() + cleanup() + return fmt.Errorf("sync temp chat history: %w", err) + } + if err := tmpFile.Close(); err != nil { + cleanup() + return fmt.Errorf("close temp chat history: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + cleanup() + return fmt.Errorf("promote temp chat history: %w", err) + } + return nil +} + +func ListETag(revision int64) string { + return fmt.Sprintf(`W/"chat-history-list-%d"`, revision) +} + +func DetailETag(id string, revision int64) string { + return fmt.Sprintf(`W/"chat-history-detail-%s-%d"`, strings.TrimSpace(id), revision) +} + +func isAllowedLimit(limit int) bool { + _, ok := allowedLimits[limit] + return ok +} + +func cloneFile(in File) File { + out := File{ + Version: in.Version, + Limit: in.Limit, + Revision: in.Revision, + Items: make([]SummaryEntry, len(in.Items)), + } + copy(out.Items, in.Items) + return out +} + +func cloneEntry(item Entry) Entry { + item.Usage = cloneMap(item.Usage) + item.Messages = cloneMessages(item.Messages) + return item +} + +func cloneMap(in map[string]any) map[string]any { + if in == nil { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneMessages(messages []Message) []Message { + if len(messages) == 0 { + return []Message{} + } + out := make([]Message, len(messages)) + copy(out, messages) + return out +} diff --git a/internal/chathistory/store_test.go b/internal/chathistory/store_test.go new file mode 100644 index 0000000..d88d32f --- /dev/null +++ b/internal/chathistory/store_test.go @@ -0,0 +1,256 @@ +package chathistory + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" +) + +func TestStoreCreatesAndPersistsEntries(t *testing.T) { + path := filepath.Join(t.TempDir(), "chat_history.json") + store := New(path) + + started, err := store.Start(StartParams{ + CallerID: "caller:abc", + AccountID: "user@example.com", + Model: "deepseek-chat", + Stream: true, + UserInput: "hello", + }) + if err != nil { + t.Fatalf("start entry failed: %v", err) + } + + updated, err := store.Update(started.ID, UpdateParams{ + Status: "success", + ReasoningContent: "thinking", + Content: "answer", + StatusCode: 200, + ElapsedMs: 321, + FinishReason: "stop", + Usage: map[string]any{"total_tokens": 9}, + Completed: true, + }) + if err != nil { + t.Fatalf("update entry failed: %v", err) + } + if updated.Status != "success" || updated.Content != "answer" { + t.Fatalf("unexpected updated entry: %#v", updated) + } + + snapshot, err := store.Snapshot() + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if snapshot.Limit != DefaultLimit { + t.Fatalf("unexpected default limit: %d", snapshot.Limit) + } + if len(snapshot.Items) != 1 { + t.Fatalf("expected one item, got %d", len(snapshot.Items)) + } + if snapshot.Items[0].CompletedAt == 0 { + t.Fatalf("expected completed_at to be populated") + } + if snapshot.Items[0].Preview != "answer" { + t.Fatalf("expected summary preview=answer, got %#v", snapshot.Items[0]) + } + + reloaded := New(path) + reloadedSnapshot, err := reloaded.Snapshot() + if err != nil { + t.Fatalf("reload snapshot failed: %v", err) + } + if len(reloadedSnapshot.Items) != 1 { + t.Fatalf("unexpected reloaded summaries: %#v", reloadedSnapshot.Items) + } + full, err := reloaded.Get(started.ID) + if err != nil { + t.Fatalf("get detail failed: %v", err) + } + if full.Content != "answer" { + t.Fatalf("expected detail content=answer, got %#v", full) + } +} + +func TestStoreTrimsToConfiguredLimit(t *testing.T) { + path := filepath.Join(t.TempDir(), "chat_history.json") + store := New(path) + if _, err := store.SetLimit(10); err != nil { + t.Fatalf("set limit failed: %v", err) + } + + for i := 0; i < 12; i++ { + entry, err := store.Start(StartParams{Model: "deepseek-chat", UserInput: "msg"}) + if err != nil { + t.Fatalf("start %d failed: %v", i, err) + } + if _, err := store.Update(entry.ID, UpdateParams{Status: "success", Content: "ok", Completed: true}); err != nil { + t.Fatalf("update %d failed: %v", i, err) + } + } + + snapshot, err := store.Snapshot() + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if len(snapshot.Items) != 10 { + t.Fatalf("expected 10 items, got %d", len(snapshot.Items)) + } +} + +func TestStoreDeleteClearAndLimitValidation(t *testing.T) { + path := filepath.Join(t.TempDir(), "chat_history.json") + store := New(path) + entry, err := store.Start(StartParams{UserInput: "hello"}) + if err != nil { + t.Fatalf("start failed: %v", err) + } + if err := store.Delete(entry.ID); err != nil { + t.Fatalf("delete failed: %v", err) + } + snapshot, err := store.Snapshot() + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if len(snapshot.Items) != 0 { + t.Fatalf("expected empty items after delete, got %d", len(snapshot.Items)) + } + if _, err := store.SetLimit(999); err == nil { + t.Fatalf("expected invalid limit error") + } + if err := store.Clear(); err != nil { + t.Fatalf("clear failed: %v", err) + } +} + +func TestStoreDisablePreservesHistoryAndBlocksNewEntries(t *testing.T) { + path := filepath.Join(t.TempDir(), "chat_history.json") + store := New(path) + + entry, err := store.Start(StartParams{UserInput: "hello"}) + if err != nil { + t.Fatalf("start failed: %v", err) + } + if _, err := store.Update(entry.ID, UpdateParams{Status: "success", Content: "world", Completed: true}); err != nil { + t.Fatalf("update failed: %v", err) + } + + snapshot, err := store.SetLimit(DisabledLimit) + if err != nil { + t.Fatalf("disable failed: %v", err) + } + if snapshot.Limit != DisabledLimit { + t.Fatalf("expected disabled limit, got %d", snapshot.Limit) + } + if len(snapshot.Items) != 1 { + t.Fatalf("expected disabled mode to preserve summaries, got %d", len(snapshot.Items)) + } + if store.Enabled() { + t.Fatalf("expected store to report disabled") + } + if _, err := store.Start(StartParams{UserInput: "later"}); err != ErrDisabled { + t.Fatalf("expected ErrDisabled, got %v", err) + } +} + +func TestStoreConcurrentUpdatesKeepSplitFilesValid(t *testing.T) { + path := filepath.Join(t.TempDir(), "chat_history.json") + store := New(path) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + entry, err := store.Start(StartParams{ + CallerID: "caller:test", + Model: "deepseek-chat", + UserInput: "hello", + }) + if err != nil { + t.Errorf("start failed: %v", err) + return + } + _, err = store.Update(entry.ID, UpdateParams{ + Status: "success", + Content: "answer", + ElapsedMs: int64(idx), + Completed: true, + }) + if err != nil { + t.Errorf("update failed: %v", err) + } + }(i) + } + wg.Wait() + + snapshot, err := store.Snapshot() + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if len(snapshot.Items) != 8 { + t.Fatalf("expected 8 items, got %d", len(snapshot.Items)) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read index failed: %v", err) + } + var persisted File + if err := json.Unmarshal(raw, &persisted); err != nil { + t.Fatalf("persisted index is invalid json: %v", err) + } + if len(persisted.Items) != 8 { + t.Fatalf("expected persisted items=8, got %d", len(persisted.Items)) + } + + detailFiles, err := os.ReadDir(path + ".d") + if err != nil { + t.Fatalf("read detail dir failed: %v", err) + } + if len(detailFiles) != 8 { + t.Fatalf("expected 8 detail files, got %d", len(detailFiles)) + } +} + +func TestStoreAutoMigratesLegacyMonolith(t *testing.T) { + path := filepath.Join(t.TempDir(), "chat_history.json") + legacy := legacyFile{ + Version: 1, + Limit: 20, + Items: []Entry{{ + ID: "chat_legacy", + CreatedAt: 1, + UpdatedAt: 2, + Status: "success", + UserInput: "hello", + Content: "world", + ReasoningContent: "thinking", + }}, + } + body, _ := json.MarshalIndent(legacy, "", " ") + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatalf("write legacy file failed: %v", err) + } + + store := New(path) + if err := store.Err(); err != nil { + t.Fatalf("expected legacy migration success, got %v", err) + } + snapshot, err := store.Snapshot() + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if len(snapshot.Items) != 1 { + t.Fatalf("expected one migrated summary, got %#v", snapshot.Items) + } + full, err := store.Get("chat_legacy") + if err != nil { + t.Fatalf("get migrated detail failed: %v", err) + } + if full.Content != "world" { + t.Fatalf("expected migrated detail content preserved, got %#v", full) + } +} diff --git a/internal/config/paths.go b/internal/config/paths.go index 99e3fde..e3cc249 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -37,6 +37,10 @@ func RawStreamSampleRoot() string { return ResolvePath("DS2API_RAW_STREAM_SAMPLE_ROOT", "tests/raw_stream_samples") } +func ChatHistoryPath() string { + return ResolvePath("DS2API_CHAT_HISTORY_PATH", "data/chat_history.json") +} + func StaticAdminDir() string { return ResolvePath("DS2API_STATIC_ADMIN_DIR", "static/admin") } diff --git a/internal/server/router.go b/internal/server/router.go index ebb306a..a2a078e 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -4,7 +4,10 @@ import ( "context" "encoding/json" "fmt" + "log" "net/http" + "os" + "runtime" "strings" "time" @@ -17,6 +20,7 @@ import ( "ds2api/internal/adapter/openai" "ds2api/internal/admin" "ds2api/internal/auth" + "ds2api/internal/chathistory" "ds2api/internal/config" "ds2api/internal/deepseek" "ds2api/internal/webui" @@ -46,17 +50,21 @@ func NewApp() (*App, error) { } else { config.Logger.Info("[PoW] pure Go solver ready") } + chatHistoryStore := chathistory.New(config.ChatHistoryPath()) + if err := chatHistoryStore.Err(); err != nil { + config.Logger.Warn("[chat_history] unavailable", "path", chatHistoryStore.Path(), "error", err) + } - openaiHandler := &openai.Handler{Store: store, Auth: resolver, DS: dsClient} + openaiHandler := &openai.Handler{Store: store, Auth: resolver, DS: dsClient, ChatHistory: chatHistoryStore} claudeHandler := &claude.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: openaiHandler} geminiHandler := &gemini.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: openaiHandler} - adminHandler := &admin.Handler{Store: store, Pool: pool, DS: dsClient, OpenAI: openaiHandler} + adminHandler := &admin.Handler{Store: store, Pool: pool, DS: dsClient, OpenAI: openaiHandler, ChatHistory: chatHistoryStore} webuiHandler := webui.NewHandler() r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) - r.Use(middleware.Logger) + r.Use(filteredLogger()) r.Use(middleware.Recoverer) r.Use(cors) r.Use(timeout(0)) @@ -99,11 +107,47 @@ func timeout(d time.Duration) func(http.Handler) http.Handler { return middleware.Timeout(d) } +func filteredLogger() func(http.Handler) http.Handler { + color := true + if isWindowsRuntime() { + color = false + } + base := &middleware.DefaultLogFormatter{ + Logger: log.New(os.Stdout, "", log.LstdFlags), + NoColor: !color, + } + return middleware.RequestLogger(&filteredLogFormatter{base: base}) +} + +func isWindowsRuntime() bool { + return runtime.GOOS == "windows" +} + +type filteredLogFormatter struct { + base *middleware.DefaultLogFormatter +} + +func (f *filteredLogFormatter) NewLogEntry(r *http.Request) middleware.LogEntry { + if r != nil && r.Method == http.MethodGet { + path := strings.TrimSpace(r.URL.Path) + if path == "/admin/chat-history" || strings.HasPrefix(path, "/admin/chat-history/") { + return noopLogEntry{} + } + } + return f.base.NewLogEntry(r) +} + +type noopLogEntry struct{} + +func (noopLogEntry) Write(_ int, _ int, _ http.Header, _ time.Duration, _ interface{}) {} + +func (noopLogEntry) Panic(_ interface{}, _ []byte) {} + func cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Ds2-Target-Account, X-Vercel-Protection-Bypass") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Ds2-Target-Account, X-Ds2-Source, X-Vercel-Protection-Bypass") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return diff --git a/webui/src/features/apiTester/useChatStreamClient.js b/webui/src/features/apiTester/useChatStreamClient.js index b6fad86..d1a2229 100644 --- a/webui/src/features/apiTester/useChatStreamClient.js +++ b/webui/src/features/apiTester/useChatStreamClient.js @@ -123,6 +123,7 @@ export function useChatStreamClient({ const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${effectiveKey}`, + 'X-Ds2-Source': 'admin-webui-api-tester', } if (requestAccount) { headers['X-Ds2-Target-Account'] = requestAccount diff --git a/webui/src/features/chatHistory/ChatHistoryContainer.jsx b/webui/src/features/chatHistory/ChatHistoryContainer.jsx new file mode 100644 index 0000000..c9951c0 --- /dev/null +++ b/webui/src/features/chatHistory/ChatHistoryContainer.jsx @@ -0,0 +1,892 @@ +import { ArrowDown, ArrowUp, Bot, ChevronDown, Clock3, Loader2, MessageSquareText, RefreshCcw, Sparkles, Trash2, UserRound, X } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import clsx from 'clsx' + +import { useI18n } from '../../i18n' + +const LIMIT_OPTIONS = [0, 10, 20, 50] +const DISABLED_LIMIT = 0 +const MESSAGE_COLLAPSE_AT = 700 +const VIEW_MODE_KEY = 'ds2api_chat_history_view_mode' + +function formatDateTime(value, lang) { + if (!value) return '-' + try { + return new Intl.DateTimeFormat(lang === 'zh' ? 'zh-CN' : 'en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(value)) + } catch { + return '-' + } +} + +function formatElapsed(ms, t) { + if (!ms) return t('chatHistory.metaUnknown') + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)}s` +} + +function previewText(item) { + return item?.preview || item?.content || item?.reasoning_content || item?.error || item?.user_input || '' +} + +function statusTone(status) { + switch (status) { + case 'success': + return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-600' + case 'error': + return 'border-destructive/20 bg-destructive/10 text-destructive' + case 'stopped': + return 'border-amber-500/20 bg-amber-500/10 text-amber-600' + default: + return 'border-border bg-secondary/60 text-muted-foreground' + } +} + +function ExpandableText({ text = '', threshold = MESSAGE_COLLAPSE_AT, expandLabel, collapseLabel, buttonClassName = 'text-white hover:text-white/80' }) { + const shouldCollapse = text.length > threshold + const [expanded, setExpanded] = useState(false) + const contentRef = useRef(null) + const [maxHeight, setMaxHeight] = useState('none') + + useEffect(() => { + setExpanded(false) + }, [text]) + + const visibleText = shouldCollapse && !expanded ? `${text.slice(0, threshold)}...` : text + + useEffect(() => { + if (!contentRef.current) return + setMaxHeight(`${contentRef.current.scrollHeight}px`) + }, [expanded, visibleText]) + + return ( +
+
+
+ {visibleText} +
+
+ {shouldCollapse && ( + + )} +
+ ) +} + +function ListModeIcon() { + return ( + + ) +} + +function MergeModeIcon() { + return ( + + ) +} + +function RequestMessages({ item, t }) { + const messages = Array.isArray(item?.messages) && item.messages.length > 0 + ? item.messages + : [{ role: 'user', content: item?.user_input || t('chatHistory.emptyUserInput') }] + + return ( +
+ {messages.map((message, index) => { + const role = message.role || 'user' + const isUser = role === 'user' + const isAssistant = role === 'assistant' + const isTool = role === 'tool' + const label = isUser + ? t('chatHistory.role.user') + : (isAssistant ? t('chatHistory.role.assistant') : (isTool ? t('chatHistory.role.tool') : t('chatHistory.role.system'))) + return ( +
+
+ {isUser + ? + : } +
+
+
+ {label} +
+
+
+ {message.content || t('chatHistory.emptyUserInput')} +
+
+
+
+ ) + })} +
+ ) +} + +function MergedPromptView({ item, t }) { + const merged = item?.final_prompt || '' + return ( +
+
+ {t('chatHistory.mergedInput')} +
+
+ +
+
+ ) +} + +function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName }) { + if (!selectedItem) return null + + return ( + <> + {viewMode === 'list' + ? + : } + +
+
+ +
+
+ {(selectedItem.reasoning_content || '').trim() && ( +
+
+ + {t('chatHistory.reasoningTrace')} +
+
+ {selectedItem.reasoning_content} +
+
+ )} + +
+ {selectedItem.status === 'error' + ? {selectedItem.error || t('chatHistory.failedOutput')} + : (selectedItem.content || t('chatHistory.emptyAssistantOutput'))} +
+
+
+ +
+
{t('chatHistory.metaTitle')}
+
+
+
{t('chatHistory.metaAccount')}
+
{selectedItem.account_id || t('chatHistory.metaUnknown')}
+
+
+
{t('chatHistory.metaElapsed')}
+
+ + {formatElapsed(selectedItem.elapsed_ms, t)} +
+
+
+
{t('chatHistory.metaModel')}
+
{selectedItem.model || t('chatHistory.metaUnknown')}
+
+
+
{t('chatHistory.metaStatusCode')}
+
{selectedItem.status_code || '-'}
+
+
+
{t('chatHistory.metaStream')}
+
{selectedItem.stream ? t('chatHistory.streamMode') : t('chatHistory.nonStreamMode')}
+
+
+
{t('chatHistory.metaCaller')}
+
{selectedItem.caller_id || t('chatHistory.metaUnknown')}
+
+
+
+ + + + ) +} + +export default function ChatHistoryContainer({ authFetch, onMessage }) { + const { t, lang } = useI18n() + const apiFetch = authFetch || fetch + const [items, setItems] = useState([]) + const [limit, setLimit] = useState(20) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [selectedId, setSelectedId] = useState('') + const [selectedDetail, setSelectedDetail] = useState(null) + const [savingLimit, setSavingLimit] = useState(false) + const [clearing, setClearing] = useState(false) + const [deletingId, setDeletingId] = useState('') + const [detail, setDetail] = useState('') + const [confirmClearOpen, setConfirmClearOpen] = useState(false) + const [autoRefreshReady, setAutoRefreshReady] = useState(false) + const [viewMode, setViewMode] = useState(() => { + if (typeof localStorage === 'undefined') return 'list' + const stored = localStorage.getItem(VIEW_MODE_KEY) + return stored === 'merged' ? 'merged' : 'list' + }) + const [isMobileView, setIsMobileView] = useState(() => typeof window !== 'undefined' ? window.innerWidth < 1024 : false) + const [mobileDetailOpen, setMobileDetailOpen] = useState(false) + const [mobileDetailVisible, setMobileDetailVisible] = useState(false) + const [mobileOrigin, setMobileOrigin] = useState({ x: 50, y: 50 }) + const [pendingJumpToAssistant, setPendingJumpToAssistant] = useState(false) + + const inFlightRef = useRef(false) + const detailInFlightRef = useRef(false) + const listETagRef = useRef('') + const detailETagRef = useRef('') + const assistantStartRef = useRef(null) + const detailScrollRef = useRef(null) + const mobileCloseTimerRef = useRef(null) + + const selectedSummary = items.find(item => item.id === selectedId) || items[0] || null + const selectedItem = selectedDetail && selectedDetail.id === selectedId ? selectedDetail : null + + const syncItems = (nextItems) => { + setItems(nextItems) + setSelectedId(prev => { + if (!nextItems.length) return '' + if (prev && nextItems.some(item => item.id === prev)) return prev + return nextItems[0].id + }) + } + + const loadList = async ({ mode = 'silent', announceError = false } = {}) => { + if (inFlightRef.current) return + inFlightRef.current = true + if (mode === 'manual') { + setRefreshing(true) + } else if (mode === 'initial') { + setLoading(true) + } + if (announceError) { + setDetail('') + } + try { + const headers = {} + if (listETagRef.current) { + headers['If-None-Match'] = listETagRef.current + } + const res = await apiFetch('/admin/chat-history', { headers }) + if (res.status === 304) { + return + } + const data = await res.json() + if (!res.ok) { + throw new Error(data?.detail || t('chatHistory.loadFailed')) + } + listETagRef.current = res.headers.get('ETag') || '' + setLimit(typeof data.limit === 'number' ? data.limit : 20) + syncItems(Array.isArray(data.items) ? data.items : []) + } catch (error) { + setDetail(error.message || t('chatHistory.loadFailed')) + if (announceError) { + onMessage?.('error', error.message || t('chatHistory.loadFailed')) + } + } finally { + if (mode === 'initial') { + setLoading(false) + } + if (mode === 'manual') { + setRefreshing(false) + } + inFlightRef.current = false + } + } + + const loadDetail = async (id, { announceError = false } = {}) => { + if (!id || detailInFlightRef.current) return + detailInFlightRef.current = true + try { + const headers = {} + if (detailETagRef.current) { + headers['If-None-Match'] = detailETagRef.current + } + const res = await apiFetch(`/admin/chat-history/${encodeURIComponent(id)}`, { headers }) + if (res.status === 304) { + return + } + const data = await res.json() + if (!res.ok) { + throw new Error(data?.detail || t('chatHistory.loadFailed')) + } + detailETagRef.current = res.headers.get('ETag') || '' + setSelectedDetail(data.item || null) + } catch (error) { + if (announceError) { + onMessage?.('error', error.message || t('chatHistory.loadFailed')) + } + } finally { + detailInFlightRef.current = false + } + } + + useEffect(() => { + loadList({ mode: 'initial', announceError: true }).finally(() => { + setAutoRefreshReady(true) + }) + }, []) + + useEffect(() => { + if (!autoRefreshReady) return undefined + const timer = window.setInterval(() => { + loadList({ mode: 'silent', announceError: false }) + }, 5000) + return () => window.clearInterval(timer) + }, [autoRefreshReady]) + + useEffect(() => { + if (!autoRefreshReady || !selectedId || selectedSummary?.status !== 'streaming') return undefined + const timer = window.setInterval(() => { + loadDetail(selectedId, { announceError: false }) + }, 1000) + return () => window.clearInterval(timer) + }, [autoRefreshReady, selectedId, selectedSummary?.status]) + + useEffect(() => { + if (!selectedId) return undefined + detailETagRef.current = '' + setSelectedDetail(null) + loadDetail(selectedId, { announceError: false }) + }, [selectedId, mobileDetailOpen]) + + useEffect(() => { + if (!pendingJumpToAssistant || !selectedItem || selectedItem.id !== selectedId) return undefined + const frame = window.requestAnimationFrame(() => { + assistantStartRef.current?.scrollIntoView({ behavior: 'auto', block: 'start' }) + setPendingJumpToAssistant(false) + }) + return () => window.cancelAnimationFrame(frame) + }, [pendingJumpToAssistant, selectedId, selectedItem?.id, selectedItem?.revision, mobileDetailOpen, viewMode]) + + useEffect(() => { + if (typeof localStorage === 'undefined') return + localStorage.setItem(VIEW_MODE_KEY, viewMode) + }, [viewMode]) + + useEffect(() => { + if (typeof window === 'undefined') return undefined + const handleResize = () => setIsMobileView(window.innerWidth < 1024) + handleResize() + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + + useEffect(() => { + if (!isMobileView) { + setMobileDetailOpen(false) + setMobileDetailVisible(false) + } + }, [isMobileView]) + + useEffect(() => { + return () => { + if (mobileCloseTimerRef.current) { + window.clearTimeout(mobileCloseTimerRef.current) + } + } + }, []) + + const handleRefresh = async ({ manual = true } = {}) => { + await loadList({ mode: manual ? 'manual' : 'silent', announceError: manual }) + if (selectedId) { + detailETagRef.current = '' + await loadDetail(selectedId, { announceError: manual }) + } + } + + const handleLimitChange = async (nextLimit) => { + if (nextLimit === limit || savingLimit) return + setSavingLimit(true) + try { + const res = await apiFetch('/admin/chat-history/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ limit: nextLimit }), + }) + const data = await res.json() + if (!res.ok) { + throw new Error(data?.detail || t('chatHistory.updateLimitFailed')) + } + const resolvedLimit = typeof data.limit === 'number' ? data.limit : nextLimit + setLimit(resolvedLimit) + listETagRef.current = '' + syncItems(Array.isArray(data.items) ? data.items : []) + onMessage?.('success', t('chatHistory.limitUpdated', { limit: resolvedLimit === DISABLED_LIMIT ? t('chatHistory.off') : resolvedLimit })) + } catch (error) { + onMessage?.('error', error.message || t('chatHistory.updateLimitFailed')) + } finally { + setSavingLimit(false) + } + } + + const handleDeleteItem = async (id) => { + if (!id || deletingId) return + setDeletingId(id) + try { + const res = await apiFetch(`/admin/chat-history/${encodeURIComponent(id)}`, { method: 'DELETE' }) + const data = await res.json() + if (!res.ok) { + throw new Error(data?.detail || t('chatHistory.deleteFailed')) + } + if (selectedId === id) { + detailETagRef.current = '' + setSelectedDetail(null) + } + syncItems(items.filter(item => item.id !== id)) + onMessage?.('success', t('chatHistory.deleteSuccess')) + } catch (error) { + onMessage?.('error', error.message || t('chatHistory.deleteFailed')) + } finally { + setDeletingId('') + } + } + + const handleClear = async () => { + if (clearing || !items.length) return + setClearing(true) + try { + const res = await apiFetch('/admin/chat-history', { method: 'DELETE' }) + const data = await res.json() + if (!res.ok) { + throw new Error(data?.detail || t('chatHistory.clearFailed')) + } + listETagRef.current = '' + detailETagRef.current = '' + setSelectedDetail(null) + syncItems([]) + onMessage?.('success', t('chatHistory.clearSuccess')) + } catch (error) { + onMessage?.('error', error.message || t('chatHistory.clearFailed')) + } finally { + setClearing(false) + } + } + + const openMobileDetail = (itemId, event) => { + const x = typeof window !== 'undefined' && event?.clientX ? (event.clientX / window.innerWidth) * 100 : 50 + const y = typeof window !== 'undefined' && event?.clientY ? (event.clientY / window.innerHeight) * 100 : 50 + setMobileOrigin({ x, y }) + setPendingJumpToAssistant(true) + setSelectedId(itemId) + setMobileDetailOpen(true) + setMobileDetailVisible(false) + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => setMobileDetailVisible(true)) + }) + } + + const closeMobileDetail = () => { + setMobileDetailVisible(false) + if (mobileCloseTimerRef.current) { + window.clearTimeout(mobileCloseTimerRef.current) + } + mobileCloseTimerRef.current = window.setTimeout(() => { + setMobileDetailOpen(false) + }, 180) + } + + const handleSelectItem = (itemId, event) => { + if (isMobileView) { + openMobileDetail(itemId, event) + return + } + setPendingJumpToAssistant(true) + setSelectedId(itemId) + } + + if (loading) { + return ( +
+
+ + {t('chatHistory.loading')} +
+
+ ) + } + + return ( +
+
+
+
{t('chatHistory.retentionTitle')}
+
{t('chatHistory.retentionDesc')}
+
+
+ {LIMIT_OPTIONS.map(option => ( + + ))} + + +
+
+ + {detail && ( +
+ {detail} +
+ )} + +
+
+
+
{t('chatHistory.listTitle')}
+
{items.length}
+
+
+ {!items.length && ( +
+ +
{t('chatHistory.emptyTitle')}
+
{t('chatHistory.emptyDesc')}
+
+ )} + + {items.map(item => ( + +
+
+
+ {previewText(item) || t('chatHistory.noPreview')} +
+
+ {formatDateTime(item.completed_at || item.updated_at || item.created_at, lang)} +
+ + ))} +
+
+ +
+
+
+
{t('chatHistory.detailTitle')}
+
+ {selectedSummary ? formatDateTime(selectedSummary.completed_at || selectedSummary.updated_at || selectedSummary.created_at, lang) : t('chatHistory.selectPrompt')} +
+
+
+
+ + +
+ + {selectedSummary && ( + + {t(`chatHistory.status.${selectedSummary.status || 'streaming'}`)} + + )} +
+
+ +
+ {!selectedItem && ( +
+ {t('chatHistory.selectPrompt')} +
+ )} + + {selectedItem && ( + + )} +
+
+ + + {isMobileView && mobileDetailOpen && selectedItem && ( +
+
event.stopPropagation()} + className={clsx( + 'w-full h-full rounded-2xl border border-border bg-card shadow-2xl overflow-hidden flex flex-col transition-transform duration-200 ease-out', + mobileDetailVisible ? 'scale-100' : 'scale-90' + )} + style={{ transformOrigin: `${mobileOrigin.x}% ${mobileOrigin.y}%` }} + > +
+
+
{t('chatHistory.detailTitle')}
+
+ {formatDateTime(selectedItem.completed_at || selectedItem.updated_at || selectedItem.created_at, lang)} +
+
+
+
+ + +
+ + +
+
+ +
+ +
+
+
+ )} + + {confirmClearOpen && ( +
+
+
+
+
+ +
+
+
{t('chatHistory.confirmClearTitle')}
+
{t('chatHistory.confirmClearDesc')}
+
+
+ +
+
+ + +
+
+
+ )} + + ) +} diff --git a/webui/src/layout/DashboardShell.jsx b/webui/src/layout/DashboardShell.jsx index 5c1f5c7..b0542a6 100644 --- a/webui/src/layout/DashboardShell.jsx +++ b/webui/src/layout/DashboardShell.jsx @@ -10,12 +10,14 @@ import { X, Server, Users, - Globe + Globe, + History } from 'lucide-react' import clsx from 'clsx' import AccountManagerContainer from '../features/account/AccountManagerContainer' import ApiTesterContainer from '../features/apiTester/ApiTesterContainer' +import ChatHistoryContainer from '../features/chatHistory/ChatHistoryContainer' import BatchImport from '../components/BatchImport' import VercelSyncContainer from '../features/vercel/VercelSyncContainer' import SettingsContainer from '../features/settings/SettingsContainer' @@ -33,6 +35,7 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s { id: 'accounts', label: t('nav.accounts.label'), icon: Users, description: t('nav.accounts.desc') }, { id: 'proxies', label: t('nav.proxies.label'), icon: Globe, description: t('nav.proxies.desc') }, { id: 'test', label: t('nav.test.label'), icon: Server, description: t('nav.test.desc') }, + { id: 'history', label: t('nav.history.label'), icon: History, description: t('nav.history.desc') }, { id: 'import', label: t('nav.import.label'), icon: Upload, description: t('nav.import.desc') }, { id: 'vercel', label: t('nav.vercel.label'), icon: Cloud, description: t('nav.vercel.desc') }, { id: 'settings', label: t('nav.settings.label'), icon: SettingsIcon, description: t('nav.settings.desc') }, @@ -98,6 +101,8 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s return case 'test': return + case 'history': + return case 'import': return case 'vercel': diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index e9883dc..73a1186 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -17,6 +17,10 @@ "label": "API Test", "desc": "Test API connectivity and responses" }, + "history": { + "label": "Conversations", + "desc": "Browse server-side external chat history" + }, "import": { "label": "Batch Import", "desc": "Bulk import account configuration" @@ -234,6 +238,66 @@ "enterMessage": "Enter a message...", "adminConsoleLabel": "DeepSeek admin console" }, + "chatHistory": { + "loading": "Loading conversation history...", + "loadFailed": "Failed to load conversation history.", + "retentionTitle": "Retention", + "retentionDesc": "The server keeps only the latest N external /v1/chat/completions conversations.", + "off": "OFF", + "refresh": "Refresh", + "clearAll": "Clear all", + "clearSuccess": "Conversation history cleared.", + "clearFailed": "Failed to clear conversation history.", + "deleteSuccess": "Conversation deleted.", + "deleteFailed": "Failed to delete conversation.", + "updateLimitFailed": "Failed to update retention limit.", + "limitUpdated": "Retention limit updated to {limit}", + "listTitle": "History", + "detailTitle": "Details", + "viewModeList": "List mode", + "viewModeMerged": "Merged mode", + "emptyTitle": "No conversation history yet", + "emptyDesc": "When external clients call /v1/chat/completions, the server will save the results here automatically.", + "untitled": "Untitled conversation", + "noPreview": "No preview available.", + "selectPrompt": "Select a record on the left to view details.", + "mergedInput": "Final message sent to DeepSeek", + "emptyMergedPrompt": "No merged prompt is available.", + "expand": "Expand", + "collapse": "Collapse", + "reasoningTrace": "Reasoning Trace", + "failedOutput": "The request failed and no assistant output is available.", + "emptyAssistantOutput": "No assistant output is available.", + "emptyUserInput": "No user input is available.", + "confirmClearTitle": "Clear all records?", + "confirmClearDesc": "This deletes every server-side conversation record and cannot be undone.", + "confirmClearAction": "Clear all", + "metaTitle": "Metadata", + "metaAccount": "Account", + "metaElapsed": "Elapsed", + "metaModel": "Model", + "metaStatusCode": "Status code", + "metaStream": "Output mode", + "metaCaller": "Caller fingerprint", + "metaTime": "Completed at", + "metaUnknown": "Unknown", + "backToTop": "Back to top", + "backToBottom": "Jump to bottom", + "streamMode": "Streaming", + "nonStreamMode": "Non-streaming", + "status": { + "streaming": "Streaming", + "success": "Success", + "error": "Error", + "stopped": "Stopped" + }, + "role": { + "user": "User", + "assistant": "Assistant", + "tool": "Tool", + "system": "System" + } + }, "batchImport": { "templates": { "full": { diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index ca7acb7..84bbeac 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -17,6 +17,10 @@ "label": "API 测试", "desc": "测试 API 连接与响应" }, + "history": { + "label": "对话记录", + "desc": "查看服务器保存的外部对话历史" + }, "import": { "label": "批量导入", "desc": "批量导入账号配置" @@ -234,6 +238,66 @@ "enterMessage": "输入消息...", "adminConsoleLabel": "DeepSeek 管理员界面" }, + "chatHistory": { + "loading": "正在加载对话记录...", + "loadFailed": "加载对话记录失败", + "retentionTitle": "保留条数", + "retentionDesc": "服务器端只保留最新 N 条外部 /v1/chat/completions 对话记录。", + "off": "OFF", + "refresh": "刷新", + "clearAll": "清空全部", + "clearSuccess": "对话记录已清空", + "clearFailed": "清空对话记录失败", + "deleteSuccess": "对话记录已删除", + "deleteFailed": "删除对话记录失败", + "updateLimitFailed": "更新保留条数失败", + "limitUpdated": "保留条数已更新为 {limit}", + "listTitle": "历史列表", + "detailTitle": "对话详情", + "viewModeList": "列表模式", + "viewModeMerged": "合并模式", + "emptyTitle": "还没有可用的对话记录", + "emptyDesc": "当外部客户端调用 /v1/chat/completions 时,服务端会自动把结果写入这里。", + "untitled": "未命名对话", + "noPreview": "暂无预览内容", + "selectPrompt": "从左侧选择一条记录查看详情。", + "mergedInput": "最终发送给 DeepSeek 的完整消息", + "emptyMergedPrompt": "没有可展示的完整消息。", + "expand": "展开全部", + "collapse": "收起", + "reasoningTrace": "思维链过程", + "failedOutput": "请求失败,未生成可展示的回答。", + "emptyAssistantOutput": "没有可展示的生成内容。", + "emptyUserInput": "没有可展示的用户输入。", + "confirmClearTitle": "确认清空全部记录?", + "confirmClearDesc": "此操作会删除服务器里的全部对话记录,无法恢复。", + "confirmClearAction": "确认清空", + "metaTitle": "元信息", + "metaAccount": "使用账号", + "metaElapsed": "耗时", + "metaModel": "模型", + "metaStatusCode": "状态码", + "metaStream": "输出模式", + "metaCaller": "调用方指纹", + "metaTime": "完成时间", + "metaUnknown": "未知", + "backToTop": "回到顶部", + "backToBottom": "跳到底部", + "streamMode": "流式", + "nonStreamMode": "非流式", + "status": { + "streaming": "进行中", + "success": "成功", + "error": "失败", + "stopped": "已停止" + }, + "role": { + "user": "用户", + "assistant": "助手", + "tool": "工具", + "system": "系统" + } + }, "batchImport": { "templates": { "full": {