Compare commits

..

32 Commits

Author SHA1 Message Date
shaw
99cbfa1567 fix(admin): 修复退款金额精度问题
- 显示完整余额精度,避免四舍五入导致的退款失败
- 添加"全部"按钮,一键填入完整余额
- 移除最小金额限制,支持任意正数金额
2026-01-14 10:22:31 +08:00
Wesley Liddick
3f8c8d70ad Merge pull request #274 from mt21625457/main
fix(openai): OAuth 请求强制 store=false
2026-01-14 09:53:09 +08:00
yangjianbo
9c567fad92 fix(网关): 优化 OAuth 请求中 store 参数的处理逻辑 2026-01-14 09:46:10 +08:00
yangjianbo
0abb3a6843 Merge branch 'main' of https://github.com/mt21625457/aicodex2api 2026-01-14 09:22:37 +08:00
yangjianbo
3663951d11 fix(网关): OAuth 请求强制 store=false
避免上游 Store 必须为 false 的错误

仅在缺失或 true 时写回 store

测试: go test ./internal/service -run TestApplyCodexOAuthTransform

测试: make test-backend(golangci-lint 已单独执行)
2026-01-14 09:17:58 +08:00
Wesley Liddick
55fced3942 Merge pull request #269 from mt21625457/main
fix: 修复opencode 适配openai 套餐的错误,通过sub2api完美转发 opencode
2026-01-13 17:33:07 +08:00
yangjianbo
7bbf49fd65 为类型断言补充 ok 校验并添加中文说明,避免 errcheck 报错(backend/internal/service/
openai_codex_transform_test.go:36、backend/internal/service/
    openai_codex_transform_test.go:89、backend/internal/service/
    openai_codex_transform_test.go:104)。
2026-01-13 17:22:57 +08:00
yangjianbo
eea6c2d02c fix(网关): 补齐Codex指令回退与输入过滤 2026-01-13 17:02:31 +08:00
yangjianbo
70eaa450db fix(网关): 修复工具续链校验与存储策略
完善 function_call_output 续链校验与引用匹配
续链场景强制 store=true,过滤 input 时避免副作用
补充续链判断与过滤相关单元测试

测试: go test ./...
2026-01-13 16:47:35 +08:00
Wesley Liddick
55796a118d Merge pull request #264 from IanShaw027/fix/openai-opencode-compatibility
fix(openai): 增强 OpenCode 兼容性和模型规范化
2026-01-13 16:01:37 +08:00
ianshaw
d7fa47d732 refactor(openai): 移除不必要的 seedOpenAISessionHeaders 函数 2026-01-12 20:38:46 -08:00
ianshaw
3d6e01a58f fix(openai): 增强 OpenCode 兼容性和模型规范化
## 主要改动

1. **模型规范化扩展到所有账号**
   - 将 Codex 模型规范化(如 gpt-5-nano → gpt-5.1)应用到所有 OpenAI 账号类型
   - 不再仅限于 OAuth 非 CLI 请求
   - 解决 Codex CLI 使用 ChatGPT 账号时的模型兼容性问题

2. **reasoning.effort 参数规范化**
   - 自动将 `minimal` 转换为 `none`
   - 解决 gpt-5.1 模型不支持 `minimal` 值的问题

3. **Session/Conversation ID fallback 机制**
   - 从请求体多个字段提取 session_id/conversation_id
   - 优先级:prompt_cache_key → session_id → conversation_id → previous_response_id
   - 支持 Codex CLI 的会话保持

4. **Tool Call ID fallback**
   - 当 call_id 为空时使用 id 字段作为 fallback
   - 确保 tool call 输出能正确匹配
   - 保留 item_reference 类型的 items

5. **Header 优化**
   - 添加 conversation_id 到允许的 headers
   - 移除删除 session headers 的逻辑

## 相关 Issue
- 参考 OpenCode issue #3118 关于 item_reference 的讨论
2026-01-12 20:18:53 -08:00
IanShaw027
f9713e8733 fix(codex): 添加codex CLI instructions fallback机制
## 问题
- 使用OpenAI API key时,opencode客户端可能因instructions不兼容而报错
- 依赖外部GitHub获取instructions,网络故障时会失败

## 解决方案
1. 将codex CLI标准instructions嵌入到项目中
2. 实现自动fallback机制:
   - 优先使用opencode GitHub的instructions
   - 失败时自动fallback到本地codex CLI instructions
3. 添加辅助函数用于错误检测和手动替换

## 改动
- 新增: internal/service/prompts/codex_cli_instructions.md
  - 从codex项目复制的标准instructions
  - 使用go:embed嵌入到二进制文件

- 修改: internal/service/openai_codex_transform.go
  - 添加embed支持
  - 增强getOpenCodeCodexHeader()的fallback逻辑
  - 新增GetCodexCLIInstructions()公开函数
  - 新增ReplaceWithCodexInstructions()用于手动替换
  - 新增IsInstructionError()用于错误检测

## 优势
- 零停机:GitHub不可用时仍能正常工作
- 离线可用:不依赖外部网络
- 兼容性:使用标准codex CLI instructions
- 部署简单:instructions嵌入到二进制文件
2026-01-13 11:14:32 +08:00
yangjianbo
0e44829720 Merge branch 'main' into dev 2026-01-13 10:29:12 +08:00
shaw
93db889a10 fix: Gemini OpenCode 教程 baseURL 改为 v1beta 2026-01-13 09:52:37 +08:00
Wesley Liddick
0df7385c4e Merge pull request #226 from xilu0/main
feat(gateway): 优化 Antigravity/Gemini 思考块处理 此提交解决了思考块 (thinking blocks) 在转发过程中的兼容性问题
2026-01-13 09:39:43 +08:00
Wesley Liddick
1a3fa6411c Merge pull request #260 from IanShaw027/fix/sync-openai-gpt5-models
fix: 同步 OpenAI GPT-5 模型列表并完善参数处理
2026-01-13 09:31:00 +08:00
Wesley Liddick
64614756d1 Merge pull request #259 from cyhhao/main
fix: adjust OpenCode OpenAI example store placement
2026-01-13 09:30:26 +08:00
Wesley Liddick
bb1fd54d4d Merge pull request #257 from Edric-Li/feat/ops-fullscreen-scrollbar
feat(ops): 添加运维监控全屏模式 & 优化滚动条
2026-01-13 09:29:25 +08:00
ianshaw
d85288a6c0 Revert "fix(gateway): 修复 base_url 包含 /chat/completions 时路径拼接错误"
This reverts commit 7fdc25df3c.
2026-01-12 13:29:04 -08:00
ianshaw
3402acb606 feat(gateway): 对所有请求(包括 Codex CLI)应用模型映射
- 移除 Codex CLI 的模型映射跳过逻辑
- 添加详细的模型映射日志,包含账号名称和请求类型
- 确保所有 OpenAI 请求都能正确应用账号配置的模型映射
2026-01-12 13:23:05 -08:00
ianshaw
7fdc25df3c fix(gateway): 修复 base_url 包含 /chat/completions 时路径拼接错误
问题:
- 当账号的 base_url 配置为 https://example.com/v1/chat/completions 时
- 代码直接追加 /responses,导致路径变成 /v1/chat/completions/responses
- 上游返回 404 错误

修复:
- 在追加 /responses 前,先移除 base_url 中的 /chat/completions 后缀
- 确保最终路径为 https://example.com/v1/responses

影响范围:
- OpenAI API Key 账号的测试接口
- OpenAI API Key 账号的实际网关请求

Related-to: #231
2026-01-12 11:39:45 -08:00
ianshaw
ea699cbdc2 docs(frontend): 完善 OpenCode 配置说明
更新 API 密钥页面 OpenCode 配置提示信息:
- 补充支持 opencode.jsonc 后缀名
- 说明可使用默认 provider(openai/anthropic/google)或自定义 provider_id
- 说明 API Key 支持直接配置或通过 /connect 命令配置
- 保留"示例仅供参考,模型与选项可按需调整"的提示

配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc)
2026-01-12 11:17:47 -08:00
ianshaw
fe6a3f4267 fix(gateway): 完善 max_output_tokens 参数处理逻辑
根据不同平台和账号类型处理 max_output_tokens 参数:
- OpenAI OAuth (Responses API): 保留 max_output_tokens(支持)
- OpenAI API Key: 删除 max_output_tokens(不支持)
- Anthropic (Claude): 转换 max_output_tokens 为 max_tokens
- Gemini: 删除 max_output_tokens(由 Gemini 专用转换处理)
- 其他平台: 删除(安全起见)

同时处理 max_completion_tokens 参数,仅在 OpenAI OAuth 时保留。

修复客户端(如 OpenCode)发送不支持参数导致上游返回 400 错误的问题。

Related-to: #231
2026-01-12 11:08:28 -08:00
ianshaw
fe8198c8cd fix(frontend): 同步 OpenAI GPT-5 系列模型列表
修复编辑账号页面 GPT-5 模型只显示 3 个的问题:
- 原来只有: gpt-5, gpt-5-mini, gpt-5-nano
- 现在添加完整的 22 个模型,包括:
  * GPT-5 系列: gpt-5, gpt-5-codex, gpt-5-chat, gpt-5-pro, gpt-5-mini, gpt-5-nano 及各时间戳版本
  * GPT-5.1 系列: gpt-5.1, gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini 及各版本
  * GPT-5.2 系列: gpt-5.2, gpt-5.2-codex, gpt-5.2-pro 及各版本
- 更新快捷预设按钮,新增 GPT-5.1, GPT-5.2, GPT-5.1 Codex 选项

与后端定价文件 (model_prices_and_context_window.json) 保持一致。

Fixes issue introduced in fb86002 (feat: 添加模型白名单选择器组件)
Related-to: fb86002ef9
2026-01-12 10:14:50 -08:00
cyhhao
675e61385f Merge branch 'main' of github.com:Wei-Shaw/sub2api 2026-01-12 22:36:14 +08:00
cyhhao
67acac1082 fix: adjust OpenCode OpenAI example store placement 2026-01-12 22:31:43 +08:00
Edric Li
d02e1db018 style: 优化滚动条自动隐藏效果
- 默认隐藏滚动条,悬停时显示
- 支持 Webkit (Chrome/Safari/Edge) 和 Firefox
- 滚动条样式与暗色主题适配
2026-01-12 22:10:59 +08:00
Edric Li
0da515071b feat(ops): 添加运维监控全屏模式
- 支持通过 URL 参数 ?fullscreen=1 进入全屏模式
- 全屏模式下隐藏非必要 UI 元素(选择器、按钮、提示等)
- 增大健康评分圆环和字体以提升可读性
- 支持 ESC 键退出全屏
- 添加全屏按钮的 i18n 翻译
2026-01-12 22:10:59 +08:00
xiluo
524d80ae1c feat(gateway): 优化 Antigravity/Gemini 思考块处理
此提交解决了思考块 (thinking blocks) 在转发过程中的兼容性问题。

主要变更:

1. **思考块优化 (Thinking Blocks)**:
   - 在 AntigravityGatewayService 中增加了 sanitizeThinkingBlocks 处理,强制移除思考块中不支持的 cache_control 字段(避免 Anthropic/Vertex AI 报错)
   - 实现历史思考块展平 (Flattening):将非最后一条消息中的思考块转换为普通文本块,以绕过上游对历史思考块签名的严格校验
   - 增加 cleanCacheControlFromGeminiJSON 作为最后一道防线,确保转换后的 Gemini 请求中不残留非法的 cache_control

2. **GatewayService 缓存控制优化**:
   - 更新缓存控制逻辑,跳过 thinking 块(thinking 块不支持 cache_control 字段)
   - 增加 removeCacheControlFromThinkingBlocks 函数强制清理

关联 Issue: #225
2026-01-12 13:36:59 +00:00
yangjianbo
9618cb5643 Merge branch 'main' into test 2026-01-12 15:15:03 +08:00
yangjianbo
9c02ab789d fix(rate_limiter): 更新速率限制逻辑,支持返回修复状态 2026-01-12 14:42:58 +08:00
22 changed files with 1524 additions and 167 deletions

View File

@@ -57,6 +57,13 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅(
--- ---
## OpenAI Responses 兼容注意事项
- 当请求包含 `function_call_output` 时,需要携带 `previous_response_id`,或在 `input` 中包含带 `call_id``tool_call`/`function_call`,或带非空 `id` 且与 `function_call_output.call_id` 匹配的 `item_reference`
- 若依赖上游历史记录,网关会强制 `store=true` 并需要复用 `previous_response_id`,以避免出现 “No tool call found for function call output” 错误。
---
## 部署方式 ## 部署方式
### 方式一:脚本安装(推荐) ### 方式一:脚本安装(推荐)

View File

@@ -275,12 +275,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
var failoverErr *service.UpstreamFailoverError var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) { if errors.As(err, &failoverErr) {
failedAccountIDs[account.ID] = struct{}{} failedAccountIDs[account.ID] = struct{}{}
lastFailoverStatus = failoverErr.StatusCode
if switchCount >= maxAccountSwitches { if switchCount >= maxAccountSwitches {
lastFailoverStatus = failoverErr.StatusCode
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted) h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
return return
} }
lastFailoverStatus = failoverErr.StatusCode
switchCount++ switchCount++
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches) log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
continue continue
@@ -409,12 +408,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
var failoverErr *service.UpstreamFailoverError var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) { if errors.As(err, &failoverErr) {
failedAccountIDs[account.ID] = struct{}{} failedAccountIDs[account.ID] = struct{}{}
lastFailoverStatus = failoverErr.StatusCode
if switchCount >= maxAccountSwitches { if switchCount >= maxAccountSwitches {
lastFailoverStatus = failoverErr.StatusCode
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted) h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
return return
} }
lastFailoverStatus = failoverErr.StatusCode
switchCount++ switchCount++
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches) log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
continue continue

View File

