fix(openai): 解析/截断/续写兼容 DeepSeek DSML 工具调用方言;bump 0.6.5

This commit is contained in:
nyoung committed 2026-10-09 09:45:30 +08:00
1 parent fed260559c
commit 7f7ab190aa
8 files changed
+444 -16

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# dsfree2api — build / test helpers
GO ?= go
VERSION ?= 0.6.4
VERSION ?= 0.6.5
LDFLAGS := -s -w -X github.com/nyoungo/dsfree2api/internal/admin.Version=$(VERSION)
BIN := bin/dsfree2api
IMAGE ?= dsfree2api:latest
+1 -1
View File
@@ -34,7 +34,7 @@ const (
)
// Version is reported by the console and /api/overview.
var Version = "0.6.4"
var Version = "0.6.5"
//go:embed web/*
var webFS embed.FS
+38 -2
View File
@@ -28,6 +28,9 @@ func ContinuationContext(partial string, head, tail int) string {
// caps each generation (~1k tokens), so large tool-call JSON arrives in
// pieces; the model must continue byte-for-byte instead of restarting.
func ContinueInstruction(partial string, round, maxRounds int) string {
if preferDSMLContinue(partial) {
return dsmlContinueInstruction(partial, round, maxRounds)
}
tail := partial
if n := len(tail); n > 80 {
tail = tail[n-80:]
@@ -51,9 +54,36 @@ func ContinueInstruction(partial string, round, maxRounds int) string {
deadline, utf8.RuneCountInString(partial), tail)
}
// dsmlContinueInstruction 是 DSML 方言的续写指令:要求模型按原标记格式
// 逐字符续写并补齐缺失的闭合标签。XML 内容里的换行是合法字符,因此不套用
// JSON 分支的“换行必须写成 \n 转义”规则。
func dsmlContinueInstruction(partial string, round, maxRounds int) string {
tail := partial
if n := len(tail); n > 80 {
tail = tail[n-80:]
}
deadline := fmt.Sprintf("这是第 %d/%d 次续写。", round, maxRounds)
if round >= maxRounds {
deadline += "这是最后一次续写机会:本轮必须先收束剩余内容(补齐未完成的标记),随后立即闭合全部标签并结束回复,禁止再展开任何新段落。"
} else {
deadline += "每轮只能输出约 2300 字符;若主体已完成或接近完成,请立即补齐缺失的闭合标签收尾,不要无限扩展。"
}
return fmt.Sprintf(
"上一条 assistant 回复因通道输出上限被截断(上面保留了它的开头与最新末尾,中间已省略),%s目前已输出约 %d 个字符,最后 80 个字符是:\n%s\n\n"+
"请只输出【续写片段】:从上面末尾片段的最后一个字符之后开始逐字符接着写,直到整个工具调用标记完整闭合。"+
"严格禁止:重复任何已输出内容、以 < 或 { 开头、重新生成整段工具调用结构、任何解释或前后缀。"+
"参数内容写完后必须立即按原格式依次补齐缺失的闭合标签(parameter、invoke 及外层 calls),然后立刻结束回复;"+
"参数内容中的换行、制表符按原样输出即可,无需转义。"+
"禁止输出任何解释、Markdown 标记或后记文字。"+
"注意合理收尾:内容已经很长,若主体已经完成就立即闭合标签结束,不要无限扩展新段落。"+
"你的回复的第一个字符必须正好是被截断处的下一个字符。",
deadline, utf8.RuneCountInString(partial), tail)
}
// ToolCallTruncated reports whether text looks like a forced tool-call JSON
// reply (see toolsPrompt) that the channel cut off before it could close.
// Fenced ```json blocks are unwrapped first, mirroring TryParseToolCalls.
// Text without a JSON payload falls back to the DSML dialect check.
func ToolCallTruncated(text string) bool {
cand := strings.TrimSpace(text)
if !strings.HasPrefix(cand, "{") {
@@ -68,7 +98,8 @@ func ToolCallTruncated(text string) bool {
// evaluate the payload itself, mirroring TryParseToolCalls.
i := strings.Index(cand, `{"tool_calls"`)
if i <= 0 {
return false
// 没有 JSON 载荷:再看是不是被截断的 DSML 方言标记。
return dsmlTruncated(text)
}
cand = cand[i:]
}
@@ -274,7 +305,12 @@ func RunContinued(
break
}
raw := seg.String()
piece := TrimOverlap(partial, EscapeControlsAfter(partial, raw))
// DSML/XML 内容里的换行是合法字符,不能套用 JSON 字符串的转义规则。
healed := raw
if !preferDSMLContinue(partial) {
healed = EscapeControlsAfter(partial, raw)
}
piece := TrimOverlap(partial, healed)
if piece == "" {
warn("tool-call continuation produced no new text", "round", round+1, "seg_chars", len(raw))
break
+95
View File
@@ -0,0 +1,95 @@
package openai
import (
"regexp"
"strings"
)
// DeepSeek 模型偶尔会退回自己的 DSML 标记方言输出工具调用,形如:
//
// <||DSML|| calls>
// <||DSML|| invoke name="write">
// <||DSML|| parameter name="filePath" string="true">a.html</||DSML|| parameter>
// </||DSML|| invoke>
// </||DSML|| calls>
//
// 分隔竖线可能全角(U+FF5C)也可能半角('|')、个数不固定(还有
// <|DSML|> calls> 这种写法),且截断时闭标签整段缺失。解析策略是先把
// DSML 标签改写成普通 XML 标签,再复用既有的 tagged 路径。
//
// 组1=开/闭前缀(< 或 </),组2=标签名;标记之后的属性与 '>' 不消费,
// 保证 invoke name="…" 的属性原样保留。
var dsmlTagRe = regexp.MustCompile(`(?i)(</?)\s*[||]{1,4}DSML[||]{1,4}>?\s*([a-z_]\w*)`)
// normalizeDSML 把 DSML 方言标签改写成普通 XML 标签:竖线标记整体去掉,
// 标签名统一小写,calls 归并为已知起点标签 tool_calls。返回 (文本, 是否
// 确有标签被改写);不含 DSML 标记时原样返回,避免每轮解析都跑正则。
func normalizeDSML(text string) (string, bool) {
if !strings.Contains(strings.ToLower(text), "dsml") {
return text, false
}
out := dsmlTagRe.ReplaceAllStringFunc(text, func(m string) string {
sub := dsmlTagRe.FindStringSubmatch(m)
if sub == nil {
return m
}
name := strings.ToLower(sub[2])
if name == "calls" {
name = "tool_calls"
}
return sub[1] + name
})
return out, out != text
}
// ensureTaggedStart 给归一化后缺少已知起点标签的裸 invoke/function 块补一个
// <tool_calls> 起点:findStartTag 只认内置/自定义起点标签,DSML 若没写
// calls 外层,不补起点就进不了 tagged 解析路径。
func ensureTaggedStart(text string, opts ParseOptions) string {
lower := strings.ToLower(text)
for _, tag := range []string{"<invoke", "<function"} {
i := strings.Index(lower, tag)
if i < 0 {
continue
}
if _, _, _, ok := findStartTag(text[:i], opts); ok {
return text // 起点已在该块之前
}
return text[:i] + "<tool_calls>" + text[i:]
}
return text
}
// dsmlTruncated 报告 DSML 方言的工具调用块是否被通道输出上限截断:开标签
// 多于闭标签即视为未闭合。只认 DSML 方言本身,避免普通散文里的 XML 示例
// 误触发续写轮次。
func dsmlTruncated(text string) bool {
norm, ok := normalizeDSML(text)
if !ok {
return false
}
// 代码围栏里的标记是文档示例,解析层会跳过,同样不触发续写轮次。
start := -1
for _, tag := range []string{"<tool_calls>", "<invoke"} {
if i := strings.Index(norm, tag); i >= 0 && (start < 0 || i < start) {
start = i
}
}
if start >= 0 && isInsideCodeFence(norm, start) {
return false
}
if strings.Count(norm, "<invoke") > strings.Count(norm, "</invoke>") {
return true
}
return strings.Count(norm, "<tool_calls>") > strings.Count(norm, "</tool_calls>")
}
// preferDSMLContinue 报告续写片段应按 DSML/XML 方言组织而非 JSON:文本里
// 有 DSML 标记且没有 JSON 工具载荷(有载荷时截断的是 JSON,仍走 JSON 指令)。
func preferDSMLContinue(partial string) bool {
if strings.Contains(partial, `{"tool_calls"`) {
return false
}
_, ok := normalizeDSML(partial)
return ok
}
+240
View File
@@ -0,0 +1,240 @@
package openai
import (
"context"
"strings"
"testing"
)
// dsmlBlock 是用户原始样本的完整形态:双全角竖线标记、calls/invoke/
// parameter 三层全部闭合。
func dsmlBlock() string {
return `<||DSML|| calls><||DSML|| invoke name="write">` +
`<||DSML|| parameter name="filePath" string="true">tihu.html</||DSML|| parameter>` +
`<||DSML|| parameter name="content" string="true"><html><body>hi</body></html></||DSML|| parameter>` +
`</||DSML|| invoke></||DSML|| calls>`
}
func TestParseDSMLInvokeCalls(t *testing.T) {
remaining, calls, ok := TryParseToolCallsForTools(dsmlBlock(), toolsFor("write"))
if !ok || len(calls) != 1 {
t.Fatalf("ok=%v calls=%+v", ok, calls)
}
if remaining != "" {
t.Fatalf("remaining = %q, want empty", remaining)
}
if calls[0].Function.Name != "write" {
t.Fatalf("name = %q", calls[0].Function.Name)
}
if got := argsField(t, calls[0], "filePath"); got != "tihu.html" {
t.Fatalf("filePath = %q", got)
}
if got := argsField(t, calls[0], "content"); got != "<html><body>hi</body></html>" {
t.Fatalf("content = %q", got)
}
}
// 用户原始样本:截断场景 —— 最后一个 parameter 断在半截,没有任何闭标签。
func TestParseDSMLTruncatedInvoke(t *testing.T) {
raw := `<||DSML|| calls><||DSML|| invoke name="write">` +
`<||DSML|| parameter name="filePath" string="true">tihu.html</||DSML|| parameter>` +
`<||DSML|| parameter name="content" string="true"><html><body><canvas id="scene">stopped mid`
_, calls, ok := TryParseToolCallsForTools(raw, toolsFor("write"))
if !ok || len(calls) != 1 {
t.Fatalf("ok=%v calls=%+v", ok, calls)
}
if calls[0].Function.Name != "write" {
t.Fatalf("name = %q", calls[0].Function.Name)
}
if got := argsField(t, calls[0], "filePath"); got != "tihu.html" {
t.Fatalf("filePath = %q", got)
}
want := `<html><body><canvas id="scene">stopped mid`
if got := argsField(t, calls[0], "content"); got != want {
t.Fatalf("content = %q, want %q", got, want)
}
}
// 半角单竖线写法(<|DSML|> calls>)同样要能解析。
func TestParseDSMLPipeVariant(t *testing.T) {
raw := `<|DSML|> calls><|DSML|> invoke name="get_weather">` +
`<|DSML|> parameter name="city">北京</|DSML|> parameter>` +
`</|DSML|> invoke></|DSML|> calls>`
_, calls, ok := TryParseToolCalls(raw)
if !ok || len(calls) != 1 {
t.Fatalf("ok=%v calls=%+v", ok, calls)
}
if calls[0].Function.Name != "get_weather" || argsField(t, calls[0], "city") != "北京" {
t.Fatalf("call = %+v", calls[0])
}
}
// 没写 calls 外层的裸 invoke 块:归一化后补 <tool_calls> 起点进 tagged 路径。
func TestParseDSMLStandaloneInvoke(t *testing.T) {
raw := `<||DSML|| invoke name="read">` +
`<||DSML|| parameter name="path">/a.txt</||DSML|| parameter>` +
`</||DSML|| invoke>`
_, calls, ok := TryParseToolCalls(raw)
if !ok || len(calls) != 1 {
t.Fatalf("ok=%v calls=%+v", ok, calls)
}
if calls[0].Function.Name != "read" || argsField(t, calls[0], "path") != "/a.txt" {
t.Fatalf("call = %+v", calls[0])
}
}
// 工具调用块两侧的散文要原样保留在 remaining 里。
func TestParseDSMLKeepsProse(t *testing.T) {
raw := "好的,这就写。\n" + dsmlBlock() + "\n写完了。"
remaining, calls, ok := TryParseToolCalls(raw)
if !ok || len(calls) != 1 {
t.Fatalf("ok=%v calls=%+v", ok, calls)
}
if !strings.Contains(remaining, "好的") || !strings.Contains(remaining, "写完了") {
t.Fatalf("remaining = %q", remaining)
}
}
// 参数内容里形如 [{"name": …}] 的片段是被写入的文件数据,不能被 JSON
// 候选路径抢成假调用。
func TestParseDSMLContentJSONNotHijacked(t *testing.T) {
raw := `<||DSML|| calls><||DSML|| invoke name="write">` +
`<||DSML|| parameter name="content" string="true">[{"name": "inner"}]</||DSML|| parameter>` +
`</||DSML|| invoke></||DSML|| calls>`
_, calls, ok := TryParseToolCallsForTools(raw, toolsFor("write"))
if !ok || len(calls) != 1 {
t.Fatalf("ok=%v calls=%+v", ok, calls)
}
if calls[0].Function.Name != "write" {
t.Fatalf("content 里的 JSON 抢走了调用名: %+v", calls[0])
}
if !strings.Contains(calls[0].Function.Arguments, "inner") {
t.Fatalf("arguments = %q", calls[0].Function.Arguments)
}
}
// 调用名不在已提供的工具清单里时,DSML 块按普通文本返回,不产出假调用。
func TestParseDSMLRejectsUnknownTool(t *testing.T) {
raw := `<||DSML|| invoke name="not_a_tool">` +
`<||DSML|| parameter name="x">1</||DSML|| parameter></||DSML|| invoke>`
if _, calls, ok := TryParseToolCallsForTools(raw, toolsFor("write")); ok {
t.Fatalf("未知工具名被当成了真实调用: %+v", calls)
}
}
// 代码围栏里的 DSML 示例是文档,不是调用。
func TestParseDSMLInsideCodeFenceSkipped(t *testing.T) {
raw := "示例:\n```text\n" + dsmlBlock() + "\n```"
if _, calls, ok := TryParseToolCalls(raw); ok {
t.Fatalf("围栏示例被当成了调用: %+v", calls)
}
}
func TestNormalizeDSML(t *testing.T) {
cases := []struct{ in, want string }{
{`<||DSML|| calls>`, `<tool_calls>`},
{`</||DSML|| calls>`, `</tool_calls>`},
{`<||DSML|| invoke name="write">`, `<invoke name="write">`},
{"</" + "||DSML|| parameter>", "</" + "parameter>"},
{`<|DSML|> invoke name="x">`, `<invoke name="x">`},
{"</" + "|DSML| parameter>", "</" + "parameter>"},
{`<|DSML| parameter name="a">`, `<parameter name="a">`},
{"没有 DSML 标记的普通文本", "没有 DSML 标记的普通文本"},
}
for _, c := range cases {
got, changed := normalizeDSML(c.in)
if got != c.want {
t.Errorf("normalizeDSML(%q) = %q, want %q", c.in, got, c.want)
}
if wantChanged := c.in != c.want; changed != wantChanged {
t.Errorf("normalizeDSML(%q) changed = %v, want %v", c.in, changed, wantChanged)
}
}
}
func TestToolCallTruncatedDSML(t *testing.T) {
open := `<||DSML|| calls><||DSML|| invoke name="write">` +
`<||DSML|| parameter name="content">abc`
closed := open + `</||DSML|| parameter></||DSML|| invoke></||DSML|| calls>`
fenced := "```text\n" + open + "\n```"
prose := "不要用 DSML 标记输出工具调用。"
cases := []struct {
in string
want bool
}{
{open, true},
{closed, false},
{fenced, false},
{prose, false},
{"", false},
}
for _, c := range cases {
if got := ToolCallTruncated(c.in); got != c.want {
t.Errorf("ToolCallTruncated(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestContinueInstructionDSML(t *testing.T) {
partial := strings.Repeat("x", 120) + `<||DSML|| invoke name="write">` + "TAIL80CHARS"
got := ContinueInstruction(partial, 2, 5)
for _, want := range []string{"2/5", "TAIL80CHARS", "闭合标签", "parameter"} {
if !strings.Contains(got, want) {
t.Errorf("DSML 续写指令缺少 %q:\n%s", want, got)
}
}
// JSON 分支的收尾 8 字符规则不能混进来。
if strings.Contains(got, "右花括号") {
t.Errorf("DSML 续写指令混入了 JSON 收尾规则:\n%s", got)
}
// 含 JSON 载荷时仍走 JSON 指令。
jsonPartial := strings.Repeat("x", 120) + `{"tool_calls": [{"trunc`
if j := ContinueInstruction(jsonPartial, 2, 5); !strings.Contains(j, "右花括号") {
t.Errorf("JSON 部分未走 JSON 指令:\n%s", j)
}
}
// DSML 截断回复跨轮续写拼接:内容里的换行按 XML 语义原样保留,
// 不套用 JSON 字符串的 \n 转义。
func TestRunContinuedStitchesDSML(t *testing.T) {
turns := []string{
`<||DSML|| calls><||DSML|| invoke name="write">` +
`<||DSML|| parameter name="content" string="true">alpha "beta`,
" gamma\ndelta" +
`</||DSML|| parameter></||DSML|| invoke></||DSML|| calls>`,
}
prompts := 0
var got strings.Builder
err := RunContinued(context.Background(),
"first-prompt",
func(string, int) string { return "continue-prompt" },
3,
func(_ context.Context, prompt string, emit func(string) error) error {
prompts++
if prompts > len(turns) {
t.Fatalf("unexpected extra send #%d", prompts)
}
return emit(turns[prompts-1])
},
func(text string) error { got.WriteString(text); return nil },
func(string, ...any) {},
func(string, ...any) {},
)
if err != nil {
t.Fatalf("RunContinued: %v", err)
}
if prompts != 2 {
t.Fatalf("prompts = %d, want 2", prompts)
}
if ToolCallTruncated(got.String()) {
t.Fatal("stitched DSML reply still truncated")
}
_, calls, ok := TryParseToolCalls(got.String())
if !ok || len(calls) != 1 {
t.Fatalf("TryParseToolCalls failed on stitched reply: ok=%v calls=%+v", ok, calls)
}
want := "alpha \"beta gamma\ndelta"
if content := argsField(t, calls[0], "content"); content != want {
t.Fatalf("content = %q, want %q", content, want)
}
}
+3 -2
View File
@@ -239,8 +239,9 @@ func toolsPrompt(tools []ToolDef, toolChoice json.RawMessage) string {
`{"tool_calls": [{"id": "call_xxx", "type": "function", "function": {"name": "tool_name", "arguments": "{\"param\": \"value\"}"}}]}`,
"",
// DeepSeek 模型有时会退回自己的 DSML 标记方言(<|DSML|> calls> /
// invoke / parameter),网关不解析该方言,整段会被当普通文本透传,
// 客户端收不到 tool_calls——指令层显式禁止,只认上面的 JSON。
// invoke / parameter)。解析层已兼容该方言作为兜底(见 dsml.go),
// 但指令层仍显式禁止:JSON 是唯一保证完整送达的主路径,DSML 不走
// 流式增量、只在收尾整体补发,让模型偏离主路径没有好处。
"Never call tools with DSML/XML-style markup (for example `<|DSML|> calls>` or " +
"`<|DSML|> invoke name=\"...\">`). Those tags are discarded — only the " +
"JSON format above is accepted.",
+2 -2
View File
@@ -37,8 +37,8 @@ func TestBuildPromptWithTools(t *testing.T) {
}
// 红测试:上游模型曾用 DeepSeek 原生 DSML 标记(<|DSML|> calls> / invoke /
// parameter)代替 JSON 输出工具调用,网关三层(流式、解析、续写)都只认
// JSON,整段标记被当普通文本发给客户端。指令层必须显式禁止该方言。
// parameter)代替 JSON 输出工具调用。解析/续写层已兼容该方言兜底(dsml.go),
// 但 JSON 才是保证完整送达的主路径,指令层必须显式禁止该方言。
func TestToolsPromptForbidsDSML(t *testing.T) {
prompt := BuildPrompt([]ChatMessage{
{Role: "user", Content: MessageContent{Text: "Hi"}},
+64 -8
View File
@@ -45,7 +45,8 @@ func optionsForTools(tools []ToolDef) ParseOptions {
// output. It mirrors the Python/Rust implementations: plain JSON, ```json
// fences, generic ``` fences, XML/<tool_call>/<invoke> wrappers and the usual
// model repairs (unquoted keys, invalid backslashes, field aliases,
// swapped/ spilled arguments) are all accepted.
// swapped/ spilled arguments) are all accepted, plus DeepSeek's DSML dialect
// (see dsml.go) including truncated blocks with missing closing tags.
//
// Returns the remaining prose plus the parsed tool calls (ok=false when the
// text is ordinary chat).
@@ -61,12 +62,35 @@ func TryParseToolCallsForTools(text string, tools []ToolDef) (string, []ToolCall
// TryParseToolCallsWith is TryParseToolCalls with explicit options.
func TryParseToolCallsWith(text string, opts ParseOptions) (string, []ToolCall, bool) {
norm, isDSML := normalizeDSML(text)
// DSML 方言优先按 tagged 路径解析:参数内容里形如 [{"name":…}] 的片段是
// 正被写入的文件数据,若先跑 JSON 候选路径会被抢成假调用;解析失败时
// 仍回落到下面的常规两段路径。
if isDSML {
if remaining, calls, ok := parseTaggedToolCalls(ensureTaggedStart(norm, opts), opts); ok && matchesOfferedTools(calls, opts) {
return remaining, calls, true
}
}
if remaining, calls, ok := parseJSONToolCalls(text, opts); ok {
return remaining, calls, true
}
return parseTaggedToolCalls(text, opts)
}
// matchesOfferedTools 报告解析结果与工具清单是否相容:DSML 只是兜底方言,
// 调用名不在已提供的工具里时多半是散文引用了标记格式,按普通文本返回。
func matchesOfferedTools(calls []ToolCall, opts ParseOptions) bool {
if len(opts.ToolNames) == 0 {
return true
}
for _, c := range calls {
if !containsString(opts.ToolNames, c.Function.Name) {
return false
}
}
return len(calls) > 0
}
// ---------------------------------------------------------------------------
// JSON payload path
// ---------------------------------------------------------------------------
@@ -459,6 +483,14 @@ func parseTaggedToolCalls(text string, opts ParseOptions) (string, []ToolCall, b
// or object first, then the XML dialects.
func extractTaggedCalls(inner string, opts ParseOptions) []ToolCall {
fallbackName := extractXMLName(inner)
// 以 <invoke 开头的块是 XML 方言:先按标签解析,否则参数内容里恰好
// 形如 [{"name": …}] 的片段(例如正被写入的 JSON 文件)会被 JSON
// 路径抢走,产出一个假调用、丢掉真实调用。
if strings.HasPrefix(strings.ToLower(strings.TrimLeft(inner, " \t\r\n")), "<invoke") {
if calls := parseXMLInvokeCalls(inner, opts); len(calls) > 0 {
return calls
}
}
if calls := parseJSONInText(inner, opts, fallbackName); len(calls) > 0 {
return calls
}
@@ -511,10 +543,13 @@ func parseXMLInvokeCalls(inner string, opts ParseOptions) []ToolCall {
head := inner[abs : abs+gt+1]
bodyStart := abs + gt + 1
ci := strings.Index(lower[bodyStart:], "</invoke>")
if ci < 0 {
return calls
// 截断兜底:闭标签缺失时把剩余文本整体当作 body,最后一个
// parameter 的值由 parseXMLParams 取到末尾。
bodyEnd := len(inner)
if ci >= 0 {
bodyEnd = bodyStart + ci
}
body := inner[abs : bodyStart+ci]
body := inner[abs:bodyEnd]
name := xmlAttr(head, "name")
if name == "" {
name = xmlTagValue(body, "name")
@@ -522,6 +557,9 @@ func parseXMLInvokeCalls(inner string, opts ParseOptions) []ToolCall {
if name != "" {
calls = append(calls, makeXMLCall(name, parseXMLParams(body)))
}
if ci < 0 {
return calls
}
pos = bodyStart + ci + len("</invoke>")
}
}
@@ -592,6 +630,12 @@ func parseXMLParams(body string) map[string]any {
break
}
abs := pos + i
// `<param` 是 `<parameter` 的前缀:命中更长标签时跳过,交给
// 另一轮处理,否则会把 parameter 的值当成本轮截断尾部覆盖。
if abs+len(open) < len(lower) && isTagNameChar(lower[abs+len(open)]) {
pos = abs + len(open)
continue
}
gt := strings.IndexByte(body[abs:], '>')
if gt < 0 {
break
@@ -604,13 +648,21 @@ func parseXMLParams(body string) map[string]any {
}
name := xmlAttr(head, "name")
ci := strings.Index(lower[bodyStart:], closeTag)
if ci < 0 {
break
// 截断兜底:闭标签缺失时,值取到下一个同名开标签或 body 末尾。
end := len(body)
if ci >= 0 {
end = bodyStart + ci
} else if j := strings.Index(lower[bodyStart:], open); j >= 0 {
end = bodyStart + j
}
if name != "" {
params[name] = coerceXMLValue(body[bodyStart : bodyStart+ci])
params[name] = coerceXMLValue(body[bodyStart:end])
}
pos = bodyStart + ci + len(closeTag)
if ci < 0 {
pos = end
continue
}
pos = end + len(closeTag)
}
}
if len(params) == 0 {
@@ -626,6 +678,10 @@ func parseXMLParams(body string) map[string]any {
return params
}
func isTagNameChar(c byte) bool {
return c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
func coerceXMLValue(s string) any {
t := strings.TrimSpace(s)
if t == "" {