mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45:29 +08:00
Compare commits
2 Commits
v2.3.8_bet
...
v2.3.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce1b76c90f | ||
|
|
696b403173 |
@@ -128,9 +128,6 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) {
|
|||||||
if !containsStr(prompt, "tool_use") {
|
if !containsStr(prompt, "tool_use") {
|
||||||
t.Fatalf("expected tool_use instruction in prompt")
|
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") {
|
if containsStr(prompt, "tool_calls") {
|
||||||
t.Fatalf("expected prompt to avoid tool_calls JSON instruction")
|
t.Fatalf("expected prompt to avoid tool_calls JSON instruction")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ 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.",
|
"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.",
|
"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.",
|
"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")
|
return strings.Join(parts, "\n\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh
|
|||||||
if len(toolSchemas) == 0 {
|
if len(toolSchemas) == 0 {
|
||||||
return messages, names
|
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) 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."
|
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."
|
||||||
if policy.Mode == util.ToolChoiceRequired {
|
if policy.Mode == util.ToolChoiceRequired {
|
||||||
toolPrompt += "\n5) For this response, you MUST call at least one tool from the allowed list."
|
toolPrompt += "\n5) For this response, you MUST call at least one tool from the allowed list."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -651,48 +651,6 @@ func TestHandleStreamFencedToolCallSnippetPromotesToolCall(t *testing.T) {
|
|||||||
if strings.Contains(strings.ToLower(got), "tool_calls") {
|
if strings.Contains(strings.ToLower(got), "tool_calls") {
|
||||||
t.Fatalf("expected raw fenced tool_calls snippet stripped from content, got=%q", got)
|
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" {
|
if streamFinishReason(frames) != "tool_calls" {
|
||||||
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
|
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,4 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
|
|||||||
if !strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") {
|
if !strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") {
|
||||||
t.Fatalf("vercel prepare finalPrompt missing history marker instruction: %q", finalPrompt)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,9 +182,6 @@ func findToolSegmentStart(s string) int {
|
|||||||
if start < 0 {
|
if start < 0 {
|
||||||
start = bestKeyIdx
|
start = bestKeyIdx
|
||||||
}
|
}
|
||||||
if fenceStart, ok := openFenceStartBefore(s, start); ok {
|
|
||||||
return fenceStart
|
|
||||||
}
|
|
||||||
return start
|
return start
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +191,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
|
|||||||
return "", nil, "", false
|
return "", nil, "", false
|
||||||
}
|
}
|
||||||
lower := strings.ToLower(captured)
|
lower := strings.ToLower(captured)
|
||||||
|
|
||||||
keyIdx := -1
|
keyIdx := -1
|
||||||
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
|
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
|
||||||
for _, kw := range keywords {
|
for _, kw := range keywords {
|
||||||
@@ -203,7 +200,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
|
|||||||
keyIdx = idx
|
keyIdx = idx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if keyIdx < 0 {
|
if keyIdx < 0 {
|
||||||
return "", nil, "", false
|
return "", nil, "", false
|
||||||
}
|
}
|
||||||
@@ -229,45 +226,5 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
|
|||||||
// For now, keep the original logic but rely on loose JSON repair.
|
// For now, keep the original logic but rely on loose JSON repair.
|
||||||
return captured, nil, "", true
|
return captured, nil, "", true
|
||||||
}
|
}
|
||||||
prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart)
|
|
||||||
return prefixPart, parsed.Calls, suffixPart, true
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ func RegisterRoutes(r chi.Router, h *Handler) {
|
|||||||
pr.Post("/test", h.testAPI)
|
pr.Post("/test", h.testAPI)
|
||||||
pr.Post("/vercel/sync", h.syncVercel)
|
pr.Post("/vercel/sync", h.syncVercel)
|
||||||
pr.Get("/vercel/status", h.vercelStatus)
|
pr.Get("/vercel/status", h.vercelStatus)
|
||||||
pr.Post("/vercel/status", h.vercelStatus)
|
|
||||||
pr.Get("/export", h.exportConfig)
|
pr.Get("/export", h.exportConfig)
|
||||||
pr.Get("/dev/captures", h.getDevCaptures)
|
pr.Get("/dev/captures", h.getDevCaptures)
|
||||||
pr.Delete("/dev/captures", h.clearDevCaptures)
|
pr.Delete("/dev/captures", h.clearDevCaptures)
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import (
|
|||||||
func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
|
func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
|
||||||
snap := h.Store.Snapshot()
|
snap := h.Store.Snapshot()
|
||||||
safe := map[string]any{
|
safe := map[string]any{
|
||||||
"keys": snap.Keys,
|
"keys": snap.Keys,
|
||||||
"accounts": []map[string]any{},
|
"accounts": []map[string]any{},
|
||||||
"env_backed": h.Store.IsEnvBacked(),
|
|
||||||
"claude_mapping": func() map[string]string {
|
"claude_mapping": func() map[string]string {
|
||||||
if len(snap.ClaudeMapping) > 0 {
|
if len(snap.ClaudeMapping) > 0 {
|
||||||
return snap.ClaudeMapping
|
return snap.ClaudeMapping
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -13,8 +11,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ds2api/internal/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -29,7 +25,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
validated, failed := h.validateAccountsForVercelSync(r.Context(), opts.AutoValidate)
|
validated, failed := h.validateAccountsForVercelSync(r.Context(), opts.AutoValidate)
|
||||||
cfgJSON, cfgB64, err := h.exportSyncConfig(req)
|
_, cfgB64, err := h.Store.ExportJSONAndBase64()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -51,7 +47,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs)
|
savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs)
|
||||||
manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers)
|
manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers)
|
||||||
_ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix())
|
_ = h.Store.SetVercelSync(h.computeSyncHash(), time.Now().Unix())
|
||||||
result := map[string]any{"success": true, "validated_accounts": validated}
|
result := map[string]any{"success": true, "validated_accounts": validated}
|
||||||
if manual {
|
if manual {
|
||||||
result["message"] = "配置已同步到 Vercel,请手动触发重新部署"
|
result["message"] = "配置已同步到 Vercel,请手动触发重新部署"
|
||||||
@@ -213,71 +209,11 @@ func triggerVercelDeployment(ctx context.Context, client *http.Client, projectID
|
|||||||
return false, deployURL
|
return false, deployURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) vercelStatus(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) vercelStatus(w http.ResponseWriter, _ *http.Request) {
|
||||||
snap := h.Store.Snapshot()
|
snap := h.Store.Snapshot()
|
||||||
current := h.computeSyncHash()
|
current := h.computeSyncHash()
|
||||||
synced := snap.VercelSyncHash != "" && snap.VercelSyncHash == current
|
synced := snap.VercelSyncHash != "" && snap.VercelSyncHash == current
|
||||||
draftHash := ""
|
writeJSON(w, http.StatusOK, map[string]any{"synced": synced, "last_sync_time": nilIfZero(snap.VercelSyncTime), "has_synced_before": snap.VercelSyncHash != ""})
|
||||||
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) {
|
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) {
|
||||||
|
|||||||
@@ -256,40 +256,11 @@ function consumeToolCapture(state, toolNames) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedFence = trimWrappingJSONFence(prefixPart, suffixPart);
|
|
||||||
return {
|
return {
|
||||||
ready: true,
|
ready: true,
|
||||||
prefix: trimmedFence.prefix,
|
prefix: prefixPart,
|
||||||
calls: parsed.calls,
|
calls: parsed.calls,
|
||||||
suffix: trimmedFence.suffix,
|
suffix: suffixPart,
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -286,18 +286,6 @@ test('sieve emits tool_calls and keeps trailing prose when payload and prose sha
|
|||||||
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
|
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', () => {
|
test('formatOpenAIStreamToolCalls reuses ids with the same idStore', () => {
|
||||||
const idStore = new Map();
|
const idStore = new Map();
|
||||||
const calls = [{ name: 'read_file', input: { path: 'README.MD' } }];
|
const calls = [{ name: 'read_file', input: { path: 'README.MD' } }];
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
const ENV_DRAFT_KEY = 'ds2api_env_config_draft_v1'
|
|
||||||
|
|
||||||
export function useAdminConfig({ token, showMessage, t }) {
|
export function useAdminConfig({ token, showMessage, t }) {
|
||||||
const [config, setConfig] = useState({ keys: [], accounts: [] })
|
const [config, setConfig] = useState({ keys: [], accounts: [] })
|
||||||
|
|
||||||
@@ -13,11 +11,6 @@ export function useAdminConfig({ token, showMessage, t }) {
|
|||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
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)
|
setConfig(data)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -28,17 +21,6 @@ export function useAdminConfig({ token, showMessage, t }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
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()
|
||||||
}
|
}
|
||||||
}, [fetchConfig, token])
|
}, [fetchConfig, token])
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
|
|||||||
onPageSizeChange={changePageSize}
|
onPageSizeChange={changePageSize}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
envBacked={Boolean(config?.env_backed)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddKeyModal
|
<AddKeyModal
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export default function AccountsTable({
|
|||||||
onPageSizeChange,
|
onPageSizeChange,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
envBacked = false,
|
|
||||||
}) {
|
}) {
|
||||||
const [copiedId, setCopiedId] = useState(null)
|
const [copiedId, setCopiedId] = useState(null)
|
||||||
|
|
||||||
@@ -102,16 +101,14 @@ export default function AccountsTable({
|
|||||||
) : accounts.length > 0 ? (
|
) : accounts.length > 0 ? (
|
||||||
accounts.map((acc, i) => {
|
accounts.map((acc, i) => {
|
||||||
const id = resolveAccountIdentifier(acc)
|
const id = resolveAccountIdentifier(acc)
|
||||||
const runtimeUnknown = envBacked && !acc.test_status
|
|
||||||
const isActive = acc.test_status === 'ok' || acc.has_token
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
|
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
"w-2 h-2 rounded-full shrink-0",
|
"w-2 h-2 rounded-full shrink-0",
|
||||||
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
|
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
|
||||||
isActive ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
|
(acc.test_status === 'ok' || acc.has_token) ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
|
||||||
runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500"
|
"bg-amber-500"
|
||||||
)} />
|
)} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div
|
<div
|
||||||
@@ -125,7 +122,7 @@ export default function AccountsTable({
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||||
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')}</span>
|
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
|
||||||
{acc.token_preview && (
|
{acc.token_preview && (
|
||||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
||||||
{acc.token_preview}
|
{acc.token_preview}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import VercelSyncForm from './VercelSyncForm'
|
|||||||
import VercelSyncStatus from './VercelSyncStatus'
|
import VercelSyncStatus from './VercelSyncStatus'
|
||||||
import VercelGuide from './VercelGuide'
|
import VercelGuide from './VercelGuide'
|
||||||
|
|
||||||
export default function VercelSyncContainer({ onMessage, authFetch, isVercel = false, config = null }) {
|
export default function VercelSyncContainer({ onMessage, authFetch, isVercel = false }) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const apiFetch = authFetch || fetch
|
const apiFetch = authFetch || fetch
|
||||||
|
|
||||||
@@ -28,7 +28,6 @@ export default function VercelSyncContainer({ onMessage, authFetch, isVercel = f
|
|||||||
onMessage,
|
onMessage,
|
||||||
t,
|
t,
|
||||||
isVercel,
|
isVercel,
|
||||||
config,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -69,11 +69,6 @@ export default function VercelSyncForm({
|
|||||||
{t('vercel.lastSyncTime', { time: new Date(syncStatus.last_sync_time * 1000).toLocaleString() })}
|
{t('vercel.lastSyncTime', { time: new Date(syncStatus.last_sync_time * 1000).toLocaleString() })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{syncStatus?.draft_differs && (
|
|
||||||
<p className="text-xs text-amber-500 mt-2">
|
|
||||||
{t('vercel.draftDiffers')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
|
|||||||
case 'import':
|
case 'import':
|
||||||
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
|
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
|
||||||
case 'vercel':
|
case 'vercel':
|
||||||
return <VercelSync onMessage={showMessage} authFetch={authFetch} isVercel={isVercel} config={config} />
|
return <VercelSync onMessage={showMessage} authFetch={authFetch} isVercel={isVercel} />
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return <Settings onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} onForceLogout={onForceLogout} isVercel={isVercel} />
|
return <Settings onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} onForceLogout={onForceLogout} isVercel={isVercel} />
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -115,7 +115,6 @@
|
|||||||
"testingAllAccounts": "Refreshing tokens for all accounts...",
|
"testingAllAccounts": "Refreshing tokens for all accounts...",
|
||||||
"sessionActive": "Session active",
|
"sessionActive": "Session active",
|
||||||
"reauthRequired": "Re-auth required",
|
"reauthRequired": "Re-auth required",
|
||||||
"runtimeStatusUnknown": "Will be determined after sync",
|
|
||||||
"testStatusFailed": "Last test failed",
|
"testStatusFailed": "Last test failed",
|
||||||
"noAccounts": "No accounts found.",
|
"noAccounts": "No accounts found.",
|
||||||
"modalAddKeyTitle": "Add API key",
|
"modalAddKeyTitle": "Add API key",
|
||||||
@@ -295,7 +294,6 @@
|
|||||||
"statusNotSynced": "Not synced",
|
"statusNotSynced": "Not synced",
|
||||||
"statusNeverSynced": "Never synced",
|
"statusNeverSynced": "Never synced",
|
||||||
"lastSyncTime": "Last sync: {time}",
|
"lastSyncTime": "Last sync: {time}",
|
||||||
"draftDiffers": "Frontend draft differs from env config. Click Sync & redeploy.",
|
|
||||||
"pollPaused": "Status polling paused after {count} failures.",
|
"pollPaused": "Status polling paused after {count} failures.",
|
||||||
"manualRefresh": "Refresh manually",
|
"manualRefresh": "Refresh manually",
|
||||||
"howItWorks": "How it works",
|
"howItWorks": "How it works",
|
||||||
|
|||||||
@@ -115,7 +115,6 @@
|
|||||||
"testingAllAccounts": "正在刷新所有账号 Token...",
|
"testingAllAccounts": "正在刷新所有账号 Token...",
|
||||||
"sessionActive": "已建立会话",
|
"sessionActive": "已建立会话",
|
||||||
"reauthRequired": "需重新登录",
|
"reauthRequired": "需重新登录",
|
||||||
"runtimeStatusUnknown": "状态以同步后为准",
|
|
||||||
"testStatusFailed": "上次测试失败",
|
"testStatusFailed": "上次测试失败",
|
||||||
"noAccounts": "未找到任何账号",
|
"noAccounts": "未找到任何账号",
|
||||||
"modalAddKeyTitle": "添加 API 密钥",
|
"modalAddKeyTitle": "添加 API 密钥",
|
||||||
@@ -295,7 +294,6 @@
|
|||||||
"statusNotSynced": "未同步",
|
"statusNotSynced": "未同步",
|
||||||
"statusNeverSynced": "从未同步",
|
"statusNeverSynced": "从未同步",
|
||||||
"lastSyncTime": "上次同步: {time}",
|
"lastSyncTime": "上次同步: {time}",
|
||||||
"draftDiffers": "检测到前端草稿与环境变量配置不一致,请点击“同步并重新部署”。",
|
|
||||||
"pollPaused": "状态轮询已暂停:连续失败 {count} 次。",
|
"pollPaused": "状态轮询已暂停:连续失败 {count} 次。",
|
||||||
"manualRefresh": "手动刷新",
|
"manualRefresh": "手动刷新",
|
||||||
"howItWorks": "工作原理",
|
"howItWorks": "工作原理",
|
||||||
|
|||||||
Reference in New Issue
Block a user