@@ -114,6 +114,26 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body) setOpsRequestContext(c, reqModel, reqStream, body)
// 提前校验 function_call_output 是否具备可关联上下文,避免上游 400。
// 要求 previous_response_id或 input 内存在带 call_id 的 tool_call/function_call
// 或带 id 且与 call_id 匹配的 item_reference。
if service.HasFunctionCallOutput(reqBody) {
previousResponseID, _ := reqBody["previous_response_id"].(string)
if strings.TrimSpace(previousResponseID) == "" && !service.HasToolCallContext(reqBody) {
if service.HasFunctionCallOutputMissingCallID(reqBody) {
log.Printf("[OpenAI Handler] function_call_output 缺少 call_id: model=%s", reqModel)
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "function_call_output requires call_id or previous_response_id; if relying on history, ensure store=true and reuse previous_response_id")
return
}
callIDs := service.FunctionCallOutputCallIDs(reqBody)
if !service.HasItemReferenceForCallIDs(reqBody, callIDs) {
log.Printf("[OpenAI Handler] function_call_output 缺少匹配的 item_reference: model=%s", reqModel)
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "function_call_output requires item_reference ids matching each call_id, or previous_response_id/tool_call context; if relying on history, ensure store=true and reuse previous_response_id")
return
}
}
}
// Track if we've started streaming (for error handling) // Track if we've started streaming (for error handling)
streamStarted := false streamStarted := false

View File

@@ -2,7 +2,10 @@ package middleware
import ( import (
"context" "context"
"fmt"
"log"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -25,15 +28,34 @@ type RateLimitOptions struct {
var rateLimitScript = redis.NewScript(` var rateLimitScript = redis.NewScript(`
local current = redis.call('INCR', KEYS[1]) local current = redis.call('INCR', KEYS[1])
local ttl = redis.call('PTTL', KEYS[1]) local ttl = redis.call('PTTL', KEYS[1])
if current == 1 or ttl == -1 then local repaired = 0
if current == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1]) redis.call('PEXPIRE', KEYS[1], ARGV[1])
elseif ttl == -1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
repaired = 1
end end
return current return {current, repaired}
`) `)
// rateLimitRun 允许测试覆写脚本执行逻辑 // rateLimitRun 允许测试覆写脚本执行逻辑
var rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, error) { var rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, bool, error) {
return rateLimitScript.Run(ctx, client, []string{key}, windowMillis).Int64() values, err := rateLimitScript.Run(ctx, client, []string{key}, windowMillis).Slice()
if err != nil {
return 0, false, err
}
if len(values) < 2 {
return 0, false, fmt.Errorf("rate limit script returned %d values", len(values))
}
count, err := parseInt64(values[0])
if err != nil {
return 0, false, err
}
repaired, err := parseInt64(values[1])
if err != nil {
return 0, false, err
}
return count, repaired == 1, nil
} }
// RateLimiter Redis 速率限制器 // RateLimiter Redis 速率限制器
@@ -74,8 +96,9 @@ func (r *RateLimiter) LimitWithOptions(key string, limit int, window time.Durati
windowMillis := windowTTLMillis(window) windowMillis := windowTTLMillis(window)
// 使用 Lua 脚本原子操作增加计数并设置过期 // 使用 Lua 脚本原子操作增加计数并设置过期
count, err := rateLimitRun(ctx, r.redis, redisKey, windowMillis) count, repaired, err := rateLimitRun(ctx, r.redis, redisKey, windowMillis)
if err != nil { if err != nil {
log.Printf("[RateLimit] redis error: key=%s mode=%s err=%v", redisKey, failureModeLabel(failureMode), err)
if failureMode == RateLimitFailClose { if failureMode == RateLimitFailClose {
abortRateLimit(c) abortRateLimit(c)
return return
@@ -84,6 +107,9 @@ func (r *RateLimiter) LimitWithOptions(key string, limit int, window time.Durati
c.Next() c.Next()
return return
} }
if repaired {
log.Printf("[RateLimit] ttl repaired: key=%s window_ms=%d", redisKey, windowMillis)
}
// 超过限制 // 超过限制
if count > int64(limit) { if count > int64(limit) {
@@ -109,3 +135,27 @@ func abortRateLimit(c *gin.Context) {
"message": "Too many requests, please try again later", "message": "Too many requests, please try again later",
}) })
} }
func failureModeLabel(mode RateLimitFailureMode) string {
if mode == RateLimitFailClose {
return "fail-close"
}
return "fail-open"
}
func parseInt64(value any) (int64, error) {
switch v := value.(type) {
case int64:
return v, nil
case int:
return int64(v), nil
case string:
parsed, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, err
}
return parsed, nil
default:
return 0, fmt.Errorf("unexpected value type %T", value)
}
}

View File

@@ -66,13 +66,13 @@ func TestRateLimiterSuccessAndLimit(t *testing.T) {
originalRun := rateLimitRun originalRun := rateLimitRun
counts := []int64{1, 2} counts := []int64{1, 2}
callIndex := 0 callIndex := 0
rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, error) { rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, bool, error) {
if callIndex >= len(counts) { if callIndex >= len(counts) {
return counts[len(counts)-1], nil return counts[len(counts)-1], false, nil
} }
value := counts[callIndex] value := counts[callIndex]
callIndex++ callIndex++
return value, nil return value, false, nil
} }
t.Cleanup(func() { t.Cleanup(func() {
rateLimitRun = originalRun rateLimitRun = originalRun

View File

@@ -523,6 +523,9 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
proxyURL = account.Proxy.URL() proxyURL = account.Proxy.URL()
} }
// Sanitize thinking blocks (clean cache_control and flatten history thinking)
sanitizeThinkingBlocks(&claudeReq)
// 获取转换选项 // 获取转换选项
// Antigravity 上游要求必须包含身份提示词,否则会返回 429 // Antigravity 上游要求必须包含身份提示词,否则会返回 429
transformOpts := s.getClaudeTransformOptions(ctx) transformOpts := s.getClaudeTransformOptions(ctx)
@@ -534,6 +537,9 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
return nil, fmt.Errorf("transform request: %w", err) return nil, fmt.Errorf("transform request: %w", err)
} }
// Safety net: ensure no cache_control leaked into Gemini request
geminiBody = cleanCacheControlFromGeminiJSON(geminiBody)
// Antigravity 上游只支持流式请求,统一使用 streamGenerateContent // Antigravity 上游只支持流式请求,统一使用 streamGenerateContent
// 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后转换返回 // 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后转换返回
action := "streamGenerateContent" action := "streamGenerateContent"
@@ -903,6 +909,143 @@ func extractAntigravityErrorMessage(body []byte) string {
return "" return ""
} }
// cleanCacheControlFromGeminiJSON removes cache_control from Gemini JSON (emergency fix)
// This should not be needed if transformation is correct, but serves as a safety net
func cleanCacheControlFromGeminiJSON(body []byte) []byte {
// Try a more robust approach: parse and clean
var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
log.Printf("[Antigravity] Failed to parse Gemini JSON for cache_control cleaning: %v", err)
return body
}
cleaned := removeCacheControlFromAny(data)
if !cleaned {
return body
}
if result, err := json.Marshal(data); err == nil {
log.Printf("[Antigravity] Successfully cleaned cache_control from Gemini JSON")
return result
}
return body
}
// removeCacheControlFromAny recursively removes cache_control fields
func removeCacheControlFromAny(v any) bool {
cleaned := false
switch val := v.(type) {
case map[string]any:
for k, child := range val {
if k == "cache_control" {
delete(val, k)
cleaned = true
} else if removeCacheControlFromAny(child) {
cleaned = true
}
}
case []any:
for _, item := range val {
if removeCacheControlFromAny(item) {
cleaned = true
}
}
}
return cleaned
}
// sanitizeThinkingBlocks cleans cache_control and flattens history thinking blocks
// Thinking blocks do NOT support cache_control field (Anthropic API/Vertex AI requirement)
// Additionally, history thinking blocks are flattened to text to avoid upstream validation errors
func sanitizeThinkingBlocks(req *antigravity.ClaudeRequest) {
if req == nil {
return
}
log.Printf("[Antigravity] sanitizeThinkingBlocks: processing request with %d messages", len(req.Messages))
// Clean system blocks
if len(req.System) > 0 {
var systemBlocks []map[string]any
if err := json.Unmarshal(req.System, &systemBlocks); err == nil {
for i := range systemBlocks {
if blockType, _ := systemBlocks[i]["type"].(string); blockType == "thinking" || systemBlocks[i]["thinking"] != nil {
if removeCacheControlFromAny(systemBlocks[i]) {
log.Printf("[Antigravity] Deep cleaned cache_control from thinking block in system[%d]", i)
}
}
}
// Marshal back
if cleaned, err := json.Marshal(systemBlocks); err == nil {
req.System = cleaned
}
}
}
// Clean message content blocks and flatten history
lastMsgIdx := len(req.Messages) - 1
for msgIdx := range req.Messages {
raw := req.Messages[msgIdx].Content
if len(raw) == 0 {
continue
}
// Try to parse as blocks array
var blocks []map[string]any
if err := json.Unmarshal(raw, &blocks); err != nil {
continue
}
cleaned := false
for blockIdx := range blocks {
blockType, _ := blocks[blockIdx]["type"].(string)
// Check for thinking blocks (typed or untyped)
if blockType == "thinking" || blocks[blockIdx]["thinking"] != nil {
// 1. Clean cache_control
if removeCacheControlFromAny(blocks[blockIdx]) {
log.Printf("[Antigravity] Deep cleaned cache_control from thinking block in messages[%d].content[%d]", msgIdx, blockIdx)
cleaned = true
}
// 2. Flatten to text if it's a history message (not the last one)
if msgIdx < lastMsgIdx {
log.Printf("[Antigravity] Flattening history thinking block to text at messages[%d].content[%d]", msgIdx, blockIdx)
// Extract thinking content
var textContent string
if t, ok := blocks[blockIdx]["thinking"].(string); ok {
textContent = t
} else {
// Fallback for non-string content (marshal it)
if b, err := json.Marshal(blocks[blockIdx]["thinking"]); err == nil {
textContent = string(b)
}
}
// Convert to text block
blocks[blockIdx]["type"] = "text"
blocks[blockIdx]["text"] = textContent
delete(blocks[blockIdx], "thinking")
delete(blocks[blockIdx], "signature")
delete(blocks[blockIdx], "cache_control") // Ensure it's gone
cleaned = true
}
}
}
// Marshal back if modified
if cleaned {
if marshaled, err := json.Marshal(blocks); err == nil {
req.Messages[msgIdx].Content = marshaled
}
}
}
}
// stripThinkingFromClaudeRequest converts thinking blocks to text blocks in a Claude Messages request. // stripThinkingFromClaudeRequest converts thinking blocks to text blocks in a Claude Messages request.
// This preserves the thinking content while avoiding signature validation errors. // This preserves the thinking content while avoiding signature validation errors.
// Note: redacted_thinking blocks are removed because they cannot be converted to text. // Note: redacted_thinking blocks are removed because they cannot be converted to text.

View File

