mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
refactor: UI
This commit is contained in:
@@ -215,9 +215,9 @@ func Run(resources EmbeddedResources) error {
|
||||
mainWindow = app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: appName,
|
||||
Width: 700,
|
||||
Height: 520,
|
||||
MinWidth: 640,
|
||||
MinHeight: 480,
|
||||
Height: 530,
|
||||
MinWidth: 700,
|
||||
MinHeight: 530,
|
||||
DisableResize: false,
|
||||
Frameless: goruntime.GOOS == "windows",
|
||||
URL: "/",
|
||||
|
||||
@@ -839,7 +839,7 @@ func (adapter *OpenAIAdapter) streamChatCompletions(ctx context.Context, req Str
|
||||
}
|
||||
}
|
||||
|
||||
if choice.FinishReason != nil {
|
||||
if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" {
|
||||
if err := flushTaggedContentTail(); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAIChatCompletionsIgnoresBlankFinishReason(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "text/event-stream")
|
||||
chunks := []string{
|
||||
`{"model":"deepseek-v4-flash","choices":[{"delta":{"reasoning_content":"first"},"finish_reason":""}]}`,
|
||||
`{"model":"deepseek-v4-flash","choices":[{"delta":{"reasoning_content":" second"},"finish_reason":""}]}`,
|
||||
`{"model":"deepseek-v4-flash","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"Ls","arguments":""}}]},"finish_reason":""}]}`,
|
||||
`{"model":"deepseek-v4-flash","choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"path\":"}}]},"finish_reason":""}]}`,
|
||||
`{"model":"deepseek-v4-flash","choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"/tmp\"}"}}]},"finish_reason":"tool_calls"}]}`,
|
||||
`{"model":"deepseek-v4-flash","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":7}}`,
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
_, _ = fmt.Fprintf(writer, "data: %s\n\n", chunk)
|
||||
}
|
||||
_, _ = fmt.Fprint(writer, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := &OpenAIAdapter{client: server.Client()}
|
||||
events := make([]ModelEvent, 0, 8)
|
||||
err := adapter.Stream(context.Background(), StreamRequest{
|
||||
RequestID: "request-1",
|
||||
RunID: "run-1",
|
||||
ModelCallID: "model-call-1",
|
||||
BaseURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
ProviderModelID: "deepseek-v4-flash",
|
||||
OpenAIEndpoint: "/v1/chat/completions",
|
||||
Messages: []Message{{Role: "user", Content: "list files"}},
|
||||
MaxTokens: 128,
|
||||
RequestKnobs: map[string]any{},
|
||||
}, func(event ModelEvent) error {
|
||||
events = append(events, event)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stream failed: %v", err)
|
||||
}
|
||||
|
||||
assertOpenAIEventKindCount(t, events, ModelEventKindThinkingDelta, 2)
|
||||
assertOpenAIEventKindCount(t, events, ModelEventKindThinkingCompleted, 1)
|
||||
assertOpenAIEventKindCount(t, events, ModelEventKindToolLikeCompleted, 1)
|
||||
assertOpenAIEventKindCount(t, events, ModelEventKindTurnFinished, 1)
|
||||
|
||||
toolEvent := firstOpenAIEventForTest(events, ModelEventKindToolLikeCompleted)
|
||||
if toolEvent == nil || toolEvent.ToolInvocation == nil {
|
||||
t.Fatalf("completed tool invocation missing: %#v", toolEvent)
|
||||
}
|
||||
if toolEvent.ToolInvocation.ToolName != "Ls" {
|
||||
t.Fatalf("tool name = %q, want Ls", toolEvent.ToolInvocation.ToolName)
|
||||
}
|
||||
if got := string(toolEvent.ToolInvocation.ArgsJSON); got != `{"path":"/tmp"}` {
|
||||
t.Fatalf("tool args = %q, want %q", got, `{"path":"/tmp"}`)
|
||||
}
|
||||
|
||||
finished := firstOpenAIEventForTest(events, ModelEventKindTurnFinished)
|
||||
if finished == nil || finished.FinishReason != "tool_calls" {
|
||||
t.Fatalf("finish reason = %q, want tool_calls", finished.FinishReason)
|
||||
}
|
||||
if finished.InputTokens != 12 || finished.OutputTokens != 7 {
|
||||
t.Fatalf("usage = input:%d output:%d, want input:12 output:7", finished.InputTokens, finished.OutputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOpenAIEventKindCount(t *testing.T, events []ModelEvent, kind ModelEventKind, want int) {
|
||||
t.Helper()
|
||||
got := 0
|
||||
for _, event := range events {
|
||||
if event.Kind == kind {
|
||||
got++
|
||||
}
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("event kind %s count = %d, want %d; events=%#v", kind, got, want, events)
|
||||
}
|
||||
}
|
||||
|
||||
func firstOpenAIEventForTest(events []ModelEvent, kind ModelEventKind) *ModelEvent {
|
||||
for index := range events {
|
||||
if events[index].Kind == kind {
|
||||
return &events[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1736,6 +1736,13 @@ func (service *Service) handleToolInvocation(stream *ActiveStream, invocation ru
|
||||
stream.ToolInvocationCount++
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
if !isKnownToolName(trimmedToolName) {
|
||||
displayToolName := trimmedToolName
|
||||
if displayToolName == "" {
|
||||
displayToolName = "<empty>"
|
||||
}
|
||||
return service.completePreDispatchToolError(stream, invocation, nil, false, false, fmt.Errorf("Model hallucination: attempted to invoke a nonexistent tool: %s", displayToolName))
|
||||
}
|
||||
if !isToolAllowedInMode(mode, subagentTypeName, trimmedToolName) {
|
||||
return service.completePreDispatchToolError(stream, invocation, nil, false, false, fmt.Errorf("tool invocation is not enabled in mode %s: %s", mode.String(), invocation.ToolName))
|
||||
}
|
||||
|
||||
@@ -181,6 +181,25 @@ func supportedToolNamesForMode(mode agentv1.AgentMode) map[string]struct{} {
|
||||
}
|
||||
}
|
||||
|
||||
func isKnownToolName(toolName string) bool {
|
||||
trimmedToolName := strings.TrimSpace(toolName)
|
||||
if trimmedToolName == "" {
|
||||
return false
|
||||
}
|
||||
for _, supported := range []map[string]struct{}{
|
||||
agentModeToolNames,
|
||||
askModeToolNames,
|
||||
planModeToolNames,
|
||||
debugModeToolNames,
|
||||
multitaskModeToolNames,
|
||||
} {
|
||||
if _, ok := supported[trimmedToolName]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isToolAllowedInMode(mode agentv1.AgentMode, subagentTypeName string, toolName string) bool {
|
||||
trimmedToolName := strings.TrimSpace(toolName)
|
||||
if trimmedToolName == "" {
|
||||
|
||||
@@ -16,6 +16,7 @@ func (store *Store) LegacyRuntimeSnapshot(ctx context.Context) (legacyruntime.Ru
|
||||
for _, item := range cfg.ModelAdapters {
|
||||
adapters = append(adapters, legacyruntime.ModelAdapterConfig{
|
||||
ID: item.ID,
|
||||
Sort: item.Sort,
|
||||
DisplayName: item.DisplayName,
|
||||
Type: item.Type,
|
||||
BaseURL: item.BaseURL,
|
||||
|
||||
@@ -147,6 +147,7 @@ func (manager *Manager) LegacyRuntimeSnapshot(_ context.Context) (legacyruntime.
|
||||
for _, item := range cfg.ModelAdapters {
|
||||
adapters = append(adapters, legacyruntime.ModelAdapterConfig{
|
||||
ID: item.ID,
|
||||
Sort: item.Sort,
|
||||
DisplayName: item.DisplayName,
|
||||
Type: item.Type,
|
||||
BaseURL: item.BaseURL,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
|
||||
type ModelAdapterConfig struct {
|
||||
ID string `json:"id,omitempty" yaml:"-"`
|
||||
Sort int `json:"sort" yaml:"sort"`
|
||||
DisplayName string `json:"displayName" yaml:"displayName"`
|
||||
Type string `json:"type" yaml:"type"`
|
||||
BaseURL string `json:"baseURL" yaml:"baseURL"`
|
||||
@@ -104,6 +106,7 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
|
||||
}
|
||||
nextType := normalizeModelAdapterType(item.Type)
|
||||
next := ModelAdapterConfig{
|
||||
Sort: item.Sort,
|
||||
DisplayName: strings.TrimSpace(item.DisplayName),
|
||||
Type: nextType,
|
||||
BaseURL: baseURL,
|
||||
@@ -164,9 +167,30 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
|
||||
seenChannelIDs[next.ID] = struct{}{}
|
||||
normalized = append(normalized, next)
|
||||
}
|
||||
normalizeModelAdapterSorts(normalized)
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeModelAdapterSorts(adapters []ModelAdapterConfig) {
|
||||
sort.SliceStable(adapters, func(leftIndex, rightIndex int) bool {
|
||||
left := adapters[leftIndex].Sort
|
||||
right := adapters[rightIndex].Sort
|
||||
switch {
|
||||
case left <= 0 && right <= 0:
|
||||
return false
|
||||
case left <= 0:
|
||||
return false
|
||||
case right <= 0:
|
||||
return true
|
||||
default:
|
||||
return left < right
|
||||
}
|
||||
})
|
||||
for index := range adapters {
|
||||
adapters[index].Sort = index + 1
|
||||
}
|
||||
}
|
||||
|
||||
func validateJSONMap(value string, fieldName string) error {
|
||||
text := strings.TrimSpace(value)
|
||||
if text == "" {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func testModelAdapter(displayName string, sortValue int) ModelAdapterConfig {
|
||||
return ModelAdapterConfig{
|
||||
Sort: sortValue,
|
||||
DisplayName: displayName,
|
||||
Type: "openai",
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
APIKey: "test-key",
|
||||
TooltipData: displayName,
|
||||
ModelID: displayName,
|
||||
ReasoningEffort: "medium",
|
||||
OpenAIEndpoint: "/v1/responses",
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeModelAdapterConfigsPreservesLegacyArrayOrder(t *testing.T) {
|
||||
adapters, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{
|
||||
testModelAdapter("first", 0),
|
||||
testModelAdapter("second", 0),
|
||||
testModelAdapter("third", 0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeModelAdapterConfigs returned error: %v", err)
|
||||
}
|
||||
|
||||
for index, expectedName := range []string{"first", "second", "third"} {
|
||||
if adapters[index].DisplayName != expectedName {
|
||||
t.Fatalf("adapter %d = %q, want %q", index, adapters[index].DisplayName, expectedName)
|
||||
}
|
||||
if adapters[index].Sort != index+1 {
|
||||
t.Fatalf("adapter %d sort = %d, want %d", index, adapters[index].Sort, index+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeModelAdapterConfigsUsesStableExplicitSort(t *testing.T) {
|
||||
adapters, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{
|
||||
testModelAdapter("legacy", 0),
|
||||
testModelAdapter("third", 30),
|
||||
testModelAdapter("first", 10),
|
||||
testModelAdapter("second-a", 20),
|
||||
testModelAdapter("second-b", 20),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeModelAdapterConfigs returned error: %v", err)
|
||||
}
|
||||
|
||||
expectedNames := []string{"first", "second-a", "second-b", "third", "legacy"}
|
||||
for index, expectedName := range expectedNames {
|
||||
if adapters[index].DisplayName != expectedName {
|
||||
t.Fatalf("adapter %d = %q, want %q", index, adapters[index].DisplayName, expectedName)
|
||||
}
|
||||
if adapters[index].Sort != index+1 {
|
||||
t.Fatalf("adapter %d sort = %d, want %d", index, adapters[index].Sort, index+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-101
@@ -15,19 +15,11 @@ import (
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
)
|
||||
|
||||
// modelEditorContext 保存当前模型编辑器窗口的初始化上下文。
|
||||
type modelEditorContext struct {
|
||||
Index int `json:"index"`
|
||||
AdapterJSON string `json:"adapterJSON"`
|
||||
}
|
||||
|
||||
// WindowService 定义了当前模块中的 WindowService 类型。
|
||||
type WindowService struct {
|
||||
app *application.App
|
||||
updater *updater.Manager
|
||||
modelConfigWindow *application.WebviewWindow
|
||||
modelEditorWindow *application.WebviewWindow
|
||||
editorCtx *modelEditorContext
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -102,8 +94,8 @@ func (s *WindowService) OpenModelConfigWindow() {
|
||||
Title: "模型配置",
|
||||
Width: 980,
|
||||
Height: 700,
|
||||
MinWidth: 820,
|
||||
MinHeight: 560,
|
||||
MinWidth: 980,
|
||||
MinHeight: 700,
|
||||
DisableResize: false,
|
||||
Frameless: goruntime.GOOS == "windows",
|
||||
URL: "/#/model-config",
|
||||
@@ -144,97 +136,6 @@ func (s *WindowService) OpenModelConfigWindow() {
|
||||
s.modelConfigWindow = win
|
||||
}
|
||||
|
||||
// OpenModelEditorWindow 打开模型编辑器独立窗口。
|
||||
// index < 0 表示新增,>= 0 表示编辑对应索引的适配器。
|
||||
// adapterJSON 为编辑器初始数据的 JSON 字符串。
|
||||
func (s *WindowService) OpenModelEditorWindow(index int, adapterJSON string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.app == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.editorCtx = &modelEditorContext{
|
||||
Index: index,
|
||||
AdapterJSON: adapterJSON,
|
||||
}
|
||||
|
||||
if s.modelEditorWindow != nil {
|
||||
s.modelEditorWindow.Show()
|
||||
s.modelEditorWindow.Focus()
|
||||
return
|
||||
}
|
||||
|
||||
title := "新增模型配置"
|
||||
if index >= 0 {
|
||||
title = "编辑模型配置"
|
||||
}
|
||||
|
||||
win := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: title,
|
||||
Width: 840,
|
||||
Height: 680,
|
||||
MinWidth: 740,
|
||||
MinHeight: 600,
|
||||
DisableResize: false,
|
||||
Frameless: goruntime.GOOS == "windows",
|
||||
URL: fmt.Sprintf("/#/model-editor?index=%d", index),
|
||||
Hidden: false,
|
||||
HideOnEscape: false,
|
||||
MinimiseButtonState: application.ButtonEnabled,
|
||||
MaximiseButtonState: application.ButtonEnabled,
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: application.RGBA{Red: 25, Green: 25, Blue: 25, Alpha: 255},
|
||||
Mac: application.MacWindow{
|
||||
Backdrop: application.MacBackdropLiquidGlass,
|
||||
DisableShadow: false,
|
||||
TitleBar: application.MacTitleBar{
|
||||
AppearsTransparent: true,
|
||||
Hide: false,
|
||||
HideTitle: true,
|
||||
FullSizeContent: true,
|
||||
UseToolbar: false,
|
||||
HideToolbarSeparator: true,
|
||||
},
|
||||
WebviewPreferences: application.MacWebviewPreferences{
|
||||
FullscreenEnabled: u.False,
|
||||
TextInteractionEnabled: u.True,
|
||||
AllowsBackForwardNavigationGestures: u.False,
|
||||
},
|
||||
},
|
||||
Windows: application.WindowsWindow{
|
||||
HiddenOnTaskbar: false,
|
||||
},
|
||||
})
|
||||
|
||||
win.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.modelEditorWindow = nil
|
||||
s.editorCtx = nil
|
||||
})
|
||||
|
||||
s.modelEditorWindow = win
|
||||
}
|
||||
|
||||
// GetModelEditorContext 返回当前编辑器窗口的初始化上下文。
|
||||
func (s *WindowService) GetModelEditorContext() map[string]any {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.editorCtx == nil {
|
||||
return map[string]any{
|
||||
"index": -1,
|
||||
"adapterJSON": "{}",
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"index": s.editorCtx.Index,
|
||||
"adapterJSON": s.editorCtx.AdapterJSON,
|
||||
}
|
||||
}
|
||||
|
||||
// OpenHistoryWindow 用于处理与 OpenHistoryWindow 相关的逻辑。
|
||||
func (s *WindowService) OpenHistoryWindow() {
|
||||
_ = os.MkdirAll(client.ResolveLogsRootPath(), 0o755)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -36,6 +37,8 @@ const (
|
||||
// ModelAdapterConfig 定义了当前模块中的 ModelAdapterConfig 类型。
|
||||
type ModelAdapterConfig struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
// Sort 表示模型渠道的展示顺序。
|
||||
Sort int `json:"sort"`
|
||||
// DisplayName 表示当前声明中的 DisplayName。
|
||||
DisplayName string `json:"displayName"`
|
||||
// Type 表示当前声明中的 Type。
|
||||
@@ -103,6 +106,7 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
|
||||
return nil, err
|
||||
}
|
||||
next := ModelAdapterConfig{
|
||||
Sort: item.Sort,
|
||||
DisplayName: strings.TrimSpace(item.DisplayName),
|
||||
Type: normalizeModelAdapterType(item.Type),
|
||||
BaseURL: baseURL,
|
||||
@@ -163,9 +167,30 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
|
||||
seenChannelIDs[next.ID] = struct{}{}
|
||||
normalized = append(normalized, next)
|
||||
}
|
||||
normalizeModelAdapterSorts(normalized)
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeModelAdapterSorts(adapters []ModelAdapterConfig) {
|
||||
sort.SliceStable(adapters, func(leftIndex, rightIndex int) bool {
|
||||
left := adapters[leftIndex].Sort
|
||||
right := adapters[rightIndex].Sort
|
||||
switch {
|
||||
case left <= 0 && right <= 0:
|
||||
return false
|
||||
case left <= 0:
|
||||
return false
|
||||
case right <= 0:
|
||||
return true
|
||||
default:
|
||||
return left < right
|
||||
}
|
||||
})
|
||||
for index := range adapters {
|
||||
adapters[index].Sort = index + 1
|
||||
}
|
||||
}
|
||||
|
||||
func validateJSONMap(value string, fieldName string) error {
|
||||
text := strings.TrimSpace(value)
|
||||
if text == "" {
|
||||
|
||||
Reference in New Issue
Block a user