fix: tool call anchor & wire format, streaming chunk passthrough, WebUI narrow-screen, docs bilingual

This commit is contained in:
root
2026-08-08 11:26:58 +08:00
parent 8e9de95274
commit 5d50b69153
18 changed files with 1119 additions and 42 deletions

View File

@ -44,10 +44,10 @@ type ChatChoice struct {
}
type RespMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []types.ToolCall `json:"tool_calls,omitempty"`
Role string `json:"role,omitempty"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
}
type ChatChunk struct {
@ -77,13 +77,49 @@ func isAuto(m string) bool {
}
// resolveCands picks the ordered candidate providers for a requested model.
func (g *Gateway) resolveCands(model string) ([]*provider.Provider, string) {
if model == "" || isAuto(model) {
// toolCalling requests are anchored: they resolve to exactly one provider
// (highest-priority available) so a tool-call round never switches models.
func (g *Gateway) resolveCands(req *chatRequest) ([]*provider.Provider, string) {
model := req.Model
if model == "" {
model = g.core.DefaultModel()
}
cands, effective := g.resolveByModel(model)
if !toolRequest(req) {
return cands, effective
}
// tool-call request: pin to one provider (no AUTO fallback across models)
if len(cands) == 0 {
return nil, effective
}
first := cands[0]
eff := first.ModelFor(model)
if eff == "" {
eff = firstModel(first)
}
return []*provider.Provider{first}, eff
}
func (g *Gateway) resolveByModel(model string) ([]*provider.Provider, string) {
if isAuto(model) {
return g.core.Registry().Resolve("AUTO"), ""
}
return g.core.Registry().Resolve(model), model
}
// toolRequest reports whether the request participates in a tool-call round.
func toolRequest(req *chatRequest) bool {
if len(req.Tools) > 0 || req.ToolChoice != nil {
return true
}
for _, m := range req.Messages {
if m.Role == "tool" || len(m.ToolCalls) > 0 {
return true
}
}
return false
}
func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST")
@ -102,7 +138,7 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
if model == "" {
model = g.core.DefaultModel()
}
cands, effective := g.resolveCands(model)
cands, effective := g.resolveCands(&req)
if len(cands) == 0 {
writeError(w, http.StatusServiceUnavailable, "no_provider", "no LLM source configured")
return
@ -159,6 +195,31 @@ func imageOnly(cands []*provider.Provider) []*provider.Provider {
return out
}
// toolCallsWire converts unified tool calls to the OpenAI wire format:
// tool_calls:[{id,type,function:{name,arguments:StringJSON}}]. Clients expect
// arguments to be a JSON string, not an object.
func toolCallsWire(tcs []types.ToolCall) json.RawMessage {
wire := make([]map[string]interface{}, 0, len(tcs))
for _, tc := range tcs {
args := "{}"
if tc.Arguments != nil {
if b, err := json.Marshal(tc.Arguments); err == nil {
args = string(b)
}
}
wire = append(wire, map[string]interface{}{
"id": tc.ID,
"type": tc.Type,
"function": map[string]interface{}{
"name": tc.Name,
"arguments": args,
},
})
}
b, _ := json.Marshal(wire)
return b
}
func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string) {
resp, err := g.core.Scheduler().Chat(ctx, scheduler.FromRegistry(cands), req)
if err != nil {
@ -170,7 +231,7 @@ func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands [
msg.ReasoningContent = resp.ReasoningContent
}
if len(resp.ToolCalls) > 0 {
msg.ToolCalls = resp.ToolCalls
msg.ToolCalls = toolCallsWire(resp.ToolCalls)
}
out := ChatCompletion{
ID: newID(),
@ -223,7 +284,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
chunk := ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: effective,
}
delta := RespMessage{Content: ck.Content}
delta := RespMessage{Role: "assistant", Content: ck.Content}
if ck.ReasoningContent != "" {
delta.ReasoningContent = ck.ReasoningContent
}
@ -269,7 +330,7 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
if model == "" {
model = g.core.DefaultModel()
}
cands, _ := g.resolveCands(model)
cands, _ := g.resolveByModel(model)
cands = imageOnly(cands)
if len(cands) == 0 {
writeError(w, http.StatusServiceUnavailable, "no_provider", "no image source configured")

View File

@ -155,6 +155,50 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
.empty { color:var(--muted); text-align:center; padding:24px 0; }
#modal-wrap { position:fixed; inset:0; background:rgba(15,22,44,.45); display:flex; align-items:flex-start;
justify-content:center; overflow:auto; padding:48px 20px; z-index:50; }
.twrap { overflow-x:auto; -webkit-overflow-scrolling:touch; }
/* ---------- responsive / narrow screens ---------- */
@media (max-width: 900px) {
header { padding:12px 16px; }
nav { padding:12px 16px 0; overflow-x:auto; }
nav button { padding:7px 12px; white-space:nowrap; }
main { padding:16px 16px 40px; }
.card { padding:16px; }
}
@media (max-width: 640px) {
header { gap:8px; padding:10px 12px; }
.brand h1 { font-size:15px; }
.brand .sub { display:none; }
.hd-actions button { padding:5px 9px; }
nav { gap:4px; padding:10px 12px 0; }
nav button { padding:6px 10px; font-size:13px; }
main { padding:12px 12px 32px; }
.card { padding:13px; border-radius:12px; margin-bottom:14px; }
.card h2 { font-size:13px; }
th,td { padding:8px 10px; }
.row { flex-direction:column; gap:0; }
.model-row { flex-wrap:wrap; }
.model-row input { flex:1 1 120px; }
.model-row select { flex:0 0 auto; }
.tab-chat { height:calc(100vh - 150px); }
.msg { gap:7px; }
.avatar { width:24px; height:24px; font-size:11px; }
.bubble { max-width:90%; padding:8px 11px; font-size:13px; }
.chat-log { padding:14px 12px 6px; gap:14px; }
.chat-tools { flex-wrap:wrap; gap:8px; }
.chat-tools .tl { display:none; }
.chat-tools select { flex:1 1 auto; min-width:0; }
.chat-box { gap:7px; }
.chat-box .sendbtn { padding:10px 13px; }
.attach-btn { width:38px; height:38px; }
.chat-box textarea { font-size:13.5px; }
.chat-composer { padding:8px 9px 10px; }
#modal-wrap { padding:14px 10px; }
.dropzone { padding:18px 14px; }
#toast { left:12px; right:12px; bottom:12px; text-align:center; }
pre.configbox { font-size:11.5px; padding:11px; }
.att img { height:52px; max-width:90px; }
}
</style>
</head>
<body>