diff --git a/internal/adapter/claude/handler_util_test.go b/internal/adapter/claude/handler_util_test.go index b6c009a..136f1ce 100644 --- a/internal/adapter/claude/handler_util_test.go +++ b/internal/adapter/claude/handler_util_test.go @@ -128,6 +128,9 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) { if !containsStr(prompt, "tool_use") { t.Fatalf("expected tool_use instruction in prompt") } + if !containsStr(prompt, "Never output [TOOL_CALL_HISTORY] or [TOOL_RESULT_HISTORY] markers yourself") { + t.Fatalf("expected marker guard instruction in prompt") + } if containsStr(prompt, "tool_calls") { t.Fatalf("expected prompt to avoid tool_calls JSON instruction") } diff --git a/internal/adapter/claude/handler_utils.go b/internal/adapter/claude/handler_utils.go index 2f0c08a..0a1fa75 100644 --- a/internal/adapter/claude/handler_utils.go +++ b/internal/adapter/claude/handler_utils.go @@ -54,6 +54,7 @@ func buildClaudeToolPrompt(tools []any) string { "When you need a tool, respond with Claude-native tool use (tool_use) using the provided tool schema. Do not print tool-call JSON in text.", "History markers in conversation: [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] are your previous tool calls; [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] are runtime tool outputs, not user input.", "After a valid [TOOL_RESULT_HISTORY], continue with final answer instead of repeating the same call unless required fields are still missing.", + "Never output [TOOL_CALL_HISTORY] or [TOOL_RESULT_HISTORY] markers yourself; they are system-side context only.", ) return strings.Join(parts, "\n\n") } diff --git a/internal/adapter/openai/handler_toolcall_format.go b/internal/adapter/openai/handler_toolcall_format.go index 3adfd15..7f2a340 100644 --- a/internal/adapter/openai/handler_toolcall_format.go +++ b/internal/adapter/openai/handler_toolcall_format.go @@ -53,7 +53,7 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh if len(toolSchemas) == 0 { return messages, names } - toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY a JSON code block like this:\n```json\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n```\n\n【EXAMPLE】\nUser: Please check the weather in Beijing and Shanghai, and update my todo list.\nAssistant:\n```json\n{\"tool_calls\": [\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Beijing\"}},\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Shanghai\"}},\n {\"name\": \"update_todo\", \"input\": {\"todos\": [{\"content\": \"Buy milk\"}, {\"content\": \"Write report\"}]}}\n]}\n```\n\nHistory markers in conversation:\n- [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] means a tool call you already made earlier.\n- [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] means the runtime returned a tool result (not user input).\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON code block. The response must start with ```json and end with ```.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error.\n4) Do not repeat a tool call that is already satisfied by an existing [TOOL_RESULT_HISTORY] block.\n5) JSON SYNTAX STRICTLY REQUIRED: All property names MUST be enclosed in double quotes (e.g., \"name\", not name).\n6) ARRAY FORMAT: If providing a list of items, you MUST enclose them in square brackets `[]` (e.g., \"todos\": [{\"item\": \"a\"}, {\"item\": \"b\"}]). DO NOT output comma-separated objects without brackets." + toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY a JSON code block like this:\n```json\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n```\n\n【EXAMPLE】\nUser: Please check the weather in Beijing and Shanghai, and update my todo list.\nAssistant:\n```json\n{\"tool_calls\": [\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Beijing\"}},\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Shanghai\"}},\n {\"name\": \"update_todo\", \"input\": {\"todos\": [{\"content\": \"Buy milk\"}, {\"content\": \"Write report\"}]}}\n]}\n```\n\nHistory markers in conversation:\n- [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] means a tool call you already made earlier.\n- [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] means the runtime returned a tool result (not user input).\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON code block. The response must start with ```json and end with ```.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error.\n4) Do not repeat a tool call that is already satisfied by an existing [TOOL_RESULT_HISTORY] block.\n5) Never output [TOOL_CALL_HISTORY] or [TOOL_RESULT_HISTORY] markers in your answer; these markers are system-side context only.\n6) JSON SYNTAX STRICTLY REQUIRED: All property names MUST be enclosed in double quotes (e.g., \"name\", not name).\n7) ARRAY FORMAT: If providing a list of items, you MUST enclose them in square brackets `[]` (e.g., \"todos\": [{\"item\": \"a\"}, {\"item\": \"b\"}]). DO NOT output comma-separated objects without brackets." if policy.Mode == util.ToolChoiceRequired { toolPrompt += "\n5) For this response, you MUST call at least one tool from the allowed list." } diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/adapter/openai/handler_toolcall_test.go index ef22803..f85ad48 100644 --- a/internal/adapter/openai/handler_toolcall_test.go +++ b/internal/adapter/openai/handler_toolcall_test.go @@ -651,6 +651,48 @@ func TestHandleStreamFencedToolCallSnippetPromotesToolCall(t *testing.T) { if strings.Contains(strings.ToLower(got), "tool_calls") { t.Fatalf("expected raw fenced tool_calls snippet stripped from content, got=%q", got) } + if strings.Contains(strings.ToLower(got), "```json") || strings.Contains(got, "\n```\n") { + t.Fatalf("expected consumed fenced tool payload to not leave empty code fence, got=%q", got) + } + if streamFinishReason(frames) != "tool_calls" { + t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String()) + } +} + +func TestHandleStreamStandaloneToolCallAfterClosedFenceKeepsFence(t *testing.T) { + h := &Handler{} + resp := makeSSEHTTPResponse( + fmt.Sprintf(`data: {"p":"response/content","v":%q}`, "先给一个代码示例:\n```text\nhello\n```\n"), + fmt.Sprintf(`data: {"p":"response/content","v":%q}`, "{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"), + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + h.handleStream(rec, req, resp, "cid7g", "deepseek-chat", "prompt", false, false, []string{"search"}) + + frames, done := parseSSEDataFrames(t, rec.Body.String()) + if !done { + t.Fatalf("expected [DONE], body=%s", rec.Body.String()) + } + if !streamHasToolCallsDelta(frames) { + t.Fatalf("expected tool_calls delta for standalone payload, body=%s", rec.Body.String()) + } + content := strings.Builder{} + for _, frame := range frames { + choices, _ := frame["choices"].([]any) + for _, item := range choices { + choice, _ := item.(map[string]any) + delta, _ := choice["delta"].(map[string]any) + if c, ok := delta["content"].(string); ok { + content.WriteString(c) + } + } + } + got := content.String() + if !strings.Contains(got, "```") { + t.Fatalf("expected closed fence before standalone tool json to be preserved, got=%q", got) + } if streamFinishReason(frames) != "tool_calls" { t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String()) } diff --git a/internal/adapter/openai/prompt_build_test.go b/internal/adapter/openai/prompt_build_test.go index bd6223e..09b3a10 100644 --- a/internal/adapter/openai/prompt_build_test.go +++ b/internal/adapter/openai/prompt_build_test.go @@ -80,4 +80,7 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t * if !strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") { t.Fatalf("vercel prepare finalPrompt missing history marker instruction: %q", finalPrompt) } + if !strings.Contains(finalPrompt, "Never output [TOOL_CALL_HISTORY] or [TOOL_RESULT_HISTORY] markers in your answer") { + t.Fatalf("vercel prepare finalPrompt missing marker-output guard instruction: %q", finalPrompt) + } } diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index ca2223a..7618b01 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -182,6 +182,9 @@ func findToolSegmentStart(s string) int { if start < 0 { start = bestKeyIdx } + if fenceStart, ok := openFenceStartBefore(s, start); ok { + return fenceStart + } return start } @@ -191,7 +194,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix return "", nil, "", false } lower := strings.ToLower(captured) - + keyIdx := -1 keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"} for _, kw := range keywords { @@ -200,7 +203,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix keyIdx = idx } } - + if keyIdx < 0 { return "", nil, "", false } @@ -226,5 +229,45 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix // For now, keep the original logic but rely on loose JSON repair. return captured, nil, "", true } + prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart) return prefixPart, parsed.Calls, suffixPart, true } + +func trimWrappingJSONFence(prefix, suffix string) (string, string) { + trimmedPrefix := strings.TrimRight(prefix, " \t\r\n") + fenceIdx := strings.LastIndex(trimmedPrefix, "```") + if fenceIdx < 0 { + return prefix, suffix + } + // Only strip when the trailing fence in prefix behaves like an opening fence. + // A legitimate closing fence before a standalone tool JSON must be preserved. + if strings.Count(trimmedPrefix[:fenceIdx+3], "```")%2 == 0 { + return prefix, suffix + } + fenceHeader := strings.TrimSpace(trimmedPrefix[fenceIdx+3:]) + if fenceHeader != "" && !strings.EqualFold(fenceHeader, "json") { + return prefix, suffix + } + + trimmedSuffix := strings.TrimLeft(suffix, " \t\r\n") + if !strings.HasPrefix(trimmedSuffix, "```") { + return prefix, suffix + } + consumedLeading := len(suffix) - len(trimmedSuffix) + return trimmedPrefix[:fenceIdx], suffix[consumedLeading+3:] +} + +func openFenceStartBefore(s string, pos int) (int, bool) { + if pos <= 0 || pos > len(s) { + return -1, false + } + segment := s[:pos] + lastFence := strings.LastIndex(segment, "```") + if lastFence < 0 { + return -1, false + } + if strings.Count(segment, "```")%2 == 1 { + return lastFence, true + } + return -1, false +} diff --git a/internal/admin/handler.go b/internal/admin/handler.go index fc20533..125abd0 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -36,6 +36,7 @@ func RegisterRoutes(r chi.Router, h *Handler) { pr.Post("/test", h.testAPI) pr.Post("/vercel/sync", h.syncVercel) pr.Get("/vercel/status", h.vercelStatus) + pr.Post("/vercel/status", h.vercelStatus) pr.Get("/export", h.exportConfig) pr.Get("/dev/captures", h.getDevCaptures) pr.Delete("/dev/captures", h.clearDevCaptures) diff --git a/internal/admin/handler_config_read.go b/internal/admin/handler_config_read.go index e32aabd..9839887 100644 --- a/internal/admin/handler_config_read.go +++ b/internal/admin/handler_config_read.go @@ -8,8 +8,9 @@ import ( func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) { snap := h.Store.Snapshot() safe := map[string]any{ - "keys": snap.Keys, - "accounts": []map[string]any{}, + "keys": snap.Keys, + "accounts": []map[string]any{}, + "env_backed": h.Store.IsEnvBacked(), "claude_mapping": func() map[string]string { if len(snap.ClaudeMapping) > 0 { return snap.ClaudeMapping diff --git a/internal/admin/handler_vercel.go b/internal/admin/handler_vercel.go index 2c6356c..5f66a68 100644 --- a/internal/admin/handler_vercel.go +++ b/internal/admin/handler_vercel.go @@ -3,6 +3,8 @@ package admin import ( "bytes" "context" + "crypto/md5" + "encoding/base64" "encoding/json" "fmt" "io" @@ -11,6 +13,8 @@ import ( "os" "strings" "time" + + "ds2api/internal/config" ) func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) { @@ -25,7 +29,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) { return } validated, failed := h.validateAccountsForVercelSync(r.Context(), opts.AutoValidate) - _, cfgB64, err := h.Store.ExportJSONAndBase64() + cfgJSON, cfgB64, err := h.exportSyncConfig(req) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()}) return @@ -47,7 +51,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) { } savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs) manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers) - _ = h.Store.SetVercelSync(h.computeSyncHash(), time.Now().Unix()) + _ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix()) result := map[string]any{"success": true, "validated_accounts": validated} if manual { result["message"] = "配置已同步到 Vercel,请手动触发重新部署" @@ -209,11 +213,71 @@ func triggerVercelDeployment(ctx context.Context, client *http.Client, projectID return false, deployURL } -func (h *Handler) vercelStatus(w http.ResponseWriter, _ *http.Request) { +func (h *Handler) vercelStatus(w http.ResponseWriter, r *http.Request) { snap := h.Store.Snapshot() current := h.computeSyncHash() synced := snap.VercelSyncHash != "" && snap.VercelSyncHash == current - writeJSON(w, http.StatusOK, map[string]any{"synced": synced, "last_sync_time": nilIfZero(snap.VercelSyncTime), "has_synced_before": snap.VercelSyncHash != ""}) + draftHash := "" + draftDiffers := false + if r != nil && r.Method == http.MethodPost && r.Body != nil { + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err == nil { + if cfgJSON, _, err := h.exportSyncConfig(req); err == nil { + draftHash = syncHashForJSON(cfgJSON) + draftDiffers = draftHash != "" && draftHash != current + } + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "synced": synced, + "last_sync_time": nilIfZero(snap.VercelSyncTime), + "has_synced_before": snap.VercelSyncHash != "", + "env_backed": h.Store.IsEnvBacked(), + "config_hash": current, + "last_synced_hash": snap.VercelSyncHash, + "draft_hash": draftHash, + "draft_differs": draftDiffers, + }) +} + +func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) { + override, ok := req["config_override"] + if !ok || override == nil { + return h.Store.ExportJSONAndBase64() + } + raw, err := json.Marshal(override) + if err != nil { + return "", "", err + } + var cfg config.Config + if err := json.Unmarshal(raw, &cfg); err != nil { + return "", "", err + } + cfg.DropInvalidAccounts() + cfg.ClearAccountTokens() + cfg.VercelSyncHash = "" + cfg.VercelSyncTime = 0 + b, err := json.Marshal(cfg) + if err != nil { + return "", "", err + } + return string(b), base64.StdEncoding.EncodeToString(b), nil +} + +func syncHashForJSON(s string) string { + var cfg config.Config + if err := json.Unmarshal([]byte(s), &cfg); err != nil { + return "" + } + cfg.VercelSyncHash = "" + cfg.VercelSyncTime = 0 + cfg.ClearAccountTokens() + b, err := json.Marshal(cfg) + if err != nil { + return "" + } + sum := md5.Sum(b) + return fmt.Sprintf("%x", sum) } func vercelRequest(ctx context.Context, client *http.Client, method, endpoint string, params url.Values, headers map[string]string, body any) (map[string]any, int, error) { diff --git a/internal/js/helpers/stream-tool-sieve/sieve.js b/internal/js/helpers/stream-tool-sieve/sieve.js index ae95ffd..12534f9 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve.js +++ b/internal/js/helpers/stream-tool-sieve/sieve.js @@ -256,11 +256,40 @@ function consumeToolCapture(state, toolNames) { }; } + const trimmedFence = trimWrappingJSONFence(prefixPart, suffixPart); return { ready: true, - prefix: prefixPart, + prefix: trimmedFence.prefix, calls: parsed.calls, - suffix: suffixPart, + suffix: trimmedFence.suffix, + }; +} + +function trimWrappingJSONFence(prefix, suffix) { + const rightTrimmedPrefix = (prefix || '').replace(/[ \t\r\n]+$/g, ''); + const fenceIdx = rightTrimmedPrefix.lastIndexOf('```'); + if (fenceIdx < 0) { + return { prefix, suffix }; + } + // Only strip when this behaves like an opening fence. + // If it's a legitimate closing fence before standalone tool JSON, keep it. + const fenceCount = (rightTrimmedPrefix.slice(0, fenceIdx + 3).match(/```/g) || []).length; + if (fenceCount % 2 === 0) { + return { prefix, suffix }; + } + const header = rightTrimmedPrefix.slice(fenceIdx + 3).trim().toLowerCase(); + if (header && header !== 'json') { + return { prefix, suffix }; + } + + const leftTrimmedSuffix = (suffix || '').replace(/^[ \t\r\n]+/g, ''); + if (!leftTrimmedSuffix.startsWith('```')) { + return { prefix, suffix }; + } + const consumed = (suffix || '').length - leftTrimmedSuffix.length; + return { + prefix: rightTrimmedPrefix.slice(0, fenceIdx), + suffix: (suffix || '').slice(consumed + 3), }; } diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index 498155f..e352ca7 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -286,6 +286,18 @@ test('sieve emits tool_calls and keeps trailing prose when payload and prose sha assert.equal(leakedText.toLowerCase().includes('tool_calls'), false); }); +test('sieve preserves closed fence before standalone tool payload', () => { + const events = runSieve( + ['先给一个代码示例:\n```text\nhello\n```\n{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}'], + ['read_file'], + ); + const hasTool = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0); + const leakedText = collectText(events); + assert.equal(hasTool, true); + assert.equal(leakedText.includes('```'), true); + assert.equal(leakedText.toLowerCase().includes('tool_calls'), false); +}); + test('formatOpenAIStreamToolCalls reuses ids with the same idStore', () => { const idStore = new Map(); const calls = [{ name: 'read_file', input: { path: 'README.MD' } }]; diff --git a/webui/src/app/useAdminConfig.js b/webui/src/app/useAdminConfig.js index 3fa410d..7b5c645 100644 --- a/webui/src/app/useAdminConfig.js +++ b/webui/src/app/useAdminConfig.js @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState } from 'react' +const ENV_DRAFT_KEY = 'ds2api_env_config_draft_v1' + export function useAdminConfig({ token, showMessage, t }) { const [config, setConfig] = useState({ keys: [], accounts: [] }) @@ -11,6 +13,11 @@ export function useAdminConfig({ token, showMessage, t }) { }) if (res.ok) { const data = await res.json() + if (data?.env_backed) { + localStorage.setItem(ENV_DRAFT_KEY, JSON.stringify(data)) + } else { + localStorage.removeItem(ENV_DRAFT_KEY) + } setConfig(data) } } catch (e) { @@ -21,6 +28,17 @@ export function useAdminConfig({ token, showMessage, t }) { useEffect(() => { if (token) { + const rawDraft = localStorage.getItem(ENV_DRAFT_KEY) + if (rawDraft) { + try { + const draft = JSON.parse(rawDraft) + if (draft?.env_backed) { + setConfig(draft) + } + } catch (_e) { + localStorage.removeItem(ENV_DRAFT_KEY) + } + } fetchConfig() } }, [fetchConfig, token]) diff --git a/webui/src/features/account/AccountManagerContainer.jsx b/webui/src/features/account/AccountManagerContainer.jsx index 8300df7..6f70b66 100644 --- a/webui/src/features/account/AccountManagerContainer.jsx +++ b/webui/src/features/account/AccountManagerContainer.jsx @@ -101,6 +101,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, onPageSizeChange={changePageSize} searchQuery={searchQuery} onSearchChange={handleSearchChange} + envBacked={Boolean(config?.env_backed)} /> 0 ? ( accounts.map((acc, i) => { const id = resolveAccountIdentifier(acc) + const runtimeUnknown = envBacked && !acc.test_status + const isActive = acc.test_status === 'ok' || acc.has_token return (
- {acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')} + {acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')} {acc.token_preview && ( {acc.token_preview} diff --git a/webui/src/features/vercel/VercelSyncContainer.jsx b/webui/src/features/vercel/VercelSyncContainer.jsx index 87f833d..5acfaa8 100644 --- a/webui/src/features/vercel/VercelSyncContainer.jsx +++ b/webui/src/features/vercel/VercelSyncContainer.jsx @@ -4,7 +4,7 @@ import VercelSyncForm from './VercelSyncForm' import VercelSyncStatus from './VercelSyncStatus' import VercelGuide from './VercelGuide' -export default function VercelSyncContainer({ onMessage, authFetch, isVercel = false }) { +export default function VercelSyncContainer({ onMessage, authFetch, isVercel = false, config = null }) { const { t } = useI18n() const apiFetch = authFetch || fetch @@ -28,6 +28,7 @@ export default function VercelSyncContainer({ onMessage, authFetch, isVercel = f onMessage, t, isVercel, + config, }) return ( diff --git a/webui/src/features/vercel/VercelSyncForm.jsx b/webui/src/features/vercel/VercelSyncForm.jsx index fb1724e..a394435 100644 --- a/webui/src/features/vercel/VercelSyncForm.jsx +++ b/webui/src/features/vercel/VercelSyncForm.jsx @@ -69,6 +69,11 @@ export default function VercelSyncForm({ {t('vercel.lastSyncTime', { time: new Date(syncStatus.last_sync_time * 1000).toLocaleString() })}

)} + {syncStatus?.draft_differs && ( +

+ {t('vercel.draftDiffers')} +

+ )}
diff --git a/webui/src/layout/DashboardShell.jsx b/webui/src/layout/DashboardShell.jsx index 5e91f8a..b0dc5ea 100644 --- a/webui/src/layout/DashboardShell.jsx +++ b/webui/src/layout/DashboardShell.jsx @@ -79,7 +79,7 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s case 'import': return case 'vercel': - return + return case 'settings': return default: diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 2f0adf0..cfa68ca 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -115,6 +115,7 @@ "testingAllAccounts": "Refreshing tokens for all accounts...", "sessionActive": "Session active", "reauthRequired": "Re-auth required", + "runtimeStatusUnknown": "Will be determined after sync", "testStatusFailed": "Last test failed", "noAccounts": "No accounts found.", "modalAddKeyTitle": "Add API key", @@ -294,6 +295,7 @@ "statusNotSynced": "Not synced", "statusNeverSynced": "Never synced", "lastSyncTime": "Last sync: {time}", + "draftDiffers": "Frontend draft differs from env config. Click Sync & redeploy.", "pollPaused": "Status polling paused after {count} failures.", "manualRefresh": "Refresh manually", "howItWorks": "How it works", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 3a3a6e8..b5cf456 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -115,6 +115,7 @@ "testingAllAccounts": "正在刷新所有账号 Token...", "sessionActive": "已建立会话", "reauthRequired": "需重新登录", + "runtimeStatusUnknown": "状态以同步后为准", "testStatusFailed": "上次测试失败", "noAccounts": "未找到任何账号", "modalAddKeyTitle": "添加 API 密钥", @@ -294,6 +295,7 @@ "statusNotSynced": "未同步", "statusNeverSynced": "从未同步", "lastSyncTime": "上次同步: {time}", + "draftDiffers": "检测到前端草稿与环境变量配置不一致,请点击“同步并重新部署”。", "pollPaused": "状态轮询已暂停:连续失败 {count} 次。", "manualRefresh": "手动刷新", "howItWorks": "工作原理",