Compare commits

..

17 Commits

Author SHA1 Message Date
CJACK.
d7125ea106 Bump version from 4.1.2 to 4.1.3 2026-04-29 07:55:48 +08:00
CJACK.
929d9a8ef7 Merge pull request #352 from shern-point/fix/tool-string-schema-protection
Fix/tool type schema protection
2026-04-29 07:51:21 +08:00
CJACK.
c03f733b83 Merge pull request #353 from Gingiris/docs/add-toc
docs: add Table of Contents to README.MD and README.en.md
2026-04-29 07:50:54 +08:00
Gingiris
047fc9bee2 docs: add Table of Contents to README.MD and README.en.md
Both READMEs are 400+ lines with 14 top-level sections and multiple
subsections but have no navigation aid. Add a Table of Contents at the
top of each file to help readers quickly find relevant sections.

Changes:
- README.MD: add 目录 section with links to all h2/h3 headings
- README.en.md: add Table of Contents with matching structure
2026-04-28 12:18:37 -07:00
shern-point
52558838ef docs: document request-scoped tool schema authority 2026-04-29 02:00:20 +08:00
shern-point
f1926a6ced fix: normalize Vercel stream tool arguments by schema 2026-04-29 02:00:01 +08:00
shern-point
6e21714e23 test: cover Claude schema-aware tool normalization 2026-04-29 01:59:42 +08:00
shern-point
48c4f0df9f fix: preserve runtime tool schemas in Claude tool output 2026-04-29 01:59:24 +08:00
shern-point
a550de30af fix: expand shared tool schema extraction 2026-04-29 01:59:05 +08:00
CJACK.
23422e4a8e Merge pull request #350 from ouqiting/fix_chat_histroy
feat: parse split context files in list view
2026-04-29 01:34:10 +08:00
CJACK.
9c33bed403 Merge pull request #349 from RinZ27/fix-docker-non-root
build: improve Docker robustness and fix potential security issues
2026-04-29 01:34:00 +08:00
ouqiting
c81294f1b7 fix(chat-history): support tool turns in parsed HISTORY list view 2026-04-29 01:27:14 +08:00
ouqiting
28d2b0410f feat: parse split context files in list view 2026-04-29 01:15:29 +08:00
RinZ27
0c782407f5 build: improve Docker robustness and fix potential security issues 2026-04-28 23:49:54 +07:00
CJACK.
27eb73d48b Merge pull request #346 from CJackHwang/dev
Normalize string tool inputs and enhance schema protection
2026-04-28 22:06:41 +08:00
CJACK.
685b5011e4 Merge pull request #343 from livesRan/fix-429Resend-pr
支持 reference 引用标签转链接,并兼容 0 基序号映射
2026-04-28 21:47:15 +08:00
songguoliang
15e9eb3639 支持 reference 引用标签转链接,并兼容 0 基序号映射 2026-04-28 16:42:37 +08:00
28 changed files with 744 additions and 123 deletions

View File

