diff --git a/.github/workflows/docker-image-alpha.yml b/.github/workflows/docker-image-alpha.yml index 2a7d43ad53ff..609c15ab407e 100644 --- a/.github/workflows/docker-image-alpha.yml +++ b/.github/workflows/docker-image-alpha.yml @@ -66,8 +66,8 @@ jobs: uses: docker/metadata-action@v5 with: images: | - calciumion/new-api - ghcr.io/${{ env.GHCR_REPOSITORY }} + nick3/new-api + ghcr.io/${{ github.repository }} - name: Build & push single-arch (to both registries) uses: docker/build-push-action@v6 @@ -76,10 +76,10 @@ jobs: platforms: ${{ matrix.platform }} push: true tags: | - calciumion/new-api:alpha-${{ matrix.arch }} - calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }} - ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }} - ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.version.outputs.value }}-${{ matrix.arch }} + nick3/new-api:alpha-${{ matrix.arch }} + nick3/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }} + ghcr.io/${{ github.repository }}:alpha-${{ matrix.arch }} + ghcr.io/${{ github.repository }}:${{ steps.version.outputs.value }}-${{ matrix.arch }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max @@ -118,16 +118,16 @@ jobs: - name: Create & push manifest (Docker Hub - alpha) run: | docker buildx imagetools create \ - -t calciumion/new-api:alpha \ - calciumion/new-api:alpha-amd64 \ - calciumion/new-api:alpha-arm64 + -t nick3/new-api:alpha \ + nick3/new-api:alpha-amd64 \ + nick3/new-api:alpha-arm64 - name: Create & push manifest (Docker Hub - versioned alpha) run: | docker buildx imagetools create \ - -t calciumion/new-api:${VERSION} \ - calciumion/new-api:${VERSION}-amd64 \ - calciumion/new-api:${VERSION}-arm64 + -t nick3/new-api:${VERSION} \ + nick3/new-api:${VERSION}-amd64 \ + nick3/new-api:${VERSION}-arm64 - name: Log in to GHCR uses: docker/login-action@v3 diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml index 78517af0ee2d..44acd2abafc5 100644 --- a/.github/workflows/docker-image-arm64.yml +++ b/.github/workflows/docker-image-arm64.yml @@ -63,8 +63,8 @@ jobs: uses: docker/metadata-action@v5 with: images: | - calciumion/new-api -# ghcr.io/${{ env.GHCR_REPOSITORY }} + nick3/new-api + ghcr.io/${{ github.repository }} - name: Build & push single-arch (to both registries) uses: docker/build-push-action@v6 @@ -73,10 +73,10 @@ jobs: platforms: ${{ matrix.platform }} push: true tags: | - calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }} - calciumion/new-api:latest-${{ matrix.arch }} -# ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.TAG }}-${{ matrix.arch }} -# ghcr.io/${{ env.GHCR_REPOSITORY }}:latest-${{ matrix.arch }} + nick3/new-api:${{ env.TAG }}-${{ matrix.arch }} + nick3/new-api:latest-${{ matrix.arch }} +# ghcr.io/${{ github.repository }}:${{ env.TAG }}-${{ matrix.arch }} +# ghcr.io/${{ github.repository }}:latest-${{ matrix.arch }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max @@ -104,16 +104,16 @@ jobs: - name: Create & push manifest (Docker Hub - version) run: | docker buildx imagetools create \ - -t calciumion/new-api:${TAG} \ - calciumion/new-api:${TAG}-amd64 \ - calciumion/new-api:${TAG}-arm64 + -t nick3/new-api:${TAG} \ + nick3/new-api:${TAG}-amd64 \ + nick3/new-api:${TAG}-arm64 - name: Create & push manifest (Docker Hub - latest) run: | docker buildx imagetools create \ - -t calciumion/new-api:latest \ - calciumion/new-api:latest-amd64 \ - calciumion/new-api:latest-arm64 + -t nick3/new-api:latest \ + nick3/new-api:latest-amd64 \ + nick3/new-api:latest-arm64 # ---- GHCR ---- # - name: Log in to GHCR diff --git a/.gitignore b/.gitignore index 117c251f9fd9..cac08715bf3d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ new-api tiktoken_cache .eslintcache .gocache +/.spec-workflow electron/node_modules electron/dist +/.serena diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..255d5ccdc8b9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `main.go` launches the Gin backend, loading cross-cutting helpers from `middleware/`, `logger/`, and configuration in `setting/`. +- HTTP flow maps from `router/` to handler logic in `controller/`, with business services in `service/`, persistence in `model/`, and shared contracts in `dto/` and `types/`. +- Frontend lives in `web/` (Vite + Bun), shared utilities in `common/` and `constant/`, docs in `docs/`, and maintenance scripts or migrations in `bin/`. + +## Build, Test, and Development Commands +- `go run main.go` or `make start-backend` runs the API against the active `.env`; use `go build ./...` before raising a PR. +- Inside `web/`, execute `bun install` then `bun run dev`; bundle assets with `make build-frontend` when preparing releases. +- `docker compose up -d` spins up supporting services for end-to-end checks; shut down with `docker compose down`. + +## Coding Style & Naming Conventions +- Back-end code must pass `gofmt` and `go vet ./...`; keep packages single-purpose and exported identifiers in PascalCase, internals in camelCase. +- Prefer dependency injection through interfaces, avoid cyclic imports, and centralize shared constants in `constant/`. +- Frontend follows Prettier (`bun run lint:fix`) and ESLint (`bun run eslint:fix`); stick to functional components, hook-based state, and kebab-case filenames in `web/public/`. +- Add new configuration keys to `.env.example` using uppercase snake_case with concise inline notes. + +## Testing Guidelines +- Write table-driven `*_test.go` cases alongside implementation and run `go test ./... -race`; mock external services via interfaces. +- Use `bin/time_test.sh [model]` for latency baselines and document findings in PRs; add Vitest specs under `web/src/__tests__/` as UI logic grows. + +## Commit & Pull Request Guidelines +- Follow the repository’s Conventional Commit pattern (`fix:`, `feat:`, `chore:`) with subjects ≤72 characters and informative bodies. +- PRs should outline scope, risk, verification, linked issues, and include screenshots or payload samples when altering UX or APIs; confirm Go build/test and frontend lint/build before requesting review. + +## Security & Configuration Tips +- Keep secrets out of version control; rely on `.env` locally and managed secret stores in deployment. +- Rotate provider credentials through the admin console and document operational changes in `docs/`. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 000000000000..fdc7bbda34d1 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,34 @@ +## Release v0.9.8 + +Release date: 2025-10-15 + +Summary +- Minor release: small improvements and housekeeping following v0.9.7-patch.1. No breaking API changes. + +Notable changes +- Chore: Bump version to v0.9.8 and update release metadata. +- Chore: Prepare release notes. + +Migration notes +- No database migrations required. + +Contact +- If you see any issues after upgrading, please open an issue or contact the maintainers. + +# Release v0.9.7-patch.1 + +Release date: 2025-10-13 + +Summary +- Patch release to address minor regressions and housekeeping after the alpha cycle. This does not introduce new public APIs. + +Notable fixes +- Fix: Restore backwards-compatible behavior in configuration parsing that could break some deployments. +- Fix: Correct logging header to include request IDs for certain background jobs. +- Chore: Bump version and update release metadata. + +Migration notes +- No database migrations required. + +Contact +- If you see any issues after upgrading, please open an issue or contact the maintainers. diff --git a/VERSION b/VERSION index e69de29bb2d1..eeab19d3bbb1 100644 --- a/VERSION +++ b/VERSION @@ -0,0 +1 @@ +v0.9.8 diff --git a/bin/add_log_detail_index.sql b/bin/add_log_detail_index.sql new file mode 100644 index 000000000000..d8de50a44cd8 --- /dev/null +++ b/bin/add_log_detail_index.sql @@ -0,0 +1,20 @@ +-- Migration: Add index on log_details.created_at for efficient retention cleanup +-- Date: 2025-09-30 +-- Purpose: Optimize the log detail retention cleanup process by adding an index +-- on the created_at column to avoid full table scans during deletion. + +-- Check if index exists before creating (for MySQL/MariaDB) +-- For existing databases, run this migration to add the index: + +CREATE INDEX IF NOT EXISTS idx_log_details_created_at ON log_details(created_at); + +-- For PostgreSQL, use: +-- CREATE INDEX IF NOT EXISTS idx_log_details_created_at ON log_details(created_at); + +-- For SQLite: +-- CREATE INDEX IF NOT EXISTS idx_log_details_created_at ON log_details(created_at); + +-- Verify the index was created: +-- MySQL: SHOW INDEX FROM log_details WHERE Key_name = 'idx_log_details_created_at'; +-- PostgreSQL: SELECT * FROM pg_indexes WHERE tablename = 'log_details' AND indexname = 'idx_log_details_created_at'; +-- SQLite: SELECT * FROM sqlite_master WHERE type = 'index' AND tbl_name = 'log_details' AND name = 'idx_log_details_created_at'; diff --git a/common/constants.go b/common/constants.go index 2ef2b7df2f92..1ac1d318dc3d 100644 --- a/common/constants.go +++ b/common/constants.go @@ -72,6 +72,7 @@ var DebugEnabled bool var MemoryCacheEnabled bool var LogConsumeEnabled = true +var DetailedLogRetentionDays = 30 var SMTPServer = "" var SMTPPort = 587 diff --git a/common/gin.go b/common/gin.go index e8d8bda3a164..238189608c71 100644 --- a/common/gin.go +++ b/common/gin.go @@ -18,17 +18,20 @@ import ( const KeyRequestBody = "key_request_body" func GetRequestBody(c *gin.Context) ([]byte, error) { - requestBody, _ := c.Get(KeyRequestBody) - if requestBody != nil { - return requestBody.([]byte), nil + if cached, ok := c.Get(KeyRequestBody); ok { + if data, ok := cached.([]byte); ok { + return data, nil + } } - requestBody, err := io.ReadAll(c.Request.Body) + raw, err := io.ReadAll(c.Request.Body) if err != nil { return nil, err } _ = c.Request.Body.Close() - c.Set(KeyRequestBody, requestBody) - return requestBody.([]byte), nil + CapturePayloadForLog(c, constant.ContextKeyLoggedRequestBody, raw) + c.Set(KeyRequestBody, raw) + c.Request.Body = io.NopCloser(bytes.NewBuffer(raw)) + return raw, nil } func UnmarshalBodyReusable(c *gin.Context, v any) error { diff --git a/common/payload_log.go b/common/payload_log.go new file mode 100644 index 000000000000..416d5c4eae76 --- /dev/null +++ b/common/payload_log.go @@ -0,0 +1,208 @@ +package common + +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "github.com/QuantumNous/new-api/constant" + + "github.com/gin-gonic/gin" +) + +const ( + maxLogPayloadRunes = 2048 + truncatedSuffixFmt = "… [truncated %d chars]" +) + +func fullPayloadKeyFor(previewKey constant.ContextKey) (constant.ContextKey, bool) { + switch previewKey { + case constant.ContextKeyLoggedRequestBody: + return constant.ContextKeyLoggedRequestBodyFull, true + case constant.ContextKeyLoggedResponseBody: + return constant.ContextKeyLoggedResponseBodyFull, true + default: + return "", false + } +} + +func setFullPayload(c *gin.Context, previewKey constant.ContextKey, segments []string) { + if c == nil { + return + } + fullKey, ok := fullPayloadKeyFor(previewKey) + if !ok { + return + } + if len(segments) == 0 { + c.Set(string(fullKey), []string{}) + return + } + // Ensure we don't retain caller slices. + copySegments := append([]string(nil), segments...) + c.Set(string(fullKey), copySegments) +} + +func appendFullPayloadSegment(c *gin.Context, previewKey constant.ContextKey, segment string) { + if c == nil || segment == "" { + return + } + fullKey, ok := fullPayloadKeyFor(previewKey) + if !ok { + return + } + if existing, exists := c.Get(string(fullKey)); exists { + switch payload := existing.(type) { + case []string: + payload = append(payload, segment) + c.Set(string(fullKey), payload) + return + case string: + c.Set(string(fullKey), []string{payload, segment}) + return + } + } + c.Set(string(fullKey), []string{segment}) +} + +// GetFullPayloadString joins the accumulated segments stored under the provided key. +// It returns an empty string when no data has been captured. +func GetFullPayloadString(c *gin.Context, key constant.ContextKey) string { + if c == nil { + return "" + } + value, exists := c.Get(string(key)) + if !exists { + return "" + } + switch payload := value.(type) { + case []string: + return strings.Join(payload, "") + case string: + return payload + case []byte: + return string(payload) + default: + return fmt.Sprintf("%v", payload) + } +} + +func isBinaryPayload(data []byte) bool { + if len(data) == 0 { + return false + } + if !utf8.Valid(data) { + return true + } + sample := data + if len(sample) > 256 { + sample = sample[:256] + } + var controlCount int + for _, r := range string(sample) { + if r == '\n' || r == '\r' || r == '\t' { + continue + } + if r < 0x20 && !unicode.IsPrint(r) { + controlCount++ + } + } + return controlCount > len(sample)/10 // heuristically treat as binary if >10% control chars +} + +func truncatedSuffix(overflow int) string { + if overflow < 0 { + overflow = 0 + } + return fmt.Sprintf(truncatedSuffixFmt, overflow) +} + +func applyLogLimit(value string) string { + runes := []rune(value) + if len(runes) <= maxLogPayloadRunes { + return value + } + trimmed := string(runes[:maxLogPayloadRunes]) + return trimmed + truncatedSuffix(len(runes)-maxLogPayloadRunes) +} + +func formatPayloadForLog(data []byte) string { + if len(data) == 0 { + return "" + } + if isBinaryPayload(data) { + return fmt.Sprintf("[binary payload omitted: %d bytes]", len(data)) + } + return applyLogLimit(string(data)) +} + +func setPayloadIfEmpty(c *gin.Context, key constant.ContextKey, value string) { + if value == "" { + return + } + if existing := c.GetString(string(key)); existing != "" { + return + } + c.Set(string(key), value) +} + +// CapturePayloadForLog stores a truncated preview of the given byte slice under the provided context key. +// It only sets the payload if one has not already been captured. +func CapturePayloadForLog(c *gin.Context, key constant.ContextKey, data []byte) string { + preview := formatPayloadForLog(data) + setPayloadIfEmpty(c, key, preview) + if len(data) > 0 && !isBinaryPayload(data) { + setFullPayload(c, key, []string{string(data)}) + } + return preview +} + +// CapturePayloadStringForLog stores a string payload after applying the global truncation rules. +// It only writes when the key is not already populated. +func CapturePayloadStringForLog(c *gin.Context, key constant.ContextKey, value string) string { + if value == "" { + return "" + } + preview := applyLogLimit(value) + setPayloadIfEmpty(c, key, preview) + setFullPayload(c, key, []string{value}) + return preview +} + +// AppendPayloadChunkForLog appends streaming chunks while respecting the global truncation limit. +func AppendPayloadChunkForLog(c *gin.Context, key constant.ContextKey, chunk string) { + chunk = strings.TrimSpace(chunk) + if chunk == "" || chunk == "[DONE]" { + return + } + existing := c.GetString(string(key)) + if existing == "" { + c.Set(string(key), applyLogLimit(chunk)) + appendFullPayloadSegment(c, key, chunk) + return + } + if strings.Contains(existing, "[truncated") { + appendFullPayloadSegment(c, key, chunk) + return + } + existingRunes := []rune(existing) + chunkRunes := []rune(chunk) + total := len(existingRunes) + len(chunkRunes) + if total <= maxLogPayloadRunes { + c.Set(string(key), existing+chunk) + appendFullPayloadSegment(c, key, chunk) + return + } + remaining := maxLogPayloadRunes - len(existingRunes) + if remaining <= 0 { + suffix := truncatedSuffix(len(chunkRunes)) + c.Set(string(key), string(existingRunes[:maxLogPayloadRunes])+suffix) + appendFullPayloadSegment(c, key, chunk) + return + } + trimmedChunk := string(chunkRunes[:remaining]) + overflow := total - maxLogPayloadRunes + c.Set(string(key), existing+trimmedChunk+truncatedSuffix(overflow)) + appendFullPayloadSegment(c, key, chunk) +} diff --git a/constant/context_key.go b/constant/context_key.go index f7640272cd25..d7701e4a1911 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -47,4 +47,10 @@ const ( ContextKeyUserName ContextKey = "username" ContextKeySystemPromptOverride ContextKey = "system_prompt_override" + + /* logging payload previews */ + ContextKeyLoggedRequestBody ContextKey = "logged_request_body" + ContextKeyLoggedResponseBody ContextKey = "logged_response_body" + ContextKeyLoggedRequestBodyFull ContextKey = "logged_request_body_full" + ContextKeyLoggedResponseBodyFull ContextKey = "logged_response_body_full" ) diff --git a/controller/channel-test.go b/controller/channel-test.go index 9f6e479fd146..5a7359dcbfd2 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -369,17 +369,18 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio, usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) model.RecordConsumeLog(c, 1, model.RecordConsumeLogParams{ - ChannelId: channel.Id, - PromptTokens: usage.PromptTokens, - CompletionTokens: usage.CompletionTokens, - ModelName: info.OriginModelName, - TokenName: "模型测试", - Quota: quota, - Content: "模型测试", - UseTimeSeconds: int(consumedTime), - IsStream: info.IsStream, - Group: info.UsingGroup, - Other: other, + ChannelId: channel.Id, + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + ModelName: info.OriginModelName, + TokenName: "模型测试", + Quota: quota, + Content: "模型测试", + UseTimeSeconds: int(consumedTime), + IsStream: info.IsStream, + Group: info.UsingGroup, + Other: other, + ResponseBodyPreview: string(respBody), }) common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody))) return testResult{ diff --git a/controller/option.go b/controller/option.go index 56f65f5ffcc5..378bf4922f5f 100644 --- a/controller/option.go +++ b/controller/option.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strconv" "strings" "github.com/QuantumNous/new-api/common" @@ -120,6 +121,25 @@ func UpdateOption(c *gin.Context) { }) return } + case "DetailedLogRetentionDays": + daysString := fmt.Sprintf("%v", option.Value) + parsedFloat, parseErr := strconv.ParseFloat(daysString, 64) + if parseErr != nil || parsedFloat < 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "详细使用日志保留时间必须是非负整数天数", + }) + return + } + if parsedFloat != float64(int(parsedFloat)) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "详细使用日志保留时间必须是非负整数天数", + }) + return + } + days := int(parsedFloat) + option.Value = strconv.Itoa(days) case "GroupRatio": err = ratio_setting.CheckGroupRatio(option.Value.(string)) if err != nil { diff --git a/controller/relay.go b/controller/relay.go index f8a233e99590..f7dc74f7986a 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -66,6 +66,10 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { requestId := c.GetString(common.RequestIdKey) group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) originalModel := common.GetContextKeyString(c, constant.ContextKeyOriginalModel) + if relayFormat == types.RelayFormatOpenAIRealtime { + common.CapturePayloadStringForLog(c, constant.ContextKeyLoggedRequestBody, "[websocket stream request]") + common.CapturePayloadStringForLog(c, constant.ContextKeyLoggedResponseBody, "[websocket stream response]") + } var ( newAPIError *types.NewAPIError @@ -90,14 +94,18 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { case types.RelayFormatOpenAIRealtime: helper.WssError(c, ws, newAPIError.ToOpenAIError()) case types.RelayFormatClaude: - c.JSON(newAPIError.StatusCode, gin.H{ + payload := gin.H{ "type": "error", "error": newAPIError.ToClaudeError(), - }) + } + common.CapturePayloadStringForLog(c, constant.ContextKeyLoggedResponseBody, common.GetJsonString(payload)) + c.JSON(newAPIError.StatusCode, payload) default: - c.JSON(newAPIError.StatusCode, gin.H{ + payload := gin.H{ "error": newAPIError.ToOpenAIError(), - }) + } + common.CapturePayloadStringForLog(c, constant.ContextKeyLoggedResponseBody, common.GetJsonString(payload)) + c.JSON(newAPIError.StatusCode, payload) } } }() diff --git a/controller/user.go b/controller/user.go index eda4f7f12e63..bd2ea68ecf24 100644 --- a/controller/user.go +++ b/controller/user.go @@ -1108,7 +1108,6 @@ type UpdateUserSettingRequest struct { GotifyToken string `json:"gotify_token,omitempty"` GotifyPriority int `json:"gotify_priority,omitempty"` AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"` - RecordIpLog bool `json:"record_ip_log"` } func UpdateUserSetting(c *gin.Context) { @@ -1243,7 +1242,6 @@ func UpdateUserSetting(c *gin.Context) { NotifyType: req.QuotaWarningType, QuotaWarningThreshold: req.QuotaWarningThreshold, AcceptUnsetRatioModel: req.AcceptUnsetModelRatioModel, - RecordIpLog: req.RecordIpLog, } // 如果是webhook类型,添加webhook相关设置 diff --git a/docker-compose.yml b/docker-compose.yml index a9d00967cf49..7f8320b11333 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ version: '3.4' # For compatibility with older Docker versions services: new-api: - image: calciumion/new-api:latest + image: nick3/new-api:latest container_name: new-api restart: always command: --log-dir /app/logs diff --git a/docs/log_detail_optimization.md b/docs/log_detail_optimization.md new file mode 100644 index 000000000000..d9242d80ce03 --- /dev/null +++ b/docs/log_detail_optimization.md @@ -0,0 +1,122 @@ +# Log Detail Retention Cleanup Optimization + +## 问题说明 + +原始代码在清理过期的 `log_details` 记录时存在性能问题: +- `created_at` 字段没有索引,导致 `WHERE created_at < ?` 查询进行全表扫描 +- 在大数据集下,删除操作会非常缓慢并锁表 + +## 解决方案 + +### 1. 添加数据库索引 + +**修改文件**: `model/log.go` + +在 `LogDetail` 结构体的 `created_at` 字段上添加索引: + +```go +CreatedAt int64 `json:"created_at" gorm:"bigint;index;autoCreateTime"` +``` + +这将使 GORM 在自动迁移时创建索引。 + +### 2. 数据库迁移脚本 + +**新增文件**: `bin/add_log_detail_index.sql` + +对于现有数据库,运行以下 SQL 添加索引: + +```sql +CREATE INDEX IF NOT EXISTS idx_log_details_created_at ON log_details(created_at); +``` + +支持 MySQL/MariaDB、PostgreSQL 和 SQLite。 + +### 3. 优化删除策略 + +**修改文件**: `model/log_retention.go` + +改进包括: +- **添加 `ORDER BY created_at ASC`**: 利用索引确保高效的查询执行路径 +- **上下文取消检查**: 支持优雅中断清理过程 +- **批次间延迟**: 在批次之间添加 100ms 延迟,减少数据库负载高峰 + +## 性能影响 + +### 索引添加前: +- 全表扫描,时间复杂度 O(n) +- 大表可能需要数分钟到数小时 +- 长时间持有表锁 + +### 索引添加后: +- 索引查找,时间复杂度 O(log n + k),k 为结果集大小 +- 即使百万级记录,单批次也能在毫秒级完成 +- 显著减少锁表时间 + +## 部署步骤 + +### 新部署(自动) +新部署会通过 GORM AutoMigrate 自动创建索引。 + +### 现有部署(需要手动迁移) + +1. **备份数据库**(重要!) + ```bash + # MySQL + mysqldump -u user -p database_name > backup.sql + + # PostgreSQL + pg_dump -U user database_name > backup.sql + ``` + +2. **创建索引** + ```bash + # 连接到数据库并执行 + mysql -u user -p database_name < bin/add_log_detail_index.sql + + # 或在数据库客户端中执行 + CREATE INDEX IF NOT EXISTS idx_log_details_created_at ON log_details(created_at); + ``` + +3. **验证索引** + ```sql + -- MySQL + SHOW INDEX FROM log_details WHERE Key_name = 'idx_log_details_created_at'; + + -- PostgreSQL + SELECT * FROM pg_indexes WHERE tablename = 'log_details' AND indexname = 'idx_log_details_created_at'; + + -- SQLite + SELECT * FROM sqlite_master WHERE type = 'index' AND tbl_name = 'log_details'; + ``` + +4. **部署新代码** + ```bash + # 构建并重启服务 + make build + systemctl restart one-api # 或您的服务名称 + ``` + +## 监控建议 + +部署后监控以下指标: +- 日志清理任务的执行时间 +- 数据库查询性能(通过慢查询日志) +- 删除操作的批次数和总删除量 + +## 注意事项 + +1. **索引创建时间**: 对于大表(百万级以上记录),创建索引可能需要几分钟时间,期间会锁表 +2. **存储空间**: 索引会占用额外的磁盘空间(通常为数据大小的 5-10%) +3. **维护窗口**: 建议在低流量时段执行索引创建操作 + +## 相关文件 + +- `model/log.go` - LogDetail 模型定义 +- `model/log_retention.go` - 日志保留清理逻辑 +- `bin/add_log_detail_index.sql` - 索引迁移脚本 + +## 参考 + +- GORM 索引文档: https://gorm.io/docs/indexes.html +- MySQL 索引优化: https://dev.mysql.com/doc/refman/8.0/en/optimization-indexes.html diff --git a/dto/user_settings.go b/dto/user_settings.go index 16ce7b9851af..d1000a635a20 100644 --- a/dto/user_settings.go +++ b/dto/user_settings.go @@ -11,7 +11,6 @@ type UserSetting struct { GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌 GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级 AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型 - RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置 } diff --git a/main.go b/main.go index 481d0a6002eb..54ceb129ceb2 100644 --- a/main.go +++ b/main.go @@ -88,6 +88,7 @@ func main() { // 热更新配置 go model.SyncOptions(common.SyncFrequency) + model.StartLogDetailRetentionCleaner() // 数据看板 go model.UpdateQuotaData() diff --git a/model/log.go b/model/log.go index 7495d647d0aa..64b54ebb7643 100644 --- a/model/log.go +++ b/model/log.go @@ -8,6 +8,7 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/types" @@ -15,28 +16,58 @@ import ( "github.com/bytedance/gopkg/util/gopool" "gorm.io/gorm" + "gorm.io/gorm/schema" ) +type LargeText string + +func (LargeText) GormDataType() string { + return "text" +} + +func (LargeText) GormDBDataType(db *gorm.DB, _ *schema.Field) string { + switch db.Dialector.Name() { + case "mysql": + return "LONGTEXT" + case "postgres": + return "TEXT" + default: + return "TEXT" + } +} + type Log struct { - Id int `json:"id" gorm:"index:idx_created_at_id,priority:1"` - UserId int `json:"user_id" gorm:"index"` - CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"` - Type int `json:"type" gorm:"index:idx_created_at_type"` - Content string `json:"content"` - Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"` - TokenName string `json:"token_name" gorm:"index;default:''"` - ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` - Quota int `json:"quota" gorm:"default:0"` - PromptTokens int `json:"prompt_tokens" gorm:"default:0"` - CompletionTokens int `json:"completion_tokens" gorm:"default:0"` - UseTime int `json:"use_time" gorm:"default:0"` - IsStream bool `json:"is_stream"` - ChannelId int `json:"channel" gorm:"index"` - ChannelName string `json:"channel_name" gorm:"->"` - TokenId int `json:"token_id" gorm:"default:0;index"` - Group string `json:"group" gorm:"index"` - Ip string `json:"ip" gorm:"index;default:''"` - Other string `json:"other"` + Id int `json:"id" gorm:"index:idx_created_at_id,priority:1"` + UserId int `json:"user_id" gorm:"index"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"` + Type int `json:"type" gorm:"index:idx_created_at_type"` + Content string `json:"content"` + Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"` + TokenName string `json:"token_name" gorm:"index;default:''"` + ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` + Quota int `json:"quota" gorm:"default:0"` + PromptTokens int `json:"prompt_tokens" gorm:"default:0"` + CompletionTokens int `json:"completion_tokens" gorm:"default:0"` + UseTime int `json:"use_time" gorm:"default:0"` + IsStream bool `json:"is_stream"` + ChannelId int `json:"channel" gorm:"index"` + ChannelName string `json:"channel_name" gorm:"->"` + TokenId int `json:"token_id" gorm:"default:0;index"` + Group string `json:"group" gorm:"index"` + Ip string `json:"ip" gorm:"index;default:''"` + Other string `json:"other"` + Detail *LogDetail `json:"detail,omitempty" gorm:"-"` +} + +type LogDetail struct { + LogId int `json:"log_id" gorm:"primaryKey"` + RequestBody LargeText `json:"request_body"` + ResponseBody LargeText `json:"response_body"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index;autoCreateTime"` +} + +func (LogDetail) TableName() string { + return "log_details" } // don't use iota, avoid change log type value @@ -74,6 +105,7 @@ func GetLogByKey(key string) (logs []*Log, err error) { } else { err = LOG_DB.Joins("left join tokens on tokens.id = logs.token_id").Where("tokens.key = ?", strings.TrimPrefix(key, "sk-")).Find(&logs).Error } + attachLogDetails(logs) formatUserLogs(logs) return logs, err } @@ -101,13 +133,6 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content)) username := c.GetString("username") otherStr := common.MapToJsonStr(other) - // 判断是否需要记录 IP - needRecordIp := false - if settingMap, err := GetUserSetting(userId, false); err == nil { - if settingMap.RecordIpLog { - needRecordIp = true - } - } log := &Log{ UserId: userId, Username: username, @@ -125,10 +150,10 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, IsStream: isStream, Group: group, Ip: func() string { - if needRecordIp { - return c.ClientIP() + if c == nil { + return "" } - return "" + return c.ClientIP() }(), Other: otherStr, } @@ -136,21 +161,25 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, if err != nil { logger.LogError(c, "failed to record log: "+err.Error()) } + reqPreview, respPreview := resolveLogPayloads(c, "", "") + persistLogDetail(c, log.Id, reqPreview, respPreview) } type RecordConsumeLogParams struct { - ChannelId int `json:"channel_id"` - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - ModelName string `json:"model_name"` - TokenName string `json:"token_name"` - Quota int `json:"quota"` - Content string `json:"content"` - TokenId int `json:"token_id"` - UseTimeSeconds int `json:"use_time_seconds"` - IsStream bool `json:"is_stream"` - Group string `json:"group"` - Other map[string]interface{} `json:"other"` + ChannelId int `json:"channel_id"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + ModelName string `json:"model_name"` + TokenName string `json:"token_name"` + Quota int `json:"quota"` + Content string `json:"content"` + TokenId int `json:"token_id"` + UseTimeSeconds int `json:"use_time_seconds"` + IsStream bool `json:"is_stream"` + Group string `json:"group"` + Other map[string]interface{} `json:"other"` + RequestBodyPreview string `json:"-" gorm:"-"` + ResponseBodyPreview string `json:"-" gorm:"-"` } func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) { @@ -160,13 +189,6 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params))) username := c.GetString("username") otherStr := common.MapToJsonStr(params.Other) - // 判断是否需要记录 IP - needRecordIp := false - if settingMap, err := GetUserSetting(userId, false); err == nil { - if settingMap.RecordIpLog { - needRecordIp = true - } - } log := &Log{ UserId: userId, Username: username, @@ -184,10 +206,10 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) IsStream: params.IsStream, Group: params.Group, Ip: func() string { - if needRecordIp { - return c.ClientIP() + if c == nil { + return "" } - return "" + return c.ClientIP() }(), Other: otherStr, } @@ -195,6 +217,8 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) if err != nil { logger.LogError(c, "failed to record log: "+err.Error()) } + requestPreview, responsePreview := resolveLogPayloads(c, params.RequestBodyPreview, params.ResponseBodyPreview) + persistLogDetail(c, log.Id, requestPreview, responsePreview) if common.DataExportEnabled { gopool.Go(func() { LogQuotaData(userId, username, params.ModelName, params.Quota, common.GetTimestamp(), params.PromptTokens+params.CompletionTokens) @@ -202,6 +226,81 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) } } +func attachLogDetails(logs []*Log) { + if len(logs) == 0 { + return + } + ids := make([]int, 0, len(logs)) + for _, log := range logs { + if log != nil { + ids = append(ids, log.Id) + } + } + if len(ids) == 0 { + return + } + detailsMap, err := GetLogDetailsByIDs(ids) + if err != nil { + // silently ignore; detail retrieval failure should not break log listing + logger.LogError(context.Background(), "failed to load log details: "+err.Error()) + return + } + for _, log := range logs { + if log == nil { + continue + } + if detail, ok := detailsMap[log.Id]; ok { + log.Detail = detail + } + } +} + +func resolveLogPayloads(c *gin.Context, requestPreview string, responsePreview string) (string, string) { + request := requestPreview + response := responsePreview + if c == nil { + return request, response + } + if request == "" { + full := common.GetFullPayloadString(c, constant.ContextKeyLoggedRequestBodyFull) + if full != "" { + request = full + } else { + request = common.GetContextKeyString(c, constant.ContextKeyLoggedRequestBody) + } + } + if response == "" { + full := common.GetFullPayloadString(c, constant.ContextKeyLoggedResponseBodyFull) + if full != "" { + response = full + } else { + response = common.GetContextKeyString(c, constant.ContextKeyLoggedResponseBody) + } + } + return request, response +} + +func persistLogDetail(c *gin.Context, logId int, request string, response string) { + if logId == 0 { + return + } + if request == "" && response == "" { + return + } + detail := &LogDetail{ + LogId: logId, + RequestBody: LargeText(request), + ResponseBody: LargeText(response), + } + if err := LOG_DB.Create(detail).Error; err != nil { + ctx := context.Background() + if c != nil { + ctx = c + } + logger.LogError(ctx, "failed to record log detail: "+err.Error()) + } +} + func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string) (logs []*Log, total int64, err error) { var tx *gorm.DB if logType == LogTypeUnknown { @@ -239,6 +338,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName if err != nil { return nil, 0, err } + attachLogDetails(logs) channelIds := types.NewSet[int]() for _, log := range logs { @@ -299,6 +399,7 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int return nil, 0, err } + attachLogDetails(logs) formatUserLogs(logs) return logs, total, err } @@ -314,6 +415,21 @@ func SearchUserLogs(userId int, keyword string) (logs []*Log, err error) { return logs, err } +func GetLogDetailsByIDs(ids []int) (map[int]*LogDetail, error) { + if len(ids) == 0 { + return map[int]*LogDetail{}, nil + } + var details []*LogDetail + if err := LOG_DB.Where("log_id IN ?", ids).Find(&details).Error; err != nil { + return nil, err + } + result := make(map[int]*LogDetail, len(details)) + for _, detail := range details { + result[detail.LogId] = detail + } + return result, nil +} + type Stat struct { Quota int `json:"quota"` Rpm int `json:"rpm"` diff --git a/model/log_retention.go b/model/log_retention.go new file mode 100644 index 000000000000..91bbcfa5bf8c --- /dev/null +++ b/model/log_retention.go @@ -0,0 +1,79 @@ +package model + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" +) + +const ( + logDetailCleanupInterval = 6 * time.Hour + logDetailCleanupBatchSize = 5000 +) + +var logDetailCleanupOnce sync.Once + +func StartLogDetailRetentionCleaner() { + logDetailCleanupOnce.Do(func() { + go runLogDetailCleanupLoop() + }) +} + +func runLogDetailCleanupLoop() { + ctx := context.Background() + pruneExpiredLogDetails(ctx) + ticker := time.NewTicker(logDetailCleanupInterval) + defer ticker.Stop() + for range ticker.C { + pruneExpiredLogDetails(ctx) + } +} + +func pruneExpiredLogDetails(ctx context.Context) { + days := common.DetailedLogRetentionDays + if days <= 0 { + return + } + + cutoff := time.Now().AddDate(0, 0, -days).Unix() + var totalDeleted int64 + + for { + // Check context cancellation + if ctx.Err() != nil { + logger.LogError(ctx, "log detail cleanup cancelled: "+ctx.Err().Error()) + break + } + + // Use indexed ORDER BY to ensure efficient query execution + // The index on created_at enables the database to efficiently + // identify and delete the oldest records in each batch + result := LOG_DB.Where("created_at < ?", cutoff). + Order("created_at ASC"). + Limit(logDetailCleanupBatchSize). + Delete(&LogDetail{}) + + if result.Error != nil { + logger.LogError(ctx, fmt.Sprintf("failed to prune log detail records: %s", result.Error.Error())) + break + } + if result.RowsAffected == 0 { + break + } + totalDeleted += result.RowsAffected + if result.RowsAffected < logDetailCleanupBatchSize { + break + } + + // Add a small delay between batches to reduce database load + time.Sleep(100 * time.Millisecond) + } + + if totalDeleted > 0 { + logger.LogInfo(ctx, fmt.Sprintf("pruned %d log detail records older than %d days", totalDeleted, days)) + } +} diff --git a/model/main.go b/model/main.go index 04842f13f5bc..683c162f38ec 100644 --- a/model/main.go +++ b/model/main.go @@ -257,6 +257,7 @@ func migrateDB() error { &Redemption{}, &Ability{}, &Log{}, + &LogDetail{}, &Midjourney{}, &TopUp{}, &QuotaData{}, @@ -290,6 +291,7 @@ func migrateDBFast() error { {&Redemption{}, "Redemption"}, {&Ability{}, "Ability"}, {&Log{}, "Log"}, + {&LogDetail{}, "LogDetail"}, {&Midjourney{}, "Midjourney"}, {&TopUp{}, "TopUp"}, {&QuotaData{}, "QuotaData"}, @@ -330,7 +332,7 @@ func migrateDBFast() error { func migrateLOGDB() error { var err error - if err = LOG_DB.AutoMigrate(&Log{}); err != nil { + if err = LOG_DB.AutoMigrate(&Log{}, &LogDetail{}); err != nil { return err } return nil diff --git a/model/option.go b/model/option.go index e9fd50d7f357..aa8bc3aa28e1 100644 --- a/model/option.go +++ b/model/option.go @@ -1,6 +1,7 @@ package model import ( + "fmt" "strconv" "strings" "time" @@ -46,6 +47,7 @@ func InitOptionMap() { common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled) common.OptionMap["AutomaticEnableChannelEnabled"] = strconv.FormatBool(common.AutomaticEnableChannelEnabled) common.OptionMap["LogConsumeEnabled"] = strconv.FormatBool(common.LogConsumeEnabled) + common.OptionMap["DetailedLogRetentionDays"] = strconv.Itoa(common.DetailedLogRetentionDays) common.OptionMap["DisplayInCurrencyEnabled"] = strconv.FormatBool(common.DisplayInCurrencyEnabled) common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled) common.OptionMap["DrawingEnabled"] = strconv.FormatBool(common.DrawingEnabled) @@ -346,6 +348,19 @@ func updateOptionMap(key string, value string) (err error) { setting.StripeMinTopUp, _ = strconv.Atoi(value) case "StripePromotionCodesEnabled": setting.StripePromotionCodesEnabled = value == "true" + case "DetailedLogRetentionDays": + var convErr error + days := common.DetailedLogRetentionDays + if value != "" { + days, convErr = strconv.Atoi(value) + } + if convErr != nil { + return fmt.Errorf("invalid DetailedLogRetentionDays: %w", convErr) + } + if days < 0 { + days = 0 + } + common.DetailedLogRetentionDays = days case "CreemApiKey": setting.CreemApiKey = value case "CreemProducts": diff --git a/relay/helper/common.go b/relay/helper/common.go index 3bb1c80c9108..2031d36dc408 100644 --- a/relay/helper/common.go +++ b/relay/helper/common.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/types" @@ -46,6 +47,7 @@ func ClaudeData(c *gin.Context, resp dto.ClaudeResponse) error { if err != nil { common.SysError("error marshalling stream response: " + err.Error()) } else { + common.AppendPayloadChunkForLog(c, constant.ContextKeyLoggedResponseBody, string(jsonData)) c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)}) c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonData)}) } @@ -54,6 +56,7 @@ func ClaudeData(c *gin.Context, resp dto.ClaudeResponse) error { } func ClaudeChunkData(c *gin.Context, resp dto.ClaudeResponse, data string) { + common.AppendPayloadChunkForLog(c, constant.ContextKeyLoggedResponseBody, data) c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)}) c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("data: %s\n", data)}) _ = FlushWriter(c) @@ -68,6 +71,7 @@ func ResponseChunkData(c *gin.Context, resp dto.ResponsesStreamResponse, data st func StringData(c *gin.Context, str string) error { //str = strings.TrimPrefix(str, "data: ") //str = strings.TrimSuffix(str, "\r") + common.AppendPayloadChunkForLog(c, constant.ContextKeyLoggedResponseBody, str) c.Render(-1, common.CustomEvent{Data: "data: " + str}) _ = FlushWriter(c) return nil diff --git a/service/http.go b/service/http.go index 7bd54c4acd00..061871af5376 100644 --- a/service/http.go +++ b/service/http.go @@ -7,6 +7,7 @@ import ( "net/http" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/gin-gonic/gin" @@ -28,6 +29,7 @@ func IOCopyBytesGracefully(c *gin.Context, src *http.Response, data []byte) { } body := io.NopCloser(bytes.NewBuffer(data)) + common.CapturePayloadForLog(c, constant.ContextKeyLoggedResponseBody, data) // We shouldn't set the header before we parse the response body, because the parse part may fail. // And then we will have to send an error response, but in this case, the header has already been set. diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 18d3748019ea..5284e81ccd5e 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -85,7 +85,6 @@ const PersonalSetting = () => { gotifyToken: '', gotifyPriority: 5, acceptUnsetModelRatioModel: false, - recordIpLog: false, }); useEffect(() => { @@ -158,7 +157,6 @@ const PersonalSetting = () => { settings.gotify_priority !== undefined ? settings.gotify_priority : 5, acceptUnsetModelRatioModel: settings.accept_unset_model_ratio_model || false, - recordIpLog: settings.record_ip_log || false, }); } }, [userState?.user?.setting]); @@ -426,7 +424,6 @@ const PersonalSetting = () => { })(), accept_unset_model_ratio_model: notificationSettings.acceptUnsetModelRatioModel, - record_ip_log: notificationSettings.recordIpLog, }); if (res.data.success) { diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 4a53ecd7b9a7..1ba8e863aacb 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -30,6 +30,7 @@ import { Spin, Card, Radio, + Input, Select, } from '@douyinfe/semi-ui'; const { Text } = Typography; @@ -43,6 +44,8 @@ import { import axios from 'axios'; import { useTranslation } from 'react-i18next'; +const retentionPresets = ['1', '3', '7', '30', '90']; + const SystemSetting = () => { const { t } = useTranslation(); let [inputs, setInputs] = useState({ @@ -105,6 +108,7 @@ const SystemSetting = () => { 'fetch_setting.ip_list': [], 'fetch_setting.allowed_ports': [], 'fetch_setting.apply_ip_filter_for_domain': false, + DetailedLogRetentionDays: 30, }); const [originInputs, setOriginInputs] = useState({}); @@ -121,6 +125,9 @@ const SystemSetting = () => { const [domainList, setDomainList] = useState([]); const [ipList, setIpList] = useState([]); const [allowedPorts, setAllowedPorts] = useState([]); + const [passkeyOrigins, setPasskeyOrigins] = useState([]); + const [logRetentionSelection, setLogRetentionSelection] = useState('30'); + const [logRetentionCustom, setLogRetentionCustom] = useState(''); const getOptions = async () => { setLoading(true); @@ -203,6 +210,11 @@ const SystemSetting = () => { case 'MinTopUp': item.value = parseFloat(item.value); break; + case 'DetailedLogRetentionDays': { + const parsedDays = parseInt(item.value, 10); + item.value = Number.isNaN(parsedDays) ? 30 : parsedDays; + break; + } default: break; } @@ -210,6 +222,24 @@ const SystemSetting = () => { }); setInputs(newInputs); setOriginInputs(newInputs); + const retentionValue = newInputs.DetailedLogRetentionDays; + const normalizedRetention = + typeof retentionValue === 'number' + ? retentionValue + : parseInt(retentionValue, 10); + if (!Number.isNaN(normalizedRetention)) { + const retentionStr = `${normalizedRetention}`; + if (retentionPresets.includes(retentionStr)) { + setLogRetentionSelection(retentionStr); + setLogRetentionCustom(''); + } else { + setLogRetentionSelection('custom'); + setLogRetentionCustom(retentionStr); + } + } else { + setLogRetentionSelection('30'); + setLogRetentionCustom(''); + } // 同步模式布尔到本地状态 if ( typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined' @@ -395,6 +425,63 @@ const SystemSetting = () => { } }; + const handleRetentionRadioChange = (val) => { + const selected = val && val.target ? val.target.value : val; + const resolved = `${selected}`; + setLogRetentionSelection(resolved); + if (resolved !== 'custom') { + setLogRetentionCustom(''); + const numeric = parseInt(resolved, 10); + if (!Number.isNaN(numeric)) { + setInputs((prev) => ({ + ...prev, + DetailedLogRetentionDays: numeric, + })); + } + } + }; + + const handleRetentionCustomChange = (value) => { + const nextValue = value && value.target ? value.target.value : value; + setLogRetentionCustom(nextValue); + const numeric = parseInt(nextValue, 10); + if (!Number.isNaN(numeric) && numeric >= 0) { + setInputs((prev) => ({ + ...prev, + DetailedLogRetentionDays: numeric, + })); + } + }; + + const submitDetailedLogRetention = async () => { + let daysValue; + if (logRetentionSelection === 'custom') { + const trimmed = (logRetentionCustom || '').trim(); + const parsed = parseInt(trimmed, 10); + if (trimmed === '' || Number.isNaN(parsed) || parsed < 0) { + showError(t('保留时间必须是非负整数')); + return; + } + daysValue = parsed; + } else { + const parsed = parseInt(logRetentionSelection, 10); + if (Number.isNaN(parsed) || parsed < 0) { + showError(t('保留时间必须是非负整数')); + return; + } + daysValue = parsed; + } + + await updateOptions([ + { key: 'DetailedLogRetentionDays', value: daysValue }, + ]); + const nextSelection = retentionPresets.includes(`${daysValue}`) + ? `${daysValue}` + : 'custom'; + setLogRetentionSelection(nextSelection); + setLogRetentionCustom(nextSelection === 'custom' ? `${daysValue}` : ''); + }; + const handleAddEmail = () => { if (emailToAdd && emailToAdd.trim() !== '') { const domain = emailToAdd.trim(); @@ -952,6 +1039,47 @@ const SystemSetting = () => { + + + + + {t('使用日志保留设置说明')} + + + {retentionPresets.map((preset) => ( + + {t('{{days}}天', { days: preset })} + + ))} + {t('自定义')} + + {logRetentionSelection === 'custom' ? ( +
+ +
+ ) : null} +
+ +
+
+
- {t('通知、价格和隐私相关设置')} + {t('通知和价格相关设置')}
@@ -727,30 +727,6 @@ const NotificationSettings = ({ - {/* 隐私设置 Tab */} - - - {t('隐私设置')} - - } - itemKey='privacy' - > -
- handleFormChange('recordIpLog', value)} - extraText={t( - '开启后,仅"消费"和"错误"日志将记录您的客户端IP地址', - )} - /> -
-
- {/* 左侧边栏设置 Tab - 根据后端权限控制显示 */} {hasSidebarSettingsPermission() && ( . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useCallback, useMemo, useState } from 'react'; +import { + SideSheet, + Typography, + Space, + Descriptions, + Divider, + RadioGroup, + Radio, + Tag, + Button, + Tooltip, + Toast, +} from '@douyinfe/semi-ui'; +import { IconClose, IconCopy } from '@douyinfe/semi-icons'; + +const { Title, Text, Paragraph } = Typography; + +const safeParseJson = (raw) => { + if (!raw || typeof raw !== 'string') { + return null; + } + try { + return JSON.parse(raw); + } catch (error) { + return null; + } +}; + +const decodeUnicodeEscapes = (raw) => { + if (typeof raw !== 'string') { + return raw; + } + if (!/(\\u[0-9a-fA-F]{4})|(\\n)|(\\r)|(\\t)/.test(raw)) { + return raw; + } + let output = raw.replace(/\\u([0-9a-fA-F]{4})/g, (_, code) => + String.fromCharCode(parseInt(code, 16)), + ); + output = output.replace(/\\n/g, '\n'); + output = output.replace(/\\r/g, '\r'); + output = output.replace(/\\t/g, '\t'); + return output; +}; + +const formatJsonString = (raw) => { + if (!raw || typeof raw !== 'string') { + return ''; + } + const parsed = safeParseJson(raw); + if (!parsed) { + return raw.trim(); + } + try { + return JSON.stringify(parsed, null, 2); + } catch (error) { + return raw.trim(); + } +}; + +const splitStreamingResponse = (raw) => { + if (!raw || typeof raw !== 'string') { + return []; + } + + const trimmed = raw.trim(); + if (!trimmed) { + return []; + } + + const sseObjects = []; + const segments = trimmed.split(/\r?\n\r?\n/); + segments.forEach((segment) => { + if (!segment) { + return; + } + const dataLines = segment + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith('data:')); + if (dataLines.length === 0) { + return; + } + const candidate = dataLines.map((line) => line.slice(5).trim()).join(''); + if (!candidate || candidate === '[DONE]') { + return; + } + const parsed = safeParseJson(candidate); + if (parsed) { + sseObjects.push(parsed); + } + }); + + if (sseObjects.length > 0) { + // For OpenAI-style streaming responses, return the original array + // This will be processed by aggregateOpenAIStreamChunks later + return sseObjects; + } + + const objects = []; + let buffer = ''; + let depth = 0; + let inString = false; + let escapeNext = false; + + for (let i = 0; i < trimmed.length; i += 1) { + const char = trimmed[i]; + buffer += char; + + if (escapeNext) { + escapeNext = false; + continue; + } + + if (char === '\\') { + escapeNext = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + } + + if (depth === 0 && buffer.trim()) { + const parsed = safeParseJson(buffer); + if (parsed) { + objects.push(parsed); + } + buffer = ''; + } + } + + return objects; +}; + +const normaliseContent = (content) => { + if (!content) { + return ''; + } + if (typeof content === 'string') { + return decodeUnicodeEscapes(content); + } + if (Array.isArray(content)) { + const combined = content + .map((item) => { + if (typeof item === 'string') { + return item; + } + if (item?.text) { + return item.text; + } + if (item?.type === 'output_text' && item?.text_output) { + return item.text_output; + } + return JSON.stringify(item); + }) + .join(''); + return decodeUnicodeEscapes(combined); + } + if (typeof content === 'object') { + if (content.text) { + return decodeUnicodeEscapes(content.text); + } + if (content.value) { + return decodeUnicodeEscapes(content.value); + } + if (content.content) { + return normaliseContent(content.content); + } + return decodeUnicodeEscapes(JSON.stringify(content)); + } + return decodeUnicodeEscapes(String(content)); +}; + +const toFormattedString = (value) => { + if (value === undefined || value === null) { + return ''; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + const parsed = safeParseJson(trimmed); + if (parsed && typeof parsed === 'object') { + try { + return JSON.stringify(parsed, null, 2); + } catch (error) { + return decodeUnicodeEscapes(trimmed); + } + } + return decodeUnicodeEscapes(trimmed); + } + try { + return JSON.stringify(value, null, 2); + } catch (error) { + return decodeUnicodeEscapes(String(value)); + } +}; + +const ensureArray = (value) => { + if (Array.isArray(value)) { + return value; + } + if (value === undefined || value === null) { + return []; + } + return [value]; +}; + +const createSegment = (segment) => { + if (!segment) { + return null; + } + if (typeof segment.value === 'string' && segment.value.trim() === '') { + return null; + } + return segment; +}; + +const createTextSegment = (value) => { + const text = normaliseContent(value); + if (!text || text.trim() === '') { + return null; + } + return { type: 'text', value: text }; +}; + +const createReasoningSegment = (value) => { + const text = normaliseContent(value); + if (!text || text.trim() === '') { + return null; + } + return { type: 'reasoning', value: text }; +}; + +const createToolCallSegment = (tool) => { + if (!tool) { + return null; + } + const name = + tool.name || + tool.function?.name || + tool.tool_name || + tool.function_name || + tool.type || + 'tool'; + const argsSource = + tool.arguments ?? + tool.input ?? + tool.payload ?? + tool.function?.arguments ?? + tool.parameters ?? + tool.input_json ?? + tool.delta?.arguments ?? + tool.delta?.partial_json; + const formatted = toFormattedString(argsSource ?? ''); + return createSegment({ + type: 'tool_call', + id: tool.id || tool.tool_call_id || tool.toolUseId || tool.tool_use_id, + name, + value: formatted || '{}', + }); +}; + +const createToolResultSegment = (result) => { + if (!result) { + return null; + } + const valueSource = + result.content ?? + result.result ?? + result.output ?? + result.text ?? + result.value ?? + result.data ?? + result.message ?? + result.body; + const formatted = toFormattedString( + valueSource !== undefined ? valueSource : result, + ); + if (!formatted) { + return null; + } + return { + type: 'tool_result', + id: result.tool_use_id || result.toolUseId || result.id || result.tool_call_id, + name: result.name || result.tool_name || result.toolName || 'tool', + value: formatted, + }; +}; + +const createJsonSegment = (label, value) => { + const formatted = toFormattedString(value); + if (!formatted) { + return null; + } + return { type: 'json', label, value: formatted }; +}; + +const segmentsToPlainText = (segments) => { + if (!Array.isArray(segments) || segments.length === 0) { + return ''; + } + return segments + .filter((segment) => segment.type === 'text' || segment.type === 'reasoning') + .map((segment) => segment.value) + .join('\n'); +}; + +const copyToClipboard = async (text) => { + if (!text || !text.trim()) { + return false; + } + + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + + if (typeof document === 'undefined') { + return false; + } + + try { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'absolute'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + + const selection = document.getSelection(); + const selectedRange = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + textarea.select(); + const succeeded = document.execCommand('copy'); + + document.body.removeChild(textarea); + + if (selectedRange && selection) { + selection.removeAllRanges(); + selection.addRange(selectedRange); + } + + return succeeded; + } catch (error) { + return false; + } +}; + +const getSegmentCopyText = (segment, t) => { + if (!segment) { + return ''; + } + + const value = typeof segment.value === 'string' ? segment.value : String(segment.value ?? ''); + + switch (segment.type) { + case 'reasoning': + return `${t('思考过程')}:\n${value}`.trim(); + case 'tool_call': { + const parts = [t('工具调用')]; + if (segment.name) { + parts.push(`(${segment.name})`); + } + if (segment.id) { + parts.push(`${t('ID')}: ${segment.id}`); + } + return `${parts.join(' ')}\n${value}`.trim(); + } + case 'tool_result': { + const parts = [t('工具结果')]; + if (segment.name) { + parts.push(`(${segment.name})`); + } + if (segment.id) { + parts.push(`${t('ID')}: ${segment.id}`); + } + return `${parts.join(' ')}\n${value}`.trim(); + } + case 'json': { + const label = segment.label ? t(segment.label) : ''; + return label ? `${label}:\n${value}`.trim() : value; + } + default: + return value; + } +}; + +const buildMessageCopyText = (message, t) => { + if (!message) { + return ''; + } + + const segments = Array.isArray(message.segments) + ? message.segments.filter(Boolean) + : []; + + if (segments.length === 0) { + return (message.text ?? '').trim(); + } + + return segments + .map((segment) => getSegmentCopyText(segment, t)) + .filter((text) => text && text.trim()) + .join('\n\n'); +}; + +const appendSegment = (segments, segment) => { + const built = createSegment(segment); + if (built) { + segments.push(built); + } +}; + +const addTextSegment = (segments, value) => { + appendSegment(segments, createTextSegment(value)); +}; + +const addReasoningSegment = (segments, value) => { + appendSegment(segments, createReasoningSegment(value)); +}; + +const addToolCallSegment = (segments, tool) => { + appendSegment(segments, createToolCallSegment(tool)); +}; + +const addToolResultSegment = (segments, result) => { + appendSegment(segments, createToolResultSegment(result)); +}; + +const addJsonSegment = (segments, label, value) => { + appendSegment(segments, createJsonSegment(label, value)); +}; + +const handleContentNode = (node, segments) => { + if (node === undefined || node === null) { + return; + } + if (typeof node === 'string' || typeof node === 'number' || typeof node === 'boolean') { + addTextSegment(segments, String(node)); + return; + } + if (Array.isArray(node)) { + node.forEach((child) => handleContentNode(child, segments)); + return; + } + + const type = node.type || node.kind; + + if (type === 'text' || type === 'input_text' || type === 'output_text') { + addTextSegment(segments, node.text ?? node.value ?? node.data ?? ''); + return; + } + + if (type === 'tool_use' || type === 'function_call') { + addToolCallSegment(segments, node); + return; + } + + if (type === 'tool_result' || node.role === 'tool') { + addToolResultSegment(segments, node); + return; + } + + if (type === 'reasoning' || type === 'thinking' || node.thinking) { + addReasoningSegment(segments, node.text ?? node.reasoning ?? node.thinking ?? node.value); + return; + } + + if (node.message) { + handleContentNode(node.message, segments); + return; + } + + if (node.content) { + handleContentNode(node.content, segments); + return; + } + + if (node.text) { + addTextSegment(segments, node.text); + return; + } + + if (node.value) { + addTextSegment(segments, node.value); + return; + } + + addJsonSegment(segments, type || 'data', node); +}; + +const buildMessageSegments = (source) => { + const segments = []; + + if (!source) { + return segments; + } + + if (source.reasoning || source.reasoning_content) { + addReasoningSegment(segments, source.reasoning ?? source.reasoning_content); + } + + if (source.thinking) { + addReasoningSegment(segments, source.thinking); + } + + if (source.message) { + handleContentNode(source.message, segments); + } + + if (source.delta && (source.delta.text || source.delta.content)) { + handleContentNode(source.delta.text ?? source.delta.content, segments); + } + + if (source.content !== undefined) { + handleContentNode(source.content, segments); + } else if (source.text !== undefined) { + addTextSegment(segments, source.text); + } + + if (source.tool_calls) { + ensureArray(source.tool_calls).forEach((tool) => { + addToolCallSegment(segments, tool); + }); + } + + if (source.function_call) { + addToolCallSegment(segments, { + id: source.function_call.id, + name: source.function_call.name, + arguments: source.function_call.arguments, + }); + } + + if (source.tool_call) { + addToolCallSegment(segments, source.tool_call); + } + + if (source.tool_results) { + ensureArray(source.tool_results).forEach((result) => { + addToolResultSegment(segments, result); + }); + } + + if (source.result) { + addToolResultSegment(segments, source.result); + } + + if (source.output) { + handleContentNode(source.output, segments); + } + + return segments; +}; + +const buildMessageFromSource = (source, fallbackRole = 'assistant') => { + const role = source?.role || fallbackRole; + const segments = buildMessageSegments(source); + const text = segmentsToPlainText(segments); + return { + role, + segments, + text, + }; +}; + +const aggregateOpenAIStreamChunks = (streamObjects) => { + if (!Array.isArray(streamObjects) || streamObjects.length === 0) { + return null; + } + + let role = 'assistant'; + let reasoningBuffer = ''; + let fullContent = ''; + const toolCalls = []; + + streamObjects.forEach((chunk) => { + const choices = ensureArray(chunk?.choices); + const choice = choices[0]; + if (!choice || !choice.delta) { + return; + } + const delta = choice.delta; + if (delta.role) { + role = delta.role; + } + if (delta.content !== undefined && delta.content !== null) { + // Concatenate the content fragments instead of pushing them to an array + fullContent += delta.content; + } + if (delta.reasoning_content) { + reasoningBuffer += normaliseContent(delta.reasoning_content); + } + if (Array.isArray(delta.tool_calls)) { + delta.tool_calls.forEach((toolDelta, index) => { + const targetIndex = toolDelta.index ?? index; + const existing = + toolCalls[targetIndex] || { + id: toolDelta.id, + type: toolDelta.type, + function: { + name: toolDelta.function?.name || '', + arguments: '', + }, + }; + if (toolDelta.id) { + existing.id = toolDelta.id; + } + if (toolDelta.type) { + existing.type = toolDelta.type; + } + if (toolDelta.function?.name) { + existing.function = existing.function || {}; + existing.function.name = toolDelta.function.name; + } + if (toolDelta.function?.arguments) { + existing.function = existing.function || {}; + existing.function.arguments = + (existing.function.arguments || '') + toolDelta.function.arguments; + } + toolCalls[targetIndex] = existing; + }); + } + }); + + const message = { role }; + + // Set the full concatenated content as a single string + if (fullContent) { + message.content = fullContent; + } + + if (reasoningBuffer.trim()) { + message.reasoning = reasoningBuffer; + } + + if (toolCalls.length > 0) { + message.tool_calls = toolCalls.map((call) => { + if (call.function && call.function.arguments) { + call.function.arguments = call.function.arguments; + } + return call; + }); + } + + return message; +}; + +const aggregateClaudeStreamEvents = (events) => { + if (!Array.isArray(events) || events.length === 0) { + return null; + } + + const blocks = new Map(); + let role = 'assistant'; + let reasoning = ''; + + const getBlock = (index) => { + if (!blocks.has(index)) { + blocks.set(index, { + type: 'text', + text: '', + name: undefined, + id: undefined, + input: undefined, + partialJson: '', + content: undefined, + reasoning: '', + }); + } + return blocks.get(index); + }; + + events.forEach((event) => { + if (!event || typeof event !== 'object') { + return; + } + const type = event.type; + switch (type) { + case 'message_start': + role = event.message?.role || role; + break; + case 'content_block_start': { + const index = event.index ?? blocks.size; + const block = getBlock(index); + block.type = event.content_block?.type || block.type; + block.text = event.content_block?.text || ''; + block.name = event.content_block?.name; + block.id = event.content_block?.id; + block.input = event.content_block?.input; + block.content = event.content_block?.content; + block.reasoning = event.content_block?.thinking || ''; + break; + } + case 'content_block_delta': { + const index = event.index ?? 0; + const block = getBlock(index); + const delta = event.delta || {}; + switch (delta.type) { + case 'text_delta': + block.type = block.type || 'text'; + block.text = (block.text || '') + (delta.text || ''); + break; + case 'thinking_delta': + block.reasoning = (block.reasoning || '') + (delta.thinking || ''); + break; + case 'input_json_delta': + block.partialJson = + (block.partialJson || '') + (delta.partial_json ?? delta.partialJson ?? ''); + break; + case 'tool_use_delta': + block.partialJson = (block.partialJson || '') + (delta.arguments || ''); + break; + default: + if (delta.text) { + block.text = (block.text || '') + delta.text; + } + break; + } + break; + } + case 'content_block_stop': { + const index = event.index ?? 0; + const block = getBlock(index); + if (block.partialJson) { + const parsed = safeParseJson(block.partialJson); + block.input = parsed ?? block.partialJson; + } + break; + } + case 'message_delta': + if (event.delta?.role) { + role = event.delta.role; + } + if (event.delta?.reasoning) { + reasoning += normaliseContent(event.delta.reasoning); + } + break; + case 'message_stop': + if (event.message?.role) { + role = event.message.role; + } + break; + default: + break; + } + }); + + const sortedBlocks = Array.from(blocks.entries()).sort((a, b) => a[0] - b[0]); + + const content = sortedBlocks + .filter(([, block]) => block.type !== 'reasoning') + .map(([, block]) => { + if (block.reasoning) { + reasoning += block.reasoning; + } + if (block.type === 'text' || !block.type) { + return { type: 'text', text: block.text || '' }; + } + if (block.type === 'tool_use') { + const input = + block.input !== undefined + ? block.input + : block.partialJson && block.partialJson.trim() + ? block.partialJson + : undefined; + return { + type: 'tool_use', + id: block.id, + name: block.name, + input, + }; + } + if (block.type === 'tool_result') { + return { + type: 'tool_result', + id: block.id, + name: block.name, + content: block.content ?? block.text ?? block.input, + }; + } + return { + type: block.type, + content: block.content ?? block.text ?? block.input, + }; + }); + + const message = { role }; + + if (content.length > 0) { + message.content = content; + } + + if (reasoning.trim()) { + message.reasoning = reasoning; + } + + return message; +}; + +const buildMessageFromStreamObjects = (streamObjects) => { + if (!Array.isArray(streamObjects) || streamObjects.length === 0) { + return null; + } + + if (streamObjects.some((obj) => Array.isArray(obj?.choices))) { + return aggregateOpenAIStreamChunks(streamObjects); + } + + if (streamObjects.some((obj) => typeof obj?.type === 'string')) { + return aggregateClaudeStreamEvents(streamObjects); + } + + return null; +}; + +const collectRequestMessages = (requestObject) => { + if (!requestObject) { + return []; + } + + const messages = []; + + const pushMessage = (source, fallbackRole) => { + const message = buildMessageFromSource(source, fallbackRole); + if (message.segments.length === 0 && !message.text) { + const fallbackContent = normaliseContent( + source?.content ?? source?.text ?? source, + ); + if (fallbackContent && fallbackContent.trim()) { + const fallbackSegment = createTextSegment(fallbackContent); + messages.push({ + role: source?.role || fallbackRole || 'user', + segments: fallbackSegment ? [fallbackSegment] : [], + text: fallbackContent, + }); + } + return; + } + messages.push(message); + }; + + if (Array.isArray(requestObject.messages)) { + requestObject.messages.forEach((message) => { + if (!message) { + return; + } + pushMessage(message, message.role || 'user'); + }); + } + + if (requestObject.input !== undefined) { + const input = requestObject.input; + if (Array.isArray(input)) { + input.forEach((node) => { + if (node === undefined || node === null) { + return; + } + if (typeof node === 'string') { + pushMessage({ role: 'user', content: node }, 'user'); + return; + } + pushMessage(node, node.role || node.type || 'user'); + }); + } else if (typeof input === 'string') { + pushMessage({ role: 'user', content: input }, 'user'); + } else if (typeof input === 'object') { + pushMessage( + { ...input, role: input.role || input.type || 'user' }, + input.role || input.type || 'user', + ); + } + } + + if (messages.length === 0 && requestObject.prompt) { + pushMessage({ role: 'user', content: requestObject.prompt }, 'user'); + } + + return messages; +}; + +const collectResponseUsage = (responseObject, streamObjects) => { + if (responseObject?.usage) { + return responseObject.usage; + } + if (Array.isArray(streamObjects)) { + const withUsage = [...streamObjects].reverse().find((obj) => obj.usage); + return withUsage?.usage ?? null; + } + return null; +}; + +const collectResponseMessages = (responseObject, streamObjects) => { + const messages = []; + + const pushMessage = (source, fallbackRole) => { + if (!source) { + return; + } + const message = buildMessageFromSource(source, fallbackRole); + if (message.segments.length === 0 && !message.text) { + const fallbackContent = normaliseContent( + source?.content ?? + source?.text ?? + source?.delta?.content ?? + source, + ); + if (fallbackContent && fallbackContent.trim()) { + const fallbackSegment = createTextSegment(fallbackContent); + messages.push({ + role: source?.role || fallbackRole || 'assistant', + segments: fallbackSegment ? [fallbackSegment] : [], + text: fallbackContent, + }); + } + return; + } + messages.push(message); + }; + + if (responseObject?.choices) { + responseObject.choices.forEach((choice) => { + if (!choice) { + return; + } + if (choice.message) { + pushMessage(choice.message, choice.message.role || 'assistant'); + } else if (choice.delta) { + pushMessage(choice.delta, choice.delta.role || 'assistant'); + } + }); + } + + if (responseObject?.content !== undefined) { + pushMessage(responseObject, responseObject.role || 'assistant'); + } + + if (responseObject?.output) { + ensureArray(responseObject.output).forEach((item) => { + if (!item) { + return; + } + const role = item.role || responseObject.role || 'assistant'; + pushMessage({ ...item, role }, role); + }); + } + + if (responseObject?.message) { + pushMessage(responseObject.message, responseObject.message.role || 'assistant'); + } + + if (responseObject?.result) { + pushMessage(responseObject.result, responseObject.result.role || 'assistant'); + } + + if (Array.isArray(responseObject?.messages)) { + responseObject.messages.forEach((item) => { + pushMessage(item, item?.role || 'assistant'); + }); + } + + if (responseObject?.completion) { + pushMessage( + { role: responseObject.role || 'assistant', content: responseObject.completion }, + responseObject.role || 'assistant', + ); + } + + if (responseObject?.reply) { + pushMessage( + { role: responseObject.role || 'assistant', content: responseObject.reply }, + responseObject.role || 'assistant', + ); + } + + if ( + messages.length === 0 && + Array.isArray(streamObjects) && + streamObjects.length > 0 + ) { + const streamMessage = buildMessageFromStreamObjects(streamObjects); + if (streamMessage) { + pushMessage(streamMessage, streamMessage.role || 'assistant'); + } + } + + return messages; +}; + +const buildRequestParams = (requestObject, t) => { + if (!requestObject) { + return []; + } + const params = []; + const pushIfPresent = (label, value) => { + if (value !== undefined && value !== null && value !== '') { + params.push({ key: label, value: decodeUnicodeEscapes(String(value)) }); + } + }; + + pushIfPresent(t('模型'), requestObject.model); + pushIfPresent(t('流式输出'), requestObject.stream); + pushIfPresent(t('温度'), requestObject.temperature); + pushIfPresent('top_p', requestObject.top_p); + pushIfPresent(t('最大Tokens'), requestObject.max_tokens); + pushIfPresent(t('响应格式'), requestObject.response_format); + if (requestObject.tools) { + pushIfPresent(t('工具调用'), JSON.stringify(requestObject.tools)); + } + if (requestObject.user) { + pushIfPresent(t('用户'), requestObject.user); + } + return params; +}; + +const CollapsibleText = ({ text, t, isCode = false, maxLines = 6 }) => { + const [expanded, setExpanded] = useState(false); + if (!text || text.trim() === '') { + return {t('暂无数据')}; + } + + const lines = text.split('\n'); + const shouldTruncate = lines.length > maxLines || text.length > 600; + const displayedText = + shouldTruncate && !expanded ? lines.slice(0, maxLines).join('\n') : text; + + return ( +
+ {isCode ? ( +
+          {displayedText}
+        
+ ) : ( + + {displayedText} + + )} + {shouldTruncate && ( + + )} +
+ ); +}; + +const MessageSegmentView = ({ segment, t }) => { + if (!segment) { + return null; + } + + switch (segment.type) { + case 'text': + return ; + case 'reasoning': + return ( +
+ {t('思考过程')} + +
+ ); + case 'tool_call': + return ( +
+ + {t('工具调用')} + {segment.name ? ( + + {segment.name} + + ) : null} + {segment.id ? ( + + {t('ID')}: {segment.id} + + ) : null} + + +
+ ); + case 'tool_result': + return ( +
+ + {t('工具结果')} + {segment.name ? ( + + {segment.name} + + ) : null} + {segment.id ? ( + + {t('ID')}: {segment.id} + + ) : null} + + +
+ ); + case 'json': + return ( +
+ {segment.label ? ( + {t(segment.label)} + ) : null} + +
+ ); + default: + return ; + } +}; + +const MessageContent = ({ message, t }) => { + if (!message) { + return null; + } + const segments = Array.isArray(message.segments) + ? message.segments.filter(Boolean) + : []; + + if (segments.length === 0) { + if (message.text && message.text.trim()) { + return ; + } + return {t('暂无数据')}; + } + + return ( + + {segments.map((segment, index) => ( + + ))} + + ); +}; + +const buildFormattedView = ({ + t, + requestJson, + responseJson, + requestMessages, + responseMessages, + responseUsage, + onCopyMessage, +}) => { + const renderMessageList = ( + messages, + emptyText, + keyPrefix, + tagColor, + containerClassName, + ) => { + if (!messages || messages.length === 0) { + return {emptyText}; + } + + return messages.map((message, index) => ( +
+ + + {message.role} + +
+ +
+
+ +
+ )); + }; + + return ( + + {t('请求参数')} + + + + + {t('请求消息')} + + {renderMessageList( + requestMessages, + t('该请求没有消息内容'), + 'request-msg', + 'purple', + 'rounded-md border border-[var(--semi-color-border)] bg-[var(--semi-color-fill-0)] px-3 py-2 w-full', + )} + + + + + {t('响应概览')} + { + const rows = []; + if (responseJson?.model) { + rows.push({ key: t('实际模型'), value: responseJson.model }); + } + if (responseUsage) { + if (responseUsage.prompt_tokens !== undefined) { + rows.push({ + key: t('提示Tokens'), + value: responseUsage.prompt_tokens, + }); + } + if (responseUsage.completion_tokens !== undefined) { + rows.push({ + key: t('补全Tokens'), + value: responseUsage.completion_tokens, + }); + } + if (responseUsage.total_tokens !== undefined) { + rows.push({ + key: t('总Tokens'), + value: responseUsage.total_tokens, + }); + } + } + if (rows.length === 0) { + rows.push({ key: t('状态'), value: t('未提供响应统计信息') }); + } + return rows; + })()} + size='small' + style={{ width: '100%' }} + /> + + + + {t('响应消息')} + + {renderMessageList( + responseMessages, + t('该响应没有消息内容'), + 'response-msg', + 'blue', + 'rounded-md border border-[var(--semi-color-border)] bg-[var(--semi-color-fill-1)] px-3 py-2 w-full', + )} + + + ); +}; + +const UsageLogDetailDrawer = ({ + visible, + onClose, + viewMode, + onViewModeChange, + log, + t, +}) => { + const requestRaw = log?.detail?.request_body || ''; + const responseRaw = log?.detail?.response_body || ''; + + const requestJson = useMemo(() => safeParseJson(requestRaw), [requestRaw]); + const responseJson = useMemo(() => safeParseJson(responseRaw), [responseRaw]); + const streamObjects = useMemo( + () => (!responseJson ? splitStreamingResponse(responseRaw) : []), + [responseJson, responseRaw], + ); + + const requestMessages = useMemo( + () => collectRequestMessages(requestJson), + [requestJson], + ); + const responseMessages = useMemo( + () => collectResponseMessages(responseJson, streamObjects), + [responseJson, streamObjects], + ); + const responseUsage = useMemo( + () => collectResponseUsage(responseJson, streamObjects), + [responseJson, streamObjects], + ); + + const handleCopyMessage = useCallback( + async (message) => { + const copyText = buildMessageCopyText(message, t); + if (!copyText) { + Toast.warning(t('暂无数据')); + return; + } + + const success = await copyToClipboard(copyText); + if (success) { + Toast.success(t('消息已复制到剪贴板')); + } else { + Toast.error(t('无法复制到剪贴板,请手动复制')); + } + }, + [t], + ); + + const formattedView = useMemo( + () => + buildFormattedView({ + t, + requestJson, + responseJson, + requestMessages, + responseMessages, + responseUsage, + onCopyMessage: handleCopyMessage, + }), + [ + t, + requestJson, + responseJson, + requestMessages, + responseMessages, + responseUsage, + handleCopyMessage, + ], + ); + + const handleModeChange = (next) => { + const value = + typeof next === 'string' + ? next + : typeof next === 'object' && next !== null + ? next.target?.value + : undefined; + if (value) { + onViewModeChange(value); + } + }; + + return ( + } + onClick={onClose} + /> + } + bodyStyle={{ padding: 24, height: '100%', overflow: 'auto' }} + > + + + {t('格式化视图')} + {t('原始数据')} + + + {viewMode === 'formatted' ? ( + formattedView + ) : ( + +
+ {t('请求体')} +
+                {formatJsonString(requestRaw) || t('暂无数据')}
+              
+
+
+ {t('响应体')} +
+                {(() => {
+                  if (responseJson) {
+                    return formatJsonString(responseRaw);
+                  }
+                  if (streamObjects.length > 0) {
+                    return streamObjects
+                      .map((obj) => JSON.stringify(obj, null, 2))
+                      .join('\n\n');
+                  }
+                  return responseRaw ? responseRaw.trim() : t('暂无数据');
+                })()}
+              
+
+
+ )} +
+
+ ); +}; + +export default UsageLogDetailDrawer; diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx index 9af30226aba3..388e53d74fdf 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx @@ -25,6 +25,7 @@ import { Tooltip, Popover, Typography, + Button, } from '@douyinfe/semi-ui'; import { timestamp2string, @@ -40,7 +41,7 @@ import { renderClaudeModelPrice, renderModelPrice, } from '../../../helpers'; -import { IconHelpCircle } from '@douyinfe/semi-icons'; +import { IconEyeOpened } from '@douyinfe/semi-icons'; import { Route } from 'lucide-react'; const colors = [ @@ -241,6 +242,7 @@ export const getLogsColumns = ({ copyText, showUserInfoFunc, isAdminUser, + openDetailDrawer, }) => { return [ { @@ -459,13 +461,6 @@ export const getLogsColumns = ({ title: (
{t('IP')} - - -
), dataIndex: 'ip', @@ -522,7 +517,7 @@ export const getLogsColumns = ({ key: COLUMN_KEYS.DETAILS, title: t('详情'), dataIndex: 'content', - fixed: 'right', + fixed: !isAdminUser ? 'right' : false, render: (text, record, index) => { let other = getLogOther(record.other); if (other == null || record.type !== 2) { @@ -582,5 +577,26 @@ export const getLogsColumns = ({ ); }, }, + isAdminUser && { + key: COLUMN_KEYS.ACTION, + title: t('操作'), + dataIndex: 'actions', + fixed: 'right', + width: 72, + render: (text, record) => ( + +