@@ -1227,6 +1227,9 @@ func enforceCacheControlLimit(body []byte) []byte {
return body return body
} }
// 清理 thinking 块中的非法 cache_controlthinking 块不支持该字段)
removeCacheControlFromThinkingBlocks(data)
// 计算当前 cache_control 块数量 // 计算当前 cache_control 块数量
count := countCacheControlBlocks(data) count := countCacheControlBlocks(data)
if count <= maxCacheControlBlocks { if count <= maxCacheControlBlocks {
@@ -1254,6 +1257,7 @@ func enforceCacheControlLimit(body []byte) []byte {
} }
// countCacheControlBlocks 统计 system 和 messages 中的 cache_control 块数量 // countCacheControlBlocks 统计 system 和 messages 中的 cache_control 块数量
// 注意thinking 块不支持 cache_control统计时跳过
func countCacheControlBlocks(data map[string]any) int { func countCacheControlBlocks(data map[string]any) int {
count := 0 count := 0
@@ -1261,6 +1265,10 @@ func countCacheControlBlocks(data map[string]any) int {
if system, ok := data["system"].([]any); ok { if system, ok := data["system"].([]any); ok {
for _, item := range system { for _, item := range system {
if m, ok := item.(map[string]any); ok { if m, ok := item.(map[string]any); ok {
// thinking 块不支持 cache_control跳过
if blockType, _ := m["type"].(string); blockType == "thinking" {
continue
}
if _, has := m["cache_control"]; has { if _, has := m["cache_control"]; has {
count++ count++
} }
@@ -1275,6 +1283,10 @@ func countCacheControlBlocks(data map[string]any) int {
if content, ok := msgMap["content"].([]any); ok { if content, ok := msgMap["content"].([]any); ok {
for _, item := range content { for _, item := range content {
if m, ok := item.(map[string]any); ok { if m, ok := item.(map[string]any); ok {
// thinking 块不支持 cache_control跳过
if blockType, _ := m["type"].(string); blockType == "thinking" {
continue
}
if _, has := m["cache_control"]; has { if _, has := m["cache_control"]; has {
count++ count++
} }
@@ -1290,6 +1302,7 @@ func countCacheControlBlocks(data map[string]any) int {
// removeCacheControlFromMessages 从 messages 中移除一个 cache_control从头开始 // removeCacheControlFromMessages 从 messages 中移除一个 cache_control从头开始
// 返回 true 表示成功移除false 表示没有可移除的 // 返回 true 表示成功移除false 表示没有可移除的
// 注意:跳过 thinking 块(它不支持 cache_control
func removeCacheControlFromMessages(data map[string]any) bool { func removeCacheControlFromMessages(data map[string]any) bool {
messages, ok := data["messages"].([]any) messages, ok := data["messages"].([]any)
if !ok { if !ok {
@@ -1307,6 +1320,10 @@ func removeCacheControlFromMessages(data map[string]any) bool {
} }
for _, item := range content { for _, item := range content {
if m, ok := item.(map[string]any); ok { if m, ok := item.(map[string]any); ok {
// thinking 块不支持 cache_control跳过
if blockType, _ := m["type"].(string); blockType == "thinking" {
continue
}
if _, has := m["cache_control"]; has { if _, has := m["cache_control"]; has {
delete(m, "cache_control") delete(m, "cache_control")
return true return true
@@ -1319,6 +1336,7 @@ func removeCacheControlFromMessages(data map[string]any) bool {
// removeCacheControlFromSystem 从 system 中移除一个 cache_control从尾部开始保护注入的 prompt // removeCacheControlFromSystem 从 system 中移除一个 cache_control从尾部开始保护注入的 prompt
// 返回 true 表示成功移除false 表示没有可移除的 // 返回 true 表示成功移除false 表示没有可移除的
// 注意:跳过 thinking 块(它不支持 cache_control
func removeCacheControlFromSystem(data map[string]any) bool { func removeCacheControlFromSystem(data map[string]any) bool {
system, ok := data["system"].([]any) system, ok := data["system"].([]any)
if !ok { if !ok {
@@ -1328,6 +1346,10 @@ func removeCacheControlFromSystem(data map[string]any) bool {
// 从尾部开始移除,保护开头注入的 Claude Code prompt // 从尾部开始移除,保护开头注入的 Claude Code prompt
for i := len(system) - 1; i >= 0; i-- { for i := len(system) - 1; i >= 0; i-- {
if m, ok := system[i].(map[string]any); ok { if m, ok := system[i].(map[string]any); ok {
// thinking 块不支持 cache_control跳过
if blockType, _ := m["type"].(string); blockType == "thinking" {
continue
}
if _, has := m["cache_control"]; has { if _, has := m["cache_control"]; has {
delete(m, "cache_control") delete(m, "cache_control")
return true return true
@@ -1337,6 +1359,44 @@ func removeCacheControlFromSystem(data map[string]any) bool {
return false return false
} }
// removeCacheControlFromThinkingBlocks 强制清理所有 thinking 块中的非法 cache_control
// thinking 块不支持 cache_control 字段,这个函数确保所有 thinking 块都不含该字段
func removeCacheControlFromThinkingBlocks(data map[string]any) {
// 清理 system 中的 thinking 块
if system, ok := data["system"].([]any); ok {
for _, item := range system {
if m, ok := item.(map[string]any); ok {
if blockType, _ := m["type"].(string); blockType == "thinking" {
if _, has := m["cache_control"]; has {
delete(m, "cache_control")
log.Printf("[Warning] Removed illegal cache_control from thinking block in system")
}
}
}
}
}
// 清理 messages 中的 thinking 块
if messages, ok := data["messages"].([]any); ok {
for msgIdx, msg := range messages {
if msgMap, ok := msg.(map[string]any); ok {
if content, ok := msgMap["content"].([]any); ok {
for contentIdx, item := range content {
if m, ok := item.(map[string]any); ok {
if blockType, _ := m["type"].(string); blockType == "thinking" {
if _, has := m["cache_control"]; has {
delete(m, "cache_control")
log.Printf("[Warning] Removed illegal cache_control from thinking block in messages[%d].content[%d]", msgIdx, contentIdx)
}
}
}
}
}
}
}
}
}
// Forward 转发请求到Claude API // Forward 转发请求到Claude API
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) { func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) {
startTime := time.Now() startTime := time.Now()

View File

@@ -1,6 +1,7 @@
package service package service
import ( import (
_ "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -16,6 +17,9 @@ const (
codexCacheTTL = 15 * time.Minute codexCacheTTL = 15 * time.Minute
) )
//go:embed prompts/codex_cli_instructions.md
var codexCLIInstructions string
var codexModelMap = map[string]string{ var codexModelMap = map[string]string{
"gpt-5.1-codex": "gpt-5.1-codex", "gpt-5.1-codex": "gpt-5.1-codex",
"gpt-5.1-codex-low": "gpt-5.1-codex", "gpt-5.1-codex-low": "gpt-5.1-codex",
@@ -70,6 +74,8 @@ type opencodeCacheMetadata struct {
func applyCodexOAuthTransform(reqBody map[string]any) codexTransformResult { func applyCodexOAuthTransform(reqBody map[string]any) codexTransformResult {
result := codexTransformResult{} result := codexTransformResult{}
// 工具续链需求会影响存储策略与 input 过滤逻辑。
needsToolContinuation := NeedsToolContinuation(reqBody)
model := "" model := ""
if v, ok := reqBody["model"].(string); ok { if v, ok := reqBody["model"].(string); ok {
@@ -84,6 +90,8 @@ func applyCodexOAuthTransform(reqBody map[string]any) codexTransformResult {
result.NormalizedModel = normalizedModel result.NormalizedModel = normalizedModel
} }
// OAuth 走 ChatGPT internal API 时store 必须为 false显式 true 也会强制覆盖。
// 避免上游返回 "Store must be set to false"。
if v, ok := reqBody["store"].(bool); !ok || v { if v, ok := reqBody["store"].(bool); !ok || v {
reqBody["store"] = false reqBody["store"] = false
result.Modified = true result.Modified = true
@@ -119,10 +127,18 @@ func applyCodexOAuthTransform(reqBody map[string]any) codexTransformResult {
reqBody["instructions"] = instructions reqBody["instructions"] = instructions
result.Modified = true result.Modified = true
} }
} else if existingInstructions == "" {
// 未获取到 opencode 指令时,回退使用 Codex CLI 指令。
codexInstructions := strings.TrimSpace(getCodexCLIInstructions())
if codexInstructions != "" {
reqBody["instructions"] = codexInstructions
result.Modified = true
}
} }
// 续链场景保留 item_reference 与 id避免 call_id 上下文丢失。
if input, ok := reqBody["input"].([]any); ok { if input, ok := reqBody["input"].([]any); ok {
input = filterCodexInput(input) input = filterCodexInput(input, needsToolContinuation)
reqBody["input"] = input reqBody["input"] = input
result.Modified = true result.Modified = true
} }
@@ -235,14 +251,75 @@ func getOpenCodeCachedPrompt(url, cacheFileName, metaFileName string) string {
} }
func getOpenCodeCodexHeader() string { func getOpenCodeCodexHeader() string {
return getOpenCodeCachedPrompt(opencodeCodexHeaderURL, "opencode-codex-header.txt", "opencode-codex-header-meta.json") // 优先从 opencode 仓库缓存获取指令。
opencodeInstructions := getOpenCodeCachedPrompt(opencodeCodexHeaderURL, "opencode-codex-header.txt", "opencode-codex-header-meta.json")
// 若 opencode 指令可用,直接返回。
if opencodeInstructions != "" {
return opencodeInstructions
}
// 否则回退使用本地 Codex CLI 指令。
return getCodexCLIInstructions()
}
func getCodexCLIInstructions() string {
return codexCLIInstructions
} }
func GetOpenCodeInstructions() string { func GetOpenCodeInstructions() string {
return getOpenCodeCodexHeader() return getOpenCodeCodexHeader()
} }
func filterCodexInput(input []any) []any { // GetCodexCLIInstructions 返回内置的 Codex CLI 指令内容。
func GetCodexCLIInstructions() string {
return getCodexCLIInstructions()
}
// ReplaceWithCodexInstructions 将请求 instructions 替换为内置 Codex 指令(必要时)。
func ReplaceWithCodexInstructions(reqBody map[string]any) bool {
codexInstructions := strings.TrimSpace(getCodexCLIInstructions())
if codexInstructions == "" {
return false
}
existingInstructions, _ := reqBody["instructions"].(string)
if strings.TrimSpace(existingInstructions) != codexInstructions {
reqBody["instructions"] = codexInstructions
return true
}
return false
}
// IsInstructionError 判断错误信息是否与指令格式/系统提示相关。
func IsInstructionError(errorMessage string) bool {
if errorMessage == "" {
return false
}
lowerMsg := strings.ToLower(errorMessage)
instructionKeywords := []string{
"instruction",
"instructions",
"system prompt",
"system message",
"invalid prompt",
"prompt format",
}
for _, keyword := range instructionKeywords {
if strings.Contains(lowerMsg, keyword) {
return true
}
}
return false
}
// filterCodexInput 按需过滤 item_reference 与 id。
// preserveReferences 为 true 时保持引用与 id以满足续链请求对上下文的依赖。
func filterCodexInput(input []any, preserveReferences bool) []any {
filtered := make([]any, 0, len(input)) filtered := make([]any, 0, len(input))
for _, item := range input { for _, item := range input {
m, ok := item.(map[string]any) m, ok := item.(map[string]any)
@@ -250,15 +327,62 @@ func filterCodexInput(input []any) []any {
filtered = append(filtered, item) filtered = append(filtered, item)
continue continue
} }
if typ, ok := m["type"].(string); ok && typ == "item_reference" { typ, _ := m["type"].(string)
if typ == "item_reference" {
if !preserveReferences {
continue
}
newItem := make(map[string]any, len(m))
for key, value := range m {
newItem[key] = value
}
filtered = append(filtered, newItem)
continue continue
} }
delete(m, "id")
filtered = append(filtered, m) newItem := m
copied := false
// 仅在需要修改字段时创建副本,避免直接改写原始输入。
ensureCopy := func() {
if copied {
return
}
newItem = make(map[string]any, len(m))
for key, value := range m {
newItem[key] = value
}
copied = true
}
if isCodexToolCallItemType(typ) {
if callID, ok := m["call_id"].(string); !ok || strings.TrimSpace(callID) == "" {
if id, ok := m["id"].(string); ok && strings.TrimSpace(id) != "" {
ensureCopy()
newItem["call_id"] = id
}
}
}
if !preserveReferences {
ensureCopy()
delete(newItem, "id")
if !isCodexToolCallItemType(typ) {
delete(newItem, "call_id")
}
}
filtered = append(filtered, newItem)
} }
return filtered return filtered
} }
func isCodexToolCallItemType(typ string) bool {
if typ == "" {
return false
}
return strings.HasSuffix(typ, "_call") || strings.HasSuffix(typ, "_call_output")
}
func normalizeCodexTools(reqBody map[string]any) bool { func normalizeCodexTools(reqBody map[string]any) bool {
rawTools, ok := reqBody["tools"] rawTools, ok := reqBody["tools"]
if !ok || rawTools == nil { if !ok || rawTools == nil {

View File

@@ -0,0 +1,167 @@
package service
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestApplyCodexOAuthTransform_ToolContinuationPreservesInput(t *testing.T) {
// 续链场景:保留 item_reference 与 id但不再强制 store=true。
setupCodexCache(t)
reqBody := map[string]any{
"model": "gpt-5.2",
"input": []any{
map[string]any{"type": "item_reference", "id": "ref1", "text": "x"},
map[string]any{"type": "function_call_output", "call_id": "call_1", "output": "ok", "id": "o1"},
},
"tool_choice": "auto",
}
applyCodexOAuthTransform(reqBody)
// 未显式设置 store=true默认为 false。
store, ok := reqBody["store"].(bool)
require.True(t, ok)
require.False(t, store)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 2)
// 校验 input[0] 为 map避免断言失败导致测试中断。
first, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "item_reference", first["type"])
require.Equal(t, "ref1", first["id"])
// 校验 input[1] 为 map确保后续字段断言安全。
second, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "o1", second["id"])
}
func TestApplyCodexOAuthTransform_ExplicitStoreFalsePreserved(t *testing.T) {
// 续链场景:显式 store=false 不再强制为 true保持 false。
setupCodexCache(t)
reqBody := map[string]any{
"model": "gpt-5.1",
"store": false,
"input": []any{
map[string]any{"type": "function_call_output", "call_id": "call_1"},
},
"tool_choice": "auto",
}
applyCodexOAuthTransform(reqBody)
store, ok := reqBody["store"].(bool)
require.True(t, ok)
require.False(t, store)
}
func TestApplyCodexOAuthTransform_ExplicitStoreTrueForcedFalse(t *testing.T) {
// 显式 store=true 也会强制为 false。
setupCodexCache(t)
reqBody := map[string]any{
"model": "gpt-5.1",
"store": true,
"input": []any{
map[string]any{"type": "function_call_output", "call_id": "call_1"},
},
"tool_choice": "auto",
}
applyCodexOAuthTransform(reqBody)
store, ok := reqBody["store"].(bool)
require.True(t, ok)
require.False(t, store)
}
func TestApplyCodexOAuthTransform_NonContinuationDefaultsStoreFalseAndStripsIDs(t *testing.T) {
// 非续链场景:未设置 store 时默认 false并移除 input 中的 id。
setupCodexCache(t)
reqBody := map[string]any{
"model": "gpt-5.1",
"input": []any{
map[string]any{"type": "text", "id": "t1", "text": "hi"},
},
}
applyCodexOAuthTransform(reqBody)
store, ok := reqBody["store"].(bool)
require.True(t, ok)
require.False(t, store)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 1)
// 校验 input[0] 为 map避免类型不匹配触发 errcheck。
item, ok := input[0].(map[string]any)
require.True(t, ok)
_, hasID := item["id"]
require.False(t, hasID)
}
func TestFilterCodexInput_RemovesItemReferenceWhenNotPreserved(t *testing.T) {
input := []any{
map[string]any{"type": "item_reference", "id": "ref1"},
map[string]any{"type": "text", "id": "t1", "text": "hi"},
}
filtered := filterCodexInput(input, false)
require.Len(t, filtered, 1)
// 校验 filtered[0] 为 map确保字段检查可靠。
item, ok := filtered[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "text", item["type"])
_, hasID := item["id"]
require.False(t, hasID)
}
func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) {
// 空 input 应保持为空且不触发异常。
setupCodexCache(t)
reqBody := map[string]any{
"model": "gpt-5.1",
"input": []any{},
}
applyCodexOAuthTransform(reqBody)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 0)
}
func setupCodexCache(t *testing.T) {
t.Helper()
// 使用临时 HOME 避免触发网络拉取 header。
tempDir := t.TempDir()
t.Setenv("HOME", tempDir)
cacheDir := filepath.Join(tempDir, ".opencode", "cache")
require.NoError(t, os.MkdirAll(cacheDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "opencode-codex-header.txt"), []byte("header"), 0o644))
meta := map[string]any{
"etag": "",
"lastFetch": time.Now().UTC().Format(time.RFC3339),
"lastChecked": time.Now().UnixMilli(),
}
data, err := json.Marshal(meta)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "opencode-codex-header-meta.json"), data, 0o644))
}

View File

@@ -42,6 +42,7 @@ var openaiSSEDataRe = regexp.MustCompile(`^data:\s*`)
var openaiAllowedHeaders = map[string]bool{ var openaiAllowedHeaders = map[string]bool{
"accept-language": true, "accept-language": true,
"content-type": true, "content-type": true,
"conversation_id": true,
"user-agent": true, "user-agent": true,
"originator": true, "originator": true,
"session_id": true, "session_id": true,
@@ -545,16 +546,35 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
isCodexCLI := openai.IsCodexCLIRequest(c.GetHeader("User-Agent")) isCodexCLI := openai.IsCodexCLIRequest(c.GetHeader("User-Agent"))
// Apply model mapping (skip for Codex CLI for transparent forwarding) // 对所有请求执行模型映射(包含 Codex CLI
mappedModel := reqModel mappedModel := account.GetMappedModel(reqModel)
if !isCodexCLI { if mappedModel != reqModel {
mappedModel = account.GetMappedModel(reqModel) log.Printf("[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, mappedModel, account.Name, isCodexCLI)
if mappedModel != reqModel { reqBody["model"] = mappedModel
reqBody["model"] = mappedModel bodyModified = true
}
// 针对所有 OpenAI 账号执行 Codex 模型名规范化,确保上游识别一致。
if model, ok := reqBody["model"].(string); ok {
normalizedModel := normalizeCodexModel(model)
if normalizedModel != "" && normalizedModel != model {
log.Printf("[OpenAI] Codex model normalization: %s -> %s (account: %s, type: %s, isCodexCLI: %v)",
model, normalizedModel, account.Name, account.Type, isCodexCLI)
reqBody["model"] = normalizedModel
mappedModel = normalizedModel
bodyModified = true bodyModified = true
} }
} }
// 规范化 reasoning.effort 参数minimal -> none与上游允许值对齐。
if reasoning, ok := reqBody["reasoning"].(map[string]any); ok {
if effort, ok := reasoning["effort"].(string); ok && effort == "minimal" {
reasoning["effort"] = "none"
bodyModified = true
log.Printf("[OpenAI] Normalized reasoning.effort: minimal -> none (account: %s)", account.Name)
}
}
if account.Type == AccountTypeOAuth && !isCodexCLI { if account.Type == AccountTypeOAuth && !isCodexCLI {
codexResult := applyCodexOAuthTransform(reqBody) codexResult := applyCodexOAuthTransform(reqBody)
if codexResult.Modified { if codexResult.Modified {
@@ -568,6 +588,44 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
} }
} }
// Handle max_output_tokens based on platform and account type
if !isCodexCLI {
if maxOutputTokens, hasMaxOutputTokens := reqBody["max_output_tokens"]; hasMaxOutputTokens {
switch account.Platform {
case PlatformOpenAI:
// For OpenAI API Key, remove max_output_tokens (not supported)
// For OpenAI OAuth (Responses API), keep it (supported)
if account.Type == AccountTypeAPIKey {
delete(reqBody, "max_output_tokens")
bodyModified = true
}
case PlatformAnthropic:
// For Anthropic (Claude), convert to max_tokens
delete(reqBody, "max_output_tokens")
if _, hasMaxTokens := reqBody["max_tokens"]; !hasMaxTokens {
reqBody["max_tokens"] = maxOutputTokens
}
bodyModified = true
case PlatformGemini:
// For Gemini, remove (will be handled by Gemini-specific transform)
delete(reqBody, "max_output_tokens")
bodyModified = true
default:
// For unknown platforms, remove to be safe
delete(reqBody, "max_output_tokens")
bodyModified = true
}
}
// Also handle max_completion_tokens (similar logic)
if _, hasMaxCompletionTokens := reqBody["max_completion_tokens"]; hasMaxCompletionTokens {
if account.Type == AccountTypeAPIKey || account.Platform != PlatformOpenAI {
delete(reqBody, "max_completion_tokens")
bodyModified = true
}
}
}
// Re-serialize body only if modified // Re-serialize body only if modified
if bodyModified { if bodyModified {
var err error var err error
@@ -747,9 +805,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
if promptCacheKey != "" { if promptCacheKey != "" {
req.Header.Set("conversation_id", promptCacheKey) req.Header.Set("conversation_id", promptCacheKey)
req.Header.Set("session_id", promptCacheKey) req.Header.Set("session_id", promptCacheKey)
} else {
req.Header.Del("conversation_id")
req.Header.Del("session_id")
} }
} }

View File

@@ -0,0 +1,213 @@
package service
import "strings"
// NeedsToolContinuation 判定请求是否需要工具调用续链处理。
// 满足以下任一信号即视为续链previous_response_id、input 内包含 function_call_output/item_reference、
// 或显式声明 tools/tool_choice。
func NeedsToolContinuation(reqBody map[string]any) bool {
if reqBody == nil {
return false
}
if hasNonEmptyString(reqBody["previous_response_id"]) {
return true
}
if hasToolsSignal(reqBody) {
return true
}
if hasToolChoiceSignal(reqBody) {
return true
}
if inputHasType(reqBody, "function_call_output") {
return true
}
if inputHasType(reqBody, "item_reference") {
return true
}
return false
}
// HasFunctionCallOutput 判断 input 是否包含 function_call_output用于触发续链校验。
func HasFunctionCallOutput(reqBody map[string]any) bool {
if reqBody == nil {
return false
}
return inputHasType(reqBody, "function_call_output")
}
// HasToolCallContext 判断 input 是否包含带 call_id 的 tool_call/function_call
// 用于判断 function_call_output 是否具备可关联的上下文。
func HasToolCallContext(reqBody map[string]any) bool {
if reqBody == nil {
return false
}
input, ok := reqBody["input"].([]any)
if !ok {
return false
}
for _, item := range input {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}
itemType, _ := itemMap["type"].(string)
if itemType != "tool_call" && itemType != "function_call" {
continue
}
if callID, ok := itemMap["call_id"].(string); ok && strings.TrimSpace(callID) != "" {
return true
}
}
return false
}
// FunctionCallOutputCallIDs 提取 input 中 function_call_output 的 call_id 集合。
// 仅返回非空 call_id用于与 item_reference.id 做匹配校验。
func FunctionCallOutputCallIDs(reqBody map[string]any) []string {
if reqBody == nil {
return nil
}
input, ok := reqBody["input"].([]any)
if !ok {
return nil
}
ids := make(map[string]struct{})
for _, item := range input {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}
itemType, _ := itemMap["type"].(string)
if itemType != "function_call_output" {
continue
}
if callID, ok := itemMap["call_id"].(string); ok && strings.TrimSpace(callID) != "" {
ids[callID] = struct{}{}
}
}
if len(ids) == 0 {
return nil
}
result := make([]string, 0, len(ids))
for id := range ids {
result = append(result, id)
}
return result
}
// HasFunctionCallOutputMissingCallID 判断是否存在缺少 call_id 的 function_call_output。
func HasFunctionCallOutputMissingCallID(reqBody map[string]any) bool {
if reqBody == nil {
return false
}
input, ok := reqBody["input"].([]any)
if !ok {
return false
}
for _, item := range input {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}
itemType, _ := itemMap["type"].(string)
if itemType != "function_call_output" {
continue
}
callID, _ := itemMap["call_id"].(string)
if strings.TrimSpace(callID) == "" {
return true
}
}
return false
}
// HasItemReferenceForCallIDs 判断 item_reference.id 是否覆盖所有 call_id。
// 用于仅依赖引用项完成续链场景的校验。
func HasItemReferenceForCallIDs(reqBody map[string]any, callIDs []string) bool {
if reqBody == nil || len(callIDs) == 0 {
return false
}
input, ok := reqBody["input"].([]any)
if !ok {
return false
}
referenceIDs := make(map[string]struct{})
for _, item := range input {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}
itemType, _ := itemMap["type"].(string)
if itemType != "item_reference" {
continue
}
idValue, _ := itemMap["id"].(string)
idValue = strings.TrimSpace(idValue)
if idValue == "" {
continue
}
referenceIDs[idValue] = struct{}{}
}
if len(referenceIDs) == 0 {
return false
}
for _, callID := range callIDs {
if _, ok := referenceIDs[callID]; !ok {
return false
}
}
return true
}
// inputHasType 判断 input 中是否存在指定类型的 item。
func inputHasType(reqBody map[string]any, want string) bool {
input, ok := reqBody["input"].([]any)
if !ok {
return false
}
for _, item := range input {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}
itemType, _ := itemMap["type"].(string)
if itemType == want {
return true
}
}
return false
}
// hasNonEmptyString 判断字段是否为非空字符串。
func hasNonEmptyString(value any) bool {
stringValue, ok := value.(string)
return ok && strings.TrimSpace(stringValue) != ""
}
// hasToolsSignal 判断 tools 字段是否显式声明(存在且不为空)。
func hasToolsSignal(reqBody map[string]any) bool {
raw, exists := reqBody["tools"]
if !exists || raw == nil {
return false
}
if tools, ok := raw.([]any); ok {
return len(tools) > 0
}
return false
}
// hasToolChoiceSignal 判断 tool_choice 是否显式声明(非空或非 nil
func hasToolChoiceSignal(reqBody map[string]any) bool {
raw, exists := reqBody["tool_choice"]
if !exists || raw == nil {
return false
}
switch value := raw.(type) {
case string:
return strings.TrimSpace(value) != ""
case map[string]any:
return len(value) > 0
default:
return false
}
}

View File

@@ -0,0 +1,98 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNeedsToolContinuationSignals(t *testing.T) {
// 覆盖所有触发续链的信号来源,确保判定逻辑完整。
cases := []struct {
name string
body map[string]any
want bool
}{
{name: "nil", body: nil, want: false},
{name: "previous_response_id", body: map[string]any{"previous_response_id": "resp_1"}, want: true},
{name: "previous_response_id_blank", body: map[string]any{"previous_response_id": " "}, want: false},
{name: "function_call_output", body: map[string]any{"input": []any{map[string]any{"type": "function_call_output"}}}, want: true},
{name: "item_reference", body: map[string]any{"input": []any{map[string]any{"type": "item_reference"}}}, want: true},
{name: "tools", body: map[string]any{"tools": []any{map[string]any{"type": "function"}}}, want: true},
{name: "tools_empty", body: map[string]any{"tools": []any{}}, want: false},
{name: "tools_invalid", body: map[string]any{"tools": "bad"}, want: false},
{name: "tool_choice", body: map[string]any{"tool_choice": "auto"}, want: true},
{name: "tool_choice_object", body: map[string]any{"tool_choice": map[string]any{"type": "function"}}, want: true},
{name: "tool_choice_empty_object", body: map[string]any{"tool_choice": map[string]any{}}, want: false},
{name: "none", body: map[string]any{"input": []any{map[string]any{"type": "text", "text": "hi"}}}, want: false},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, NeedsToolContinuation(tt.body))
})
}
}
func TestHasFunctionCallOutput(t *testing.T) {
// 仅当 input 中存在 function_call_output 才视为续链输出。
require.False(t, HasFunctionCallOutput(nil))
require.True(t, HasFunctionCallOutput(map[string]any{
"input": []any{map[string]any{"type": "function_call_output"}},
}))
require.False(t, HasFunctionCallOutput(map[string]any{
"input": "text",
}))
}
func TestHasToolCallContext(t *testing.T) {
// tool_call/function_call 必须包含 call_id才能作为可关联上下文。
require.False(t, HasToolCallContext(nil))
require.True(t, HasToolCallContext(map[string]any{
"input": []any{map[string]any{"type": "tool_call", "call_id": "call_1"}},
}))
require.True(t, HasToolCallContext(map[string]any{
"input": []any{map[string]any{"type": "function_call", "call_id": "call_2"}},
}))
require.False(t, HasToolCallContext(map[string]any{
"input": []any{map[string]any{"type": "tool_call"}},
}))
}
func TestFunctionCallOutputCallIDs(t *testing.T) {
// 仅提取非空 call_id去重后返回。
require.Empty(t, FunctionCallOutputCallIDs(nil))
callIDs := FunctionCallOutputCallIDs(map[string]any{
"input": []any{
map[string]any{"type": "function_call_output", "call_id": "call_1"},
map[string]any{"type": "function_call_output", "call_id": ""},
map[string]any{"type": "function_call_output", "call_id": "call_1"},
},
})
require.ElementsMatch(t, []string{"call_1"}, callIDs)
}
func TestHasFunctionCallOutputMissingCallID(t *testing.T) {
require.False(t, HasFunctionCallOutputMissingCallID(nil))
require.True(t, HasFunctionCallOutputMissingCallID(map[string]any{
"input": []any{map[string]any{"type": "function_call_output"}},
}))
require.False(t, HasFunctionCallOutputMissingCallID(map[string]any{
"input": []any{map[string]any{"type": "function_call_output", "call_id": "call_1"}},
}))
}
func TestHasItemReferenceForCallIDs(t *testing.T) {
// item_reference 需要覆盖所有 call_id 才视为可关联上下文。
require.False(t, HasItemReferenceForCallIDs(nil, []string{"call_1"}))
require.False(t, HasItemReferenceForCallIDs(map[string]any{}, []string{"call_1"}))
req := map[string]any{
"input": []any{
map[string]any{"type": "item_reference", "id": "call_1"},
map[string]any{"type": "item_reference", "id": "call_2"},
},
}
require.True(t, HasItemReferenceForCallIDs(req, []string{"call_1"}))
require.True(t, HasItemReferenceForCallIDs(req, []string{"call_1", "call_2"}))
require.False(t, HasItemReferenceForCallIDs(req, []string{"call_1", "call_3"}))
}

View File

@@ -0,0 +1,275 @@
You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful.
Your capabilities:
- Receive user prompts and other context provided by the harness, such as files in the workspace.
- Communicate with the user by streaming thinking & responses, and by making & updating plans.
- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section.
Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI).
# How you work
## Personality
Your default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.
# AGENTS.md spec
- Repos often contain AGENTS.md files. These files can appear anywhere within the repository.
- These files are a way for humans to give you (the agent) instructions or tips for working within the container.
- Some examples might be: coding conventions, info about how code is organized, or instructions for how to run or test code.
- Instructions in AGENTS.md files:
- The scope of an AGENTS.md file is the entire directory tree rooted at the folder that contains it.
- For every file you touch in the final patch, you must obey instructions in any AGENTS.md file whose scope includes that file.
- Instructions about code style, structure, naming, etc. apply only to code within the AGENTS.md file's scope, unless the file states otherwise.
- More-deeply-nested AGENTS.md files take precedence in the case of conflicting instructions.
- Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions.
- The contents of the AGENTS.md file at the root of the repo and any directories from the CWD up to the root are included with the developer message and don't need to be re-read. When working in a subdirectory of CWD, or a directory outside the CWD, check for any AGENTS.md files that may be applicable.
## Responsiveness
### Preamble messages
Before making tool calls, send a brief preamble to the user explaining what youre about to do. When sending preamble messages, follow these principles and examples:
- **Logically group related actions**: if youre about to run several related commands, describe them together in one preamble rather than sending a separate note for each.
- **Keep it concise**: be no more than 1-2 sentences, focused on immediate, tangible next steps. (812 words for quick updates).
- **Build on prior context**: if this is not your first tool call, use the preamble message to connect the dots with whats been done so far and create a sense of momentum and clarity for the user to understand your next actions.
- **Keep your tone light, friendly and curious**: add small touches of personality in preambles feel collaborative and engaging.
- **Exception**: Avoid adding a preamble for every trivial read (e.g., `cat` a single file) unless its part of a larger grouped action.
**Examples:**
- “Ive explored the repo; now checking the API route definitions.”
- “Next, Ill patch the config and update the related tests.”
- “Im about to scaffold the CLI commands and helper functions.”
- “Ok cool, so Ive wrapped my head around the repo. Now digging into the API routes.”
- “Configs looking tidy. Next up is patching helpers to keep things in sync.”
- “Finished poking at the DB gateway. I will now chase down error handling.”
- “Alright, build pipeline order is interesting. Checking how it reports failures.”
- “Spotted a clever caching util; now hunting where it gets used.”
## Planning
You have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go.
Note that plans are not for padding out simple work with filler steps or stating the obvious. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately.
Do not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step.
Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step. It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so.
Use a plan when:
- The task is non-trivial and will require multiple actions over a long time horizon.
- There are logical phases or dependencies where sequencing matters.
- The work has ambiguity that benefits from outlining high-level goals.
- You want intermediate checkpoints for feedback and validation.
- When the user asked you to do more than one thing in a single prompt
- The user has asked you to use the plan tool (aka "TODOs")
- You generate additional steps while working, and plan to do them before yielding to the user
### Examples
**High-quality plans**
Example 1:
1. Add CLI entry with file args
2. Parse Markdown via CommonMark library
3. Apply semantic HTML template
4. Handle code blocks, images, links
5. Add error handling for invalid files
Example 2:
1. Define CSS variables for colors
2. Add toggle with localStorage state
3. Refactor components to use variables
4. Verify all views for readability
5. Add smooth theme-change transition
Example 3:
1. Set up Node.js + WebSocket server
2. Add join/leave broadcast events
3. Implement messaging with timestamps
4. Add usernames + mention highlighting
5. Persist messages in lightweight DB
6. Add typing indicators + unread count
**Low-quality plans**
Example 1:
1. Create CLI tool
2. Add Markdown parser
3. Convert to HTML
Example 2:
1. Add dark mode toggle
2. Save preference
3. Make styles look good
Example 3:
1. Create single-file HTML game
2. Run quick sanity check
3. Summarize usage instructions
If you need to write a plan, only write high quality plans, not low quality ones.
## Task execution
You are a coding agent. Please keep going until the query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer.
You MUST adhere to the following criteria when solving queries:
- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
- Analyzing code for vulnerabilities is allowed.
- Showing user code and tool call details is allowed.
- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]}
If completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines:
- Fix the problem at the root cause rather than applying surface-level patches, when possible.
- Avoid unneeded complexity in your solution.
- Do not attempt to fix unrelated bugs or broken tests. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)
- Update documentation as necessary.
- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
- Use `git log` and `git blame` to search the history of the codebase if additional context is required.
- NEVER add copyright or license headers unless specifically requested.
- Do not waste tokens by re-reading files after calling `apply_patch` on them. The tool call will fail if it didn't work. The same goes for making folders, deleting folders, etc.
- Do not `git commit` your changes or create new git branches unless explicitly requested.
- Do not add inline comments within code unless explicitly requested.
- Do not use one-letter variable names unless explicitly requested.
- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.
## Validating your work
If the codebase has tests or the ability to build or run, consider using them to verify that your work is complete.
When testing, your philosophy should be to start as specific as possible to the code you changed so that you can catch issues efficiently, then make your way to broader tests as you build confidence. If there's no test for the code you changed, and if the adjacent patterns in the codebases show that there's a logical place for you to add a test, you may do so. However, do not add tests to codebases with no tests.
Similarly, once you're confident in correctness, you can suggest or use formatting commands to ensure that your code is well formatted. If there are issues you can iterate up to 3 times to get formatting right, but if you still can't manage it's better to save the user time and present them a correct solution where you call out the formatting in your final message. If the codebase does not have a formatter configured, do not add one.
For all of testing, running, building, and formatting, do not attempt to fix unrelated bugs. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)
Be mindful of whether to run validation commands proactively. In the absence of behavioral guidance:
- When running in non-interactive approval modes like **never** or **on-failure**, proactively run tests, lint and do whatever you need to ensure you've completed the task.
- When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first.
- When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task.
## Ambition vs. precision
For tasks that have no prior context (i.e. the user is starting something brand new), you should feel free to be ambitious and demonstrate creativity with your implementation.
If you're operating in an existing codebase, you should make sure you do exactly what the user asks with surgical precision. Treat the surrounding codebase with respect, and don't overstep (i.e. changing filenames or variables unnecessarily). You should balance being sufficiently ambitious and proactive when completing tasks of this nature.
You should use judicious initiative to decide on the right level of detail and complexity to deliver based on the user's needs. This means showing good judgment that you're capable of doing the right extras without gold-plating. This might be demonstrated by high-value, creative touches when scope of the task is vague; while being surgical and targeted when scope is tightly specified.
## Sharing progress updates
For especially longer tasks that you work on (i.e. requiring many tool calls, or a plan with multiple steps), you should provide progress updates back to the user at reasonable intervals. These updates should be structured as a concise sentence or two (no more than 8-10 words long) recapping progress so far in plain language: this update demonstrates your understanding of what needs to be done, progress so far (i.e. files explores, subtasks complete), and where you're going next.
Before doing large chunks of work that may incur latency as experienced by the user (i.e. writing a new file), you should send a concise message to the user with an update indicating what you're about to do to ensure they know what you're spending time on. Don't start editing or writing large files before informing the user what you are doing and why.
The messages you send before tool calls should describe what is immediately about to be done next in very concise language. If there was previous work done, this preamble message should also include a note about the work done so far to bring the user along.
## Presenting your work and final message
Your final message should read naturally, like an update from a concise teammate. For casual conversation, brainstorming tasks, or quick questions from the user, respond in a friendly, conversational tone. You should ask questions, suggest ideas, and adapt to the users style. If you've finished a large amount of work, when describing what you've done to the user, you should follow the final answer formatting guidelines to communicate substantive changes. You don't need to add structured formatting for one-word answers, greetings, or purely conversational exchanges.
You can skip heavy formatting for single, simple actions or confirmations. In these cases, respond in plain sentences with any relevant next step or quick option. Reserve multi-section structured responses for results that need grouping or explanation.
The user is working on the same computer as you, and has access to your work. As such there's no need to show the full contents of large files you have already written unless the user explicitly asks for them. Similarly, if you've created or modified files using `apply_patch`, there's no need to tell users to "save the file" or "copy the code into a file"—just reference the file path.
If there's something that you think you could help with as a logical next step, concisely ask the user if they want you to do so. Good examples of this are running tests, committing changes, or building out the next logical component. If theres something that you couldn't do (even with approval) but that the user might want to do (such as verifying changes by running the app), include those instructions succinctly.
Brevity is very important as a default. You should be very concise (i.e. no more than 10 lines), but can relax this requirement for tasks where additional detail and comprehensiveness is important for the user's understanding.
### Final answer structure and style guidelines
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
**Section Headers**
- Use only when they improve clarity — they are not mandatory for every answer.
- Choose descriptive names that fit the content
- Keep headers short (13 words) and in `**Title Case**`. Always start headers with `**` and end with `**`
- Leave no blank line before the first bullet under a header.
- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer.
**Bullets**
- Use `-` followed by a space for every bullet.
- Merge related points when possible; avoid a bullet for every trivial detail.
- Keep bullets to one line unless breaking for clarity is unavoidable.
- Group into short lists (46 bullets) ordered by importance.
- Use consistent keyword phrasing and formatting across sections.
**Monospace**
- Wrap all commands, file paths, env vars, and code identifiers in backticks (`` `...` ``).
- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command.
- Never mix monospace and bold markers; choose one based on whether its a keyword (`**`) or inline code/path (`` ` ``).
**File References**
When referencing files in your response, make sure to include the relevant start line and always follow the below rules:
* Use inline code to make file paths clickable.
* Each reference should have a stand alone path. Even if it's the same file.
* Accepted: absolute, workspacerelative, a/ or b/ diff prefixes, or bare filename/suffix.
* Line/column (1based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
* Do not use URIs like file://, vscode://, or https://.
* Do not provide range of lines
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5
**Structure**
- Place related bullets together; dont mix unrelated concepts in the same section.
- Order sections from general → specific → supporting info.
- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it.
- Match structure to complexity:
- Multi-part or detailed results → use clear headers and grouped bullets.
- Simple results → minimal headers, possibly just a short list or paragraph.
**Tone**
- Keep the voice collaborative and natural, like a coding partner handing off work.
- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition
- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”).
- Keep descriptions self-contained; dont refer to “above” or “below”.
- Use parallel structure in lists for consistency.
**Dont**
- Dont use literal words “bold” or “monospace” in the content.
- Dont nest bullets or create deep hierarchies.
- Dont output ANSI escape codes directly — the CLI renderer applies them.
- Dont cram unrelated keywords into a single bullet; split for clarity.
- Dont let keyword lists run long — wrap or reformat for scanability.
Generally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with whats needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable.
For casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting.
# Tool Guidelines
## Shell commands
When using the shell, you must adhere to the following guidelines:
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
- Do not use python scripts to attempt to output larger chunks of a file.
## `update_plan`
A tool named `update_plan` is available to you. You can use it to keep an uptodate, stepbystep plan for the task.
To create a new plan, call `update_plan` with a short list of 1sentence steps (no more than 5-7 words each) with a `status` for each step (`pending`, `in_progress`, or `completed`).
When steps have been completed, use `update_plan` to mark each finished step as `completed` and the next step you are working on as `in_progress`. There should always be exactly one `in_progress` step until everything is done. You can mark multiple items as complete in a single `update_plan` call.
If all steps are complete, ensure you call `update_plan` to mark all steps as `completed`.

View File

@@ -3,14 +3,17 @@
<form v-if="user" id="balance-form" @submit.prevent="handleBalanceSubmit" class="space-y-5"> <form v-if="user" id="balance-form" @submit.prevent="handleBalanceSubmit" class="space-y-5">
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700"> <div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100"><span class="text-lg font-medium text-primary-700">{{ user.email.charAt(0).toUpperCase() }}</span></div> <div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100"><span class="text-lg font-medium text-primary-700">{{ user.email.charAt(0).toUpperCase() }}</span></div>
<div class="flex-1"><p class="font-medium text-gray-900">{{ user.email }}</p><p class="text-sm text-gray-500">{{ t('admin.users.currentBalance') }}: ${{ user.balance.toFixed(2) }}</p></div> <div class="flex-1"><p class="font-medium text-gray-900">{{ user.email }}</p><p class="text-sm text-gray-500">{{ t('admin.users.currentBalance') }}: ${{ formatBalance(user.balance) }}</p></div>
</div> </div>
<div> <div>
<label class="input-label">{{ operation === 'add' ? t('admin.users.depositAmount') : t('admin.users.withdrawAmount') }}</label> <label class="input-label">{{ operation === 'add' ? t('admin.users.depositAmount') : t('admin.users.withdrawAmount') }}</label>
<div class="relative"><div class="absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-500">$</div><input v-model.number="form.amount" type="number" step="0.01" min="0.01" required class="input pl-8" /></div> <div class="relative flex gap-2">
<div class="relative flex-1"><div class="absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-500">$</div><input v-model.number="form.amount" type="number" step="any" min="0" required class="input pl-8" /></div>
<button v-if="operation === 'subtract'" type="button" @click="fillAllBalance" class="btn btn-secondary whitespace-nowrap">{{ t('admin.users.withdrawAll') }}</button>
</div>
</div> </div>
<div><label class="input-label">{{ t('admin.users.notes') }}</label><textarea v-model="form.notes" rows="3" class="input"></textarea></div> <div><label class="input-label">{{ t('admin.users.notes') }}</label><textarea v-model="form.notes" rows="3" class="input"></textarea></div>
<div v-if="form.amount > 0" class="rounded-xl border border-blue-200 bg-blue-50 p-4"><div class="flex items-center justify-between text-sm"><span>{{ t('admin.users.newBalance') }}:</span><span class="font-bold">${{ calculateNewBalance().toFixed(2) }}</span></div></div> <div v-if="form.amount > 0" class="rounded-xl border border-blue-200 bg-blue-50 p-4"><div class="flex items-center justify-between text-sm"><span>{{ t('admin.users.newBalance') }}:</span><span class="font-bold">${{ formatBalance(calculateNewBalance()) }}</span></div></div>
</form> </form>
<template #footer> <template #footer>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
@@ -35,11 +38,30 @@ const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const a
const submitting = ref(false); const form = reactive({ amount: 0, notes: '' }) const submitting = ref(false); const form = reactive({ amount: 0, notes: '' })
watch(() => props.show, (v) => { if(v) { form.amount = 0; form.notes = '' } }) watch(() => props.show, (v) => { if(v) { form.amount = 0; form.notes = '' } })
// 格式化余额显示完整精度去除尾部多余的0
const formatBalance = (value: number) => {
if (value === 0) return '0.00'
// 最多保留8位小数去除尾部的0
const formatted = value.toFixed(8).replace(/\.?0+$/, '')
// 确保至少有2位小数
const parts = formatted.split('.')
if (parts.length === 1) return formatted + '.00'
if (parts[1].length === 1) return formatted + '0'
return formatted
}
// 填入全部余额
const fillAllBalance = () => {
if (props.user) {
form.amount = props.user.balance
}
}
const calculateNewBalance = () => { const calculateNewBalance = () => {
if (!props.user) return 0 if (!props.user) return 0
const result = props.operation === 'add' ? props.user.balance + form.amount : props.user.balance - form.amount const result = props.operation === 'add' ? props.user.balance + form.amount : props.user.balance - form.amount
// 避免浮点数精度问题导致的 -0.00 显示 // 避免浮点数精度问题导致的 -0.00 显示
return result === 0 || Object.is(result, -0) ? 0 : result return Math.abs(result) < 1e-10 ? 0 : result
} }
const handleBalanceSubmit = async () => { const handleBalanceSubmit = async () => {
if (!props.user) return if (!props.user) return
@@ -47,10 +69,8 @@ const handleBalanceSubmit = async () => {
appStore.showError(t('admin.users.amountRequired')) appStore.showError(t('admin.users.amountRequired'))
return return
} }
// 使用小数点后两位精度比较,避免浮点数精度问题 // 退款时验证金额不超过实际余额
const amount = Math.round(form.amount * 100) / 100 if (props.operation === 'subtract' && form.amount > props.user.balance) {
const balance = Math.round(props.user.balance * 100) / 100
if (props.operation === 'subtract' && amount > balance) {
appStore.showError(t('admin.users.insufficientBalance')) appStore.showError(t('admin.users.insufficientBalance'))
return return
} }

View File

@@ -376,6 +376,10 @@ const currentFiles = computed((): FileConfig[] => {
const trimmed = `${baseRoot}/antigravity`.replace(/\/+$/, '') const trimmed = `${baseRoot}/antigravity`.replace(/\/+$/, '')
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta` return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
})() })()
const geminiBase = (() => {
const trimmed = baseRoot.replace(/\/+$/, '')
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
})()
if (activeClientTab.value === 'opencode') { if (activeClientTab.value === 'opencode') {
switch (props.platform) { switch (props.platform) {
@@ -384,7 +388,7 @@ const currentFiles = computed((): FileConfig[] => {
case 'openai': case 'openai':
return [generateOpenCodeConfig('openai', apiBase, apiKey)] return [generateOpenCodeConfig('openai', apiBase, apiKey)]
case 'gemini': case 'gemini':
return [generateOpenCodeConfig('gemini', apiBase, apiKey)] return [generateOpenCodeConfig('gemini', geminiBase, apiKey)]
case 'antigravity': case 'antigravity':
return [ return [
generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'), generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'),
@@ -525,14 +529,16 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
[platform]: { [platform]: {
options: { options: {
baseURL: baseUrl, baseURL: baseUrl,
apiKey, apiKey
...(platform === 'openai' ? { store: false } : {})
} }
} }
} }
const openaiModels = { const openaiModels = {
'gpt-5.2-codex': { 'gpt-5.2-codex': {
name: 'GPT-5.2 Codex', name: 'GPT-5.2 Codex',
options: {
store: false
},
variants: { variants: {
low: {}, low: {},
medium: {}, medium: {},
@@ -574,9 +580,26 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
provider[platform].models = openaiModels provider[platform].models = openaiModels
} }
const agent =
platform === 'openai'
? {
build: {
options: {
store: false
}
},
plan: {
options: {
store: false
}
}
}
: undefined
const content = JSON.stringify( const content = JSON.stringify(
{ {
provider, provider,
...(agent ? { agent } : {}),
$schema: 'https://opencode.ai/config.json' $schema: 'https://opencode.ai/config.json'
}, },
null, null,

View File

@@ -13,7 +13,17 @@ const openaiModels = [
'o1', 'o1-preview', 'o1-mini', 'o1-pro', 'o1', 'o1-preview', 'o1-mini', 'o1-pro',
'o3', 'o3-mini', 'o3-pro', 'o3', 'o3-mini', 'o3-pro',
'o4-mini', 'o4-mini',
'gpt-5', 'gpt-5-mini', 'gpt-5-nano', // GPT-5 系列(同步后端定价文件)
'gpt-5', 'gpt-5-2025-08-07', 'gpt-5-chat', 'gpt-5-chat-latest',
'gpt-5-codex', 'gpt-5-pro', 'gpt-5-pro-2025-10-06',
'gpt-5-mini', 'gpt-5-mini-2025-08-07',
'gpt-5-nano', 'gpt-5-nano-2025-08-07',
// GPT-5.1 系列
'gpt-5.1', 'gpt-5.1-2025-11-13', 'gpt-5.1-chat-latest',
'gpt-5.1-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini',
// GPT-5.2 系列
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest',
'gpt-5.2-codex', 'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11',
'chatgpt-4o-latest', 'chatgpt-4o-latest',
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview' 'gpt-4o-audio-preview', 'gpt-4o-realtime-preview'
] ]
@@ -211,7 +221,10 @@ const openaiPresetMappings = [
{ label: 'GPT-4.1', from: 'gpt-4.1', to: 'gpt-4.1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, { label: 'GPT-4.1', from: 'gpt-4.1', to: 'gpt-4.1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'o1', from: 'o1', to: 'o1', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'o1', from: 'o1', to: 'o1', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'o3', from: 'o3', to: 'o3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, { label: 'o3', from: 'o3', to: 'o3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
{ label: 'GPT-5', from: 'gpt-5', to: 'gpt-5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' } { label: 'GPT-5', from: 'gpt-5', to: 'gpt-5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
{ label: 'GPT-5.1', from: 'gpt-5.1', to: 'gpt-5.1', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
{ label: 'GPT-5.2', from: 'gpt-5.2', to: 'gpt-5.2', color: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' },
{ label: 'GPT-5.1 Codex', from: 'gpt-5.1-codex', to: 'gpt-5.1-codex', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }
] ]
const geminiPresetMappings = [ const geminiPresetMappings = [

View File

@@ -390,7 +390,7 @@ export default {
opencode: { opencode: {
title: 'OpenCode Example', title: 'OpenCode Example',
subtitle: 'opencode.json', subtitle: 'opencode.json',
hint: 'Config path: ~/.config/opencode/opencode.json (create if not exists). This is an example, adjust model and options as needed.', hint: 'Config path: ~/.config/opencode/opencode.json (or opencode.jsonc), create if not exists. Use default providers (openai/anthropic/google) or custom provider_id. API Key can be configured directly or via /connect command. This is an example, adjust models and options as needed.',
}, },
}, },
customKeyLabel: 'Custom Key', customKeyLabel: 'Custom Key',
@@ -724,6 +724,7 @@ export default {
withdraw: 'Withdraw', withdraw: 'Withdraw',
depositAmount: 'Deposit Amount', depositAmount: 'Deposit Amount',
withdrawAmount: 'Withdraw Amount', withdrawAmount: 'Withdraw Amount',
withdrawAll: 'All',
currentBalance: 'Current Balance', currentBalance: 'Current Balance',
depositNotesPlaceholder: depositNotesPlaceholder:
'e.g., New user registration bonus, promotional credit, compensation, etc.', 'e.g., New user registration bonus, promotional credit, compensation, etc.',
@@ -1943,6 +1944,9 @@ export default {
'6h': 'Last 6 hours', '6h': 'Last 6 hours',
'24h': 'Last 24 hours' '24h': 'Last 24 hours'
}, },
fullscreen: {
enter: 'Enter Fullscreen'
},
diagnosis: { diagnosis: {
title: 'Smart Diagnosis', title: 'Smart Diagnosis',
footer: 'Automated diagnostic suggestions based on current metrics', footer: 'Automated diagnostic suggestions based on current metrics',

View File

@@ -387,7 +387,7 @@ export default {
opencode: { opencode: {
title: 'OpenCode 配置示例', title: 'OpenCode 配置示例',
subtitle: 'opencode.json', subtitle: 'opencode.json',
hint: '配置文件路径:~/.config/opencode/opencode.json,不存在需手动创建。示例仅供参考,模型与选项可按需调整。', hint: '配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc不存在需手动创建。可使用默认 provideropenai/anthropic/google或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。',
}, },
}, },
customKeyLabel: '自定义密钥', customKeyLabel: '自定义密钥',
@@ -780,6 +780,7 @@ export default {
withdraw: '退款', withdraw: '退款',
depositAmount: '充值金额', depositAmount: '充值金额',
withdrawAmount: '退款金额', withdrawAmount: '退款金额',
withdrawAll: '全部',
depositNotesPlaceholder: '例如:新用户注册奖励、活动充值、补偿充值等', depositNotesPlaceholder: '例如:新用户注册奖励、活动充值、补偿充值等',
withdrawNotesPlaceholder: '例如:服务问题退款、错误充值退回、账户注销退款等', withdrawNotesPlaceholder: '例如:服务问题退款、错误充值退回、账户注销退款等',
notesOptional: '备注为可选项,有助于未来查账', notesOptional: '备注为可选项,有助于未来查账',
@@ -2088,6 +2089,9 @@ export default {
'6h': '近6小时', '6h': '近6小时',
'24h': '近24小时' '24h': '近24小时'
}, },
fullscreen: {
enter: '进入全屏'
},
diagnosis: { diagnosis: {
title: '智能诊断', title: '智能诊断',
footer: '基于当前指标的自动诊断建议', footer: '基于当前指标的自动诊断建议',

View File

@@ -19,7 +19,22 @@
@apply min-h-screen; @apply min-h-screen;
} }
/* 自定义滚动条 */ /* 自定义滚动条 - 默认隐藏,悬停或滚动时显示 */
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*:hover,
*:focus-within {
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.dark *:hover,
.dark *:focus-within {
scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
@apply h-2 w-2; @apply h-2 w-2;
} }
@@ -29,10 +44,15 @@
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply rounded-full bg-gray-300 dark:bg-dark-600; @apply rounded-full bg-transparent;
transition: background-color 0.2s ease;
} }
::-webkit-scrollbar-thumb:hover { *:hover::-webkit-scrollbar-thumb {
@apply bg-gray-300/50 dark:bg-dark-600/50;
}
*:hover::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-dark-500; @apply bg-gray-400 dark:bg-dark-500;
} }

View File

@@ -1,6 +1,6 @@
<template> <template>
<AppLayout> <component :is="isFullscreen ? 'div' : AppLayout" :class="isFullscreen ? 'flex min-h-screen flex-col justify-center bg-gray-50 dark:bg-dark-950' : ''">
<div class="space-y-6 pb-12"> <div :class="[isFullscreen ? 'p-4 md:p-6' : '', 'space-y-6 pb-12']">
<div <div
v-if="errorMessage" v-if="errorMessage"
class="rounded-2xl bg-red-50 p-4 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400" class="rounded-2xl bg-red-50 p-4 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
@@ -22,6 +22,7 @@
:thresholds="metricThresholds" :thresholds="metricThresholds"
:auto-refresh-enabled="autoRefreshEnabled" :auto-refresh-enabled="autoRefreshEnabled"
:auto-refresh-countdown="autoRefreshCountdown" :auto-refresh-countdown="autoRefreshCountdown"
:fullscreen="isFullscreen"
@update:time-range="onTimeRangeChange" @update:time-range="onTimeRangeChange"
@update:platform="onPlatformChange" @update:platform="onPlatformChange"
@update:group="onGroupChange" @update:group="onGroupChange"
@@ -31,6 +32,8 @@
@open-error-details="openErrorDetails" @open-error-details="openErrorDetails"
@open-settings="showSettingsDialog = true" @open-settings="showSettingsDialog = true"
@open-alert-rules="showAlertRulesCard = true" @open-alert-rules="showAlertRulesCard = true"
@enter-fullscreen="enterFullscreen"
@exit-fullscreen="exitFullscreen"
/> />
<!-- Row: Concurrency + Throughput --> <!-- Row: Concurrency + Throughput -->
@@ -45,6 +48,7 @@
:top-groups="throughputTrend?.top_groups ?? []" :top-groups="throughputTrend?.top_groups ?? []"
:loading="loadingTrend" :loading="loadingTrend"
:time-range="timeRange" :time-range="timeRange"
:fullscreen="isFullscreen"
@select-platform="handleThroughputSelectPlatform" @select-platform="handleThroughputSelectPlatform"
@select-group="handleThroughputSelectGroup" @select-group="handleThroughputSelectGroup"
@open-details="handleOpenRequestDetails" @open-details="handleOpenRequestDetails"
@@ -72,36 +76,37 @@
<!-- Alert Events --> <!-- Alert Events -->
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" /> <OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" />
<!-- Settings Dialog --> <!-- Settings Dialog (hidden in fullscreen mode) -->
<OpsSettingsDialog :show="showSettingsDialog" @close="showSettingsDialog = false" @saved="onSettingsSaved" /> <template v-if="!isFullscreen">
<OpsSettingsDialog :show="showSettingsDialog" @close="showSettingsDialog = false" @saved="onSettingsSaved" />
<!-- Alert Rules Dialog --> <BaseDialog :show="showAlertRulesCard" :title="t('admin.ops.alertRules.title')" width="extra-wide" @close="showAlertRulesCard = false">
<BaseDialog :show="showAlertRulesCard" :title="t('admin.ops.alertRules.title')" width="extra-wide" @close="showAlertRulesCard = false"> <OpsAlertRulesCard />
<OpsAlertRulesCard /> </BaseDialog>
</BaseDialog>
<OpsErrorDetailsModal <OpsErrorDetailsModal
:show="showErrorDetails" :show="showErrorDetails"
:time-range="timeRange" :time-range="timeRange"
:platform="platform" :platform="platform"
:group-id="groupId" :group-id="groupId"
:error-type="errorDetailsType" :error-type="errorDetailsType"
@update:show="showErrorDetails = $event" @update:show="showErrorDetails = $event"
@openErrorDetail="openError" @openErrorDetail="openError"
/> />
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" /> <OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" />
<OpsRequestDetailsModal <OpsRequestDetailsModal
v-model="showRequestDetails" v-model="showRequestDetails"
:time-range="timeRange" :time-range="timeRange"
:preset="requestDetailsPreset" :preset="requestDetailsPreset"
:platform="platform" :platform="platform"
:group-id="groupId" :group-id="groupId"
@openErrorDetail="openError" @openErrorDetail="openError"
/> />
</template>
</div> </div>
</AppLayout> </component>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -163,12 +168,36 @@ const QUERY_KEYS = {
timeRange: 'tr', timeRange: 'tr',
platform: 'platform', platform: 'platform',
groupId: 'group_id', groupId: 'group_id',
queryMode: 'mode' queryMode: 'mode',
fullscreen: 'fullscreen'
} as const } as const
const isApplyingRouteQuery = ref(false) const isApplyingRouteQuery = ref(false)
const isSyncingRouteQuery = ref(false) const isSyncingRouteQuery = ref(false)
// Fullscreen mode
const isFullscreen = computed(() => {
const val = route.query[QUERY_KEYS.fullscreen]
return val === '1' || val === 'true'
})
function exitFullscreen() {
const nextQuery = { ...route.query }
delete nextQuery[QUERY_KEYS.fullscreen]
router.replace({ query: nextQuery })
}
function enterFullscreen() {
const nextQuery = { ...route.query, [QUERY_KEYS.fullscreen]: '1' }
router.replace({ query: nextQuery })
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isFullscreen.value) {
exitFullscreen()
}
}
let dashboardFetchController: AbortController | null = null let dashboardFetchController: AbortController | null = null
let dashboardFetchSeq = 0 let dashboardFetchSeq = 0
@@ -603,6 +632,9 @@ watch(
) )
onMounted(async () => { onMounted(async () => {
// Fullscreen mode: listen for ESC key
window.addEventListener('keydown', handleKeydown)
await adminSettingsStore.fetch() await adminSettingsStore.fetch()
if (!adminSettingsStore.opsMonitoringEnabled) { if (!adminSettingsStore.opsMonitoringEnabled) {
await router.replace('/admin/settings') await router.replace('/admin/settings')
@@ -637,6 +669,7 @@ async function loadThresholds() {
} }
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
abortDashboardFetch() abortDashboardFetch()
pauseAutoRefresh() pauseAutoRefresh()
pauseCountdown() pauseCountdown()

View File

@@ -25,6 +25,7 @@ interface Props {
thresholds?: OpsMetricThresholds | null // 阈值配置 thresholds?: OpsMetricThresholds | null // 阈值配置
autoRefreshEnabled?: boolean autoRefreshEnabled?: boolean
autoRefreshCountdown?: number autoRefreshCountdown?: number
fullscreen?: boolean
} }
interface Emits { interface Emits {
@@ -37,6 +38,8 @@ interface Emits {
(e: 'openErrorDetails', kind: 'request' | 'upstream'): void (e: 'openErrorDetails', kind: 'request' | 'upstream'): void
(e: 'openSettings'): void (e: 'openSettings'): void
(e: 'openAlertRules'): void (e: 'openAlertRules'): void
(e: 'enterFullscreen'): void
(e: 'exitFullscreen'): void
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -391,15 +394,15 @@ const healthScoreClass = computed(() => {
return 'text-red-500' return 'text-red-500'
}) })
const circleSize = 100 const circleSize = computed(() => props.fullscreen ? 140 : 100)
const strokeWidth = 8 const strokeWidth = computed(() => props.fullscreen ? 10 : 8)
const radius = (circleSize - strokeWidth) / 2 const radius = computed(() => (circleSize.value - strokeWidth.value) / 2)
const circumference = 2 * Math.PI * radius const circumference = computed(() => 2 * Math.PI * radius.value)
const dashOffset = computed(() => { const dashOffset = computed(() => {
if (isSystemIdle.value) return 0 if (isSystemIdle.value) return 0
if (healthScoreValue.value == null) return 0 if (healthScoreValue.value == null) return 0
const score = Math.max(0, Math.min(100, healthScoreValue.value)) const score = Math.max(0, Math.min(100, healthScoreValue.value))
return circumference - (score / 100) * circumference return circumference.value - (score / 100) * circumference.value
}) })
interface DiagnosisItem { interface DiagnosisItem {
@@ -814,7 +817,7 @@ function handleToolbarRefresh() {
</script> </script>
<template> <template>
<div class="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700"> <div :class="['flex flex-col gap-4 rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700', props.fullscreen ? 'p-8' : 'p-6']">
<!-- Top Toolbar --> <!-- Top Toolbar -->
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-4 dark:border-dark-700"> <div class="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-4 dark:border-dark-700">
<div> <div>
@@ -830,7 +833,7 @@ function handleToolbarRefresh() {
{{ t('admin.ops.title') }} {{ t('admin.ops.title') }}
</h1> </h1>
<div class="mt-1 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400"> <div v-if="!props.fullscreen" class="mt-1 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1.5" :title="props.loading ? t('admin.ops.loadingText') : t('admin.ops.ready')"> <span class="flex items-center gap-1.5" :title="props.loading ? t('admin.ops.loadingText') : t('admin.ops.ready')">
<span class="relative flex h-2 w-2"> <span class="relative flex h-2 w-2">
<span class="relative inline-flex h-2 w-2 rounded-full" :class="props.loading ? 'bg-gray-400' : 'bg-green-500'"></span> <span class="relative inline-flex h-2 w-2 rounded-full" :class="props.loading ? 'bg-gray-400' : 'bg-green-500'"></span>
@@ -863,28 +866,30 @@ function handleToolbarRefresh() {
</div> </div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<Select <template v-if="!props.fullscreen">
:model-value="platform" <Select
:options="platformOptions" :model-value="platform"
class="w-full sm:w-[140px]" :options="platformOptions"
@update:model-value="handlePlatformChange" class="w-full sm:w-[140px]"
/> @update:model-value="handlePlatformChange"
/>
<Select <Select
:model-value="groupId" :model-value="groupId"
:options="groupOptions" :options="groupOptions"
class="w-full sm:w-[160px]" class="w-full sm:w-[160px]"
@update:model-value="handleGroupChange" @update:model-value="handleGroupChange"
/> />
<div class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div> <div class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div>
<Select <Select
:model-value="timeRange" :model-value="timeRange"
:options="timeRangeOptions" :options="timeRangeOptions"
class="relative w-full sm:w-[150px]" class="relative w-full sm:w-[150px]"
@update:model-value="handleTimeRangeChange" @update:model-value="handleTimeRangeChange"
/> />
</template>
<Select <Select
v-if="false" v-if="false"
@@ -895,6 +900,7 @@ function handleToolbarRefresh() {
/> />
<button <button
v-if="!props.fullscreen"
type="button" type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-gray-500 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600" class="flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-gray-500 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
:disabled="loading" :disabled="loading"
@@ -911,9 +917,11 @@ function handleToolbarRefresh() {
</svg> </svg>
</button> </button>
<div class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div> <div v-if="!props.fullscreen" class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div>
<!-- Alert Rules Button (hidden in fullscreen) -->
<button <button
v-if="!props.fullscreen"
type="button" type="button"
class="flex h-8 items-center gap-1.5 rounded-lg bg-blue-100 px-3 text-xs font-bold text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50" class="flex h-8 items-center gap-1.5 rounded-lg bg-blue-100 px-3 text-xs font-bold text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
:title="t('admin.ops.alertRules.title')" :title="t('admin.ops.alertRules.title')"
@@ -925,7 +933,9 @@ function handleToolbarRefresh() {
<span class="hidden sm:inline">{{ t('admin.ops.alertRules.manage') }}</span> <span class="hidden sm:inline">{{ t('admin.ops.alertRules.manage') }}</span>
</button> </button>
<!-- Settings Button (hidden in fullscreen) -->
<button <button
v-if="!props.fullscreen"
type="button" type="button"
class="flex h-8 items-center gap-1.5 rounded-lg bg-gray-100 px-3 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600" class="flex h-8 items-center gap-1.5 rounded-lg bg-gray-100 px-3 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:title="t('admin.ops.settings.title')" :title="t('admin.ops.settings.title')"
@@ -937,13 +947,26 @@ function handleToolbarRefresh() {
</svg> </svg>
<span class="hidden sm:inline">{{ t('common.settings') }}</span> <span class="hidden sm:inline">{{ t('common.settings') }}</span>
</button> </button>
<!-- Enter Fullscreen Button (hidden in fullscreen mode) -->
<button
v-if="!props.fullscreen"
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:title="t('admin.ops.fullscreen.enter')"
@click="emit('enterFullscreen')"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
</button>
</div> </div>
</div> </div>
<div v-if="overview" class="grid grid-cols-1 gap-6 lg:grid-cols-12"> <div v-if="overview" class="grid grid-cols-1 gap-6 lg:grid-cols-12">
<!-- Left: Health + Realtime --> <!-- Left: Health + Realtime -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900 lg:col-span-5"> <div :class="['rounded-2xl bg-gray-50 dark:bg-dark-900 lg:col-span-5', props.fullscreen ? 'p-6' : 'p-4']">
<div class="grid grid-cols-1 gap-6 md:grid-cols-[200px_1fr] md:items-center"> <div class="grid h-full grid-cols-1 gap-6 md:grid-cols-[200px_1fr] md:items-center">
<!-- 1) Health Score --> <!-- 1) Health Score -->
<div <div
class="group relative flex cursor-pointer flex-col items-center justify-center rounded-xl py-2 transition-all hover:bg-white/60 dark:hover:bg-dark-800/60 md:border-r md:border-gray-200 md:pr-6 dark:md:border-dark-700" class="group relative flex cursor-pointer flex-col items-center justify-center rounded-xl py-2 transition-all hover:bg-white/60 dark:hover:bg-dark-800/60 md:border-r md:border-gray-200 md:pr-6 dark:md:border-dark-700"
@@ -1026,14 +1049,14 @@ function handleToolbarRefresh() {
</svg> </svg>
<div class="absolute flex flex-col items-center"> <div class="absolute flex flex-col items-center">
<span class="text-3xl font-black" :class="healthScoreClass"> <span :class="[props.fullscreen ? 'text-5xl' : 'text-3xl', 'font-black', healthScoreClass]">
{{ isSystemIdle ? t('admin.ops.idleStatus') : (overview.health_score ?? '--') }} {{ isSystemIdle ? t('admin.ops.idleStatus') : (overview.health_score ?? '--') }}
</span> </span>
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.health') }}</span> <span :class="[props.fullscreen ? 'text-xs' : 'text-[10px]', 'font-bold uppercase tracking-wider text-gray-400']">{{ t('admin.ops.health') }}</span>
</div> </div>
</div> </div>
<div class="mt-4 text-center"> <div class="mt-4 text-center" v-if="!props.fullscreen">
<div class="flex items-center justify-center gap-1 text-xs font-medium text-gray-500"> <div class="flex items-center justify-center gap-1 text-xs font-medium text-gray-500">
{{ t('admin.ops.healthCondition') }} {{ t('admin.ops.healthCondition') }}
<HelpTooltip :content="t('admin.ops.healthHelp')" /> <HelpTooltip :content="t('admin.ops.healthHelp')" />
@@ -1051,7 +1074,7 @@ function handleToolbarRefresh() {
</div> </div>
<!-- 2) Realtime Traffic --> <!-- 2) Realtime Traffic -->
<div class="flex flex-col justify-center py-2"> <div class="flex h-full flex-col justify-center py-2">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2"> <div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="relative flex h-3 w-3 shrink-0"> <div class="relative flex h-3 w-3 shrink-0">
@@ -1059,7 +1082,7 @@ function handleToolbarRefresh() {
<span class="relative inline-flex h-3 w-3 rounded-full bg-blue-500"></span> <span class="relative inline-flex h-3 w-3 rounded-full bg-blue-500"></span>
</div> </div>
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.realtime.title') }}</h3> <h3 class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.realtime.title') }}</h3>
<HelpTooltip :content="t('admin.ops.tooltips.qps')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.qps')" />
</div> </div>
<!-- Time Window Selector --> <!-- Time Window Selector -->
@@ -1079,18 +1102,18 @@ function handleToolbarRefresh() {
</div> </div>
</div> </div>
<div class="space-y-3"> <div :class="props.fullscreen ? 'space-y-4' : 'space-y-3'">
<!-- Row 1: Current --> <!-- Row 1: Current -->
<div> <div>
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.current') }}</div> <div :class="[props.fullscreen ? 'text-xs' : 'text-[10px]', 'font-bold uppercase text-gray-400']">{{ t('admin.ops.current') }}</div>
<div class="mt-1 flex flex-wrap items-baseline gap-x-4 gap-y-2"> <div class="mt-1 flex flex-wrap items-baseline gap-x-4 gap-y-2">
<div class="flex items-baseline gap-1.5"> <div class="flex items-baseline gap-1.5">
<span class="text-xl font-black text-gray-900 dark:text-white sm:text-2xl">{{ displayRealTimeQps.toFixed(1) }}</span> <span :class="[props.fullscreen ? 'text-4xl' : 'text-xl sm:text-2xl', 'font-black text-gray-900 dark:text-white']">{{ displayRealTimeQps.toFixed(1) }}</span>
<span class="text-xs font-bold text-gray-500">QPS</span> <span :class="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">QPS</span>
</div> </div>
<div class="flex items-baseline gap-1.5"> <div class="flex items-baseline gap-1.5">
<span class="text-xl font-black text-gray-900 dark:text-white sm:text-2xl">{{ displayRealTimeTps.toFixed(1) }}</span> <span :class="[props.fullscreen ? 'text-4xl' : 'text-xl sm:text-2xl', 'font-black text-gray-900 dark:text-white']">{{ displayRealTimeTps.toFixed(1) }}</span>
<span class="text-xs font-bold text-gray-500">TPS</span> <span :class="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">TPS</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1099,8 +1122,8 @@ function handleToolbarRefresh() {
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<!-- Peak --> <!-- Peak -->
<div> <div>
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.peak') }}</div> <div :class="[props.fullscreen ? 'text-xs' : 'text-[10px]', 'font-bold uppercase text-gray-400']">{{ t('admin.ops.peak') }}</div>
<div class="mt-1 space-y-0.5 text-sm font-medium text-gray-600 dark:text-gray-400"> <div :class="[props.fullscreen ? 'text-base' : 'text-sm', 'mt-1 space-y-0.5 font-medium text-gray-600 dark:text-gray-400']">
<div class="flex items-baseline gap-1.5"> <div class="flex items-baseline gap-1.5">
<span class="font-black text-gray-900 dark:text-white">{{ realtimeQpsPeakLabel }}</span> <span class="font-black text-gray-900 dark:text-white">{{ realtimeQpsPeakLabel }}</span>
<span class="text-xs">QPS</span> <span class="text-xs">QPS</span>
@@ -1114,8 +1137,8 @@ function handleToolbarRefresh() {
<!-- Average --> <!-- Average -->
<div> <div>
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.average') }}</div> <div :class="[props.fullscreen ? 'text-xs' : 'text-[10px]', 'font-bold uppercase text-gray-400']">{{ t('admin.ops.average') }}</div>
<div class="mt-1 space-y-0.5 text-sm font-medium text-gray-600 dark:text-gray-400"> <div :class="[props.fullscreen ? 'text-base' : 'text-sm', 'mt-1 space-y-0.5 font-medium text-gray-600 dark:text-gray-400']">
<div class="flex items-baseline gap-1.5"> <div class="flex items-baseline gap-1.5">
<span class="font-black text-gray-900 dark:text-white">{{ realtimeQpsAvgLabel }}</span> <span class="font-black text-gray-900 dark:text-white">{{ realtimeQpsAvgLabel }}</span>
<span class="text-xs">QPS</span> <span class="text-xs">QPS</span>
@@ -1156,15 +1179,16 @@ function handleToolbarRefresh() {
</div> </div>
<!-- Right: 6 cards (3 cols x 2 rows) --> <!-- Right: 6 cards (3 cols x 2 rows) -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:col-span-7 lg:grid-cols-3"> <div class="grid h-full grid-cols-1 content-center gap-4 sm:grid-cols-2 lg:col-span-7 lg:grid-cols-3">
<!-- Card 1: Requests --> <!-- Card 1: Requests -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900"> <div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestsTitle') }}</span> <span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestsTitle') }}</span>
<HelpTooltip :content="t('admin.ops.tooltips.totalRequests')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.totalRequests')" />
</div> </div>
<button <button
v-if="!props.fullscreen"
class="text-[10px] font-bold text-blue-500 hover:underline" class="text-[10px] font-bold text-blue-500 hover:underline"
type="button" type="button"
@click="openDetails({ title: t('admin.ops.requestDetails.title') })" @click="openDetails({ title: t('admin.ops.requestDetails.title') })"
@@ -1197,10 +1221,11 @@ function handleToolbarRefresh() {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-[10px] font-bold uppercase text-gray-400">SLA</span> <span class="text-[10px] font-bold uppercase text-gray-400">SLA</span>
<HelpTooltip :content="t('admin.ops.tooltips.sla')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.sla')" />
<span class="h-1.5 w-1.5 rounded-full" :class="isSLABelowThreshold(slaPercent) ? 'bg-red-500' : (slaPercent ?? 0) >= 99.5 ? 'bg-green-500' : 'bg-yellow-500'"></span> <span class="h-1.5 w-1.5 rounded-full" :class="isSLABelowThreshold(slaPercent) ? 'bg-red-500' : (slaPercent ?? 0) >= 99.5 ? 'bg-green-500' : 'bg-yellow-500'"></span>
</div> </div>
<button <button
v-if="!props.fullscreen"
class="text-[10px] font-bold text-blue-500 hover:underline" class="text-[10px] font-bold text-blue-500 hover:underline"
type="button" type="button"
@click="openDetails({ title: t('admin.ops.requestDetails.title'), kind: 'error' })" @click="openDetails({ title: t('admin.ops.requestDetails.title'), kind: 'error' })"
@@ -1227,9 +1252,10 @@ function handleToolbarRefresh() {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.latencyDuration') }}</span> <span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.latencyDuration') }}</span>
<HelpTooltip :content="t('admin.ops.tooltips.latency')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.latency')" />
</div> </div>
<button <button
v-if="!props.fullscreen"
class="text-[10px] font-bold text-blue-500 hover:underline" class="text-[10px] font-bold text-blue-500 hover:underline"
type="button" type="button"
@click="openDetails({ title: t('admin.ops.latencyDuration'), sort: 'duration_desc', min_duration_ms: Math.max(Number(durationP99Ms ?? 0), 0) })" @click="openDetails({ title: t('admin.ops.latencyDuration'), sort: 'duration_desc', min_duration_ms: Math.max(Number(durationP99Ms ?? 0), 0) })"
@@ -1277,9 +1303,10 @@ function handleToolbarRefresh() {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">TTFT</span> <span class="text-[10px] font-bold uppercase text-gray-400">TTFT</span>
<HelpTooltip :content="t('admin.ops.tooltips.ttft')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.ttft')" />
</div> </div>
<button <button
v-if="!props.fullscreen"
class="text-[10px] font-bold text-blue-500 hover:underline" class="text-[10px] font-bold text-blue-500 hover:underline"
type="button" type="button"
@click="openDetails({ title: 'TTFT', sort: 'duration_desc' })" @click="openDetails({ title: 'TTFT', sort: 'duration_desc' })"
@@ -1327,9 +1354,9 @@ function handleToolbarRefresh() {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestErrors') }}</span> <span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestErrors') }}</span>
<HelpTooltip :content="t('admin.ops.tooltips.errors')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.errors')" />
</div> </div>
<button class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openErrorDetails('request')"> <button v-if="!props.fullscreen" class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openErrorDetails('request')">
{{ t('admin.ops.requestDetails.details') }} {{ t('admin.ops.requestDetails.details') }}
</button> </button>
</div> </div>
@@ -1353,9 +1380,9 @@ function handleToolbarRefresh() {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.upstreamErrors') }}</span> <span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.upstreamErrors') }}</span>
<HelpTooltip :content="t('admin.ops.tooltips.upstreamErrors')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.upstreamErrors')" />
</div> </div>
<button class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openErrorDetails('upstream')"> <button v-if="!props.fullscreen" class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openErrorDetails('upstream')">
{{ t('admin.ops.requestDetails.details') }} {{ t('admin.ops.requestDetails.details') }}
</button> </button>
</div> </div>
@@ -1383,12 +1410,12 @@ function handleToolbarRefresh() {
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900"> <div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">CPU</div> <div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">CPU</div>
<HelpTooltip :content="t('admin.ops.tooltips.cpu')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.cpu')" />
</div> </div>
<div class="mt-1 text-lg font-black" :class="cpuPercentClass"> <div class="mt-1 text-lg font-black" :class="cpuPercentClass">
{{ cpuPercentValue == null ? '-' : `${cpuPercentValue.toFixed(1)}%` }} {{ cpuPercentValue == null ? '-' : `${cpuPercentValue.toFixed(1)}%` }}
</div> </div>
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400"> <div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('common.warning') }} 80% · {{ t('common.critical') }} 95% {{ t('common.warning') }} 80% · {{ t('common.critical') }} 95%
</div> </div>
</div> </div>
@@ -1397,12 +1424,12 @@ function handleToolbarRefresh() {
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900"> <div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">MEM</div> <div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">MEM</div>
<HelpTooltip :content="t('admin.ops.tooltips.memory')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.memory')" />
</div> </div>
<div class="mt-1 text-lg font-black" :class="memPercentClass"> <div class="mt-1 text-lg font-black" :class="memPercentClass">
{{ memPercentValue == null ? '-' : `${memPercentValue.toFixed(1)}%` }} {{ memPercentValue == null ? '-' : `${memPercentValue.toFixed(1)}%` }}
</div> </div>
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400"> <div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ {{
systemMetrics?.memory_used_mb == null || systemMetrics?.memory_total_mb == null systemMetrics?.memory_used_mb == null || systemMetrics?.memory_total_mb == null
? '-' ? '-'
@@ -1415,12 +1442,12 @@ function handleToolbarRefresh() {
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900"> <div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">DB</div> <div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">DB</div>
<HelpTooltip :content="t('admin.ops.tooltips.db')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.db')" />
</div> </div>
<div class="mt-1 text-lg font-black" :class="dbMiddleClass"> <div class="mt-1 text-lg font-black" :class="dbMiddleClass">
{{ dbMiddleLabel }} {{ dbMiddleLabel }}
</div> </div>
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400"> <div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('admin.ops.conns') }} {{ dbConnOpenValue ?? '-' }} / {{ dbMaxOpenConnsValue ?? '-' }} {{ t('admin.ops.conns') }} {{ dbConnOpenValue ?? '-' }} / {{ dbMaxOpenConnsValue ?? '-' }}
· {{ t('admin.ops.active') }} {{ dbConnActiveValue ?? '-' }} · {{ t('admin.ops.active') }} {{ dbConnActiveValue ?? '-' }}
· {{ t('admin.ops.idle') }} {{ dbConnIdleValue ?? '-' }} · {{ t('admin.ops.idle') }} {{ dbConnIdleValue ?? '-' }}
@@ -1432,12 +1459,12 @@ function handleToolbarRefresh() {
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900"> <div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">Redis</div> <div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">Redis</div>
<HelpTooltip :content="t('admin.ops.tooltips.redis')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.redis')" />
</div> </div>
<div class="mt-1 text-lg font-black" :class="redisMiddleClass"> <div class="mt-1 text-lg font-black" :class="redisMiddleClass">
{{ redisMiddleLabel }} {{ redisMiddleLabel }}
</div> </div>
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400"> <div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('admin.ops.conns') }} {{ redisConnTotalValue ?? '-' }} / {{ redisPoolSizeValue ?? '-' }} {{ t('admin.ops.conns') }} {{ redisConnTotalValue ?? '-' }} / {{ redisPoolSizeValue ?? '-' }}
<span v-if="redisConnActiveValue != null"> · {{ t('admin.ops.active') }} {{ redisConnActiveValue }} </span> <span v-if="redisConnActiveValue != null"> · {{ t('admin.ops.active') }} {{ redisConnActiveValue }} </span>
<span v-if="redisConnIdleValue != null"> · {{ t('admin.ops.idle') }} {{ redisConnIdleValue }} </span> <span v-if="redisConnIdleValue != null"> · {{ t('admin.ops.idle') }} {{ redisConnIdleValue }} </span>
@@ -1448,12 +1475,12 @@ function handleToolbarRefresh() {
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900"> <div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.goroutines') }}</div> <div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.goroutines') }}</div>
<HelpTooltip :content="t('admin.ops.tooltips.goroutines')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.goroutines')" />
</div> </div>
<div class="mt-1 text-lg font-black" :class="goroutineStatusClass"> <div class="mt-1 text-lg font-black" :class="goroutineStatusClass">
{{ goroutineStatusLabel }} {{ goroutineStatusLabel }}
</div> </div>
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400"> <div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('admin.ops.current') }} <span class="font-mono">{{ goroutineCountValue ?? '-' }}</span> {{ t('admin.ops.current') }} <span class="font-mono">{{ goroutineCountValue ?? '-' }}</span>
· {{ t('common.warning') }} <span class="font-mono">{{ goroutinesWarnThreshold }}</span> · {{ t('common.warning') }} <span class="font-mono">{{ goroutinesWarnThreshold }}</span>
· {{ t('common.critical') }} <span class="font-mono">{{ goroutinesCriticalThreshold }}</span> · {{ t('common.critical') }} <span class="font-mono">{{ goroutinesCriticalThreshold }}</span>
@@ -1468,9 +1495,9 @@ function handleToolbarRefresh() {
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.jobs') }}</div> <div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.jobs') }}</div>
<HelpTooltip :content="t('admin.ops.tooltips.jobs')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.jobs')" />
</div> </div>
<button class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openJobsDetails"> <button v-if="!props.fullscreen" class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openJobsDetails">
{{ t('admin.ops.requestDetails.details') }} {{ t('admin.ops.requestDetails.details') }}
</button> </button>
</div> </div>
@@ -1479,7 +1506,7 @@ function handleToolbarRefresh() {
{{ jobsStatusLabel }} {{ jobsStatusLabel }}
</div> </div>
<div class="mt-1 text-[10px] text-gray-500 dark:text-gray-400"> <div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('common.total') }} <span class="font-mono">{{ jobHeartbeats.length }}</span> {{ t('common.total') }} <span class="font-mono">{{ jobHeartbeats.length }}</span>
· {{ t('common.warning') }} <span class="font-mono">{{ jobsWarnCount }}</span> · {{ t('common.warning') }} <span class="font-mono">{{ jobsWarnCount }}</span>
</div> </div>

View File

@@ -19,6 +19,7 @@ interface Props {
timeRange: string timeRange: string
byPlatform?: OpsThroughputPlatformBreakdownItem[] byPlatform?: OpsThroughputPlatformBreakdownItem[]
topGroups?: OpsThroughputGroupBreakdownItem[] topGroups?: OpsThroughputGroupBreakdownItem[]
fullscreen?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -179,38 +180,40 @@ function downloadChart() {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg> </svg>
{{ t('admin.ops.throughputTrend') }} {{ t('admin.ops.throughputTrend') }}
<HelpTooltip :content="t('admin.ops.tooltips.throughputTrend')" /> <HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.throughputTrend')" />
</h3> </h3>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400"> <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>{{ t('admin.ops.qps') }}</span> <span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>{{ t('admin.ops.qps') }}</span>
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-green-500"></span>{{ t('admin.ops.tpsK') }}</span> <span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-green-500"></span>{{ t('admin.ops.tpsK') }}</span>
<button <template v-if="!props.fullscreen">
type="button" <button
class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800" type="button"
:disabled="state !== 'ready'" class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:title="t('admin.ops.requestDetails.title')" :disabled="state !== 'ready'"
@click="emit('openDetails')" :title="t('admin.ops.requestDetails.title')"
> @click="emit('openDetails')"
{{ t('admin.ops.requestDetails.details') }} >
</button> {{ t('admin.ops.requestDetails.details') }}
<button </button>
type="button" <button
class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800" type="button"
:disabled="state !== 'ready'" class="ml-2 inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:title="t('admin.ops.charts.resetZoomHint')" :disabled="state !== 'ready'"
@click="resetZoom" :title="t('admin.ops.charts.resetZoomHint')"
> @click="resetZoom"
{{ t('admin.ops.charts.resetZoom') }} >
</button> {{ t('admin.ops.charts.resetZoom') }}
<button </button>
type="button" <button
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800" type="button"
:disabled="state !== 'ready'" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:title="t('admin.ops.charts.downloadChartHint')" :disabled="state !== 'ready'"
@click="downloadChart" :title="t('admin.ops.charts.downloadChartHint')"
> @click="downloadChart"
{{ t('admin.ops.charts.downloadChart') }} >
</button> {{ t('admin.ops.charts.downloadChart') }}
</button>
</template>
</div> </div>
</div> </div>