@@ -28,6 +28,8 @@ FROM debian:bookworm-slim AS runtime-base
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& groupadd -r ds2api && useradd -r -g ds2api -d /app -s /sbin/nologin ds2api \
&& mkdir -p /app/data && chown -R ds2api:ds2api /app \
&& rm -rf /var/lib/apt/lists/*
COPY --from=busybox-tools /bin/busybox /usr/local/bin/busybox
EXPOSE 5001
@@ -36,8 +38,9 @@ CMD ["/usr/local/bin/ds2api"]
FROM runtime-base AS runtime-from-source
COPY --from=go-builder /out/ds2api /usr/local/bin/ds2api
COPY --from=go-builder /app/config.example.json /app/config.example.json
COPY --from=webui-builder /app/static/admin /app/static/admin
COPY --from=go-builder --chown=ds2api:ds2api /app/config.example.json /app/config.example.json
COPY --from=webui-builder --chown=ds2api:ds2api /app/static/admin /app/static/admin
USER ds2api
FROM busybox-tools AS dist-extract
ARG TARGETARCH
@@ -60,7 +63,8 @@ RUN set -eux; \
FROM runtime-base AS runtime-from-dist
COPY --from=dist-extract /out/ds2api /usr/local/bin/ds2api
COPY --from=dist-extract /out/config.example.json /app/config.example.json
COPY --from=dist-extract /out/static/admin /app/static/admin
COPY --from=dist-extract --chown=ds2api:ds2api /out/config.example.json /app/config.example.json
COPY --from=dist-extract --chown=ds2api:ds2api /out/static/admin /app/static/admin
USER ds2api
FROM runtime-from-source AS final

View File

@@ -31,6 +31,30 @@
>
> 请勿将本项目用于违反服务条款、协议、法律法规或平台规则的场景。商业使用前请自行确认 `LICENSE`、相关协议以及你是否获得了作者的书面许可。
## 目录
- [架构概览(摘要)](#架构概览摘要)
- [核心能力](#核心能力)
- [平台兼容矩阵](#平台兼容矩阵)
- [模型支持](#模型支持)
- [OpenAI 接口](#openai-接口get-v1models)
- [Claude 接口](#claude-接口get-anthropicv1models)
- [Gemini 接口](#gemini-接口)
- [快速开始](#快速开始)
- [方式一:下载 Release 构建包](#方式一下载-release-构建包)
- [方式二Docker 运行](#方式二docker-运行)
- [方式三Vercel 部署](#方式三vercel-部署)
- [方式四:本地源码运行](#方式四本地源码运行)
- [配置说明](#配置说明)
- [鉴权模式](#鉴权模式)
- [并发模型](#并发模型)
- [Tool Call 适配](#tool-call-适配)
- [本地开发抓包工具](#本地开发抓包工具)
- [文档索引](#文档索引)
- [测试](#测试)
- [Release 自动构建GitHub Actions](#release-自动构建github-actions)
- [免责声明](#免责声明)
## 架构概览(摘要)
```mermaid

View File

@@ -28,6 +28,30 @@ Documentation entry: [Docs Index](docs/README.md) / [Architecture](docs/ARCHITEC
>
> Do not use this project in ways that violate service terms, agreements, laws, or platform rules. Before any commercial use, review the `LICENSE`, the relevant terms, and confirm that you have the author's written permission.
## Table of Contents
- [Architecture Overview (Summary)](#architecture-overview-summary)
- [Key Capabilities](#key-capabilities)
- [Platform Compatibility Matrix](#platform-compatibility-matrix)
- [Model Support](#model-support)
- [OpenAI Endpoint](#openai-endpoint-get-v1models)
- [Claude Endpoint](#claude-endpoint-get-anthropicv1models)
- [Gemini Endpoint](#gemini-endpoint)
- [Quick Start](#quick-start)
- [Option 1: Download Release Binaries](#option-1-download-release-binaries)
- [Option 2: Docker / GHCR](#option-2-docker--ghcr)
- [Option 3: Vercel](#option-3-vercel)
- [Option 4: Local Run](#option-4-local-run)
- [Configuration](#configuration)
- [Authentication Modes](#authentication-modes)
- [Concurrency Model](#concurrency-model)
- [Tool Call Adaptation](#tool-call-adaptation)
- [Local Dev Packet Capture](#local-dev-packet-capture)
- [Documentation Index](#documentation-index)
- [Testing](#testing)
- [Release Artifact Automation (GitHub Actions)](#release-artifact-automation-github-actions)
- [Disclaimer](#disclaimer)
## Architecture Overview (Summary)
```mermaid

View File

@@ -1 +1 @@
4.1.2
4.1.3

View File

@@ -35,8 +35,9 @@ func main() {
}
srv := &http.Server{
Addr: "0.0.0.0:" + port,
Handler: app.Router,
Addr: "0.0.0.0:" + port,
Handler: app.Router,
ReadHeaderTimeout: 5 * time.Second,
}
localURL := fmt.Sprintf("http://127.0.0.1:%s", port)
lanIP := detectLANIPv4()

View File

@@ -154,6 +154,7 @@ OpenAI Chat / Responses 在标准化后、current input file 之前,会默认
兼容层仍接受旧式纯 `<tool_calls>` wrapper但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现DSML 标签会在解析入口归一化回现有 XML 标签后继续走同一套 parser。
数组参数使用 `<item>...</item>` 子节点表示;当某个参数体只包含 item 子节点时Go / Node 解析器会把它还原成数组,避免 `questions` / `options` 这类 schema 中要求 array 的参数被误解析成 `{ "item": ... }` 对象。若模型把完整结构化 XML fragment 误包进 CDATA兼容层会在保护 `content` / `command` 等原文字段的前提下,尝试把非原文字段中的 CDATA XML fragment 还原成 object / array。不过如果 CDATA 只是单个平面的 XML/HTML 标签,例如 `<b>urgent</b>` 这种行内标记,兼容层会保留原始字符串,不会强行升成 object / array只有明显表示结构的 CDATA 片段,例如多兄弟节点、嵌套子节点或 `item` 列表,才会触发结构化恢复。
在 assistant 最终回包阶段,如果某个 tool 参数在声明 schema 中明确是 `string`,兼容层会在把解析后的 `tool_calls` / `function_call` 重新序列化成 OpenAI / Responses / Claude 可见参数前,递归把该路径上的 number / bool / object / array 统一转成字符串;其中 object / array 会压成紧凑 JSON 字符串。这个保护只对 schema 明确声明为 string 的路径生效,不会改写本来就是 `number` / `boolean` / `object` / `array` 的参数。这样可以兼容 DeepSeek 输出了结构化片段、但上游客户端工具 schema 又严格要求字符串参数的场景(例如 `content``prompt``path``taskId` 等)。
工具 schema 的权威来源始终是**当前请求实际携带的 schema**,而不是同名工具在其他 runtimeClaude Code / OpenCode / Codex 等)里的默认印象。兼容层现在会同时兼容 OpenAI 风格 `function.parameters`、直接工具对象上的 `parameters` / `input_schema`、以及 camelCase 的 `inputSchema` / `schema`,并在最终输出阶段按这份请求内 schema 决定是保留 array/object还是仅对明确声明为 `string` 的路径做字符串化。该规则同样适用于 Claude 的流式收尾和 Vercel Node 流式 tool-call formatter避免不同 runtime 因 schema shape 差异而出现同名工具参数类型漂移。
正例中的工具名只会来自当前请求实际声明的工具;如果当前请求没有足够的已知工具形态,就省略对应的单工具、多工具或嵌套示例,避免把不可用工具名写进 prompt。
对执行类工具,脚本内容必须进入执行参数本身:`Bash` / `execute_command` 使用 `command``exec_command` 使用 `cmd`;不要把脚本示范成 `path` / `content` 文件写入参数。

View File

@@ -1,6 +1,7 @@
package claude
import (
"ds2api/internal/toolcall"
"fmt"
"strings"
)
@@ -31,30 +32,9 @@ func extractClaudeToolNames(tools []any) []string {
}
func extractClaudeToolMeta(m map[string]any) (string, string, any) {
name, _ := m["name"].(string)
desc, _ := m["description"].(string)
schemaObj := m["input_schema"]
if schemaObj == nil {
schemaObj = m["parameters"]
}
if fn, ok := m["function"].(map[string]any); ok {
if strings.TrimSpace(name) == "" {
name, _ = fn["name"].(string)
}
if strings.TrimSpace(desc) == "" {
desc, _ = fn["description"].(string)
}
if schemaObj == nil {
if v, ok := fn["input_schema"]; ok {
schemaObj = v
}
}
if schemaObj == nil {
if v, ok := fn["parameters"]; ok {
schemaObj = v
}
}
name, desc, schemaObj := toolcall.ExtractToolMeta(m)
if strings.TrimSpace(desc) == "" {
desc = "No description available"
}
return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj
}

View File

@@ -177,7 +177,7 @@ func stripClaudeThinkingBlocks(raw []byte) []byte {
return out
}
func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Request, resp *http.Response, model string, messages []any, thinkingEnabled, searchEnabled bool, toolNames []string) {
func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Request, resp *http.Response, model string, messages []any, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any) {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
@@ -205,6 +205,7 @@ func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Requ
searchEnabled,
h.compatStripReferenceMarkers(),
toolNames,
toolsRaw,
)
streamRuntime.sendMessageStart()

View File

@@ -81,7 +81,7 @@ func TestHandleClaudeStreamRealtimeTextIncrementsWithEventHeaders(t *testing.T)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil, nil)
body := rec.Body.String()
if !strings.Contains(body, "event: message_start") {
@@ -122,7 +122,7 @@ func TestHandleClaudeStreamRealtimeThinkingDelta(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, true, false, nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, true, false, nil, nil)
frames := parseClaudeFrames(t, rec.Body.String())
foundThinkingDelta := false
@@ -149,7 +149,7 @@ func TestHandleClaudeStreamRealtimeSkipsThinkingFallbackWhenFinalTextExists(t *t
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"})
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"}, nil)
frames := parseClaudeFrames(t, rec.Body.String())
for _, f := range findClaudeFrames(frames, "content_block_start") {
@@ -180,7 +180,7 @@ func TestHandleClaudeStreamRealtimeUpstreamErrorEvent(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil, nil)
frames := parseClaudeFrames(t, rec.Body.String())
errFrames := findClaudeFrames(frames, "error")
@@ -217,7 +217,7 @@ func TestHandleClaudeStreamRealtimePingEvent(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil, nil)
frames := parseClaudeFrames(t, rec.Body.String())
if len(findClaudeFrames(frames, "ping")) == 0 {
@@ -271,7 +271,7 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"Bash"})
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"Bash"}, nil)
frames := parseClaudeFrames(t, rec.Body.String())
foundToolUse := false
@@ -299,7 +299,7 @@ func TestHandleClaudeStreamRealtimeDetectsToolUseWithLeadingProse(t *testing.T)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"write_file"})
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"write_file"}, nil)
frames := parseClaudeFrames(t, rec.Body.String())
foundToolUse := false
@@ -333,7 +333,7 @@ func TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t *testing.T
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "show example only"}}, false, false, []string{"Bash"})
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "show example only"}}, false, false, []string{"Bash"}, nil)
frames := parseClaudeFrames(t, rec.Body.String())
foundToolUse := false
@@ -365,3 +365,48 @@ func TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t *testing.T
func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.T) {
TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t)
}
func TestHandleClaudeStreamRealtimeNormalizesToolInputBySchema(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/content","v":"<tool_calls><invoke name=\"Write\">{\"input\":{\"content\":{\"message\":\"hi\"},\"taskId\":1}}</invoke></tool_calls>"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
toolsRaw := []any{
map[string]any{
"name": "Write",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{
"content": map[string]any{"type": "string"},
"taskId": map[string]any{"type": "string"},
},
},
},
}
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "write"}}, false, false, []string{"Write"}, toolsRaw)
frames := parseClaudeFrames(t, rec.Body.String())
for _, f := range findClaudeFrames(frames, "content_block_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["type"] != "input_json_delta" {
continue
}
partial := asString(delta["partial_json"])
var args map[string]any
if err := json.Unmarshal([]byte(partial), &args); err != nil {
t.Fatalf("decode partial_json failed: %v payload=%s", err, partial)
}
if args["content"] != `{"message":"hi"}` {
t.Fatalf("expected content normalized to string, got %#v", args["content"])
}
if args["taskId"] != "1" {
t.Fatalf("expected taskId normalized to string, got %#v", args["taskId"])
}
return
}
t.Fatalf("expected input_json_delta frame, body=%s", rec.Body.String())
}

View File

@@ -53,6 +53,7 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma
ResolvedModel: dsModel,
ResponseModel: strings.TrimSpace(model),
Messages: payload["messages"].([]any),
ToolsRaw: toolsRequested,
FinalPrompt: finalPrompt,
ToolNames: toolNames,
Stream: util.ToBool(req["stream"]),

View File

@@ -32,11 +32,39 @@ func TestNormalizeClaudeRequest(t *testing.T) {
if len(norm.Standard.ToolNames) == 0 {
t.Fatalf("expected tool names")
}
if norm.Standard.ToolsRaw == nil {
t.Fatalf("expected ToolsRaw preserved for downstream normalization")
}
if norm.Standard.FinalPrompt == "" {
t.Fatalf("expected non-empty final prompt")
}
}
func TestNormalizeClaudeRequestSupportsCamelCaseInputSchemaPromptInjection(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{}`)
store := config.LoadStore()
req := map[string]any{
"model": "claude-sonnet-4-5",
"messages": []any{
map[string]any{"role": "user", "content": "hello"},
},
"tools": []any{
map[string]any{
"name": "todowrite",
"description": "Write todos",
"inputSchema": map[string]any{"type": "object", "properties": map[string]any{"todos": map[string]any{"type": "array"}}},
},
},
}
norm, err := normalizeClaudeRequest(store, req)
if err != nil {
t.Fatalf("normalize failed: %v", err)
}
if !containsStr(norm.Standard.FinalPrompt, `"type":"array"`) {
t.Fatalf("expected inputSchema to be injected into prompt, got=%q", norm.Standard.FinalPrompt)
}
}
func TestNormalizeClaudeRequestInjectsToolsIntoExistingSystemMessage(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{}`)
store := config.LoadStore()

View File

@@ -18,6 +18,7 @@ type claudeStreamRuntime struct {
model string
toolNames []string
messages []any
toolsRaw any
thinkingEnabled bool
searchEnabled bool
@@ -47,6 +48,7 @@ func newClaudeStreamRuntime(
searchEnabled bool,
stripReferenceMarkers bool,
toolNames []string,
toolsRaw any,
) *claudeStreamRuntime {
return &claudeStreamRuntime{
w: w,
@@ -59,6 +61,7 @@ func newClaudeStreamRuntime(
bufferToolContent: len(toolNames) > 0,
stripReferenceMarkers: stripReferenceMarkers,
toolNames: toolNames,
toolsRaw: toolsRaw,
messageID: fmt.Sprintf("msg_%d", time.Now().UnixNano()),
thinkingBlockIndex: -1,
textBlockIndex: -1,

View File

@@ -52,6 +52,7 @@ func (s *claudeStreamRuntime) finalize(stopReason string) {
detected = toolcall.ParseStandaloneToolCalls(finalThinking, s.toolNames)
}
if len(detected) > 0 {
detected = toolcall.NormalizeParsedToolCallsForSchemas(detected, s.toolsRaw)
stopReason = "tool_use"
for i, tc := range detected {
idx := s.nextBlockIndex + i

View File

@@ -307,15 +307,15 @@ func TestChatCompletionsCurrentInputFilePersistsNeutralPrompt(t *testing.T) {
if err != nil {
t.Fatalf("expected detail item, got %v", err)
}
if full.HistoryText != "" {
t.Fatalf("expected current input file flow to leave history text empty, got %q", full.HistoryText)
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected current input upload to happen, got %d", len(ds.uploadCalls))
}
if ds.uploadCalls[0].Filename != "IGNORE.txt" {
t.Fatalf("expected IGNORE.txt upload, got %q", ds.uploadCalls[0].Filename)
}
if full.HistoryText != string(ds.uploadCalls[0].Data) {
t.Fatalf("expected uploaded current input file to be persisted in history text")
}
if len(full.Messages) != 1 {
t.Fatalf("expected neutral prompt to be the only persisted message, got %#v", full.Messages)
}

View File

@@ -26,3 +26,31 @@ func TestReplaceCitationMarkersWithLinksKeepsUnknownIndex(t *testing.T) {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestReplaceCitationMarkersWithLinksSupportsReferenceMarker(t *testing.T) {
raw := "新闻摘要[reference:1],详情[reference:2]。"
links := map[int]string{
1: "https://example.com/r1",
2: "https://example.com/r2",
}
got := replaceCitationMarkersWithLinks(raw, links)
want := "新闻摘要[1](https://example.com/r1),详情[2](https://example.com/r2)。"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestReplaceCitationMarkersWithLinksSupportsReferenceZeroBased(t *testing.T) {
raw := "来源[reference:0] 与 [reference:1]。"
links := map[int]string{
1: "https://example.com/first",
2: "https://example.com/second",
}
got := replaceCitationMarkersWithLinks(raw, links)
want := "来源[0](https://example.com/first) 与 [1](https://example.com/second)。"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}

View File

@@ -58,6 +58,7 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth,
}
stdReq.Messages = messages
stdReq.HistoryText = fileText
stdReq.CurrentInputFileApplied = true
stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID)
stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPrompt(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking)

View File

@@ -352,7 +352,7 @@ func TestApplyCurrentInputFileUploadsFullContextFile(t *testing.T) {
}
}
func TestApplyCurrentInputFileLeavesHistoryTextEmpty(t *testing.T) {
func TestApplyCurrentInputFileCarriesHistoryText(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
@@ -377,8 +377,8 @@ func TestApplyCurrentInputFileLeavesHistoryTextEmpty(t *testing.T) {
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
}
if out.HistoryText != "" {
t.Fatalf("expected current input file flow to leave history text empty, got %q", out.HistoryText)
if out.HistoryText != string(ds.uploadCalls[0].Data) {
t.Fatalf("expected current input file flow to preserve uploaded text in history, got %q", out.HistoryText)
}
}

View File

@@ -7,22 +7,27 @@ import (
"strings"
)
var citationMarkerPattern = regexp.MustCompile(`(?i)\[citation:\s*(\d+)\]`)
var citationMarkerPattern = regexp.MustCompile(`(?i)\[(citation|reference):\s*(\d+)\]`)
func ReplaceCitationMarkersWithLinks(text string, links map[int]string) string {
if strings.TrimSpace(text) == "" || len(links) == 0 {
return text
}
zeroBased := strings.Contains(strings.ToLower(text), "[reference:0]")
return citationMarkerPattern.ReplaceAllStringFunc(text, func(match string) string {
sub := citationMarkerPattern.FindStringSubmatch(match)
if len(sub) < 2 {
if len(sub) < 3 {
return match
}
idx, err := strconv.Atoi(strings.TrimSpace(sub[1]))
if err != nil || idx <= 0 {
idx, err := strconv.Atoi(strings.TrimSpace(sub[2]))
if err != nil || idx < 0 {
return match
}
url := strings.TrimSpace(links[idx])
lookupIdx := idx
if zeroBased {
lookupIdx = idx + 1
}
url := strings.TrimSpace(links[lookupIdx])
if url == "" {
return match
}

View File

@@ -205,14 +205,14 @@ async function handleVercelStream(req, res, rawBody, payload) {
if (detected.length > 0 && !toolCallsDoneEmitted) {
toolCallsEmitted = true;
toolCallsDoneEmitted = true;
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(detected, streamToolCallIDs) });
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(detected, streamToolCallIDs, payload.tools) });
} else if (toolSieveEnabled) {
const tailEvents = flushToolSieve(toolSieveState, toolNames);
for (const evt of tailEvents) {
if (evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0) {
toolCallsEmitted = true;
toolCallsDoneEmitted = true;
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) });
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs, payload.tools) });
resetStreamToolCallState(streamToolCallIDs, streamToolNames);
continue;
}
@@ -352,14 +352,14 @@ async function handleVercelStream(req, res, rawBody, payload) {
const formatted = formatIncrementalToolCallDeltas(filtered, streamToolCallIDs);
if (formatted.length > 0) {
toolCallsEmitted = true;
sendDeltaFrame({ tool_calls: formatted });
sendDeltaFrame({ tool_calls: formatted });
}
continue;
}
if (evt.type === 'tool_calls') {
toolCallsEmitted = true;
toolCallsDoneEmitted = true;
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) });
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs, payload.tools) });
resetStreamToolCallState(streamToolCallIDs, streamToolNames);
continue;
}

View File

@@ -2,11 +2,12 @@
const crypto = require('crypto');
function formatOpenAIStreamToolCalls(calls, idStore) {
function formatOpenAIStreamToolCalls(calls, idStore, toolsRaw) {
if (!Array.isArray(calls) || calls.length === 0) {
return [];
}
return calls.map((c, idx) => ({
const normalized = normalizeParsedToolCallsForSchemas(calls, toolsRaw);
return normalized.map((c, idx) => ({
index: idx,
id: ensureStreamToolCallID(idStore, idx),
type: 'function',
@@ -17,6 +18,194 @@ function formatOpenAIStreamToolCalls(calls, idStore) {
}));
}
function normalizeParsedToolCallsForSchemas(calls, toolsRaw) {
if (!Array.isArray(calls) || calls.length === 0) {
return calls;
}
const schemas = buildToolSchemaIndex(toolsRaw);
if (!schemas) {
return calls;
}
let changedAny = false;
const out = calls.map((call) => {
const name = String(call && call.name || '').trim().toLowerCase();
const schema = schemas[name];
if (!schema || !call || !call.input || typeof call.input !== 'object' || Array.isArray(call.input)) {
return call;
}
const [normalized, changed] = normalizeToolValueWithSchema(call.input, schema);
if (!changed || !normalized || typeof normalized !== 'object' || Array.isArray(normalized)) {
return call;
}
changedAny = true;
return { ...call, input: normalized };
});
return changedAny ? out : calls;
}
function buildToolSchemaIndex(toolsRaw) {
if (!Array.isArray(toolsRaw) || toolsRaw.length === 0) {
return null;
}
const out = {};
for (const item of toolsRaw) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
continue;
}
const [name, schema] = extractToolNameAndSchema(item);
if (!name || !schema || typeof schema !== 'object' || Array.isArray(schema)) {
continue;
}
out[name.toLowerCase()] = schema;
}
return Object.keys(out).length > 0 ? out : null;
}
function extractToolNameAndSchema(tool) {
const fn = tool && typeof tool.function === 'object' && !Array.isArray(tool.function) ? tool.function : null;
const name = firstNonEmptyString(tool.name, fn && fn.name);
const schema = firstNonNil(
tool.parameters,
tool.input_schema,
tool.inputSchema,
tool.schema,
fn && fn.parameters,
fn && fn.input_schema,
fn && fn.inputSchema,
fn && fn.schema,
);
return [name, schema];
}
function normalizeToolValueWithSchema(value, schema) {
if (value == null || !schema || typeof schema !== 'object' || Array.isArray(schema)) {
return [value, false];
}
if (shouldCoerceSchemaToString(schema)) {
return stringifySchemaValue(value);
}
if (looksLikeObjectSchema(schema)) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return [value, false];
}
const properties = schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties) ? schema.properties : null;
const additional = schema.additionalProperties;
let changed = false;
const out = {};
for (const [key, current] of Object.entries(value)) {
let next = current;
let fieldChanged = false;
if (properties && Object.prototype.hasOwnProperty.call(properties, key)) {
[next, fieldChanged] = normalizeToolValueWithSchema(current, properties[key]);
} else if (additional != null) {
[next, fieldChanged] = normalizeToolValueWithSchema(current, additional);
}
out[key] = next;
changed = changed || fieldChanged;
}
return changed ? [out, true] : [value, false];
}
if (looksLikeArraySchema(schema)) {
if (!Array.isArray(value) || value.length === 0 || schema.items == null) {
return [value, false];
}
let changed = false;
const out = value.map((item, idx) => {
const itemSchema = Array.isArray(schema.items) ? schema.items[idx] : schema.items;
if (itemSchema == null) {
return item;
}
const [next, itemChanged] = normalizeToolValueWithSchema(item, itemSchema);
changed = changed || itemChanged;
return next;
});
return changed ? [out, true] : [value, false];
}
return [value, false];
}
function shouldCoerceSchemaToString(schema) {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
return false;
}
if (typeof schema.const === 'string') {
return true;
}
if (Array.isArray(schema.enum) && schema.enum.length > 0 && schema.enum.every((item) => typeof item === 'string')) {
return true;
}
if (typeof schema.type === 'string') {
return schema.type.trim().toLowerCase() === 'string';
}
if (Array.isArray(schema.type) && schema.type.length > 0) {
let hasString = false;
for (const item of schema.type) {
if (typeof item !== 'string') {
return false;
}
const typ = item.trim().toLowerCase();
if (typ === 'string') {
hasString = true;
} else if (typ !== 'null') {
return false;
}
}
return hasString;
}
return false;
}
function looksLikeObjectSchema(schema) {
return !!schema && typeof schema === 'object' && !Array.isArray(schema) && (
(typeof schema.type === 'string' && schema.type.trim().toLowerCase() === 'object') ||
(schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties)) ||
schema.additionalProperties != null
);
}
function looksLikeArraySchema(schema) {
return !!schema && typeof schema === 'object' && !Array.isArray(schema) && (
(typeof schema.type === 'string' && schema.type.trim().toLowerCase() === 'array') ||
schema.items != null
);
}
function stringifySchemaValue(value) {
if (value == null) {
return [value, false];
}
if (typeof value === 'string') {
return [value, false];
}
try {
return [JSON.stringify(value), true];
} catch {
return [value, false];
}
}
function firstNonNil(...values) {
for (const value of values) {
if (value != null) {
return value;
}
}
return null;
}
function firstNonEmptyString(...values) {
for (const value of values) {
if (typeof value !== 'string') {
continue;
}
const trimmed = value.trim();
if (trimmed) {
return trimmed;
}
}
return '';
}
function ensureStreamToolCallID(idStore, index) {
if (!(idStore instanceof Map)) {
return `call_${newCallID()}`;

View File

@@ -30,13 +30,7 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy ToolChoiceP
if !ok {
continue
}
fn, _ := tool["function"].(map[string]any)
if len(fn) == 0 {
fn = tool
}
name, _ := fn["name"].(string)
desc, _ := fn["description"].(string)
schema, _ := fn["parameters"].(map[string]any)
name, desc, schema := toolcall.ExtractToolMeta(tool)
name = strings.TrimSpace(name)
if !isAllowed(name) {
continue

View File

@@ -48,7 +48,7 @@ func buildToolSchemaIndex(toolsRaw any) map[string]any {
if !ok {
continue
}
name, schema := extractToolNameAndSchema(tool)
name, _, schema := ExtractToolMeta(tool)
if name == "" || schema == nil {
continue
}
@@ -60,24 +60,31 @@ func buildToolSchemaIndex(toolsRaw any) map[string]any {
return out
}
func extractToolNameAndSchema(tool map[string]any) (string, any) {
func ExtractToolMeta(tool map[string]any) (string, string, any) {
name := strings.TrimSpace(asStringValue(tool["name"]))
schema := tool["parameters"]
if schema == nil {
schema = tool["input_schema"]
}
desc := strings.TrimSpace(asStringValue(tool["description"]))
schema := firstNonNil(
tool["parameters"],
tool["input_schema"],
tool["inputSchema"],
tool["schema"],
)
if fn, ok := tool["function"].(map[string]any); ok {
if name == "" {
name = strings.TrimSpace(asStringValue(fn["name"]))
}
if schema == nil {
schema = fn["parameters"]
}
if schema == nil {
schema = fn["input_schema"]
if desc == "" {
desc = strings.TrimSpace(asStringValue(fn["description"]))
}
schema = firstNonNil(
schema,
fn["parameters"],
fn["input_schema"],
fn["inputSchema"],
fn["schema"],
)
}
return name, schema
return name, desc, schema
}
func normalizeToolValueWithSchema(value any, schema any) (any, bool) {
@@ -264,3 +271,12 @@ func asStringValue(v any) string {
}
return ""
}
func firstNonNil(values ...any) any {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}

View File

@@ -110,3 +110,52 @@ func TestNormalizeParsedToolCallsForSchemasLeavesAmbiguousUnionUnchanged(t *test
t.Fatalf("expected ambiguous union to stay unchanged, got %#v", got[0].Input["taskId"])
}
}
func TestNormalizeParsedToolCallsForSchemasSupportsCamelCaseInputSchema(t *testing.T) {
toolsRaw := []any{
map[string]any{
"name": "Write",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{
"content": map[string]any{"type": "string"},
},
},
},
}
calls := []ParsedToolCall{{Name: "Write", Input: map[string]any{"content": map[string]any{"message": "hi"}}}}
got := NormalizeParsedToolCallsForSchemas(calls, toolsRaw)
if got[0].Input["content"] != `{"message":"hi"}` {
t.Fatalf("expected camelCase inputSchema content coercion, got %#v", got[0].Input["content"])
}
}
func TestNormalizeParsedToolCallsForSchemasPreservesArrayWhenSchemaSaysArray(t *testing.T) {
toolsRaw := []any{
map[string]any{
"name": "todowrite",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{
"todos": map[string]any{
"type": "array",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"content": map[string]any{"type": "string"},
"status": map[string]any{"type": "string"},
"priority": map[string]any{"type": "string"},
},
},
},
},
},
},
}
todos := []any{map[string]any{"content": "x", "status": "pending", "priority": "high"}}
calls := []ParsedToolCall{{Name: "todowrite", Input: map[string]any{"todos": todos}}}
got := NormalizeParsedToolCallsForSchemas(calls, toolsRaw)
if !reflect.DeepEqual(got[0].Input["todos"], todos) {
t.Fatalf("expected todos array preserved, got %#v want %#v", got[0].Input["todos"], todos)
}
}

View File

@@ -126,9 +126,12 @@ function binaryExists() {
// 查找占用端口的进程 PID
function findPidByPort(port) {
const numericPort = parseInt(port, 10);
if (isNaN(numericPort)) return [];
try {
if (isWindows) {
const output = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, {
const output = execSync(`netstat -ano | findstr :${numericPort} | findstr LISTENING`, {
encoding: 'utf-8',
shell: true,
stdio: ['pipe', 'pipe', 'ignore'],
@@ -141,7 +144,7 @@ function findPidByPort(port) {
}
return [...pids];
} else {
const output = execSync(`lsof -ti :${port}`, {
const output = execSync(`lsof -ti :${numericPort}`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'],
});
@@ -217,7 +220,7 @@ async function installFrontendDeps() {
const proc = spawn('npm', ['ci', '--registry', MIRRORS.npm], {
cwd: CONFIG.webuiDir,
stdio: 'inherit',
shell: true,
shell: isWindows,
});
proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端依赖安装失败')));
});
@@ -239,7 +242,7 @@ async function buildBackend() {
const proc = spawn('go', ['build', '-o', BINARY, './cmd/ds2api'], {
cwd: __dirname,
stdio: 'inherit',
shell: true,
shell: isWindows,
env: { ...process.env, GOPROXY: MIRRORS.goproxy },
});
proc.on('close', code => code === 0 ? resolve() : reject(new Error('后端编译失败')));
@@ -257,22 +260,21 @@ async function buildWebui() {
return new Promise((resolve, reject) => {
const proc = spawn(
'npm', ['run', 'build', '--', '--outDir', CONFIG.staticAdminDir, '--emptyOutDir'],
{ cwd: CONFIG.webuiDir, stdio: 'inherit', shell: true }
{ cwd: CONFIG.webuiDir, stdio: 'inherit', shell: isWindows }
);
proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端构建失败')));
});
}
// 启动后端开发模式go run无需预编译
async function startBackendDev() {
if (!checkGo()) throw new Error('未找到 Go请先安装 Go (https://go.dev/dl/)');
log.info(`启动后端go run... 本地 http://127.0.0.1:${CONFIG.port} 绑定 0.0.0.0:${CONFIG.port}`);
const proc = spawn('go', ['run', './cmd/ds2api'], {
async function startBackendDev() {
if (!checkGo()) throw new Error('未找到 Go请先安装 Go (https://go.dev/dl/)');
log.info(`启动后端go run... 本地 http://127.0.0.1:${CONFIG.port} 绑定 0.0.0.0:${CONFIG.port}`);
const proc = spawn('go', ['run', './cmd/ds2api'], {
cwd: __dirname,
stdio: 'inherit',
shell: true,
env: {
...process.env,
shell: isWindows,
env: { ...process.env,
PORT: CONFIG.port,
LOG_LEVEL: CONFIG.logLevel,
DS2API_ADMIN_KEY: CONFIG.adminKey,
@@ -284,13 +286,13 @@ async function startBackendDev() {
}
// 启动后端(生产模式:运行编译好的二进制)
async function startBackendProd() {
if (!binaryExists()) {
log.warn('未找到编译产物,正在编译...');
await buildBackend();
}
log.info(`启动后端(二进制)... 本地 http://127.0.0.1:${CONFIG.port} 绑定 0.0.0.0:${CONFIG.port}`);
const proc = spawn(BINARY, [], {
async function startBackendProd() {
if (!binaryExists()) {
log.warn('未找到编译产物,正在编译...');
await buildBackend();
}
log.info(`启动后端(二进制)... 本地 http://127.0.0.1:${CONFIG.port} 绑定 0.0.0.0:${CONFIG.port}`);
const proc = spawn(BINARY, [], {
cwd: __dirname,
stdio: 'inherit',
shell: false,
@@ -323,14 +325,14 @@ async function startFrontend() {
}
// 显示状态信息
function showStatus() {
console.log('\n' + '─'.repeat(50));
log.success(`后端 API: http://127.0.0.1:${CONFIG.port}`);
log.success(`管理界面: http://127.0.0.1:${CONFIG.port}/admin`);
log.info(`后端绑定: 0.0.0.0:${CONFIG.port} (可通过局域网 IP 访问)`);
if (existsSync(CONFIG.webuiDir)) {
log.success(`前端 Dev: http://localhost:${CONFIG.frontendPort}`);
}
function showStatus() {
console.log('\n' + '─'.repeat(50));
log.success(`后端 API: http://127.0.0.1:${CONFIG.port}`);
log.success(`管理界面: http://127.0.0.1:${CONFIG.port}/admin`);
log.info(`后端绑定: 0.0.0.0:${CONFIG.port} (可通过局域网 IP 访问)`);
if (existsSync(CONFIG.webuiDir)) {
log.success(`前端 Dev: http://localhost:${CONFIG.frontendPort}`);
}
console.log('─'.repeat(50));
log.info('按 Ctrl+C 停止所有服务\n');
}

View File

@@ -188,6 +188,30 @@ test('parseToolCalls treats single-item CDATA body as array', () => {
assert.deepEqual(calls[0].input.todos, ['one']);
});
test('formatOpenAIStreamToolCalls normalizes camelCase inputSchema string fields', () => {
const formatted = formatOpenAIStreamToolCalls([
{ name: 'Write', input: { content: { message: 'hi' }, taskId: 1 } },
], new Map(), [
{ name: 'Write', inputSchema: { type: 'object', properties: { content: { type: 'string' }, taskId: { type: 'string' } } } },
]);
assert.equal(formatted.length, 1);
const args = JSON.parse(formatted[0].function.arguments);
assert.equal(args.content, '{"message":"hi"}');
assert.equal(args.taskId, '1');
});
test('formatOpenAIStreamToolCalls preserves arrays when schema says array', () => {
const todos = [{ content: 'x', status: 'pending', priority: 'high' }];
const formatted = formatOpenAIStreamToolCalls([
{ name: 'todowrite', input: { todos } },
], new Map(), [
{ name: 'todowrite', inputSchema: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object' } } } } },
]);
assert.equal(formatted.length, 1);
const args = JSON.parse(formatted[0].function.arguments);
assert.deepEqual(args.todos, todos);
});
test('parseToolCalls treats CDATA object fragment as object', () => {
const fragment = '<question><![CDATA[Pick one]]></question><options><item><label><![CDATA[A]]></label></item><item><label><![CDATA[B]]></label></item></options>';
const payload = `<tool_calls><invoke name="AskUserQuestion"><parameter name="questions"><![CDATA[${fragment}]]></parameter></invoke></tool_calls>`;

View File

@@ -1,4 +1,4 @@
import { ArrowDown, ArrowUp, Bot, ChevronDown, Clock3, Loader2, MessageSquareText, RefreshCcw, Sparkles, Trash2, UserRound, X } from 'lucide-react'
import { ArrowDown, ArrowUp, Bot, ChevronDown, Clock3, Copy, Download, Loader2, MessageSquareText, RefreshCcw, Sparkles, Trash2, UserRound, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
@@ -9,9 +9,14 @@ const DISABLED_LIMIT = 0
const MESSAGE_COLLAPSE_AT = 700
const VIEW_MODE_KEY = 'ds2api_chat_history_view_mode'
const BEGIN_SENTENCE_MARKER = '<begin▁of▁sentence>'
const SYSTEM_MARKER = '<System>'
const USER_MARKER = '<User>'
const ASSISTANT_MARKER = '<Assistant>'
const TOOL_MARKER = '<Tool>'
const END_INSTRUCTIONS_MARKER = '<end▁of▁instructions>'
const END_SENTENCE_MARKER = '<end▁of▁sentence>'
const END_TOOL_RESULTS_MARKER = '<end▁of▁toolresults>'
const CURRENT_INPUT_FILE_PROMPT = 'The current request and prior conversation context have already been provided. Answer the latest user request directly.'
function formatDateTime(value, lang) {
if (!value) return '-'
@@ -109,6 +114,54 @@ function MergeModeIcon() {
)
}
function downloadTextFile(filename, text) {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
function fallbackCopyText(text) {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.setAttribute('readonly', '')
textArea.style.position = 'fixed'
textArea.style.top = '-9999px'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
let copied = false
try {
copied = document.execCommand('copy')
} finally {
document.body.removeChild(textArea)
}
if (!copied) {
throw new Error('copy failed')
}
}
async function copyTextWithFallback(text) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return
}
} catch {
// Fall through to execCommand fallback.
}
fallbackCopyText(text)
}
function skipWhitespace(text, start) {
let cursor = start
while (cursor < text.length && /\s/.test(text[cursor])) {
@@ -131,7 +184,9 @@ function parseStrictHistoryMessages(historyText) {
while (cursor < transcript.length) {
if (expectedRole === null) {
if (transcript.startsWith(USER_MARKER, cursor)) {
if (transcript.startsWith(SYSTEM_MARKER, cursor)) {
expectedRole = 'system'
} else if (transcript.startsWith(USER_MARKER, cursor)) {
expectedRole = 'user'
} else if (transcript.startsWith(ASSISTANT_MARKER, cursor)) {
expectedRole = 'assistant'
@@ -142,13 +197,32 @@ function parseStrictHistoryMessages(historyText) {
}
}
if (transcript.startsWith(SYSTEM_MARKER, cursor)) {
if (expectedRole !== 'system') return null
cursor += SYSTEM_MARKER.length
const nextInstructionsEnd = transcript.indexOf(END_INSTRUCTIONS_MARKER, cursor)
if (nextInstructionsEnd < 0) return null
parsed.push({
role: 'system',
content: transcript.slice(cursor, nextInstructionsEnd),
})
cursor = nextInstructionsEnd + END_INSTRUCTIONS_MARKER.length
expectedRole = 'user'
continue
}
if (transcript.startsWith(USER_MARKER, cursor)) {
if (expectedRole !== 'user') return null
if (expectedRole !== 'user' && expectedRole !== 'user_or_tool' && expectedRole !== 'assistant_or_user') return null
cursor += USER_MARKER.length
const nextAssistant = transcript.indexOf(ASSISTANT_MARKER, cursor)
const nextTool = transcript.indexOf(TOOL_MARKER, cursor)
const nextSentenceEnd = transcript.indexOf(END_SENTENCE_MARKER, cursor)
if (nextAssistant < 0) return null
if (nextSentenceEnd >= 0 && nextSentenceEnd < nextAssistant) {
let nextRoleIndex = nextAssistant
if (nextRoleIndex < 0 || (nextTool >= 0 && nextTool < nextRoleIndex)) {
nextRoleIndex = nextTool
}
if (nextRoleIndex < 0) return null
if (nextSentenceEnd >= 0 && nextSentenceEnd < nextRoleIndex) {
const assistantStart = skipWhitespace(transcript, nextSentenceEnd + END_SENTENCE_MARKER.length)
if (!transcript.startsWith(ASSISTANT_MARKER, assistantStart)) return null
parsed.push({
@@ -161,21 +235,26 @@ function parseStrictHistoryMessages(historyText) {
}
parsed.push({
role: 'user',
content: transcript.slice(cursor, nextAssistant),
content: transcript.slice(cursor, nextRoleIndex),
})
const assistantStart = nextAssistant + ASSISTANT_MARKER.length
if (transcript.startsWith(TOOL_MARKER, nextRoleIndex)) {
cursor = nextRoleIndex
expectedRole = 'tool'
continue
}
const assistantStart = nextRoleIndex + ASSISTANT_MARKER.length
if (transcript.indexOf(END_SENTENCE_MARKER, assistantStart) < 0) {
trailingAssistantPromptOnly = true
cursor = assistantStart
break
}
cursor = nextAssistant
cursor = nextRoleIndex
expectedRole = 'assistant'
continue
}
if (transcript.startsWith(ASSISTANT_MARKER, cursor)) {
if (expectedRole !== 'assistant') return null
if (expectedRole !== 'assistant' && expectedRole !== 'assistant_or_user') return null
cursor += ASSISTANT_MARKER.length
const nextSentenceEnd = transcript.indexOf(END_SENTENCE_MARKER, cursor)
if (nextSentenceEnd < 0) return null
@@ -184,11 +263,28 @@ function parseStrictHistoryMessages(historyText) {
content: transcript.slice(cursor, nextSentenceEnd),
})
cursor = nextSentenceEnd + END_SENTENCE_MARKER.length
expectedRole = 'user'
expectedRole = 'user_or_tool'
continue
}
if (parsed.length && expectedRole === 'user') break
if (transcript.startsWith(TOOL_MARKER, cursor)) {
if (expectedRole !== 'tool' && expectedRole !== 'user' && expectedRole !== 'user_or_tool') return null
cursor += TOOL_MARKER.length
const nextToolResultsEnd = transcript.indexOf(END_TOOL_RESULTS_MARKER, cursor)
if (nextToolResultsEnd < 0) return null
parsed.push({
role: 'tool',
content: transcript.slice(cursor, nextToolResultsEnd),
})
cursor = nextToolResultsEnd + END_TOOL_RESULTS_MARKER.length
expectedRole = 'assistant_or_user'
continue
}
if (
parsed.length
&& (expectedRole === 'user' || expectedRole === 'user_or_tool' || expectedRole === 'assistant_or_user')
) break
if (transcript.slice(cursor).trim() === '') break
return null
}
@@ -214,6 +310,14 @@ function buildListModeMessages(item, t) {
return { messages: liveMessages, historyMerged: false }
}
const placeholderOnly = liveMessages.length === 1
&& String(liveMessages[0]?.role || '').trim().toLowerCase() === 'user'
&& String(liveMessages[0]?.content || '').trim() === CURRENT_INPUT_FILE_PROMPT
if (placeholderOnly) {
return { messages: historyMessages, historyMerged: true }
}
const insertAt = liveMessages.findIndex(message => {
const role = String(message?.role || '').trim().toLowerCase()
return role !== 'system' && role !== 'developer'
@@ -275,8 +379,28 @@ function RequestMessages({ item, t, messages }) {
)
}
function MergedPromptView({ item, t }) {
function MergedPromptView({ item, t, onMessage }) {
const merged = item?.final_prompt || ''
const mergedFilename = `Merged_${item?.id || 'prompt'}.txt`
const handleCopy = async () => {
try {
await copyTextWithFallback(merged)
onMessage?.('success', t('chatHistory.copySuccess'))
} catch {
onMessage?.('error', t('chatHistory.copyFailed'))
}
}
const handleDownload = () => {
try {
downloadTextFile(mergedFilename, merged)
onMessage?.('success', t('chatHistory.downloadSuccess'))
} catch {
onMessage?.('error', t('chatHistory.downloadFailed'))
}
}
return (
<div
className="max-w-4xl mx-auto rounded-2xl border px-5 py-4"
@@ -285,8 +409,28 @@ function MergedPromptView({ item, t }) {
borderColor: 'rgba(231, 176, 8, 0.45)',
}}
>
<div className="text-[11px] uppercase tracking-[0.12em] text-[#5b4300] mb-3">
{t('chatHistory.mergedInput')}
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[11px] uppercase tracking-[0.12em] text-[#5b4300]">
{t('chatHistory.mergedInput')}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopy}
className="h-8 w-8 rounded-lg text-[#5b4300] hover:text-black hover:bg-[#fff8db]/45 flex items-center justify-center transition-colors"
title={t('chatHistory.copyMerged')}
>
<Copy className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleDownload}
className="h-8 w-8 rounded-lg text-[#5b4300] hover:text-black hover:bg-[#fff8db]/45 flex items-center justify-center transition-colors"
title={t('chatHistory.downloadMerged')}
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
<div className="text-sm leading-7 text-[#2f2200] whitespace-pre-wrap break-words font-mono">
<ExpandableText
@@ -300,14 +444,53 @@ function MergedPromptView({ item, t }) {
)
}
function HistoryTextView({ item, t }) {
function HistoryTextView({ item, t, onMessage }) {
const historyText = (item?.history_text || '').trim()
if (!historyText) return null
const historyFilename = `History_${item?.id || 'history'}.txt`
const handleCopy = async () => {
try {
await copyTextWithFallback(historyText)
onMessage?.('success', t('chatHistory.copySuccess'))
} catch {
onMessage?.('error', t('chatHistory.copyFailed'))
}
}
const handleDownload = () => {
try {
downloadTextFile(historyFilename, historyText)
onMessage?.('success', t('chatHistory.downloadSuccess'))
} catch {
onMessage?.('error', t('chatHistory.downloadFailed'))
}
}
return (
<div className="max-w-4xl mx-auto rounded-2xl border border-border bg-background px-5 py-4">
<div className="text-[11px] uppercase tracking-[0.12em] text-muted-foreground mb-3 text-left">
HISTORY
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[11px] uppercase tracking-[0.12em] text-muted-foreground text-left">
HISTORY
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopy}
className="h-8 w-8 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
title={t('chatHistory.copyHistory')}
>
<Copy className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleDownload}
className="h-8 w-8 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
title={t('chatHistory.downloadHistory')}
>
<Download className="w-4 h-4" />
</button>
</div>
</div>
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap break-words font-mono">
<ExpandableText
@@ -322,18 +505,18 @@ function HistoryTextView({ item, t }) {
)
}
function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName }) {
function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName, onMessage }) {
if (!selectedItem) return null
const listModeState = viewMode === 'list' ? buildListModeMessages(selectedItem, t) : null
const showHistoryAtTop = viewMode !== 'list' || !listModeState?.historyMerged
return (
<>
{showHistoryAtTop && <HistoryTextView item={selectedItem} t={t} />}
{showHistoryAtTop && <HistoryTextView item={selectedItem} t={t} onMessage={onMessage} />}
{viewMode === 'list'
? <RequestMessages item={selectedItem} t={t} messages={listModeState?.messages} />
: <MergedPromptView item={selectedItem} t={t} />}
: <MergedPromptView item={selectedItem} t={t} onMessage={onMessage} />}
<div ref={assistantStartRef} className="flex gap-4 max-w-4xl mx-auto">
<div className={clsx(
@@ -908,6 +1091,7 @@ export default function ChatHistoryContainer({ authFetch, onMessage }) {
detailScrollRef={detailScrollRef}
assistantStartRef={assistantStartRef}
bottomButtonClassName="absolute right-5 bottom-5"
onMessage={onMessage}
/>
)}
</div>

View File

@@ -284,6 +284,14 @@
"selectPrompt": "Select a record on the left to view details.",
"mergedInput": "Final message sent to DeepSeek",
"emptyMergedPrompt": "No merged prompt is available.",
"copyHistory": "Copy HISTORY",
"downloadHistory": "Download HISTORY",
"copyMerged": "Copy merged prompt",
"downloadMerged": "Download merged prompt",
"copySuccess": "Copied successfully.",
"copyFailed": "Copy failed.",
"downloadSuccess": "Downloaded successfully.",
"downloadFailed": "Download failed.",
"expand": "Expand",
"collapse": "Collapse",
"reasoningTrace": "Reasoning Trace",

View File

@@ -284,6 +284,14 @@
"selectPrompt": "从左侧选择一条记录查看详情。",
"mergedInput": "最终发送给 DeepSeek 的完整消息",
"emptyMergedPrompt": "没有可展示的完整消息。",
"copyHistory": "复制 HISTORY",
"downloadHistory": "下载 HISTORY",
"copyMerged": "复制完整消息",
"downloadMerged": "下载完整消息",
"copySuccess": "复制成功",
"copyFailed": "复制失败",
"downloadSuccess": "下载成功",
"downloadFailed": "下载失败",
"expand": "展开全部",
"collapse": "收起",
"reasoningTrace": "思维链过程",