优化代码
This commit is contained in:
1 parent
3553790475
commit
eda7e8bc96
23 files changed
+3113
-342
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
# CodeGraph data files — local to each machine, not for committing.
|
||||||
|
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||||
|
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# Build viewsagent for Windows (no console window).
|
# Build viewsagent for Windows (no console window).
|
||||||
# Usage: .\build.ps1
|
# Usage: .\build.ps1
|
||||||
# Requires: Go 1.24+ and gcc from MSYS2 mingw-w64 (C:\msys64\mingw64\bin).
|
# Requires: Go 1.24+ and gcc from MSYS2 mingw-w64 (E:\Tools\msys64\mingw64\bin).
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
$mingw = "C:\msys64\mingw64\bin"
|
$mingw = "E:\Tools\msys64\mingw64\bin"
|
||||||
if (Test-Path $mingw) {
|
if (Test-Path $mingw) {
|
||||||
$env:Path = "$mingw;" + $env:Path
|
$env:Path = "$mingw;" + $env:Path
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ go test ./...
|
|||||||
if ($LASTEXITCODE -ne 0) { Write-Error "tests failed, build aborted." }
|
if ($LASTEXITCODE -ne 0) { Write-Error "tests failed, build aborted." }
|
||||||
|
|
||||||
Write-Host "==> go build" -ForegroundColor Cyan
|
Write-Host "==> go build" -ForegroundColor Cyan
|
||||||
go build -trimpath -ldflags "-H windowsgui -s -w" -o "bin\viewsagent.exe" .\cmd\viewsagent
|
go build -trimpath -ldflags "-H windowsgui -s -w" -o "bin\viewsagent.exe" .
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Error "build failed." }
|
if ($LASTEXITCODE -ne 0) { Write-Error "build failed." }
|
||||||
|
|
||||||
$mb = [math]::Round((Get-Item "bin\viewsagent.exe").Length / 1MB, 1)
|
$mb = [math]::Round((Get-Item "bin\viewsagent.exe").Length / 1MB, 1)
|
||||||
|
|||||||
+76
-28
@@ -30,8 +30,9 @@ type State struct {
|
|||||||
TotalTokens int
|
TotalTokens int
|
||||||
LastLatency time.Duration
|
LastLatency time.Duration
|
||||||
Region capture.Region
|
Region capture.Region
|
||||||
Model string
|
Model string // 生效接入点里的模型名
|
||||||
Profile string
|
Profile string // 生效接入点的名字
|
||||||
|
Format string // 生效接入点的接口格式
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sink 是引擎向界面推送事件的回调集合。
|
// Sink 是引擎向界面推送事件的回调集合。
|
||||||
@@ -39,7 +40,7 @@ type State struct {
|
|||||||
type Sink interface {
|
type Sink interface {
|
||||||
OnUserMessage(text string, withImage bool)
|
OnUserMessage(text string, withImage bool)
|
||||||
OnAssistantStart(auto bool)
|
OnAssistantStart(auto bool)
|
||||||
OnAssistantDelta(delta string)
|
OnAssistantDelta(delta llm.Delta)
|
||||||
OnAssistantEnd(usage llm.Usage, err error)
|
OnAssistantEnd(usage llm.Usage, err error)
|
||||||
OnState(state State)
|
OnState(state State)
|
||||||
}
|
}
|
||||||
@@ -72,10 +73,12 @@ func New(cfg *config.Config, store *config.Store) *Engine {
|
|||||||
store: store,
|
store: store,
|
||||||
region: cfg.Region,
|
region: cfg.Region,
|
||||||
}
|
}
|
||||||
if prov, err := llm.New(toOptions(cfg.Active())); err != nil {
|
if active, ok := cfg.ActiveModel(); ok {
|
||||||
slog.Warn("模型接入点不可用", "err", err)
|
if prov, err := llm.New(toOptions(active)); err != nil {
|
||||||
} else {
|
slog.Warn("模型不可用", "err", err)
|
||||||
e.provider = prov
|
} else {
|
||||||
|
e.provider = prov
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
@@ -83,6 +86,7 @@ func New(cfg *config.Config, store *config.Store) *Engine {
|
|||||||
func toOptions(p config.Profile) llm.Options {
|
func toOptions(p config.Profile) llm.Options {
|
||||||
return llm.Options{
|
return llm.Options{
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
|
Format: p.Format,
|
||||||
BaseURL: p.BaseURL,
|
BaseURL: p.BaseURL,
|
||||||
APIKey: p.APIKey,
|
APIKey: p.APIKey,
|
||||||
Model: p.Model,
|
Model: p.Model,
|
||||||
@@ -120,7 +124,7 @@ func (e *Engine) State() State {
|
|||||||
func (e *Engine) stateLocked() State {
|
func (e *Engine) stateLocked() State {
|
||||||
active := config.Profile{}
|
active := config.Profile{}
|
||||||
if e.cfg != nil {
|
if e.cfg != nil {
|
||||||
active = e.cfg.Active()
|
active, _ = e.cfg.ActiveModel()
|
||||||
}
|
}
|
||||||
return State{
|
return State{
|
||||||
Watching: e.watching,
|
Watching: e.watching,
|
||||||
@@ -131,6 +135,7 @@ func (e *Engine) stateLocked() State {
|
|||||||
Region: e.region,
|
Region: e.region,
|
||||||
Model: active.Model,
|
Model: active.Model,
|
||||||
Profile: active.Name,
|
Profile: active.Name,
|
||||||
|
Format: active.Format,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,16 +174,28 @@ func (e *Engine) SetRegion(r capture.Region) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ApplyConfig 在设置页保存后刷新引擎内部状态。
|
// ApplyConfig 在设置页保存后刷新引擎内部状态。
|
||||||
|
// 接入点还没填完整时不算失败:配置照样生效,等用户补齐后自动恢复。
|
||||||
func (e *Engine) ApplyConfig(cfg *config.Config) error {
|
func (e *Engine) ApplyConfig(cfg *config.Config) error {
|
||||||
prov, err := llm.New(toOptions(cfg.Active()))
|
if cfg == nil {
|
||||||
if err != nil {
|
return errors.New("配置为空")
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
wasWatching := e.watching
|
wasWatching := e.watching
|
||||||
// 区域只由框选修改,不被设置页的旧副本覆盖。
|
// 区域只由框选修改,不被设置页的旧副本覆盖。
|
||||||
cfg.Region = e.region
|
cfg.Region = e.region
|
||||||
e.cfg = cfg
|
e.cfg = cfg
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
var prov llm.Provider
|
||||||
|
if active, ok := cfg.ActiveModel(); ok {
|
||||||
|
p, err := llm.New(toOptions(active))
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("模型初始化失败", "err", err)
|
||||||
|
} else {
|
||||||
|
prov = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
e.provider = prov
|
e.provider = prov
|
||||||
e.mu.Unlock()
|
e.mu.Unlock()
|
||||||
|
|
||||||
@@ -192,8 +209,8 @@ func (e *Engine) ApplyConfig(cfg *config.Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PingProfile 用给定接入点做一次连通性测试。
|
// PingModel 用给定的模型配置做一次连通性测试。
|
||||||
func (e *Engine) PingProfile(ctx context.Context, p config.Profile) error {
|
func (e *Engine) PingModel(ctx context.Context, p config.Profile) error {
|
||||||
prov, err := llm.New(toOptions(p))
|
prov, err := llm.New(toOptions(p))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -201,6 +218,43 @@ func (e *Engine) PingProfile(ctx context.Context, p config.Profile) error {
|
|||||||
return prov.Ping(ctx)
|
return prov.Ping(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PingActive 用当前生效的接入点做一次连通性测试。
|
||||||
|
func (e *Engine) PingActive(ctx context.Context) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
p, _ := e.cfg.ActiveModel()
|
||||||
|
e.mu.Unlock()
|
||||||
|
return e.PingModel(ctx, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureReady 检查模型是否已经接好,供监听前预检。
|
||||||
|
func (e *Engine) EnsureReady() error {
|
||||||
|
e.mu.Lock()
|
||||||
|
m, ok := e.cfg.ActiveModel()
|
||||||
|
ready := e.provider != nil
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return errors.New("还没有接入模型,请到设置里添加接入点并填写 BaseURL 与模型名")
|
||||||
|
}
|
||||||
|
if m.BaseURL == "" || m.Model == "" {
|
||||||
|
return errors.New("还没有接入模型,请到设置里填写 BaseURL 与模型名")
|
||||||
|
}
|
||||||
|
if m.APIKey == "" && !isLocalEndpoint(m.BaseURL) {
|
||||||
|
return errors.New("还没有接入模型,请到设置里填写 API Key")
|
||||||
|
}
|
||||||
|
if !ready {
|
||||||
|
return errors.New("模型初始化失败,请到设置里检查 BaseURL 与模型名")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLocalEndpoint 判断是不是本机服务,本机服务通常不需要 API Key。
|
||||||
|
func isLocalEndpoint(baseURL string) bool {
|
||||||
|
return strings.Contains(baseURL, "localhost") ||
|
||||||
|
strings.Contains(baseURL, "127.0.0.1") ||
|
||||||
|
strings.Contains(baseURL, "[::1]")
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot 抓取当前区域的 JPEG 预览(用于界面缩略图)。
|
// Snapshot 抓取当前区域的 JPEG 预览(用于界面缩略图)。
|
||||||
func (e *Engine) Snapshot() ([]byte, error) {
|
func (e *Engine) Snapshot() ([]byte, error) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
@@ -229,6 +283,9 @@ func (e *Engine) Ask(text string, attach bool) error {
|
|||||||
|
|
||||||
// StartWatch 开始按帧差自动监听。
|
// StartWatch 开始按帧差自动监听。
|
||||||
func (e *Engine) StartWatch() error {
|
func (e *Engine) StartWatch() error {
|
||||||
|
if err := e.EnsureReady(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
if e.watching {
|
if e.watching {
|
||||||
e.mu.Unlock()
|
e.mu.Unlock()
|
||||||
@@ -264,18 +321,6 @@ func (e *Engine) StopWatch() {
|
|||||||
e.emitState()
|
e.emitState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToggleWatch 在开始/停止之间切换。
|
|
||||||
func (e *Engine) ToggleWatch() error {
|
|
||||||
e.mu.Lock()
|
|
||||||
on := e.watching
|
|
||||||
e.mu.Unlock()
|
|
||||||
if on {
|
|
||||||
e.StopWatch()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return e.StartWatch()
|
|
||||||
}
|
|
||||||
|
|
||||||
// watchLoop 以低频抓屏 + 帧差判断是否需要提问。
|
// watchLoop 以低频抓屏 + 帧差判断是否需要提问。
|
||||||
func (e *Engine) watchLoop(stop chan struct{}) {
|
func (e *Engine) watchLoop(stop chan struct{}) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
@@ -378,7 +423,7 @@ func (e *Engine) run(text string, attach, auto bool) error {
|
|||||||
}
|
}
|
||||||
if e.provider == nil {
|
if e.provider == nil {
|
||||||
e.mu.Unlock()
|
e.mu.Unlock()
|
||||||
return errors.New("还没有可用的模型接入点,请到设置里填写")
|
return errors.New("还没有接入模型,请到设置里填写")
|
||||||
}
|
}
|
||||||
e.busy = true
|
e.busy = true
|
||||||
prov := e.provider
|
prov := e.provider
|
||||||
@@ -427,8 +472,11 @@ func (e *Engine) run(text string, attach, auto bool) error {
|
|||||||
|
|
||||||
var reply strings.Builder
|
var reply strings.Builder
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
usage, err := prov.Chat(ctx, system, msgs, func(delta string) error {
|
usage, err := prov.Chat(ctx, system, msgs, func(delta llm.Delta) error {
|
||||||
reply.WriteString(delta)
|
// 思考过程只往界面送,不进历史:历史里留正文就够了。
|
||||||
|
if delta.Text != "" {
|
||||||
|
reply.WriteString(delta.Text)
|
||||||
|
}
|
||||||
if sink != nil {
|
if sink != nil {
|
||||||
sink.OnAssistantDelta(delta)
|
sink.OnAssistantDelta(delta)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"viewsagent/internal/capture"
|
||||||
|
"viewsagent/internal/config"
|
||||||
|
"viewsagent/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readyCfg 造一份只差一个接入点的配置。
|
||||||
|
func readyCfg(p config.Profile) *config.Config {
|
||||||
|
cfg := &config.Config{Models: []config.Profile{p}}
|
||||||
|
cfg.Active = p.Name
|
||||||
|
cfg.Normalize()
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReadyRejectsIncompleteModel(t *testing.T) {
|
||||||
|
e := New(&config.Config{Models: []config.Profile{{Name: "空"}}}, nil)
|
||||||
|
if err := e.EnsureReady(); err == nil {
|
||||||
|
t.Fatal("模型未配置时应拒绝监听")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReadyRequiresAPIKey(t *testing.T) {
|
||||||
|
e := New(readyCfg(config.Profile{
|
||||||
|
Name: "远程", BaseURL: "https://api.example.com/v1", Model: "vl-model",
|
||||||
|
}), nil)
|
||||||
|
if err := e.EnsureReady(); err == nil {
|
||||||
|
t.Fatal("远程服务缺少 API Key 时应拒绝监听")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReadyAllowsLocalWithoutKey(t *testing.T) {
|
||||||
|
e := New(readyCfg(config.Profile{
|
||||||
|
Name: "本地", BaseURL: "http://localhost:11434/v1", Model: "qwen2.5vl:7b",
|
||||||
|
}), nil)
|
||||||
|
if err := e.EnsureReady(); err != nil {
|
||||||
|
t.Fatalf("本地服务不应要求 API Key: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureReadyPassesWithKey(t *testing.T) {
|
||||||
|
e := New(readyCfg(config.Profile{
|
||||||
|
Name: "远程", BaseURL: "https://api.example.com/v1", Model: "vl-model", APIKey: "sk-test",
|
||||||
|
}), nil)
|
||||||
|
if err := e.EnsureReady(); err != nil {
|
||||||
|
t.Fatalf("配置完整时应通过检测: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartWatchRefusesWhenModelNotReady(t *testing.T) {
|
||||||
|
e := New(readyCfg(config.Profile{
|
||||||
|
Name: "远程", BaseURL: "https://api.example.com/v1", Model: "vl-model",
|
||||||
|
}), nil)
|
||||||
|
e.region = capture.Region{X: 0, Y: 0, W: 100, H: 100}
|
||||||
|
|
||||||
|
if err := e.StartWatch(); err == nil {
|
||||||
|
t.Fatal("模型未接好时不应开始监听")
|
||||||
|
}
|
||||||
|
if e.State().Watching {
|
||||||
|
t.Fatal("监听不应被启动")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActiveProfileIsUsed(t *testing.T) {
|
||||||
|
cfg := &config.Config{Models: []config.Profile{
|
||||||
|
{Name: "甲", Format: llm.FormatOpenAI, BaseURL: "https://a.example/v1", Model: "a-vl", APIKey: "sk-a"},
|
||||||
|
{Name: "乙", Format: llm.FormatAnthropic, BaseURL: "https://b.example", Model: "b-vl", APIKey: "sk-b"},
|
||||||
|
}}
|
||||||
|
cfg.Active = "乙"
|
||||||
|
cfg.Normalize()
|
||||||
|
|
||||||
|
e := New(cfg, nil)
|
||||||
|
st := e.State()
|
||||||
|
if st.Profile != "乙" || st.Model != "b-vl" {
|
||||||
|
t.Fatalf("状态应反映生效接入点: %+v", st)
|
||||||
|
}
|
||||||
|
if st.Format != llm.FormatAnthropic {
|
||||||
|
t.Fatalf("状态应带出接口格式: %s", st.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
+186
-63
@@ -9,11 +9,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"viewsagent/internal/capture"
|
"viewsagent/internal/capture"
|
||||||
|
"viewsagent/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Profile 是一个 OpenAI 兼容协议的模型接入点。
|
// Profile 描述一份模型接入配置。Name 是用户自己起的名字,
|
||||||
|
// 既是左侧列表的显示文本,也是 Config.Active 引用的键。
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Format string `json:"format"`
|
||||||
BaseURL string `json:"baseURL"`
|
BaseURL string `json:"baseURL"`
|
||||||
APIKey string `json:"apiKey"`
|
APIKey string `json:"apiKey"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
@@ -22,6 +25,20 @@ type Profile struct {
|
|||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewProfile 按接口格式生成一份空白模型配置,只带好该格式必需的采样默认值。
|
||||||
|
// BaseURL 与模型名留空,由用户按自己用的服务商填写。
|
||||||
|
func NewProfile(name, format string) Profile {
|
||||||
|
if format != llm.FormatAnthropic {
|
||||||
|
format = llm.FormatOpenAI
|
||||||
|
}
|
||||||
|
return Profile{
|
||||||
|
Name: name,
|
||||||
|
Format: format,
|
||||||
|
MaxTokens: 1024,
|
||||||
|
Temperature: 0.3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Capture 是抓屏与触发策略参数。
|
// Capture 是抓屏与触发策略参数。
|
||||||
type Capture struct {
|
type Capture struct {
|
||||||
FPS float64 `json:"fps"` // 监听帧率,1~5
|
FPS float64 `json:"fps"` // 监听帧率,1~5
|
||||||
@@ -48,8 +65,11 @@ type Template struct {
|
|||||||
|
|
||||||
// Config 是完整的应用配置。
|
// Config 是完整的应用配置。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ActiveProfile string `json:"activeProfile"`
|
// Models 是全部已保存的模型配置,Active 指向其中生效的那一条。
|
||||||
Profiles []Profile `json:"profiles"`
|
Models []Profile `json:"models"`
|
||||||
|
Active string `json:"active"`
|
||||||
|
// Theme 是界面主题:system 跟随系统、contrast 黑白高对比、light 浅色高对比。
|
||||||
|
Theme string `json:"theme"`
|
||||||
Capture Capture `json:"capture"`
|
Capture Capture `json:"capture"`
|
||||||
Hotkeys Hotkeys `json:"hotkeys"`
|
Hotkeys Hotkeys `json:"hotkeys"`
|
||||||
PromptTemplate string `json:"promptTemplate"`
|
PromptTemplate string `json:"promptTemplate"`
|
||||||
@@ -113,32 +133,11 @@ func DefaultTemplates() []Template {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default 返回一份可直接使用的默认配置。
|
// Default 返回一份默认配置。
|
||||||
|
// Models 故意留空:由 Normalize 补一个空白条目,这样旧配置文件反序列化后
|
||||||
|
// 仍能通过 len(Models)==0 判断出走迁移分支。
|
||||||
func Default() *Config {
|
func Default() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
ActiveProfile: "qwen",
|
|
||||||
Profiles: []Profile{
|
|
||||||
{
|
|
||||||
Name: "qwen", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
||||||
Model: "qwen3-vl-plus", MaxTokens: 1024, Temperature: 0.3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "doubao", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
|
|
||||||
Model: "doubao-seed-1-6-vision", MaxTokens: 1024, Temperature: 0.3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "zhipu", BaseURL: "https://open.bigmodel.cn/api/paas/v4",
|
|
||||||
Model: "glm-4.6v", MaxTokens: 1024, Temperature: 0.3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "kimi", BaseURL: "https://api.moonshot.cn/v1",
|
|
||||||
Model: "kimi-latest", MaxTokens: 1024, Temperature: 0.3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "ollama", BaseURL: "http://localhost:11434/v1",
|
|
||||||
Model: "qwen2.5vl:7b", MaxTokens: 1024, Temperature: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Capture: Capture{
|
Capture: Capture{
|
||||||
FPS: 2, MaxEdge: 1280, JPEGQuality: 75,
|
FPS: 2, MaxEdge: 1280, JPEGQuality: 75,
|
||||||
DiffThreshold: 5, CooldownMs: 3000, StableFrames: 2, HistoryTurns: 6,
|
DiffThreshold: 5, CooldownMs: 3000, StableFrames: 2, HistoryTurns: 6,
|
||||||
@@ -153,25 +152,85 @@ func Default() *Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile 按名称查找接入点。
|
// ActiveModel 返回当前生效的模型配置。
|
||||||
func (c *Config) Profile(name string) (Profile, bool) {
|
func (c *Config) ActiveModel() (Profile, bool) {
|
||||||
for _, p := range c.Profiles {
|
for i := range c.Models {
|
||||||
if p.Name == name {
|
if c.Models[i].Name == c.Active {
|
||||||
return p, true
|
return c.Models[i], true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Profile{}, false
|
return Profile{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active 返回当前启用的接入点,找不到时回退到第一个。
|
// ModelNames 返回全部模型名字,顺序即列表顺序。
|
||||||
func (c *Config) Active() Profile {
|
func (c *Config) ModelNames() []string {
|
||||||
if p, ok := c.Profile(c.ActiveProfile); ok {
|
names := make([]string, 0, len(c.Models))
|
||||||
return p
|
for _, m := range c.Models {
|
||||||
|
names = append(names, m.Name)
|
||||||
}
|
}
|
||||||
if len(c.Profiles) > 0 {
|
return names
|
||||||
return c.Profiles[0]
|
}
|
||||||
|
|
||||||
|
// ProfileIndex 按名字返回接入点在列表里的下标,找不到返回 -1。
|
||||||
|
func (c *Config) ProfileIndex(name string) int {
|
||||||
|
for i := range c.Models {
|
||||||
|
if c.Models[i].Name == name {
|
||||||
|
return i
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Profile{}
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActive 把指定接入点设为生效项;名字不存在时返回 false。
|
||||||
|
func (c *Config) SetActive(name string) bool {
|
||||||
|
if c.ProfileIndex(name) < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.Active = name
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddProfile 追加一份接入点,名字为空或冲突时自动改名,并返回真正落库的副本。
|
||||||
|
func (c *Config) AddProfile(p Profile) Profile {
|
||||||
|
p.Name = strings.TrimSpace(p.Name)
|
||||||
|
if p.Name == "" {
|
||||||
|
p.Name = "接入点"
|
||||||
|
}
|
||||||
|
p.Name = c.UniqueName(p.Name)
|
||||||
|
c.Models = append(c.Models, p)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniqueName 基于前缀生成一个在现有模型里不重复的名字。
|
||||||
|
func (c *Config) UniqueName(prefix string) string {
|
||||||
|
used := make(map[string]bool, len(c.Models))
|
||||||
|
for _, m := range c.Models {
|
||||||
|
used[m.Name] = true
|
||||||
|
}
|
||||||
|
if !used[prefix] {
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
for n := 2; ; n++ {
|
||||||
|
name := fmt.Sprintf("%s (%d)", prefix, n)
|
||||||
|
if !used[name] {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveModel 删除指定名字的模型;删掉生效那条时把 Active 置空,由 Normalize 重新兜底。
|
||||||
|
func (c *Config) RemoveModel(name string) bool {
|
||||||
|
for i := range c.Models {
|
||||||
|
if c.Models[i].Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.Models = append(c.Models[:i], c.Models[i+1:]...)
|
||||||
|
if c.Active == name {
|
||||||
|
c.Active = ""
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Template 按 key 查找提示词模板。
|
// Template 按 key 查找提示词模板。
|
||||||
@@ -190,44 +249,66 @@ func (c *Config) Template(key string) Template {
|
|||||||
// Clone 返回一份深拷贝,避免设置窗口与引擎共享同一份可变配置。
|
// Clone 返回一份深拷贝,避免设置窗口与引擎共享同一份可变配置。
|
||||||
func (c *Config) Clone() *Config {
|
func (c *Config) Clone() *Config {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return Default()
|
cfg := Default()
|
||||||
|
cfg.Normalize()
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
out := *c
|
out := *c
|
||||||
out.Profiles = append([]Profile(nil), c.Profiles...)
|
out.Templates = append([]Template(nil), c.Templates...)
|
||||||
for i := range out.Profiles {
|
out.Models = make([]Profile, len(c.Models))
|
||||||
if c.Profiles[i].ExtraHeaders != nil {
|
for i, m := range c.Models {
|
||||||
h := make(map[string]string, len(c.Profiles[i].ExtraHeaders))
|
out.Models[i] = m
|
||||||
for k, v := range c.Profiles[i].ExtraHeaders {
|
if m.ExtraHeaders != nil {
|
||||||
|
h := make(map[string]string, len(m.ExtraHeaders))
|
||||||
|
for k, v := range m.ExtraHeaders {
|
||||||
h[k] = v
|
h[k] = v
|
||||||
}
|
}
|
||||||
out.Profiles[i].ExtraHeaders = h
|
out.Models[i].ExtraHeaders = h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.Templates = append([]Template(nil), c.Templates...)
|
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize 补全缺失字段,保证运行期不会出现零值。
|
// Normalize 补全缺失字段,保证运行期不会出现零值。
|
||||||
func (c *Config) Normalize() {
|
func (c *Config) Normalize() {
|
||||||
def := Default()
|
if len(c.Models) == 0 {
|
||||||
if len(c.Profiles) == 0 {
|
c.Models = []Profile{NewProfile("接入点 1", llm.FormatOpenAI)}
|
||||||
c.Profiles = def.Profiles
|
|
||||||
}
|
}
|
||||||
if c.ActiveProfile == "" {
|
for i := range c.Models {
|
||||||
c.ActiveProfile = c.Profiles[0].Name
|
m := &c.Models[i]
|
||||||
}
|
m.Name = strings.TrimSpace(m.Name)
|
||||||
for i := range c.Profiles {
|
if m.Name == "" {
|
||||||
p := &c.Profiles[i]
|
m.Name = fmt.Sprintf("接入点 %d", i+1)
|
||||||
if p.MaxTokens <= 0 {
|
|
||||||
p.MaxTokens = 1024
|
|
||||||
}
|
}
|
||||||
if p.Temperature <= 0 {
|
if m.Format != llm.FormatAnthropic {
|
||||||
p.Temperature = 0.3
|
m.Format = llm.FormatOpenAI
|
||||||
|
}
|
||||||
|
m.BaseURL = strings.TrimRight(strings.TrimSpace(m.BaseURL), "/")
|
||||||
|
m.Model = strings.TrimSpace(m.Model)
|
||||||
|
if m.MaxTokens <= 0 {
|
||||||
|
m.MaxTokens = 1024
|
||||||
|
}
|
||||||
|
if m.Temperature <= 0 {
|
||||||
|
m.Temperature = 0.3
|
||||||
}
|
}
|
||||||
p.BaseURL = strings.TrimRight(strings.TrimSpace(p.BaseURL), "/")
|
|
||||||
}
|
}
|
||||||
|
// 名字必须唯一,否则 Active 无法定位到确定的那一条。
|
||||||
|
used := make(map[string]bool, len(c.Models))
|
||||||
|
for i := range c.Models {
|
||||||
|
base := c.Models[i].Name
|
||||||
|
name := base
|
||||||
|
for n := 2; used[name]; n++ {
|
||||||
|
name = fmt.Sprintf("%s (%d)", base, n)
|
||||||
|
}
|
||||||
|
c.Models[i].Name = name
|
||||||
|
used[name] = true
|
||||||
|
}
|
||||||
|
if _, ok := c.ActiveModel(); !ok {
|
||||||
|
c.Active = c.Models[0].Name
|
||||||
|
}
|
||||||
|
|
||||||
if len(c.Templates) == 0 {
|
if len(c.Templates) == 0 {
|
||||||
c.Templates = def.Templates
|
c.Templates = DefaultTemplates()
|
||||||
}
|
}
|
||||||
if c.Capture.FPS <= 0 {
|
if c.Capture.FPS <= 0 {
|
||||||
c.Capture.FPS = 2
|
c.Capture.FPS = 2
|
||||||
@@ -251,10 +332,15 @@ func (c *Config) Normalize() {
|
|||||||
c.Capture.HistoryTurns = 6
|
c.Capture.HistoryTurns = 6
|
||||||
}
|
}
|
||||||
if c.Hotkeys.SelectRegion == "" {
|
if c.Hotkeys.SelectRegion == "" {
|
||||||
c.Hotkeys.SelectRegion = def.Hotkeys.SelectRegion
|
c.Hotkeys.SelectRegion = "Ctrl+Alt+X"
|
||||||
|
}
|
||||||
|
switch c.Theme {
|
||||||
|
case "contrast", "light":
|
||||||
|
default:
|
||||||
|
c.Theme = "system"
|
||||||
}
|
}
|
||||||
if c.Hotkeys.ToggleWatch == "" {
|
if c.Hotkeys.ToggleWatch == "" {
|
||||||
c.Hotkeys.ToggleWatch = def.Hotkeys.ToggleWatch
|
c.Hotkeys.ToggleWatch = "Ctrl+Alt+Space"
|
||||||
}
|
}
|
||||||
if c.PromptTemplate == "" {
|
if c.PromptTemplate == "" {
|
||||||
c.PromptTemplate = c.Templates[0].Key
|
c.PromptTemplate = c.Templates[0].Key
|
||||||
@@ -300,11 +386,48 @@ func (s *Store) Load() *Config {
|
|||||||
fresh.Normalize()
|
fresh.Normalize()
|
||||||
return fresh
|
return fresh
|
||||||
}
|
}
|
||||||
|
var legacy legacyConfig
|
||||||
|
_ = json.Unmarshal(raw, &legacy)
|
||||||
|
cfg.migrateLegacy(legacy)
|
||||||
cfg.Normalize()
|
cfg.Normalize()
|
||||||
cfg.migrate()
|
cfg.migrate()
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// legacyConfig 是历史版本的两种配置形状,仅用于迁移。
|
||||||
|
type legacyConfig struct {
|
||||||
|
// 上一版:单份模型 + 厂商 key。
|
||||||
|
Vendor string `json:"vendor"`
|
||||||
|
Model Profile `json:"model"`
|
||||||
|
// 更早版本:多接入点。
|
||||||
|
ActiveProfile string `json:"activeProfile"`
|
||||||
|
Profiles []Profile `json:"profiles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateLegacy 把旧版配置折叠成 Models 列表。
|
||||||
|
// 旧格式一律是 OpenAI 兼容,Anthropic 格式是这次才引入的。
|
||||||
|
func (c *Config) migrateLegacy(l legacyConfig) {
|
||||||
|
if len(c.Models) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(l.Profiles) > 0:
|
||||||
|
c.Models = append([]Profile(nil), l.Profiles...)
|
||||||
|
c.Active = l.ActiveProfile
|
||||||
|
case l.Model.BaseURL != "" || l.Model.Model != "":
|
||||||
|
c.Models = []Profile{l.Model}
|
||||||
|
c.Active = l.Model.Name
|
||||||
|
if c.Active == "" {
|
||||||
|
c.Active = l.Vendor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range c.Models {
|
||||||
|
if c.Models[i].Format == "" {
|
||||||
|
c.Models[i].Format = llm.FormatOpenAI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// migrate 把旧版本的默认值升级到当前默认值,避免老配置文件一直沿用已知冲突的热键。
|
// migrate 把旧版本的默认值升级到当前默认值,避免老配置文件一直沿用已知冲突的热键。
|
||||||
func (c *Config) migrate() {
|
func (c *Config) migrate() {
|
||||||
if strings.EqualFold(strings.TrimSpace(c.Hotkeys.SelectRegion), "Ctrl+Alt+Q") {
|
if strings.EqualFold(strings.TrimSpace(c.Hotkeys.SelectRegion), "Ctrl+Alt+Q") {
|
||||||
|
|||||||
+183
-19
@@ -1,16 +1,23 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"viewsagent/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
func TestDefaultIsUsable(t *testing.T) {
|
func TestDefaultIsUsable(t *testing.T) {
|
||||||
cfg := Default()
|
cfg := Default()
|
||||||
cfg.Normalize()
|
cfg.Normalize()
|
||||||
|
|
||||||
if len(cfg.Profiles) == 0 {
|
if len(cfg.Models) == 0 {
|
||||||
t.Fatal("默认配置应包含接入点")
|
t.Fatal("Normalize 后应至少有一份接入点")
|
||||||
}
|
}
|
||||||
if cfg.Active().Name != "qwen" {
|
if _, ok := cfg.ActiveModel(); !ok {
|
||||||
t.Fatalf("默认接入点应为 qwen,实际 %s", cfg.Active().Name)
|
t.Fatal("Normalize 后应有生效接入点")
|
||||||
}
|
}
|
||||||
if cfg.Capture.FPS <= 0 || cfg.Capture.MaxEdge <= 0 || cfg.Capture.CooldownMs <= 0 {
|
if cfg.Capture.FPS <= 0 || cfg.Capture.MaxEdge <= 0 || cfg.Capture.CooldownMs <= 0 {
|
||||||
t.Fatalf("抓屏参数不应为零值: %+v", cfg.Capture)
|
t.Fatalf("抓屏参数不应为零值: %+v", cfg.Capture)
|
||||||
@@ -27,8 +34,14 @@ func TestNormalizeFillsZeroValues(t *testing.T) {
|
|||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
cfg.Normalize()
|
cfg.Normalize()
|
||||||
|
|
||||||
if cfg.ActiveProfile == "" {
|
if len(cfg.Models) == 0 {
|
||||||
t.Fatal("Normalize 应补全 ActiveProfile")
|
t.Fatal("Normalize 应补全接入点列表")
|
||||||
|
}
|
||||||
|
if cfg.Models[0].Format != llm.FormatOpenAI {
|
||||||
|
t.Fatalf("默认接入点应为 OpenAI 兼容格式,实际 %s", cfg.Models[0].Format)
|
||||||
|
}
|
||||||
|
if cfg.Models[0].MaxTokens <= 0 || cfg.Models[0].Temperature <= 0 {
|
||||||
|
t.Fatalf("Normalize 应补全采样默认值: %+v", cfg.Models[0])
|
||||||
}
|
}
|
||||||
if len(cfg.Templates) == 0 {
|
if len(cfg.Templates) == 0 {
|
||||||
t.Fatal("Normalize 应补全模板")
|
t.Fatal("Normalize 应补全模板")
|
||||||
@@ -38,17 +51,72 @@ func TestNormalizeFillsZeroValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKeepsCustomModel(t *testing.T) {
|
||||||
|
cfg := Default()
|
||||||
|
cfg.Models = []Profile{{
|
||||||
|
Name: "我的网关", Format: llm.FormatAnthropic,
|
||||||
|
BaseURL: "https://my-gateway.example.com", Model: "my-vl-model",
|
||||||
|
}}
|
||||||
|
cfg.Normalize()
|
||||||
|
|
||||||
|
p := cfg.Models[0]
|
||||||
|
if p.BaseURL != "https://my-gateway.example.com" || p.Model != "my-vl-model" {
|
||||||
|
t.Fatalf("Normalize 不应覆盖手填的模型配置: %+v", p)
|
||||||
|
}
|
||||||
|
if p.Format != llm.FormatAnthropic {
|
||||||
|
t.Fatalf("Normalize 不应改写接口格式: %s", p.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeForcesUniqueNames(t *testing.T) {
|
||||||
|
cfg := &Config{Models: []Profile{
|
||||||
|
{Name: "同名", Format: llm.FormatOpenAI},
|
||||||
|
{Name: "同名", Format: llm.FormatOpenAI},
|
||||||
|
}}
|
||||||
|
cfg.Normalize()
|
||||||
|
|
||||||
|
if cfg.Models[0].Name == cfg.Models[1].Name {
|
||||||
|
t.Fatalf("重名接入点应被自动改名: %+v", cfg.Models)
|
||||||
|
}
|
||||||
|
if _, ok := cfg.ActiveModel(); !ok {
|
||||||
|
t.Fatal("改名后仍应有生效接入点")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeTheme(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "system",
|
||||||
|
"system": "system",
|
||||||
|
"contrast": "contrast",
|
||||||
|
"light": "light",
|
||||||
|
"dark": "system",
|
||||||
|
"Contrast": "system",
|
||||||
|
"nonsense": "system",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
cfg := &Config{Theme: in}
|
||||||
|
cfg.Normalize()
|
||||||
|
if cfg.Theme != want {
|
||||||
|
t.Fatalf("主题 %q 应归一化为 %q,实际 %q", in, want, cfg.Theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCloneIsIndependent(t *testing.T) {
|
func TestCloneIsIndependent(t *testing.T) {
|
||||||
cfg := Default()
|
cfg := Default()
|
||||||
cfg.Normalize()
|
cfg.Normalize()
|
||||||
|
|
||||||
cp := cfg.Clone()
|
cp := cfg.Clone()
|
||||||
cp.Profiles[0].Model = "changed"
|
cp.Models[0].Model = "changed"
|
||||||
|
cp.Models[0].ExtraHeaders = map[string]string{"X-Test": "1"}
|
||||||
cp.Templates[0].System = "changed"
|
cp.Templates[0].System = "changed"
|
||||||
cp.Capture.FPS = 9
|
cp.Capture.FPS = 9
|
||||||
|
|
||||||
if cfg.Profiles[0].Model == "changed" {
|
if cfg.Models[0].Model == "changed" {
|
||||||
t.Fatal("Clone 后修改接入点不应影响原配置")
|
t.Fatal("Clone 后修改模型不应影响原配置")
|
||||||
|
}
|
||||||
|
if len(cfg.Models[0].ExtraHeaders) != 0 {
|
||||||
|
t.Fatal("Clone 后修改请求头不应影响原配置")
|
||||||
}
|
}
|
||||||
if cfg.Templates[0].System == "changed" {
|
if cfg.Templates[0].System == "changed" {
|
||||||
t.Fatal("Clone 后修改模板不应影响原配置")
|
t.Fatal("Clone 后修改模板不应影响原配置")
|
||||||
@@ -58,18 +126,114 @@ func TestCloneIsIndependent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProfileLookup(t *testing.T) {
|
func TestAddProfileMakesUniqueName(t *testing.T) {
|
||||||
cfg := Default()
|
cfg := Default()
|
||||||
cfg.Normalize()
|
cfg.Normalize()
|
||||||
|
|
||||||
if _, ok := cfg.Profile("qwen"); !ok {
|
added := cfg.AddProfile(NewProfile("接入点", llm.FormatAnthropic))
|
||||||
t.Fatal("应能找到 qwen 接入点")
|
if cfg.ProfileIndex(added.Name) < 0 {
|
||||||
|
t.Fatalf("新增的接入点应能在列表里找到: %+v", cfg.Models)
|
||||||
}
|
}
|
||||||
if _, ok := cfg.Profile("不存在"); ok {
|
again := cfg.AddProfile(NewProfile("接入点", llm.FormatOpenAI))
|
||||||
t.Fatal("不应找到不存在的接入点")
|
if again.Name == added.Name {
|
||||||
}
|
t.Fatalf("重名接入点应自动改名: %s", again.Name)
|
||||||
cfg.ActiveProfile = "不存在"
|
}
|
||||||
if cfg.Active().Name != cfg.Profiles[0].Name {
|
}
|
||||||
t.Fatal("接入点丢失时应回退到第一个")
|
|
||||||
|
func TestSetActive(t *testing.T) {
|
||||||
|
cfg := Default()
|
||||||
|
cfg.Models = []Profile{{Name: "a"}, {Name: "b"}}
|
||||||
|
cfg.Normalize()
|
||||||
|
|
||||||
|
if !cfg.SetActive("b") {
|
||||||
|
t.Fatal("应能切换到存在的接入点")
|
||||||
|
}
|
||||||
|
if p, ok := cfg.ActiveModel(); !ok || p.Name != "b" {
|
||||||
|
t.Fatalf("生效接入点应为 b,实际 %+v", cfg)
|
||||||
|
}
|
||||||
|
if cfg.SetActive("不存在") {
|
||||||
|
t.Fatal("不存在的接入点不应被设为生效")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveModelClearsActive(t *testing.T) {
|
||||||
|
cfg := Default()
|
||||||
|
cfg.Models = []Profile{{Name: "a"}, {Name: "b"}}
|
||||||
|
cfg.Active = "a"
|
||||||
|
cfg.Normalize()
|
||||||
|
|
||||||
|
if !cfg.RemoveModel("a") {
|
||||||
|
t.Fatal("应能删除接入点")
|
||||||
|
}
|
||||||
|
if cfg.Active != "" {
|
||||||
|
t.Fatalf("删掉生效接入点后应清空 Active,实际 %s", cfg.Active)
|
||||||
|
}
|
||||||
|
if _, ok := cfg.ActiveModel(); ok {
|
||||||
|
t.Fatal("删掉生效接入点后不应再能取到它")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateLegacyProfiles(t *testing.T) {
|
||||||
|
cfg := Default()
|
||||||
|
cfg.migrateLegacy(legacyConfig{
|
||||||
|
ActiveProfile: "doubao",
|
||||||
|
Profiles: []Profile{
|
||||||
|
{Name: "qwen", BaseURL: "https://qwen.example/v1", Model: "qwen-vl"},
|
||||||
|
{Name: "doubao", BaseURL: "https://doubao.example/v1", Model: "doubao-vl", APIKey: "sk-old"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
cfg.Normalize()
|
||||||
|
|
||||||
|
active, ok := cfg.ActiveModel()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("应迁移出原启用接入点")
|
||||||
|
}
|
||||||
|
if active.Model != "doubao-vl" {
|
||||||
|
t.Fatalf("应迁移原启用接入点的模型,实际 %s", active.Model)
|
||||||
|
}
|
||||||
|
if active.APIKey != "sk-old" {
|
||||||
|
t.Fatal("应迁移原 API Key")
|
||||||
|
}
|
||||||
|
if active.Format != llm.FormatOpenAI {
|
||||||
|
t.Fatalf("旧格式一律按 OpenAI 兼容迁移,实际 %s", active.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadMigratesLegacyFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
legacy := `{
|
||||||
|
"activeProfile": "doubao",
|
||||||
|
"profiles": [
|
||||||
|
{"name": "qwen", "baseURL": "https://qwen.example/v1", "model": "qwen-vl"},
|
||||||
|
{"name": "doubao", "baseURL": "https://doubao.example/v1", "model": "doubao-vl", "apiKey": "sk-legacy"}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(path, []byte(legacy), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Store{path: path}
|
||||||
|
cfg := s.Load()
|
||||||
|
|
||||||
|
active, ok := cfg.ActiveModel()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("应迁移出生效接入点")
|
||||||
|
}
|
||||||
|
if active.Model != "doubao-vl" || active.APIKey != "sk-legacy" {
|
||||||
|
t.Fatalf("应迁移原模型配置: %+v", active)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Save(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), "activeProfile") || strings.Contains(string(raw), "profiles") {
|
||||||
|
t.Fatalf("保存后不应再写入旧接入点字段: %s", raw)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), `"models"`) {
|
||||||
|
t.Fatalf("保存后应写入新的接入点列表: %s", raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultAnthropicVersion 是 anthropic-version 请求头的默认值。
|
||||||
|
const DefaultAnthropicVersion = "2023-06-01"
|
||||||
|
|
||||||
|
// anthropic 是 Anthropic Messages 协议(/v1/messages + SSE)的实现。
|
||||||
|
// 与 OpenAI 兼容格式的三处关键差异:鉴权用 x-api-key 而非 Bearer;
|
||||||
|
// system 是请求体顶层字段而非一条消息;图片走 source.base64 而非 data URL。
|
||||||
|
type anthropic struct {
|
||||||
|
opts Options
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAnthropic(opts Options) *anthropic {
|
||||||
|
if opts.MaxTokens <= 0 {
|
||||||
|
opts.MaxTokens = 1024
|
||||||
|
}
|
||||||
|
if opts.Temperature <= 0 {
|
||||||
|
opts.Temperature = 0.3
|
||||||
|
}
|
||||||
|
return &anthropic{opts: opts, http: newHTTPClient()}
|
||||||
|
}
|
||||||
|
|
||||||
|
type anthropicRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []anthropicMessage `json:"messages"`
|
||||||
|
System string `json:"system,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type anthropicMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content any `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type anthropicBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Source *anthropicImage `json:"source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type anthropicImage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type anthropicUsage struct {
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicEvent 覆盖流式与非流式两种响应体,用不到的字段留空即可。
|
||||||
|
type anthropicEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message *struct {
|
||||||
|
Usage *anthropicUsage `json:"usage"`
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Thinking string `json:"thinking"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
Delta *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
// thinking_delta 里思考文本在 thinking 字段。
|
||||||
|
Thinking string `json:"thinking"`
|
||||||
|
} `json:"delta"`
|
||||||
|
Usage *anthropicUsage `json:"usage"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpoint 兼容三种写法:官方根地址、已带 /v1、以及直接给全 /messages 的代理。
|
||||||
|
func (a *anthropic) endpoint() string {
|
||||||
|
base := strings.TrimRight(a.opts.BaseURL, "/")
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(base, "/messages"):
|
||||||
|
return base
|
||||||
|
case strings.HasSuffix(base, "/v1"):
|
||||||
|
return base + "/messages"
|
||||||
|
default:
|
||||||
|
return base + "/v1/messages"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *anthropic) setHeaders(req *http.Request, stream bool) {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if stream {
|
||||||
|
req.Header.Set("Accept", "text/event-stream")
|
||||||
|
} else {
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
}
|
||||||
|
if a.opts.APIKey != "" {
|
||||||
|
req.Header.Set("x-api-key", a.opts.APIKey)
|
||||||
|
}
|
||||||
|
if a.opts.ExtraHeaders["anthropic-version"] == "" {
|
||||||
|
req.Header.Set("anthropic-version", DefaultAnthropicVersion)
|
||||||
|
}
|
||||||
|
for k, v := range a.opts.ExtraHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *anthropic) buildBody(system string, msgs []Message, stream bool) ([]byte, error) {
|
||||||
|
body := anthropicRequest{
|
||||||
|
Model: a.opts.Model,
|
||||||
|
Stream: stream,
|
||||||
|
MaxTokens: a.opts.MaxTokens,
|
||||||
|
Temperature: a.opts.Temperature,
|
||||||
|
System: strings.TrimSpace(system),
|
||||||
|
}
|
||||||
|
for _, m := range msgs {
|
||||||
|
role := "user"
|
||||||
|
if m.Role == RoleAssistant {
|
||||||
|
role = "assistant"
|
||||||
|
}
|
||||||
|
if len(m.Image) == 0 {
|
||||||
|
body.Messages = append(body.Messages, anthropicMessage{Role: role, Content: m.Text})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := m.Text
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
text = "请看图并回答。"
|
||||||
|
}
|
||||||
|
blocks := []anthropicBlock{
|
||||||
|
{Type: "text", Text: text},
|
||||||
|
{
|
||||||
|
Type: "image",
|
||||||
|
Source: &anthropicImage{
|
||||||
|
Type: "base64",
|
||||||
|
MediaType: "image/jpeg",
|
||||||
|
Data: base64.StdEncoding.EncodeToString(m.Image),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body.Messages = append(body.Messages, anthropicMessage{Role: role, Content: blocks})
|
||||||
|
}
|
||||||
|
if len(body.Messages) == 0 {
|
||||||
|
return nil, fmt.Errorf("没有可发送的消息")
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("构造请求失败: %w", err)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat 发起一次流式对话。
|
||||||
|
func (a *anthropic) Chat(ctx context.Context, system string, msgs []Message, fn DeltaFunc) (Usage, error) {
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return Usage{}, fmt.Errorf("没有可发送的消息")
|
||||||
|
}
|
||||||
|
payload, err := a.buildBody(system, msgs, true)
|
||||||
|
if err != nil {
|
||||||
|
return Usage{}, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return Usage{}, fmt.Errorf("创建请求失败: %w", err)
|
||||||
|
}
|
||||||
|
a.setHeaders(req, true)
|
||||||
|
|
||||||
|
resp, err := a.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Usage{}, fmt.Errorf("请求 %s 失败: %w", a.opts.Name, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return Usage{}, fmt.Errorf("模型返回 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部分代理会忽略 stream 参数直接返回完整 JSON,这里做兼容。
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "event-stream") {
|
||||||
|
return a.readWhole(resp.Body, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
var usage Usage
|
||||||
|
var got strings.Builder
|
||||||
|
var gotReasoning strings.Builder
|
||||||
|
err = readSSE(resp.Body, func(ev sseEvent) error {
|
||||||
|
if len(ev.Data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var e anthropicEvent
|
||||||
|
if json.Unmarshal(ev.Data, &e) != nil {
|
||||||
|
return nil // 忽略无法解析的心跳/杂项
|
||||||
|
}
|
||||||
|
switch e.Type {
|
||||||
|
case "error":
|
||||||
|
msg := "未知错误"
|
||||||
|
if e.Error != nil && e.Error.Message != "" {
|
||||||
|
msg = e.Error.Message
|
||||||
|
}
|
||||||
|
return fmt.Errorf("模型返回错误: %s", msg)
|
||||||
|
case "message_start":
|
||||||
|
if e.Message != nil && e.Message.Usage != nil {
|
||||||
|
usage.PromptTokens = e.Message.Usage.InputTokens
|
||||||
|
usage.CompletionTokens = e.Message.Usage.OutputTokens
|
||||||
|
}
|
||||||
|
case "content_block_delta":
|
||||||
|
if e.Delta == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// thinking_delta 把思考文本放在 thinking 字段,text_delta 才是正式回答。
|
||||||
|
if e.Delta.Thinking != "" {
|
||||||
|
gotReasoning.WriteString(e.Delta.Thinking)
|
||||||
|
if fn != nil {
|
||||||
|
if derr := fn(Delta{Reasoning: e.Delta.Thinking}); derr != nil {
|
||||||
|
return derr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// input_json_delta 等其它类型走别的字段,直接跳过。
|
||||||
|
if e.Delta.Text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
got.WriteString(e.Delta.Text)
|
||||||
|
if fn != nil {
|
||||||
|
if derr := fn(Delta{Text: e.Delta.Text}); derr != nil {
|
||||||
|
return derr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "message_delta":
|
||||||
|
// 结束前的这一帧才带最终的 output_tokens。
|
||||||
|
if e.Usage != nil && e.Usage.OutputTokens > 0 {
|
||||||
|
usage.CompletionTokens = e.Usage.OutputTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return usage, err
|
||||||
|
}
|
||||||
|
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||||
|
if got.Len() == 0 && gotReasoning.Len() == 0 {
|
||||||
|
return usage, fmt.Errorf("模型没有返回任何内容,请确认 %s 是支持图片输入的视觉模型", a.opts.Model)
|
||||||
|
}
|
||||||
|
return usage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readWhole 处理服务端忽略 stream 参数的情况。
|
||||||
|
func (a *anthropic) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) {
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(r, 8<<20))
|
||||||
|
if err != nil {
|
||||||
|
return Usage{}, fmt.Errorf("读取响应失败: %w", err)
|
||||||
|
}
|
||||||
|
var e anthropicEvent
|
||||||
|
if err := json.Unmarshal(raw, &e); err != nil {
|
||||||
|
return Usage{}, fmt.Errorf("解析响应失败: %w", err)
|
||||||
|
}
|
||||||
|
if e.Error != nil {
|
||||||
|
return Usage{}, fmt.Errorf("模型返回错误: %s", e.Error.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
var usage Usage
|
||||||
|
var text strings.Builder
|
||||||
|
var thinking strings.Builder
|
||||||
|
if e.Message != nil {
|
||||||
|
if e.Message.Usage != nil {
|
||||||
|
usage.PromptTokens = e.Message.Usage.InputTokens
|
||||||
|
usage.CompletionTokens = e.Message.Usage.OutputTokens
|
||||||
|
}
|
||||||
|
for _, b := range e.Message.Content {
|
||||||
|
switch b.Type {
|
||||||
|
case "text":
|
||||||
|
text.WriteString(b.Text)
|
||||||
|
case "thinking":
|
||||||
|
thinking.WriteString(b.Thinking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||||
|
if text.Len() == 0 && thinking.Len() == 0 {
|
||||||
|
return usage, fmt.Errorf("模型返回内容为空")
|
||||||
|
}
|
||||||
|
if fn != nil {
|
||||||
|
if thinking.Len() > 0 {
|
||||||
|
if err := fn(Delta{Reasoning: thinking.String()}); err != nil {
|
||||||
|
return usage, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if text.Len() > 0 {
|
||||||
|
if err := fn(Delta{Text: text.String()}); err != nil {
|
||||||
|
return usage, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return usage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping 做一次最小代价的连通性检查。
|
||||||
|
func (a *anthropic) Ping(ctx context.Context) error {
|
||||||
|
payload, err := a.buildBody("", []Message{{Role: RoleUser, Text: "ping"}}, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建请求失败: %w", err)
|
||||||
|
}
|
||||||
|
a.setHeaders(req, false)
|
||||||
|
|
||||||
|
resp, err := a.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("连接 %s 失败: %w", a.opts.BaseURL, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return fmt.Errorf("服务返回空响应")
|
||||||
|
}
|
||||||
|
// 有些代理会把错误塞进 200 的响应体里。
|
||||||
|
var probe anthropicEvent
|
||||||
|
if json.Unmarshal(raw, &probe) == nil && probe.Error != nil {
|
||||||
|
return fmt.Errorf("模型返回错误: %s", probe.Error.Message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+41
-20
@@ -9,7 +9,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// openAI 是 OpenAI 兼容协议(/chat/completions + SSE)的实现。
|
// openAI 是 OpenAI 兼容协议(/chat/completions + SSE)的实现。
|
||||||
@@ -25,19 +24,7 @@ func newOpenAI(opts Options) *openAI {
|
|||||||
if opts.Temperature <= 0 {
|
if opts.Temperature <= 0 {
|
||||||
opts.Temperature = 0.3
|
opts.Temperature = 0.3
|
||||||
}
|
}
|
||||||
return &openAI{
|
return &openAI{opts: opts, http: newHTTPClient()}
|
||||||
opts: opts,
|
|
||||||
http: &http.Client{
|
|
||||||
// 不设总超时:流式响应可能持续很久,由 ctx 控制。
|
|
||||||
Transport: &http.Transport{
|
|
||||||
Proxy: http.ProxyFromEnvironment,
|
|
||||||
MaxIdleConns: 8,
|
|
||||||
IdleConnTimeout: 90 * time.Second,
|
|
||||||
ForceAttemptHTTP2: true,
|
|
||||||
TLSHandshakeTimeout: 15 * time.Second,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type reqMessage struct {
|
type reqMessage struct {
|
||||||
@@ -72,6 +59,10 @@ type streamChunk struct {
|
|||||||
Choices []struct {
|
Choices []struct {
|
||||||
Delta struct {
|
Delta struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
// 思考内容:DeepSeek-R1 / Qwen3 等推理模型走 reasoning_content,
|
||||||
|
// 也有网关用 reasoning,两个字段都收。
|
||||||
|
Reasoning string `json:"reasoning_content"`
|
||||||
|
ReasoningAlt string `json:"reasoning"`
|
||||||
} `json:"delta"`
|
} `json:"delta"`
|
||||||
FinishReason string `json:"finish_reason"`
|
FinishReason string `json:"finish_reason"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
@@ -186,6 +177,7 @@ func (o *openAI) Chat(ctx context.Context, system string, msgs []Message, fn Del
|
|||||||
|
|
||||||
var usage Usage
|
var usage Usage
|
||||||
var got strings.Builder
|
var got strings.Builder
|
||||||
|
var gotReasoning strings.Builder
|
||||||
err = readSSE(resp.Body, func(ev sseEvent) error {
|
err = readSSE(resp.Body, func(ev sseEvent) error {
|
||||||
if len(ev.Data) == 0 || bytes.Equal(ev.Data, []byte("[DONE]")) {
|
if len(ev.Data) == 0 || bytes.Equal(ev.Data, []byte("[DONE]")) {
|
||||||
return nil
|
return nil
|
||||||
@@ -205,12 +197,24 @@ func (o *openAI) Chat(ctx context.Context, system string, msgs []Message, fn Del
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, c := range chunk.Choices {
|
for _, c := range chunk.Choices {
|
||||||
|
reasoning := c.Delta.Reasoning
|
||||||
|
if reasoning == "" {
|
||||||
|
reasoning = c.Delta.ReasoningAlt
|
||||||
|
}
|
||||||
|
if reasoning != "" {
|
||||||
|
gotReasoning.WriteString(reasoning)
|
||||||
|
if fn != nil {
|
||||||
|
if derr := fn(Delta{Reasoning: reasoning}); derr != nil {
|
||||||
|
return derr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.Delta.Content == "" {
|
if c.Delta.Content == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
got.WriteString(c.Delta.Content)
|
got.WriteString(c.Delta.Content)
|
||||||
if fn != nil {
|
if fn != nil {
|
||||||
if derr := fn(c.Delta.Content); derr != nil {
|
if derr := fn(Delta{Text: c.Delta.Content}); derr != nil {
|
||||||
return derr
|
return derr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,7 +224,7 @@ func (o *openAI) Chat(ctx context.Context, system string, msgs []Message, fn Del
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return usage, err
|
return usage, err
|
||||||
}
|
}
|
||||||
if got.Len() == 0 {
|
if got.Len() == 0 && gotReasoning.Len() == 0 {
|
||||||
return usage, fmt.Errorf("模型没有返回任何内容,请确认 %s 是支持图片输入的视觉模型", o.opts.Model)
|
return usage, fmt.Errorf("模型没有返回任何内容,请确认 %s 是支持图片输入的视觉模型", o.opts.Model)
|
||||||
}
|
}
|
||||||
return usage, nil
|
return usage, nil
|
||||||
@@ -236,6 +240,9 @@ func (o *openAI) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) {
|
|||||||
Choices []struct {
|
Choices []struct {
|
||||||
Message struct {
|
Message struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
// 非流式响应里思考内容通常挂在 message 上。
|
||||||
|
ReasoningContent string `json:"reasoning_content"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
Usage *struct {
|
Usage *struct {
|
||||||
@@ -253,7 +260,11 @@ func (o *openAI) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) {
|
|||||||
if resp.Error != nil {
|
if resp.Error != nil {
|
||||||
return Usage{}, fmt.Errorf("模型返回错误: %s", resp.Error.Message)
|
return Usage{}, fmt.Errorf("模型返回错误: %s", resp.Error.Message)
|
||||||
}
|
}
|
||||||
if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" {
|
if len(resp.Choices) == 0 {
|
||||||
|
return Usage{}, fmt.Errorf("模型返回内容为空")
|
||||||
|
}
|
||||||
|
msg := resp.Choices[0].Message
|
||||||
|
if msg.Content == "" && msg.ReasoningContent == "" && msg.Reasoning == "" {
|
||||||
return Usage{}, fmt.Errorf("模型返回内容为空")
|
return Usage{}, fmt.Errorf("模型返回内容为空")
|
||||||
}
|
}
|
||||||
var usage Usage
|
var usage Usage
|
||||||
@@ -264,10 +275,20 @@ func (o *openAI) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) {
|
|||||||
TotalTokens: resp.Usage.TotalTokens,
|
TotalTokens: resp.Usage.TotalTokens,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
text := resp.Choices[0].Message.Content
|
|
||||||
if fn != nil {
|
if fn != nil {
|
||||||
if err := fn(text); err != nil {
|
reasoning := msg.ReasoningContent
|
||||||
return usage, err
|
if reasoning == "" {
|
||||||
|
reasoning = msg.Reasoning
|
||||||
|
}
|
||||||
|
if reasoning != "" {
|
||||||
|
if err := fn(Delta{Reasoning: reasoning}); err != nil {
|
||||||
|
return usage, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg.Content != "" {
|
||||||
|
if err := fn(Delta{Text: msg.Content}); err != nil {
|
||||||
|
return usage, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return usage, nil
|
return usage, nil
|
||||||
|
|||||||
@@ -1,11 +1,36 @@
|
|||||||
// Package llm 提供与 OpenAI 兼容接口的大模型通信能力(含视觉与流式)。
|
// Package llm 提供大模型通信能力(含视觉与流式),
|
||||||
|
// 支持 OpenAI Chat Completions 与 Anthropic Messages 两种接口格式。
|
||||||
package llm
|
package llm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 接口格式。它决定请求体结构、鉴权头与流式事件的解析方式,
|
||||||
|
// 也是配置里唯一需要用户先选的东西。
|
||||||
|
const (
|
||||||
|
// FormatOpenAI 是 OpenAI Chat Completions 兼容格式(POST {base}/chat/completions)。
|
||||||
|
FormatOpenAI = "openai"
|
||||||
|
// FormatAnthropic 是 Anthropic Messages 兼容格式(POST {base}/v1/messages)。
|
||||||
|
FormatAnthropic = "anthropic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatLabel 返回接口格式的中文名,供界面显示。
|
||||||
|
func FormatLabel(format string) string {
|
||||||
|
switch format {
|
||||||
|
case FormatAnthropic:
|
||||||
|
return "Anthropic 兼容"
|
||||||
|
default:
|
||||||
|
return "OpenAI 兼容"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formats 返回全部受支持的接口格式,顺序即界面下拉顺序。
|
||||||
|
func Formats() []string { return []string{FormatOpenAI, FormatAnthropic} }
|
||||||
|
|
||||||
// Role 是消息角色。
|
// Role 是消息角色。
|
||||||
type Role string
|
type Role string
|
||||||
|
|
||||||
@@ -36,12 +61,21 @@ func (u *Usage) Add(o Usage) {
|
|||||||
u.TotalTokens += o.TotalTokens
|
u.TotalTokens += o.TotalTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeltaFunc 接收流式增量文本,返回 error 可中断请求。
|
// Delta 是一次流式增量。
|
||||||
type DeltaFunc func(delta string) error
|
// Text 是正式回答,Reasoning 是模型吐出来的思考过程;两者都按原样透传,
|
||||||
|
// 服务端吐什么、界面就显示什么。
|
||||||
|
type Delta struct {
|
||||||
|
Text string
|
||||||
|
Reasoning string
|
||||||
|
}
|
||||||
|
|
||||||
// Options 描述一个模型接入点。
|
// DeltaFunc 接收流式增量,返回 error 可中断请求。
|
||||||
|
type DeltaFunc func(delta Delta) error
|
||||||
|
|
||||||
|
// Options 描述一个模型的接入参数。
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Name string
|
Name string
|
||||||
|
Format string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
APIKey string
|
APIKey string
|
||||||
Model string
|
Model string
|
||||||
@@ -51,7 +85,6 @@ type Options struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Provider 是大模型服务的最小抽象。
|
// Provider 是大模型服务的最小抽象。
|
||||||
// 目前只有 OpenAI 兼容实现,其它私有协议按此接口新增即可。
|
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
// Chat 发起一次流式对话,system 为系统提示词。
|
// Chat 发起一次流式对话,system 为系统提示词。
|
||||||
Chat(ctx context.Context, system string, msgs []Message, fn DeltaFunc) (Usage, error)
|
Chat(ctx context.Context, system string, msgs []Message, fn DeltaFunc) (Usage, error)
|
||||||
@@ -59,13 +92,33 @@ type Provider interface {
|
|||||||
Ping(ctx context.Context) error
|
Ping(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 根据接入点创建 Provider。
|
// New 根据模型配置创建 Provider,按 Format 分派到对应协议实现。
|
||||||
func New(opts Options) (Provider, error) {
|
func New(opts Options) (Provider, error) {
|
||||||
if opts.BaseURL == "" {
|
if opts.BaseURL == "" {
|
||||||
return nil, fmt.Errorf("模型接入点缺少 BaseURL")
|
return nil, fmt.Errorf("模型配置缺少 BaseURL")
|
||||||
}
|
}
|
||||||
if opts.Model == "" {
|
if opts.Model == "" {
|
||||||
return nil, fmt.Errorf("模型接入点 %s 缺少 model", opts.Name)
|
return nil, fmt.Errorf("模型 %s 缺少模型名", opts.Name)
|
||||||
|
}
|
||||||
|
switch opts.Format {
|
||||||
|
case "", FormatOpenAI:
|
||||||
|
return newOpenAI(opts), nil
|
||||||
|
case FormatAnthropic:
|
||||||
|
return newAnthropic(opts), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("未知的接口格式 %q", opts.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHTTPClient 返回不设总超时的客户端:流式响应可能持续很久,由 ctx 控制。
|
||||||
|
func newHTTPClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
MaxIdleConns: 8,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
TLSHandshakeTimeout: 15 * time.Second,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return newOpenAI(opts), nil
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sseServer 起一个假的 SSE 服务端,按脚本逐条吐数据。
|
||||||
|
func sseServer(t *testing.T, chunks []string, gap time.Duration) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
flusher, _ := w.(http.Flusher)
|
||||||
|
for _, c := range chunks {
|
||||||
|
fmt.Fprint(w, c)
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
time.Sleep(gap)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIChunk(content, reasoning string) string {
|
||||||
|
delta := fmt.Sprintf(`{"choices":[{"delta":{"content":%q,"reasoning_content":%q}}]}`, content, reasoning)
|
||||||
|
return "data: " + delta + "\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIStreamsIncrementally(t *testing.T) {
|
||||||
|
srv := sseServer(t, []string{
|
||||||
|
openAIChunk("第一段", ""),
|
||||||
|
openAIChunk("第二段", ""),
|
||||||
|
openAIChunk("第三段", ""),
|
||||||
|
}, 60*time.Millisecond)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
prov, err := New(Options{Format: FormatOpenAI, BaseURL: srv.URL, Model: "m", APIKey: "k"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ev struct {
|
||||||
|
text string
|
||||||
|
at time.Duration
|
||||||
|
}
|
||||||
|
var evs []ev
|
||||||
|
start := time.Now()
|
||||||
|
_, err = prov.Chat(context.Background(), "", []Message{{Role: RoleUser, Text: "hi"}}, func(d Delta) error {
|
||||||
|
evs = append(evs, ev{text: d.Text, at: time.Since(start)})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(evs) != 3 {
|
||||||
|
t.Fatalf("应收到 3 段增量,实际 %d", len(evs))
|
||||||
|
}
|
||||||
|
// 第一段必须在服务端还没吐完时就到,否则就是"一次性返回"。
|
||||||
|
if evs[0].at > 50*time.Millisecond {
|
||||||
|
t.Fatalf("第一段增量到达太晚(%v),说明没有真正流式", evs[0].at)
|
||||||
|
}
|
||||||
|
if got := evs[0].text + evs[1].text + evs[2].text; got != "第一段第二段第三段" {
|
||||||
|
t.Fatalf("增量内容不对: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIParsesReasoning(t *testing.T) {
|
||||||
|
srv := sseServer(t, []string{
|
||||||
|
openAIChunk("", "先想一下…"),
|
||||||
|
openAIChunk("", "再想一下…"),
|
||||||
|
openAIChunk("正式回答", ""),
|
||||||
|
}, 10*time.Millisecond)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
prov, err := New(Options{Format: FormatOpenAI, BaseURL: srv.URL, Model: "m"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var think, text strings.Builder
|
||||||
|
_, err = prov.Chat(context.Background(), "", []Message{{Role: RoleUser, Text: "hi"}}, func(d Delta) error {
|
||||||
|
think.WriteString(d.Reasoning)
|
||||||
|
text.WriteString(d.Text)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if think.String() != "先想一下…再想一下…" {
|
||||||
|
t.Fatalf("思考内容解析不对: %q", think.String())
|
||||||
|
}
|
||||||
|
if text.String() != "正式回答" {
|
||||||
|
t.Fatalf("正文解析不对: %q", text.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicStreamsThinkingAndText(t *testing.T) {
|
||||||
|
srv := sseServer(t, []string{
|
||||||
|
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"我在想…\"}}\n\n",
|
||||||
|
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"答案\"}}\n\n",
|
||||||
|
"event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":12}}\n\n",
|
||||||
|
}, 60*time.Millisecond)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
prov, err := New(Options{Format: FormatAnthropic, BaseURL: srv.URL, Model: "m"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var think, text strings.Builder
|
||||||
|
start := time.Now()
|
||||||
|
var firstAt time.Duration
|
||||||
|
_, err = prov.Chat(context.Background(), "", []Message{{Role: RoleUser, Text: "hi"}}, func(d Delta) error {
|
||||||
|
if firstAt == 0 {
|
||||||
|
firstAt = time.Since(start)
|
||||||
|
}
|
||||||
|
think.WriteString(d.Reasoning)
|
||||||
|
text.WriteString(d.Text)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if think.String() != "我在想…" {
|
||||||
|
t.Fatalf("思考内容解析不对: %q", think.String())
|
||||||
|
}
|
||||||
|
if text.String() != "答案" {
|
||||||
|
t.Fatalf("正文解析不对: %q", text.String())
|
||||||
|
}
|
||||||
|
if firstAt > 50*time.Millisecond {
|
||||||
|
t.Fatalf("第一段增量到达太晚(%v),说明没有真正流式", firstAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
-43
@@ -1,7 +1,9 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
"image/png"
|
"image/png"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -45,16 +47,19 @@ type App struct {
|
|||||||
settings *settingsWindow
|
settings *settingsWindow
|
||||||
card *floatCard
|
card *floatCard
|
||||||
overlay *overlay
|
overlay *overlay
|
||||||
|
regionFrm *regionFrame
|
||||||
hotkeyStop func()
|
hotkeyStop func()
|
||||||
|
|
||||||
// 流式文本合批,避免每个 delta 都触发一次布局
|
// 流式文本合批,避免每个 delta 都触发一次布局
|
||||||
pendMu sync.Mutex
|
pendMu sync.Mutex
|
||||||
pending strings.Builder
|
pendingText strings.Builder
|
||||||
|
pendingThink strings.Builder
|
||||||
pendScheduled bool
|
pendScheduled bool
|
||||||
|
|
||||||
// 最近一条完整回答,用于同步到浮窗
|
// 最近一条完整回答,用于同步到浮窗
|
||||||
replyMu sync.Mutex
|
replyMu sync.Mutex
|
||||||
lastReply strings.Builder
|
lastReply strings.Builder
|
||||||
|
lastCardSync time.Time
|
||||||
|
|
||||||
quietModelSel bool
|
quietModelSel bool
|
||||||
}
|
}
|
||||||
@@ -69,6 +74,8 @@ func Run() error {
|
|||||||
|
|
||||||
fa := fyneapp.NewWithID("com.viewsagent.desktop")
|
fa := fyneapp.NewWithID("com.viewsagent.desktop")
|
||||||
installTranslations()
|
installTranslations()
|
||||||
|
// 主题要在建界面之前定下来,否则首帧会用默认配色渲染。
|
||||||
|
applyThemeMode(cfg.Theme)
|
||||||
eng := agent.New(cfg, store)
|
eng := agent.New(cfg, store)
|
||||||
|
|
||||||
a := &App{fyne: fa, eng: eng}
|
a := &App{fyne: fa, eng: eng}
|
||||||
@@ -81,6 +88,7 @@ func Run() error {
|
|||||||
if a.hotkeyStop != nil {
|
if a.hotkeyStop != nil {
|
||||||
a.hotkeyStop()
|
a.hotkeyStop()
|
||||||
}
|
}
|
||||||
|
a.regionFrm.close()
|
||||||
eng.Close()
|
eng.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -88,11 +96,15 @@ func Run() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeAutoShot 在设置了 VIEWSAGENT_SHOT 时,等界面稳定后把画布导出为 PNG 再退出。
|
// maybeAutoShot 在设置了 VIEWSAGENT_SHOT / VIEWSAGENT_SHOT_SETTINGS / VIEWSAGENT_ASK 时,
|
||||||
|
// 等界面稳定后把画布导出为 PNG 再退出。
|
||||||
|
// VIEWSAGENT_SHOT_TAB 可指定设置窗口要停在哪个标签(如「其它」)。
|
||||||
// 只用于开发期核对布局,正常运行不会触发。
|
// 只用于开发期核对布局,正常运行不会触发。
|
||||||
func (a *App) maybeAutoShot() {
|
func (a *App) maybeAutoShot() {
|
||||||
path := os.Getenv("VIEWSAGENT_SHOT")
|
path := os.Getenv("VIEWSAGENT_SHOT")
|
||||||
if path == "" {
|
settingsPath := os.Getenv("VIEWSAGENT_SHOT_SETTINGS")
|
||||||
|
ask := os.Getenv("VIEWSAGENT_ASK")
|
||||||
|
if path == "" && settingsPath == "" && ask == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
delay := 1500 * time.Millisecond
|
delay := 1500 * time.Millisecond
|
||||||
@@ -103,18 +115,46 @@ func (a *App) maybeAutoShot() {
|
|||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
|
if settingsPath != "" {
|
||||||
|
fyne.Do(func() { a.settings.show() })
|
||||||
|
time.Sleep(delay)
|
||||||
|
if tab := os.Getenv("VIEWSAGENT_SHOT_TAB"); tab != "" {
|
||||||
|
fyne.Do(func() { a.settings.selectTabByName(tab) })
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ask != "" {
|
||||||
|
fyne.Do(func() {
|
||||||
|
a.entry.SetText(ask)
|
||||||
|
a.send()
|
||||||
|
})
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
if img := a.win.Canvas().Capture(); img != nil {
|
if path != "" {
|
||||||
if f, err := os.Create(path); err == nil {
|
writePNG(path, a.win.Canvas().Capture())
|
||||||
_ = png.Encode(f, img)
|
}
|
||||||
_ = f.Close()
|
if settingsPath != "" {
|
||||||
}
|
writePNG(settingsPath, a.settings.win.Canvas().Capture())
|
||||||
}
|
}
|
||||||
a.fyne.Quit()
|
a.fyne.Quit()
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writePNG 把画布导出成 PNG 文件。
|
||||||
|
func writePNG(path string, img image.Image) {
|
||||||
|
if img == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_ = png.Encode(f, img)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) build() {
|
func (a *App) build() {
|
||||||
a.win = a.fyne.NewWindow(mainTitle)
|
a.win = a.fyne.NewWindow(mainTitle)
|
||||||
a.win.Resize(fyne.NewSize(520, 780))
|
a.win.Resize(fyne.NewSize(520, 780))
|
||||||
@@ -122,13 +162,16 @@ func (a *App) build() {
|
|||||||
|
|
||||||
a.chat = newChatView()
|
a.chat = newChatView()
|
||||||
a.card = newFloatCard(a.fyne)
|
a.card = newFloatCard(a.fyne)
|
||||||
|
a.regionFrm = newRegionFrame()
|
||||||
a.settings = newSettingsWindow(a)
|
a.settings = newSettingsWindow(a)
|
||||||
|
|
||||||
cfg := a.eng.Config()
|
cfg := a.eng.Config()
|
||||||
|
|
||||||
a.modelSel = widget.NewSelect(profileNames(cfg), a.onModelChange)
|
a.modelSel = widget.NewSelect(cfg.ModelNames(), a.onModelChange)
|
||||||
a.modelSel.PlaceHolder = "请选择接入点"
|
a.modelSel.PlaceHolder = "请选择接入点"
|
||||||
a.modelSel.SetSelected(cfg.ActiveProfile)
|
a.quietModelSel = true
|
||||||
|
a.modelSel.SetSelected(cfg.Active)
|
||||||
|
a.quietModelSel = false
|
||||||
|
|
||||||
cardBtn := widget.NewButtonWithIcon("", theme.InfoIcon(), a.toggleCard)
|
cardBtn := widget.NewButtonWithIcon("", theme.InfoIcon(), a.toggleCard)
|
||||||
cardBtn.Importance = widget.LowImportance
|
cardBtn.Importance = widget.LowImportance
|
||||||
@@ -136,7 +179,7 @@ func (a *App) build() {
|
|||||||
settingsBtn.Importance = widget.LowImportance
|
settingsBtn.Importance = widget.LowImportance
|
||||||
|
|
||||||
toolbar := container.NewHBox(
|
toolbar := container.NewHBox(
|
||||||
widget.NewLabel("模型"), a.modelSel,
|
widget.NewLabel("接入点"), a.modelSel,
|
||||||
layout.NewSpacer(), cardBtn, settingsBtn,
|
layout.NewSpacer(), cardBtn, settingsBtn,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,14 +220,14 @@ func (a *App) build() {
|
|||||||
a.win.SetContent(container.NewBorder(top, bottom, nil, nil, a.chat.object()))
|
a.win.SetContent(container.NewBorder(top, bottom, nil, nil, a.chat.object()))
|
||||||
|
|
||||||
a.applyState(a.eng.State())
|
a.applyState(a.eng.State())
|
||||||
}
|
|
||||||
|
|
||||||
func profileNames(cfg *config.Config) []string {
|
// 开发期自检:把取景框内部状态写到文件,方便排查。
|
||||||
names := make([]string, 0, len(cfg.Profiles))
|
if p := os.Getenv("VIEWSAGENT_FRAME_DEBUG"); p != "" {
|
||||||
for _, p := range cfg.Profiles {
|
go func() {
|
||||||
names = append(names, p.Name)
|
time.Sleep(800 * time.Millisecond)
|
||||||
|
_ = os.WriteFile(p, []byte(a.regionFrm.Debug()), 0o644)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
return names
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 用户操作 ----------
|
// ---------- 用户操作 ----------
|
||||||
@@ -195,15 +238,46 @@ func (a *App) send() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.entry.SetText("")
|
a.entry.SetText("")
|
||||||
if err := a.eng.Ask(text, a.attach.Checked); err != nil {
|
// 请求必须放到后台:网络请求期间 UI 线程不能被占住,
|
||||||
a.chat.addNotice(err.Error())
|
// 否则流式增量只能排队等全部返回后一次性刷新。
|
||||||
}
|
go func() {
|
||||||
|
if err := a.eng.Ask(text, a.attach.Checked); err != nil {
|
||||||
|
fyne.Do(func() { a.chat.addNotice(err.Error()) })
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) toggleWatch() {
|
func (a *App) toggleWatch() {
|
||||||
if err := a.eng.ToggleWatch(); err != nil {
|
if a.eng.State().Watching {
|
||||||
a.chat.addNotice(err.Error())
|
a.eng.StopWatch()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if err := a.eng.EnsureReady(); err != nil {
|
||||||
|
a.chat.addNotice(err.Error())
|
||||||
|
dialog.ShowError(err, a.win)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 监听前先探测一次模型连通性,通不过就不启动监听。
|
||||||
|
a.watchBtn.Disable()
|
||||||
|
a.statusLbl.SetText("正在检测模型连接 …")
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
err := a.eng.PingActive(ctx)
|
||||||
|
fyne.Do(func() {
|
||||||
|
a.watchBtn.Enable()
|
||||||
|
if err != nil {
|
||||||
|
a.statusLbl.SetText("就绪")
|
||||||
|
a.chat.addNotice("模型连接检测失败,未开始监听:" + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.eng.StartWatch(); err != nil {
|
||||||
|
a.chat.addNotice(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.chat.addNotice("模型检测通过,已开始监听")
|
||||||
|
})
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) toggleCard() {
|
func (a *App) toggleCard() {
|
||||||
@@ -221,23 +295,23 @@ func (a *App) clearChat() {
|
|||||||
a.chat.addNotice("已清空对话上下文")
|
a.chat.addNotice("已清空对话上下文")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onModelChange 切换生效的接入点。
|
||||||
func (a *App) onModelChange(name string) {
|
func (a *App) onModelChange(name string) {
|
||||||
if a.quietModelSel || name == "" {
|
if a.quietModelSel || name == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfg := a.eng.Config()
|
cfg := a.eng.Config().Clone()
|
||||||
if _, ok := cfg.Profile(name); !ok {
|
if !cfg.SetActive(name) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfg.ActiveProfile = name
|
|
||||||
if err := a.eng.ApplyConfig(cfg); err != nil {
|
if err := a.eng.ApplyConfig(cfg); err != nil {
|
||||||
a.chat.addNotice("切换模型失败:" + err.Error())
|
a.chat.addNotice("切换接入点失败:" + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.eng.Store().Save(cfg); err != nil {
|
if err := a.eng.Store().Save(cfg); err != nil {
|
||||||
slog.Warn("保存模型选择失败", "err", err)
|
slog.Warn("保存接入点选择失败", "err", err)
|
||||||
}
|
}
|
||||||
a.chat.addNotice("已切换模型:" + name)
|
a.chat.addNotice("已切换到接入点 " + name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveConfig 把引擎里的配置落盘并立即生效。
|
// saveConfig 把引擎里的配置落盘并立即生效。
|
||||||
@@ -245,7 +319,7 @@ func (a *App) saveConfig() error {
|
|||||||
return a.applyAndSave(a.eng.Config())
|
return a.applyAndSave(a.eng.Config())
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyAndSave 保存并应用一份配置(覆盖模型下拉选项)。
|
// applyAndSave 保存并应用一份配置(覆盖接入点下拉选项)。
|
||||||
func (a *App) applyAndSave(cfg *config.Config) error {
|
func (a *App) applyAndSave(cfg *config.Config) error {
|
||||||
cfg.Normalize()
|
cfg.Normalize()
|
||||||
if err := a.eng.Store().Save(cfg); err != nil {
|
if err := a.eng.Store().Save(cfg); err != nil {
|
||||||
@@ -255,9 +329,12 @@ func (a *App) applyAndSave(cfg *config.Config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
a.quietModelSel = true
|
a.quietModelSel = true
|
||||||
a.modelSel.Options = profileNames(cfg)
|
a.modelSel.Options = cfg.ModelNames()
|
||||||
a.modelSel.SetSelected(cfg.ActiveProfile)
|
a.modelSel.SetSelected(cfg.Active)
|
||||||
a.quietModelSel = false
|
a.quietModelSel = false
|
||||||
|
// 主题变了就立刻换肤,不用重启。
|
||||||
|
applyThemeMode(cfg.Theme)
|
||||||
|
a.chat.retheme()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +344,8 @@ func (a *App) startSelection() {
|
|||||||
if a.overlay != nil {
|
if a.overlay != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 框选过程中整屏都是选区浮层,先把常驻红框收起来,免得两层边框打架。
|
||||||
|
a.regionFrm.suspend()
|
||||||
// 先隐藏主窗,避免它出现在冻结帧里挡住要框选的内容
|
// 先隐藏主窗,避免它出现在冻结帧里挡住要框选的内容
|
||||||
a.win.Hide()
|
a.win.Hide()
|
||||||
time.Sleep(220 * time.Millisecond)
|
time.Sleep(220 * time.Millisecond)
|
||||||
@@ -274,6 +353,7 @@ func (a *App) startSelection() {
|
|||||||
shot, err := (capture.Screen{}).Desktop()
|
shot, err := (capture.Screen{}).Desktop()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.win.Show()
|
a.win.Show()
|
||||||
|
a.regionFrm.resume(a.eng.Region())
|
||||||
a.chat.addNotice("抓屏失败:" + err.Error())
|
a.chat.addNotice("抓屏失败:" + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -286,16 +366,19 @@ func (a *App) onRegionSelected(r capture.Region) {
|
|||||||
a.overlay = nil
|
a.overlay = nil
|
||||||
a.win.Show()
|
a.win.Show()
|
||||||
if err := a.eng.SetRegion(r); err != nil {
|
if err := a.eng.SetRegion(r); err != nil {
|
||||||
|
a.regionFrm.resume(a.eng.Region())
|
||||||
a.chat.addNotice(err.Error())
|
a.chat.addNotice(err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.card.setRegion(r)
|
a.card.setRegion(r)
|
||||||
|
a.regionFrm.resume(r)
|
||||||
a.chat.addNotice(fmt.Sprintf("已框选监视区域 %s", r))
|
a.chat.addNotice(fmt.Sprintf("已框选监视区域 %s", r))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) onRegionCancel() {
|
func (a *App) onRegionCancel() {
|
||||||
a.overlay = nil
|
a.overlay = nil
|
||||||
a.win.Show()
|
a.win.Show()
|
||||||
|
a.regionFrm.resume(a.eng.Region())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) showPreview() {
|
func (a *App) showPreview() {
|
||||||
@@ -361,13 +444,21 @@ func (a *App) OnAssistantStart(auto bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OnAssistantDelta 收到流式增量,40ms 合批后刷新界面。
|
// OnAssistantDelta 收到流式增量,40ms 合批后刷新界面。
|
||||||
func (a *App) OnAssistantDelta(delta string) {
|
// 思考过程与正式回答分两路合批,保证两边的顺序都不乱。
|
||||||
a.replyMu.Lock()
|
func (a *App) OnAssistantDelta(delta llm.Delta) {
|
||||||
a.lastReply.WriteString(delta)
|
if delta.Text != "" {
|
||||||
a.replyMu.Unlock()
|
a.replyMu.Lock()
|
||||||
|
a.lastReply.WriteString(delta.Text)
|
||||||
|
a.replyMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
a.pendMu.Lock()
|
a.pendMu.Lock()
|
||||||
a.pending.WriteString(delta)
|
if delta.Text != "" {
|
||||||
|
a.pendingText.WriteString(delta.Text)
|
||||||
|
}
|
||||||
|
if delta.Reasoning != "" {
|
||||||
|
a.pendingThink.WriteString(delta.Reasoning)
|
||||||
|
}
|
||||||
if a.pendScheduled {
|
if a.pendScheduled {
|
||||||
a.pendMu.Unlock()
|
a.pendMu.Unlock()
|
||||||
return
|
return
|
||||||
@@ -380,15 +471,42 @@ func (a *App) OnAssistantDelta(delta string) {
|
|||||||
|
|
||||||
func (a *App) flushPending() {
|
func (a *App) flushPending() {
|
||||||
a.pendMu.Lock()
|
a.pendMu.Lock()
|
||||||
text := a.pending.String()
|
text := a.pendingText.String()
|
||||||
a.pending.Reset()
|
think := a.pendingThink.String()
|
||||||
|
a.pendingText.Reset()
|
||||||
|
a.pendingThink.Reset()
|
||||||
a.pendScheduled = false
|
a.pendScheduled = false
|
||||||
a.pendMu.Unlock()
|
a.pendMu.Unlock()
|
||||||
|
|
||||||
if text == "" {
|
if text == "" && think == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fyne.Do(func() { a.chat.appendDelta(text) })
|
fyne.Do(func() {
|
||||||
|
if think != "" {
|
||||||
|
a.chat.appendThinking(think)
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
a.chat.appendDelta(text)
|
||||||
|
}
|
||||||
|
a.syncCard()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncCard 让置顶浮窗跟着流式内容一起刷新。
|
||||||
|
// 浮窗每次都要重排 Markdown,这里限流到 5 次/秒,结束时会再补一次完整刷新。
|
||||||
|
func (a *App) syncCard() {
|
||||||
|
if !a.card.visibleNow() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(a.lastCardSync) < 200*time.Millisecond {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.lastCardSync = now
|
||||||
|
a.replyMu.Lock()
|
||||||
|
text := a.lastReply.String()
|
||||||
|
a.replyMu.Unlock()
|
||||||
|
a.card.update(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnAssistantEnd 结束一条助手回答。
|
// OnAssistantEnd 结束一条助手回答。
|
||||||
@@ -427,6 +545,8 @@ func (a *App) applyState(st agent.State) {
|
|||||||
a.regionLbl.SetText("未框选区域")
|
a.regionLbl.SetText("未框选区域")
|
||||||
}
|
}
|
||||||
a.card.setRegion(st.Region)
|
a.card.setRegion(st.Region)
|
||||||
|
// 屏幕上的红框跟着选区走,始终提示当前盯的是哪一块。
|
||||||
|
a.regionFrm.apply(st.Region)
|
||||||
|
|
||||||
if st.Busy {
|
if st.Busy {
|
||||||
a.sendBtn.Disable()
|
a.sendBtn.Disable()
|
||||||
@@ -443,8 +563,16 @@ func (a *App) applyState(st agent.State) {
|
|||||||
case st.Busy:
|
case st.Busy:
|
||||||
state = "正在回答"
|
state = "正在回答"
|
||||||
}
|
}
|
||||||
|
label := st.Model
|
||||||
|
if st.Profile != "" {
|
||||||
|
if label != "" {
|
||||||
|
label = st.Profile + " · " + label
|
||||||
|
} else {
|
||||||
|
label = st.Profile
|
||||||
|
}
|
||||||
|
}
|
||||||
status := fmt.Sprintf("%s · 模型 %s · 自动触发 %d 次 · 累计 %d tokens",
|
status := fmt.Sprintf("%s · 模型 %s · 自动触发 %d 次 · 累计 %d tokens",
|
||||||
state, st.Model, st.Triggers, st.TotalTokens)
|
state, label, st.Triggers, st.TotalTokens)
|
||||||
if st.LastLatency > 0 {
|
if st.LastLatency > 0 {
|
||||||
status += fmt.Sprintf(" · 上次 %dms", st.LastLatency.Milliseconds())
|
status += fmt.Sprintf(" · 上次 %dms", st.LastLatency.Milliseconds())
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-4
@@ -31,13 +31,29 @@ func colorFor(k bubbleKind) color.Color {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// borderFor 返回气泡描边色:错误提示用更重的描边,保证一眼能认出。
|
||||||
|
func borderFor(k bubbleKind) color.Color {
|
||||||
|
if k == bubbleError {
|
||||||
|
return colorStrongBorder()
|
||||||
|
}
|
||||||
|
return colorBorder()
|
||||||
|
}
|
||||||
|
|
||||||
// bubble 是一条消息气泡:圆角背景 + 角色标题 + 正文。
|
// bubble 是一条消息气泡:圆角背景 + 角色标题 + 正文。
|
||||||
// 流式阶段用 Label 追加文本,结束后换成 RichText 以获得 Markdown 排版。
|
// 流式阶段用 Label 追加文本,结束后换成 RichText 以获得 Markdown 排版。
|
||||||
|
// 思考过程单独占一块(可折叠),不与正式回答混在一起。
|
||||||
type bubble struct {
|
type bubble struct {
|
||||||
root *fyne.Container
|
root *fyne.Container
|
||||||
bg *canvas.Rectangle
|
bg *canvas.Rectangle
|
||||||
body *fyne.Container
|
body *fyne.Container
|
||||||
head *widget.Label
|
head *widget.Label
|
||||||
|
kind bubbleKind
|
||||||
|
|
||||||
|
thinkBtn *widget.Button
|
||||||
|
thinkText *widget.RichText
|
||||||
|
thinkBox *fyne.Container
|
||||||
|
think strings.Builder
|
||||||
|
|
||||||
live *widget.Label
|
live *widget.Label
|
||||||
text strings.Builder
|
text strings.Builder
|
||||||
}
|
}
|
||||||
@@ -45,7 +61,7 @@ type bubble struct {
|
|||||||
func newBubble(role string, kind bubbleKind) *bubble {
|
func newBubble(role string, kind bubbleKind) *bubble {
|
||||||
bg := canvas.NewRectangle(colorFor(kind))
|
bg := canvas.NewRectangle(colorFor(kind))
|
||||||
bg.CornerRadius = 10
|
bg.CornerRadius = 10
|
||||||
bg.StrokeColor = colorBorder()
|
bg.StrokeColor = borderFor(kind)
|
||||||
bg.StrokeWidth = 1
|
bg.StrokeWidth = 1
|
||||||
|
|
||||||
head := widget.NewLabelWithStyle(role, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
head := widget.NewLabelWithStyle(role, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
@@ -57,7 +73,62 @@ func newBubble(role string, kind bubbleKind) *bubble {
|
|||||||
body := container.NewVBox(head, live)
|
body := container.NewVBox(head, live)
|
||||||
root := container.NewStack(bg, container.New(innerPadLayout{}, body))
|
root := container.NewStack(bg, container.New(innerPadLayout{}, body))
|
||||||
|
|
||||||
return &bubble{root: root, bg: bg, body: body, head: head, live: live}
|
return &bubble{root: root, bg: bg, body: body, head: head, kind: kind, live: live}
|
||||||
|
}
|
||||||
|
|
||||||
|
// retheme 主题切换后重新上色。
|
||||||
|
func (b *bubble) retheme() {
|
||||||
|
b.bg.FillColor = colorFor(b.kind)
|
||||||
|
b.bg.StrokeColor = borderFor(b.kind)
|
||||||
|
b.bg.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureThinking 建出思考过程区块,并插在角色标题与正文之间。
|
||||||
|
func (b *bubble) ensureThinking() {
|
||||||
|
if b.thinkBox != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.thinkText = widget.NewRichText()
|
||||||
|
b.thinkText.Wrapping = fyne.TextWrapWord
|
||||||
|
|
||||||
|
b.thinkBtn = widget.NewButton("▾ 思考过程", func() { b.toggleThinking() })
|
||||||
|
b.thinkBtn.Importance = widget.LowImportance
|
||||||
|
b.thinkBtn.Alignment = widget.ButtonAlignLeading
|
||||||
|
|
||||||
|
b.thinkBox = container.NewVBox(b.thinkBtn, b.thinkText)
|
||||||
|
objs := b.body.Objects
|
||||||
|
next := make([]fyne.CanvasObject, 0, len(objs)+1)
|
||||||
|
next = append(next, objs[0], b.thinkBox)
|
||||||
|
next = append(next, objs[1:]...)
|
||||||
|
b.body.Objects = next
|
||||||
|
b.body.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// toggleThinking 折叠/展开思考过程。
|
||||||
|
func (b *bubble) toggleThinking() {
|
||||||
|
if b.thinkText == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.thinkText.Visible() {
|
||||||
|
b.thinkText.Hide()
|
||||||
|
b.thinkBtn.SetText("▸ 思考过程")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.thinkText.Show()
|
||||||
|
b.thinkBtn.SetText("▾ 思考过程")
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendThinking 追加思考过程文本。
|
||||||
|
func (b *bubble) appendThinking(delta string) {
|
||||||
|
b.ensureThinking()
|
||||||
|
b.think.WriteString(delta)
|
||||||
|
b.thinkText.Segments = []widget.RichTextSegment{
|
||||||
|
&widget.TextSegment{
|
||||||
|
Text: b.think.String(),
|
||||||
|
Style: widget.RichTextStyle{TextStyle: fyne.TextStyle{Italic: true}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b.thinkText.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
// append 追加流式文本。
|
// append 追加流式文本。
|
||||||
@@ -76,12 +147,27 @@ func (b *bubble) finish(err error) {
|
|||||||
}
|
}
|
||||||
text := strings.TrimSpace(b.text.String())
|
text := strings.TrimSpace(b.text.String())
|
||||||
if text == "" {
|
if text == "" {
|
||||||
text = "(没有返回内容)"
|
if b.think.Len() > 0 {
|
||||||
|
text = "(模型只给出了思考过程,没有输出回答)"
|
||||||
|
} else {
|
||||||
|
text = "(没有返回内容)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rt := widget.NewRichTextFromMarkdown(text)
|
rt := widget.NewRichTextFromMarkdown(text)
|
||||||
rt.Wrapping = fyne.TextWrapWord
|
rt.Wrapping = fyne.TextWrapWord
|
||||||
b.body.Objects = []fyne.CanvasObject{b.head, rt}
|
objs := b.body.Objects
|
||||||
|
next := make([]fyne.CanvasObject, 0, len(objs))
|
||||||
|
next = append(next, objs[0])
|
||||||
|
if b.thinkBox != nil {
|
||||||
|
next = append(next, b.thinkBox)
|
||||||
|
}
|
||||||
|
next = append(next, rt)
|
||||||
|
b.body.Objects = next
|
||||||
b.body.Refresh()
|
b.body.Refresh()
|
||||||
|
// 思考过程默认折起来,把正文让到前面;点标题还能再展开。
|
||||||
|
if b.thinkText != nil && b.thinkText.Visible() {
|
||||||
|
b.toggleThinking()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// innerPadLayout 给气泡留内边距。
|
// innerPadLayout 给气泡留内边距。
|
||||||
@@ -113,6 +199,7 @@ type chatView struct {
|
|||||||
list *fyne.Container
|
list *fyne.Container
|
||||||
scroll *container.Scroll
|
scroll *container.Scroll
|
||||||
current *bubble
|
current *bubble
|
||||||
|
bubbles []*bubble
|
||||||
}
|
}
|
||||||
|
|
||||||
func newChatView() *chatView {
|
func newChatView() *chatView {
|
||||||
@@ -127,10 +214,18 @@ func newChatView() *chatView {
|
|||||||
func (c *chatView) object() fyne.CanvasObject { return c.scroll }
|
func (c *chatView) object() fyne.CanvasObject { return c.scroll }
|
||||||
|
|
||||||
func (c *chatView) appendBubble(b *bubble) {
|
func (c *chatView) appendBubble(b *bubble) {
|
||||||
|
c.bubbles = append(c.bubbles, b)
|
||||||
c.list.Add(b.root)
|
c.list.Add(b.root)
|
||||||
c.scroll.ScrollToBottom()
|
c.scroll.ScrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// retheme 主题切换后给所有气泡重新上色。
|
||||||
|
func (c *chatView) retheme() {
|
||||||
|
for _, b := range c.bubbles {
|
||||||
|
b.retheme()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// addUser 添加一条用户消息。
|
// addUser 添加一条用户消息。
|
||||||
func (c *chatView) addUser(text string, withImage bool) {
|
func (c *chatView) addUser(text string, withImage bool) {
|
||||||
if withImage {
|
if withImage {
|
||||||
@@ -161,6 +256,15 @@ func (c *chatView) appendDelta(delta string) {
|
|||||||
c.scroll.ScrollToBottom()
|
c.scroll.ScrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appendThinking 追加思考过程文本。
|
||||||
|
func (c *chatView) appendThinking(delta string) {
|
||||||
|
if c.current == nil {
|
||||||
|
c.beginAssistant(false)
|
||||||
|
}
|
||||||
|
c.current.appendThinking(delta)
|
||||||
|
c.scroll.ScrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
// endAssistant 结束当前助手消息。
|
// endAssistant 结束当前助手消息。
|
||||||
func (c *chatView) endAssistant(err error) {
|
func (c *chatView) endAssistant(err error) {
|
||||||
if c.current == nil {
|
if c.current == nil {
|
||||||
@@ -182,6 +286,7 @@ func (c *chatView) addNotice(text string) {
|
|||||||
// clear 清空消息列表。
|
// clear 清空消息列表。
|
||||||
func (c *chatView) clear() {
|
func (c *chatView) clear() {
|
||||||
c.list.Objects = nil
|
c.list.Objects = nil
|
||||||
|
c.bubbles = nil
|
||||||
c.current = nil
|
c.current = nil
|
||||||
c.list.Refresh()
|
c.list.Refresh()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"viewsagent/internal/capture"
|
||||||
|
"viewsagent/internal/win32"
|
||||||
|
)
|
||||||
|
|
||||||
|
// regionFrame 是常驻的取景框提示。
|
||||||
|
//
|
||||||
|
// 屏幕上一直显示当前框选区域的红框,让使用者随时知道自己在看哪一块;
|
||||||
|
// 这个框被 SetWindowDisplayAffinity 排除在截屏与录屏之外,
|
||||||
|
// 所以模型看到的画面、录屏软件录到的画面里都不会有它。
|
||||||
|
type regionFrame struct {
|
||||||
|
frame *win32.Frame
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
last capture.Region
|
||||||
|
hasLast bool
|
||||||
|
suspended bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRegionFrame 创建取景框。创建失败时返回 nil,调用方自行决定是否降级。
|
||||||
|
func newRegionFrame() *regionFrame {
|
||||||
|
f, err := win32.NewFrame()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("取景框创建失败,屏幕将不再显示选区边框", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ®ionFrame{frame: f}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply 把取景框移到指定区域;区域无效时隐藏。
|
||||||
|
func (f *regionFrame) apply(r capture.Region) {
|
||||||
|
if f == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.suspended {
|
||||||
|
f.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f.hasLast && f.last == r {
|
||||||
|
f.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.last = r
|
||||||
|
f.hasLast = true
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
if !r.Valid() {
|
||||||
|
f.frame.Hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.frame.SetRegion(win32.Rect{
|
||||||
|
Left: r.X,
|
||||||
|
Top: r.Y,
|
||||||
|
Right: r.X + r.W,
|
||||||
|
Bottom: r.Y + r.H,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspend 临时隐藏取景框(框选过程中全屏有选区浮层,避免两层边框打架)。
|
||||||
|
func (f *regionFrame) suspend() {
|
||||||
|
if f == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.suspended = true
|
||||||
|
f.hasLast = false
|
||||||
|
f.mu.Unlock()
|
||||||
|
f.frame.Hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resume 恢复取景框,并按当前区域重新落位。
|
||||||
|
func (f *regionFrame) resume(r capture.Region) {
|
||||||
|
if f == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.suspended = false
|
||||||
|
f.mu.Unlock()
|
||||||
|
f.apply(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// close 销毁取景框窗口。
|
||||||
|
func (f *regionFrame) close() {
|
||||||
|
if f == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.frame.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug 返回取景框内部状态,供自检使用。
|
||||||
|
func (f *regionFrame) Debug() string {
|
||||||
|
if f == nil || f.frame == nil {
|
||||||
|
return "取景框未创建"
|
||||||
|
}
|
||||||
|
return f.frame.Debug()
|
||||||
|
}
|
||||||
@@ -64,7 +64,8 @@ func newOverlay(app fyne.App, shot image.Image, vd win32.Rect,
|
|||||||
objs = append(objs, o.dims[i])
|
objs = append(objs, o.dims[i])
|
||||||
}
|
}
|
||||||
for i := range o.edges {
|
for i := range o.edges {
|
||||||
o.edges[i] = canvas.NewRectangle(color.NRGBA{R: 0x5A, G: 0xC8, B: 0xFF, A: 0xFF})
|
// 描边跟着主题走:黑白主题下是纯白/纯黑,不会残留彩色。
|
||||||
|
o.edges[i] = canvas.NewRectangle(lookupColor(theme.ColorNameForeground))
|
||||||
objs = append(objs, o.edges[i])
|
objs = append(objs, o.edges[i])
|
||||||
}
|
}
|
||||||
o.hint = canvas.NewText("拖拽框选区域 · Esc 取消", color.White)
|
o.hint = canvas.NewText("拖拽框选区域 · Esc 取消", color.White)
|
||||||
|
|||||||
+546
-148
@@ -2,8 +2,10 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,9 +13,11 @@ import (
|
|||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/container"
|
"fyne.io/fyne/v2/container"
|
||||||
"fyne.io/fyne/v2/dialog"
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
"viewsagent/internal/config"
|
"viewsagent/internal/config"
|
||||||
|
"viewsagent/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const settingsTitle = "viewsagent-settings"
|
const settingsTitle = "viewsagent-settings"
|
||||||
@@ -24,14 +28,21 @@ type settingsWindow struct {
|
|||||||
win fyne.Window
|
win fyne.Window
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
|
||||||
// 模型接入点
|
// 模型接入:左边是接入点列表,右边是当前接入点的接口详情。
|
||||||
profileSel *widget.Select
|
profileList *widget.List
|
||||||
pName *widget.Entry
|
selIndex int
|
||||||
pBase *widget.Entry
|
loading bool // 回填表单时抑制回写
|
||||||
pModel *widget.Entry
|
|
||||||
pKey *widget.Entry
|
formatSel *widget.Select
|
||||||
pMax *widget.Entry
|
nameEntry *widget.Entry
|
||||||
pTemp *widget.Entry
|
baseEntry *widget.Entry
|
||||||
|
modelEntry *widget.Entry
|
||||||
|
keyEntry *widget.Entry
|
||||||
|
maxEntry *widget.Entry
|
||||||
|
tempEntry *widget.Entry
|
||||||
|
headerEntry *widget.Entry
|
||||||
|
authLabel *widget.Label
|
||||||
|
previewHost *fyne.Container
|
||||||
|
|
||||||
// 提示词
|
// 提示词
|
||||||
tmplSel *widget.Select
|
tmplSel *widget.Select
|
||||||
@@ -50,18 +61,24 @@ type settingsWindow struct {
|
|||||||
hkSelect *widget.Entry
|
hkSelect *widget.Entry
|
||||||
hkToggle *widget.Entry
|
hkToggle *widget.Entry
|
||||||
alwaysTop *widget.Check
|
alwaysTop *widget.Check
|
||||||
|
themeSel *widget.Select
|
||||||
|
|
||||||
|
// tabs 是设置窗口的标签容器,开发期截图时用来切页。
|
||||||
|
tabs *container.AppTabs
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSettingsWindow(app *App) *settingsWindow {
|
func newSettingsWindow(app *App) *settingsWindow {
|
||||||
s := &settingsWindow{app: app}
|
s := &settingsWindow{app: app}
|
||||||
|
s.cfg = app.eng.Config().Clone()
|
||||||
s.win = app.fyne.NewWindow(settingsTitle)
|
s.win = app.fyne.NewWindow(settingsTitle)
|
||||||
s.win.Resize(fyne.NewSize(620, 620))
|
s.win.Resize(fyne.NewSize(900, 700))
|
||||||
s.win.SetContent(container.NewAppTabs(
|
s.tabs = container.NewAppTabs(
|
||||||
container.NewTabItem("模型接入", s.buildProfileTab()),
|
container.NewTabItem("模型", s.buildModelTab()),
|
||||||
container.NewTabItem("提示词", s.buildTemplateTab()),
|
container.NewTabItem("提示词", s.buildTemplateTab()),
|
||||||
container.NewTabItem("抓屏与触发", s.buildCaptureTab()),
|
container.NewTabItem("抓屏与触发", s.buildCaptureTab()),
|
||||||
container.NewTabItem("其它", s.buildMiscTab()),
|
container.NewTabItem("其它", s.buildMiscTab()),
|
||||||
))
|
)
|
||||||
|
s.win.SetContent(s.tabs)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,18 +87,32 @@ func (s *settingsWindow) show() {
|
|||||||
s.win.Show()
|
s.win.Show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectTabByName 切到指定名字的标签页,找不到就保持当前页。
|
||||||
|
func (s *settingsWindow) selectTabByName(name string) {
|
||||||
|
for _, item := range s.tabs.Items {
|
||||||
|
if item.Text == name {
|
||||||
|
s.tabs.SelectTab(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// reload 从引擎当前配置重新载入界面。
|
// reload 从引擎当前配置重新载入界面。
|
||||||
func (s *settingsWindow) reload() {
|
func (s *settingsWindow) reload() {
|
||||||
// 用副本编辑,避免与引擎并发读写同一份配置。
|
// 用副本编辑,避免与引擎并发读写同一份配置。
|
||||||
s.cfg = s.app.eng.Config().Clone()
|
s.cfg = s.app.eng.Config().Clone()
|
||||||
|
|
||||||
names := make([]string, 0, len(s.cfg.Profiles))
|
s.loading = true
|
||||||
for _, p := range s.cfg.Profiles {
|
s.profileList.Refresh()
|
||||||
names = append(names, p.Name)
|
idx := s.cfg.ProfileIndex(s.cfg.Active)
|
||||||
|
if idx < 0 {
|
||||||
|
idx = 0
|
||||||
}
|
}
|
||||||
s.profileSel.Options = names
|
s.selIndex = idx
|
||||||
s.profileSel.SetSelected(s.cfg.ActiveProfile)
|
s.profileList.Select(idx)
|
||||||
s.applyProfile()
|
// 选中的项没变化时 List 不会再回调,这里显式回填一次表单。
|
||||||
|
s.loadProfileToForm()
|
||||||
|
s.loading = false
|
||||||
|
|
||||||
keys := make([]string, 0, len(s.cfg.Templates))
|
keys := make([]string, 0, len(s.cfg.Templates))
|
||||||
for _, t := range s.cfg.Templates {
|
for _, t := range s.cfg.Templates {
|
||||||
@@ -103,88 +134,366 @@ func (s *settingsWindow) reload() {
|
|||||||
s.hkSelect.SetText(s.cfg.Hotkeys.SelectRegion)
|
s.hkSelect.SetText(s.cfg.Hotkeys.SelectRegion)
|
||||||
s.hkToggle.SetText(s.cfg.Hotkeys.ToggleWatch)
|
s.hkToggle.SetText(s.cfg.Hotkeys.ToggleWatch)
|
||||||
s.alwaysTop.SetChecked(s.cfg.AlwaysOnTopCard)
|
s.alwaysTop.SetChecked(s.cfg.AlwaysOnTopCard)
|
||||||
|
s.themeSel.Options = themeOptions()
|
||||||
|
s.themeSel.SetSelected(themeLabel(s.cfg.Theme))
|
||||||
}
|
}
|
||||||
|
|
||||||
func entry(label string, obj fyne.CanvasObject) fyne.CanvasObject {
|
// ---------- 模型页 ----------
|
||||||
return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj)
|
|
||||||
|
// buildModelTab 组装模型页:左边是接入点列表,右边是当前接入点的接口详情。
|
||||||
|
func (s *settingsWindow) buildModelTab() fyne.CanvasObject {
|
||||||
|
s.buildModelFields()
|
||||||
|
|
||||||
|
split := container.NewHSplit(s.buildProfilePane(), s.buildDetailPane())
|
||||||
|
split.Offset = 0.24
|
||||||
|
return split
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *settingsWindow) buildProfileTab() fyne.CanvasObject {
|
// buildModelFields 创建接口详情的输入控件。
|
||||||
s.profileSel = widget.NewSelect(nil, func(string) { s.applyProfile() })
|
func (s *settingsWindow) buildModelFields() {
|
||||||
s.profileSel.PlaceHolder = "请选择接入点"
|
s.formatSel = widget.NewSelect(formatLabels(), func(string) { s.onFormatChanged() })
|
||||||
s.pName = widget.NewEntry()
|
s.formatSel.PlaceHolder = "请选择接口格式"
|
||||||
s.pBase = widget.NewEntry()
|
|
||||||
s.pBase.SetPlaceHolder("https://dashscope.aliyuncs.com/compatible-mode/v1")
|
s.nameEntry = widget.NewEntry()
|
||||||
s.pModel = widget.NewEntry()
|
s.nameEntry.SetPlaceHolder("例如:本地 Ollama")
|
||||||
s.pModel.SetPlaceHolder("qwen3-vl-plus")
|
s.nameEntry.OnChanged = func(string) { s.commitProfile() }
|
||||||
s.pKey = widget.NewPasswordEntry()
|
|
||||||
s.pMax = widget.NewEntry()
|
s.baseEntry = widget.NewEntry()
|
||||||
s.pTemp = widget.NewEntry()
|
s.baseEntry.OnChanged = func(string) { s.commitProfile() }
|
||||||
|
|
||||||
|
s.modelEntry = widget.NewEntry()
|
||||||
|
s.modelEntry.OnChanged = func(string) { s.commitProfile() }
|
||||||
|
|
||||||
|
s.keyEntry = widget.NewPasswordEntry()
|
||||||
|
s.keyEntry.OnChanged = func(string) { s.commitProfile() }
|
||||||
|
|
||||||
|
s.maxEntry = widget.NewEntry()
|
||||||
|
s.maxEntry.OnChanged = func(string) { s.commitProfile() }
|
||||||
|
|
||||||
|
s.tempEntry = widget.NewEntry()
|
||||||
|
s.tempEntry.OnChanged = func(string) { s.commitProfile() }
|
||||||
|
|
||||||
|
s.headerEntry = widget.NewMultiLineEntry()
|
||||||
|
s.headerEntry.SetMinRowsVisible(3)
|
||||||
|
s.headerEntry.SetPlaceHolder("每行一条,例如:\nX-Api-Key: sk-xxx")
|
||||||
|
s.headerEntry.OnChanged = func(string) { s.commitProfile() }
|
||||||
|
|
||||||
|
s.previewHost = container.NewVBox()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildProfilePane 造左侧的接入点列表。
|
||||||
|
func (s *settingsWindow) buildProfilePane() fyne.CanvasObject {
|
||||||
|
s.profileList = widget.NewList(
|
||||||
|
func() int {
|
||||||
|
if s.cfg == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(s.cfg.Models)
|
||||||
|
},
|
||||||
|
func() fyne.CanvasObject {
|
||||||
|
name := widget.NewLabel("接入点")
|
||||||
|
name.Truncation = fyne.TextTruncateEllipsis
|
||||||
|
meta := widget.NewLabel("")
|
||||||
|
meta.Importance = widget.LowImportance
|
||||||
|
meta.Truncation = fyne.TextTruncateEllipsis
|
||||||
|
return container.NewVBox(name, meta)
|
||||||
|
},
|
||||||
|
func(id widget.ListItemID, obj fyne.CanvasObject) { s.updateProfileRow(id, obj) },
|
||||||
|
)
|
||||||
|
s.profileList.OnSelected = func(id widget.ListItemID) { s.selectProfile(int(id)) }
|
||||||
|
|
||||||
|
addBtn := widget.NewButtonWithIcon("新增", theme.ContentAddIcon(), func() { s.askNewFormat() })
|
||||||
|
dupBtn := widget.NewButtonWithIcon("复制", theme.ContentCopyIcon(), func() { s.duplicateProfile() })
|
||||||
|
delBtn := widget.NewButtonWithIcon("删除", theme.DeleteIcon(), func() { s.deleteProfile() })
|
||||||
|
useBtn := widget.NewButton("设为生效", func() { s.activateProfile() })
|
||||||
|
|
||||||
|
top := container.NewVBox(widget.NewLabel("接入点"), widget.NewSeparator())
|
||||||
|
bottom := container.NewVBox(
|
||||||
|
widget.NewSeparator(),
|
||||||
|
container.NewHBox(addBtn, dupBtn, delBtn),
|
||||||
|
useBtn,
|
||||||
|
)
|
||||||
|
return container.NewBorder(top, bottom, nil, nil, s.profileList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDetailPane 造右侧的接口详情。
|
||||||
|
func (s *settingsWindow) buildDetailPane() fyne.CanvasObject {
|
||||||
|
s.authLabel = widget.NewLabel("API Key")
|
||||||
|
|
||||||
form := container.NewVBox(
|
form := container.NewVBox(
|
||||||
entry("接入点", s.profileSel),
|
entry("接口格式", s.formatSel),
|
||||||
entry("名称", s.pName),
|
entry("配置名称", s.nameEntry),
|
||||||
entry("BaseURL", s.pBase),
|
entry("BaseURL", s.baseEntry),
|
||||||
entry("模型", s.pModel),
|
entry("模型名", s.modelEntry),
|
||||||
entry("API Key", s.pKey),
|
container.NewBorder(nil, nil, s.authLabel, nil, s.keyEntry),
|
||||||
entry("max_tokens", s.pMax),
|
entry("max_tokens", s.maxEntry),
|
||||||
entry("temperature", s.pTemp),
|
entry("temperature", s.tempEntry),
|
||||||
|
entry("额外请求头", s.headerEntry),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewCard("请求协议", "", s.previewHost),
|
||||||
)
|
)
|
||||||
|
|
||||||
addBtn := widget.NewButton("新增接入点", func() {
|
testBtn := widget.NewButton("测试连接", func() { s.testConnection() })
|
||||||
name := strings.TrimSpace(s.pName.Text)
|
saveBtn := widget.NewButton("保存并应用", func() { s.saveAll() })
|
||||||
if name == "" {
|
bottom := container.NewVBox(widget.NewSeparator(), container.NewHBox(testBtn, saveBtn))
|
||||||
name = fmt.Sprintf("custom-%d", len(s.cfg.Profiles)+1)
|
|
||||||
}
|
|
||||||
if _, exists := s.cfg.Profile(name); exists {
|
|
||||||
dialog.ShowInformation("重名", "已有同名接入点,请换一个名字", s.win)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.cfg.Profiles = append(s.cfg.Profiles, config.Profile{
|
|
||||||
Name: name, BaseURL: s.pBase.Text, Model: s.pModel.Text,
|
|
||||||
APIKey: s.pKey.Text, MaxTokens: 1024, Temperature: 0.3,
|
|
||||||
})
|
|
||||||
s.cfg.ActiveProfile = name
|
|
||||||
s.reload()
|
|
||||||
})
|
|
||||||
|
|
||||||
testBtn := widget.NewButton("测试连接", func() {
|
return container.NewBorder(nil, bottom, nil, nil, container.NewVScroll(form))
|
||||||
p := s.readProfile()
|
|
||||||
btn := widget.NewLabel("正在连接 " + p.BaseURL + " …")
|
|
||||||
s.win.Canvas().Content().Refresh()
|
|
||||||
d := dialog.NewCustomWithoutButtons("测试连接", btn, s.win)
|
|
||||||
d.Show()
|
|
||||||
go func() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
err := s.app.eng.PingProfile(ctx, p)
|
|
||||||
fyne.Do(func() {
|
|
||||||
d.Hide()
|
|
||||||
if err != nil {
|
|
||||||
dialog.ShowError(fmt.Errorf("连接失败:%w", err), s.win)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.ShowInformation("连接成功", "模型 "+p.Model+" 可用", s.win)
|
|
||||||
})
|
|
||||||
}()
|
|
||||||
})
|
|
||||||
|
|
||||||
saveBtn := widget.NewButton("保存并应用", func() {
|
|
||||||
s.commitAll()
|
|
||||||
if err := s.app.applyAndSave(s.cfg); err != nil {
|
|
||||||
dialog.ShowError(err, s.win)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.ShowInformation("已保存", "配置已写入并立即生效", s.win)
|
|
||||||
})
|
|
||||||
|
|
||||||
return container.NewBorder(nil,
|
|
||||||
container.NewVBox(widget.NewSeparator(), container.NewHBox(addBtn, testBtn, saveBtn)),
|
|
||||||
nil, nil,
|
|
||||||
container.NewVScroll(form),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateProfileRow 刷新列表里的一行。
|
||||||
|
func (s *settingsWindow) updateProfileRow(id widget.ListItemID, obj fyne.CanvasObject) {
|
||||||
|
rows := obj.(*fyne.Container).Objects
|
||||||
|
name := rows[0].(*widget.Label)
|
||||||
|
meta := rows[1].(*widget.Label)
|
||||||
|
|
||||||
|
p, ok := s.profileAt(int(id))
|
||||||
|
if !ok {
|
||||||
|
name.SetText("")
|
||||||
|
meta.SetText("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name.SetText(p.Name)
|
||||||
|
detail := llm.FormatLabel(p.Format)
|
||||||
|
if p.Model != "" {
|
||||||
|
detail += " · " + p.Model
|
||||||
|
} else {
|
||||||
|
detail += " · 未填模型名"
|
||||||
|
}
|
||||||
|
if p.Name == s.cfg.Active {
|
||||||
|
name.SetText("● " + p.Name)
|
||||||
|
name.Importance = widget.HighImportance
|
||||||
|
} else {
|
||||||
|
name.Importance = widget.MediumImportance
|
||||||
|
}
|
||||||
|
meta.SetText(detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileAt 取列表里的第 i 份接入点。
|
||||||
|
func (s *settingsWindow) profileAt(i int) (config.Profile, bool) {
|
||||||
|
if s.cfg == nil || i < 0 || i >= len(s.cfg.Models) {
|
||||||
|
return config.Profile{}, false
|
||||||
|
}
|
||||||
|
return s.cfg.Models[i], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectProfile 切换左侧选中的接入点,右侧随之刷新。
|
||||||
|
func (s *settingsWindow) selectProfile(idx int) {
|
||||||
|
if _, ok := s.profileAt(idx); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.selIndex = idx
|
||||||
|
s.loadProfileToForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadProfileToForm 把选中接入点的值回填到右侧表单。
|
||||||
|
func (s *settingsWindow) loadProfileToForm() {
|
||||||
|
p, ok := s.profileAt(s.selIndex)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prev := s.loading
|
||||||
|
s.loading = true
|
||||||
|
s.nameEntry.SetText(p.Name)
|
||||||
|
s.formatSel.SetSelected(llm.FormatLabel(p.Format))
|
||||||
|
s.baseEntry.SetText(p.BaseURL)
|
||||||
|
s.modelEntry.SetText(p.Model)
|
||||||
|
s.keyEntry.SetText(p.APIKey)
|
||||||
|
s.maxEntry.SetText(strconv.Itoa(p.MaxTokens))
|
||||||
|
s.tempEntry.SetText(fmt.Sprintf("%g", p.Temperature))
|
||||||
|
s.headerEntry.SetText(formatHeaders(p.ExtraHeaders))
|
||||||
|
s.loading = prev
|
||||||
|
s.applyFormatHints(p.Format)
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitProfile 把右侧表单的值写回当前接入点。
|
||||||
|
func (s *settingsWindow) commitProfile() {
|
||||||
|
if s.loading || s.cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := s.profileAt(s.selIndex); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := s.cfg.Models[s.selIndex]
|
||||||
|
oldName := p.Name
|
||||||
|
// 名字被清空时保留原名,否则生效项会指向一个空名字。
|
||||||
|
name := strings.TrimSpace(s.nameEntry.Text)
|
||||||
|
if name == "" {
|
||||||
|
name = oldName
|
||||||
|
}
|
||||||
|
p.Name = name
|
||||||
|
p.Format = s.selectedFormat()
|
||||||
|
p.BaseURL = strings.TrimRight(strings.TrimSpace(s.baseEntry.Text), "/")
|
||||||
|
p.Model = strings.TrimSpace(s.modelEntry.Text)
|
||||||
|
p.APIKey = strings.TrimSpace(s.keyEntry.Text)
|
||||||
|
p.MaxTokens = atoiDefault(s.maxEntry.Text, 1024)
|
||||||
|
p.Temperature = atofDefault(s.tempEntry.Text, 0.3)
|
||||||
|
p.ExtraHeaders = parseHeaders(s.headerEntry.Text)
|
||||||
|
s.cfg.Models[s.selIndex] = p
|
||||||
|
|
||||||
|
// 生效项改名时,Active 也要跟着改,否则会指向不存在的接入点。
|
||||||
|
if oldName != p.Name && s.cfg.Active == oldName {
|
||||||
|
s.cfg.Active = p.Name
|
||||||
|
}
|
||||||
|
s.profileList.RefreshItem(s.selIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formProfile 用界面上的值构造一份接入点(不落盘)。
|
||||||
|
func (s *settingsWindow) formProfile() config.Profile {
|
||||||
|
if _, ok := s.profileAt(s.selIndex); !ok {
|
||||||
|
if p, found := s.cfg.ActiveModel(); found {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return config.Profile{Format: llm.FormatOpenAI}
|
||||||
|
}
|
||||||
|
return config.Profile{
|
||||||
|
Name: strings.TrimSpace(s.nameEntry.Text),
|
||||||
|
Format: s.selectedFormat(),
|
||||||
|
BaseURL: strings.TrimRight(strings.TrimSpace(s.baseEntry.Text), "/"),
|
||||||
|
Model: strings.TrimSpace(s.modelEntry.Text),
|
||||||
|
APIKey: strings.TrimSpace(s.keyEntry.Text),
|
||||||
|
MaxTokens: atoiDefault(s.maxEntry.Text, 1024),
|
||||||
|
Temperature: atofDefault(s.tempEntry.Text, 0.3),
|
||||||
|
ExtraHeaders: parseHeaders(s.headerEntry.Text),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectedFormat 取下拉里选中的接口格式。
|
||||||
|
func (s *settingsWindow) selectedFormat() string {
|
||||||
|
for _, f := range llm.Formats() {
|
||||||
|
if llm.FormatLabel(f) == s.formatSel.Selected {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return llm.FormatOpenAI
|
||||||
|
}
|
||||||
|
|
||||||
|
// onFormatChanged 切换接口格式:字段提示与协议说明跟着变。
|
||||||
|
func (s *settingsWindow) onFormatChanged() {
|
||||||
|
if s.loading {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.commitProfile()
|
||||||
|
s.applyFormatHints(s.selectedFormat())
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyFormatHints 按接口格式刷新占位符、鉴权标签与协议说明。
|
||||||
|
func (s *settingsWindow) applyFormatHints(format string) {
|
||||||
|
hint := hintFor(format)
|
||||||
|
s.baseEntry.SetPlaceHolder(hint.basePlaceholder)
|
||||||
|
s.modelEntry.SetPlaceHolder(hint.modelPlaceholder)
|
||||||
|
s.keyEntry.SetPlaceHolder(hint.keyPlaceholder)
|
||||||
|
s.authLabel.SetText(hint.authLabel)
|
||||||
|
|
||||||
|
s.previewHost.RemoveAll()
|
||||||
|
for _, line := range hint.protocol {
|
||||||
|
key := widget.NewLabel(line[0])
|
||||||
|
key.Importance = widget.LowImportance
|
||||||
|
s.previewHost.Add(container.NewBorder(nil, nil, key, nil, widget.NewLabel(line[1])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// askNewFormat 新增接入点前先选接口格式。
|
||||||
|
func (s *settingsWindow) askNewFormat() {
|
||||||
|
var d dialog.Dialog
|
||||||
|
btns := container.NewVBox(
|
||||||
|
widget.NewButton(llm.FormatLabel(llm.FormatOpenAI), func() {
|
||||||
|
d.Hide()
|
||||||
|
s.addProfile(llm.FormatOpenAI)
|
||||||
|
}),
|
||||||
|
widget.NewButton(llm.FormatLabel(llm.FormatAnthropic), func() {
|
||||||
|
d.Hide()
|
||||||
|
s.addProfile(llm.FormatAnthropic)
|
||||||
|
}),
|
||||||
|
widget.NewLabel("先选接口格式,再按该格式填写地址与鉴权。"),
|
||||||
|
)
|
||||||
|
d = dialog.NewCustom("新增接入点", "取消", btns, s.win)
|
||||||
|
d.Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// addProfile 按选定格式新增一份接入点。
|
||||||
|
func (s *settingsWindow) addProfile(format string) {
|
||||||
|
p := s.cfg.AddProfile(config.NewProfile("接入点", format))
|
||||||
|
s.profileList.Refresh()
|
||||||
|
s.profileList.Select(s.cfg.ProfileIndex(p.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// duplicateProfile 复制当前接入点。
|
||||||
|
func (s *settingsWindow) duplicateProfile() {
|
||||||
|
p, ok := s.profileAt(s.selIndex)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cp := p
|
||||||
|
cp.Name = s.cfg.UniqueName(p.Name + " 副本")
|
||||||
|
cp.ExtraHeaders = copyHeaders(p.ExtraHeaders)
|
||||||
|
s.cfg.Models = append(s.cfg.Models, cp)
|
||||||
|
s.profileList.Refresh()
|
||||||
|
s.profileList.Select(s.cfg.ProfileIndex(cp.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteProfile 删除当前接入点。
|
||||||
|
func (s *settingsWindow) deleteProfile() {
|
||||||
|
p, ok := s.profileAt(s.selIndex)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dialog.NewConfirm("删除接入点", "确定删除「"+p.Name+"」?", func(confirmed bool) {
|
||||||
|
if !confirmed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cfg.RemoveModel(p.Name)
|
||||||
|
s.profileList.Refresh()
|
||||||
|
if s.cfg.ProfileIndex(s.cfg.Active) < 0 {
|
||||||
|
s.cfg.Active = ""
|
||||||
|
}
|
||||||
|
if len(s.cfg.Models) == 0 {
|
||||||
|
s.selIndex = -1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := s.selIndex
|
||||||
|
if idx >= len(s.cfg.Models) {
|
||||||
|
idx = len(s.cfg.Models) - 1
|
||||||
|
}
|
||||||
|
s.profileList.Select(idx)
|
||||||
|
}, s.win)
|
||||||
|
}
|
||||||
|
|
||||||
|
// activateProfile 把当前接入点设为生效项。
|
||||||
|
func (s *settingsWindow) activateProfile() {
|
||||||
|
p, ok := s.profileAt(s.selIndex)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cfg.Active = p.Name
|
||||||
|
s.profileList.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// testConnection 用当前表单的值测试一次连通性。
|
||||||
|
func (s *settingsWindow) testConnection() {
|
||||||
|
p := s.formProfile()
|
||||||
|
if p.BaseURL == "" || p.Model == "" {
|
||||||
|
dialog.ShowError(errors.New("请先填写 BaseURL 与模型名"), s.win)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d := dialog.NewCustomWithoutButtons("测试连接", widget.NewLabel("正在连接 "+p.BaseURL+" …"), s.win)
|
||||||
|
d.Show()
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
err := s.app.eng.PingModel(ctx, p)
|
||||||
|
fyne.Do(func() {
|
||||||
|
d.Hide()
|
||||||
|
if err != nil {
|
||||||
|
dialog.ShowError(fmt.Errorf("连接失败:%w", err), s.win)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dialog.ShowInformation("连接成功", llm.FormatLabel(p.Format)+" 接口可用:"+p.Model, s.win)
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 其它页 ----------
|
||||||
|
|
||||||
func (s *settingsWindow) buildTemplateTab() fyne.CanvasObject {
|
func (s *settingsWindow) buildTemplateTab() fyne.CanvasObject {
|
||||||
s.tmplSel = widget.NewSelect(nil, func(string) { s.applyTemplate() })
|
s.tmplSel = widget.NewSelect(nil, func(string) { s.applyTemplate() })
|
||||||
s.tmplSel.PlaceHolder = "请选择模板"
|
s.tmplSel.PlaceHolder = "请选择模板"
|
||||||
@@ -193,12 +502,7 @@ func (s *settingsWindow) buildTemplateTab() fyne.CanvasObject {
|
|||||||
s.tmplSystem.Wrapping = fyne.TextWrapWord
|
s.tmplSystem.Wrapping = fyne.TextWrapWord
|
||||||
|
|
||||||
saveBtn := widget.NewButton("保存提示词", func() {
|
saveBtn := widget.NewButton("保存提示词", func() {
|
||||||
key := s.tmplSel.Selected
|
s.commitTemplate()
|
||||||
for i := range s.cfg.Templates {
|
|
||||||
if s.cfg.Templates[i].Key == key {
|
|
||||||
s.cfg.Templates[i].System = s.tmplSystem.Text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.app.applyAndSave(s.cfg); err != nil {
|
if err := s.app.applyAndSave(s.cfg); err != nil {
|
||||||
dialog.ShowError(err, s.win)
|
dialog.ShowError(err, s.win)
|
||||||
return
|
return
|
||||||
@@ -235,12 +539,7 @@ func (s *settingsWindow) buildCaptureTab() fyne.CanvasObject {
|
|||||||
)
|
)
|
||||||
|
|
||||||
saveBtn := widget.NewButton("保存并应用", func() {
|
saveBtn := widget.NewButton("保存并应用", func() {
|
||||||
s.commitAll()
|
s.saveAll()
|
||||||
if err := s.app.applyAndSave(s.cfg); err != nil {
|
|
||||||
dialog.ShowError(err, s.win)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.ShowInformation("已保存", "抓屏与触发参数已更新", s.win)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return container.NewBorder(nil,
|
return container.NewBorder(nil,
|
||||||
@@ -254,6 +553,8 @@ func (s *settingsWindow) buildMiscTab() fyne.CanvasObject {
|
|||||||
s.hkSelect = widget.NewEntry()
|
s.hkSelect = widget.NewEntry()
|
||||||
s.hkToggle = widget.NewEntry()
|
s.hkToggle = widget.NewEntry()
|
||||||
s.alwaysTop = widget.NewCheck("显示置顶答案浮窗", nil)
|
s.alwaysTop = widget.NewCheck("显示置顶答案浮窗", nil)
|
||||||
|
s.themeSel = widget.NewSelect(themeOptions(), func(string) {})
|
||||||
|
s.themeSel.PlaceHolder = "请选择主题"
|
||||||
|
|
||||||
openDir := widget.NewButton("打开配置目录", func() {
|
openDir := widget.NewButton("打开配置目录", func() {
|
||||||
dir := s.app.eng.Store().Dir()
|
dir := s.app.eng.Store().Dir()
|
||||||
@@ -263,20 +564,17 @@ func (s *settingsWindow) buildMiscTab() fyne.CanvasObject {
|
|||||||
})
|
})
|
||||||
|
|
||||||
saveBtn := widget.NewButton("保存并应用", func() {
|
saveBtn := widget.NewButton("保存并应用", func() {
|
||||||
s.commitAll()
|
s.saveAll()
|
||||||
if err := s.app.applyAndSave(s.cfg); err != nil {
|
|
||||||
dialog.ShowError(err, s.win)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.ShowInformation("已保存", "热键修改需要重启程序生效", s.win)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
form := container.NewVBox(
|
form := container.NewVBox(
|
||||||
|
entry("主题", s.themeSel),
|
||||||
entry("框选热键", s.hkSelect),
|
entry("框选热键", s.hkSelect),
|
||||||
entry("监听开关热键", s.hkToggle),
|
entry("监听开关热键", s.hkToggle),
|
||||||
s.alwaysTop,
|
s.alwaysTop,
|
||||||
container.NewHBox(openDir),
|
container.NewHBox(openDir),
|
||||||
widget.NewLabel("热键语法示例:Ctrl+Alt+Q、Ctrl+Shift+F2"),
|
widget.NewLabel("热键语法示例:Ctrl+Alt+Q、Ctrl+Shift+F2"),
|
||||||
|
widget.NewLabel("黑白高对比:纯黑底纯白字,去掉所有彩色,文字与背景对比最强。"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return container.NewBorder(nil,
|
return container.NewBorder(nil,
|
||||||
@@ -286,23 +584,6 @@ func (s *settingsWindow) buildMiscTab() fyne.CanvasObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyProfile 把选中的接入点填进输入框。
|
|
||||||
func (s *settingsWindow) applyProfile() {
|
|
||||||
if s.cfg == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p, ok := s.cfg.Profile(s.profileSel.Selected)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.pName.SetText(p.Name)
|
|
||||||
s.pBase.SetText(p.BaseURL)
|
|
||||||
s.pModel.SetText(p.Model)
|
|
||||||
s.pKey.SetText(p.APIKey)
|
|
||||||
s.pMax.SetText(strconv.Itoa(p.MaxTokens))
|
|
||||||
s.pTemp.SetText(fmt.Sprintf("%g", p.Temperature))
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyTemplate 把选中的模板填进编辑框。
|
// applyTemplate 把选中的模板填进编辑框。
|
||||||
func (s *settingsWindow) applyTemplate() {
|
func (s *settingsWindow) applyTemplate() {
|
||||||
if s.cfg == nil {
|
if s.cfg == nil {
|
||||||
@@ -316,35 +597,39 @@ func (s *settingsWindow) applyTemplate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// readProfile 用界面上的值构造一个接入点(不落盘)。
|
// ---------- 提交 ----------
|
||||||
func (s *settingsWindow) readProfile() config.Profile {
|
|
||||||
return config.Profile{
|
// saveAll 把界面上的所有值写回配置并落盘生效。
|
||||||
Name: strings.TrimSpace(s.pName.Text),
|
func (s *settingsWindow) saveAll() {
|
||||||
BaseURL: strings.TrimSpace(s.pBase.Text),
|
s.commitProfile()
|
||||||
Model: strings.TrimSpace(s.pModel.Text),
|
s.commitTemplate()
|
||||||
APIKey: strings.TrimSpace(s.pKey.Text),
|
s.commitCapture()
|
||||||
MaxTokens: atoiDefault(s.pMax.Text, 1024),
|
s.commitMisc()
|
||||||
Temperature: atofDefault(s.pTemp.Text, 0.3),
|
s.cfg.Normalize()
|
||||||
|
if err := s.app.applyAndSave(s.cfg); err != nil {
|
||||||
|
dialog.ShowError(err, s.win)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
s.reload()
|
||||||
|
dialog.ShowInformation("已保存", "配置已写入并立即生效", s.win)
|
||||||
}
|
}
|
||||||
|
|
||||||
// commitAll 把界面上的所有值写回配置对象。
|
// commitTemplate 把模板编辑框的内容写回配置。
|
||||||
func (s *settingsWindow) commitAll() {
|
func (s *settingsWindow) commitTemplate() {
|
||||||
p := s.readProfile()
|
key := s.tmplSel.Selected
|
||||||
for i := range s.cfg.Profiles {
|
if key == "" {
|
||||||
if s.cfg.Profiles[i].Name == s.profileSel.Selected {
|
return
|
||||||
p.Name = s.cfg.Profiles[i].Name
|
}
|
||||||
s.cfg.Profiles[i] = p
|
for i := range s.cfg.Templates {
|
||||||
|
if s.cfg.Templates[i].Key == key {
|
||||||
|
s.cfg.Templates[i].System = s.tmplSystem.Text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.profileSel.Selected != "" {
|
s.cfg.PromptTemplate = key
|
||||||
s.cfg.ActiveProfile = s.profileSel.Selected
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if key := s.tmplSel.Selected; key != "" {
|
|
||||||
s.cfg.PromptTemplate = key
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// commitCapture 把抓屏与触发参数写回配置。
|
||||||
|
func (s *settingsWindow) commitCapture() {
|
||||||
s.cfg.Capture.FPS = atofDefault(s.fps.Text, 2)
|
s.cfg.Capture.FPS = atofDefault(s.fps.Text, 2)
|
||||||
s.cfg.Capture.MaxEdge = atoiDefault(s.maxEdge.Text, 1280)
|
s.cfg.Capture.MaxEdge = atoiDefault(s.maxEdge.Text, 1280)
|
||||||
s.cfg.Capture.JPEGQuality = atoiDefault(s.quality.Text, 75)
|
s.cfg.Capture.JPEGQuality = atoiDefault(s.quality.Text, 75)
|
||||||
@@ -352,11 +637,124 @@ func (s *settingsWindow) commitAll() {
|
|||||||
s.cfg.Capture.CooldownMs = atoiDefault(s.cooldown.Text, 3000)
|
s.cfg.Capture.CooldownMs = atoiDefault(s.cooldown.Text, 3000)
|
||||||
s.cfg.Capture.StableFrames = atoiDefault(s.stable.Text, 2)
|
s.cfg.Capture.StableFrames = atoiDefault(s.stable.Text, 2)
|
||||||
s.cfg.Capture.HistoryTurns = atoiDefault(s.turns.Text, 6)
|
s.cfg.Capture.HistoryTurns = atoiDefault(s.turns.Text, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitMisc 把热键、浮窗选项与主题写回配置。
|
||||||
|
func (s *settingsWindow) commitMisc() {
|
||||||
s.cfg.Hotkeys.SelectRegion = strings.TrimSpace(s.hkSelect.Text)
|
s.cfg.Hotkeys.SelectRegion = strings.TrimSpace(s.hkSelect.Text)
|
||||||
s.cfg.Hotkeys.ToggleWatch = strings.TrimSpace(s.hkToggle.Text)
|
s.cfg.Hotkeys.ToggleWatch = strings.TrimSpace(s.hkToggle.Text)
|
||||||
s.cfg.AlwaysOnTopCard = s.alwaysTop.Checked
|
s.cfg.AlwaysOnTopCard = s.alwaysTop.Checked
|
||||||
s.cfg.Normalize()
|
s.cfg.Theme = themeFromLabel(s.themeSel.Selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 小组件与解析 ----------
|
||||||
|
|
||||||
|
func entry(label string, obj fyne.CanvasObject) fyne.CanvasObject {
|
||||||
|
return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatLabels 返回接口格式下拉的可选项,只有两种兼容格式。
|
||||||
|
func formatLabels() []string {
|
||||||
|
labels := make([]string, 0, len(llm.Formats()))
|
||||||
|
for _, f := range llm.Formats() {
|
||||||
|
labels = append(labels, llm.FormatLabel(f))
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatHint 描述一种接口格式下的字段提示与协议差异。
|
||||||
|
type formatHint struct {
|
||||||
|
basePlaceholder string
|
||||||
|
modelPlaceholder string
|
||||||
|
keyPlaceholder string
|
||||||
|
authLabel string
|
||||||
|
protocol [][2]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// hintFor 返回接口格式对应的提示。
|
||||||
|
func hintFor(format string) formatHint {
|
||||||
|
if format == llm.FormatAnthropic {
|
||||||
|
return formatHint{
|
||||||
|
basePlaceholder: "https://api.anthropic.com",
|
||||||
|
modelPlaceholder: "claude-3-5-sonnet-20241022",
|
||||||
|
keyPlaceholder: "sk-ant-…",
|
||||||
|
authLabel: "API Key(x-api-key)",
|
||||||
|
protocol: [][2]string{
|
||||||
|
{"端点", "POST {BaseURL}/v1/messages"},
|
||||||
|
{"鉴权", "x-api-key: <API Key>,默认附带 anthropic-version: " + llm.DefaultAnthropicVersion},
|
||||||
|
{"提示词", "请求体顶层 system 字段"},
|
||||||
|
{"图片", "source.base64"},
|
||||||
|
{"参数", "max_tokens 必填"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formatHint{
|
||||||
|
basePlaceholder: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
modelPlaceholder: "qwen3-vl-plus",
|
||||||
|
keyPlaceholder: "sk-…",
|
||||||
|
authLabel: "API Key(Bearer)",
|
||||||
|
protocol: [][2]string{
|
||||||
|
{"端点", "POST {BaseURL}/chat/completions"},
|
||||||
|
{"鉴权", "Authorization: Bearer <API Key>"},
|
||||||
|
{"提示词", "作为一条 system 消息"},
|
||||||
|
{"图片", "image_url(data URL)"},
|
||||||
|
{"参数", "max_tokens 可选"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHeaders 解析"每行一条 Key: Value"的额外请求头。
|
||||||
|
func parseHeaders(text string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, value, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatHeaders 把额外请求头还原成每行一条的文本,顺序固定。
|
||||||
|
func formatHeaders(h map[string]string) string {
|
||||||
|
if len(h) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(h))
|
||||||
|
for k := range h {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
lines := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
lines = append(lines, k+": "+h[k])
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyHeaders 深拷贝一份请求头表。
|
||||||
|
func copyHeaders(h map[string]string) map[string]string {
|
||||||
|
if len(h) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(h))
|
||||||
|
for k, v := range h {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func atoiDefault(s string, def int) int {
|
func atoiDefault(s string, def int) int {
|
||||||
|
|||||||
+31
-4
@@ -23,19 +23,46 @@ func withAlpha(c color.Color, a uint8) color.Color {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mix 按比例把 b 混进 a,用来按前景/背景派生灰度层级。
|
||||||
|
// 这样气泡底色永远跟着主题走,文字与底色对比也始终够。
|
||||||
|
func mix(a, b color.Color, t float32) color.Color {
|
||||||
|
an := color.NRGBAModel.Convert(a).(color.NRGBA)
|
||||||
|
bn := color.NRGBAModel.Convert(b).(color.NRGBA)
|
||||||
|
blend := func(x, y uint8) uint8 {
|
||||||
|
return uint8(float32(x) + (float32(y)-float32(x))*t)
|
||||||
|
}
|
||||||
|
return color.NRGBA{
|
||||||
|
R: blend(an.R, bn.R),
|
||||||
|
G: blend(an.G, bn.G),
|
||||||
|
B: blend(an.B, bn.B),
|
||||||
|
A: 0xFF,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 界面配色集中在这里,方便统一调整。
|
// 界面配色集中在这里,方便统一调整。
|
||||||
|
// 所有颜色都从当前主题派生,因此黑白主题下自动变成纯灰度。
|
||||||
|
|
||||||
|
// colorUserBubble 是用户气泡:比助手气泡略深一层,文字仍是前景色。
|
||||||
func colorUserBubble() color.Color {
|
func colorUserBubble() color.Color {
|
||||||
return withAlpha(lookupColor(theme.ColorNamePrimary), 0x3A)
|
return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.18)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// colorBotBubble 是助手气泡:贴着画布底色,只比背景亮一点点。
|
||||||
func colorBotBubble() color.Color {
|
func colorBotBubble() color.Color {
|
||||||
return withAlpha(lookupColor(theme.ColorNameInputBackground), 0xF2)
|
return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.06)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// colorErrBubble 是提示/错误气泡:中等灰度,靠边框区分。
|
||||||
func colorErrBubble() color.Color {
|
func colorErrBubble() color.Color {
|
||||||
return withAlpha(lookupColor(theme.ColorNameError), 0x28)
|
return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// colorBorder 是气泡描边。
|
||||||
func colorBorder() color.Color {
|
func colorBorder() color.Color {
|
||||||
return withAlpha(lookupColor(theme.ColorNameInputBorder), 0xB0)
|
return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.45)
|
||||||
|
}
|
||||||
|
|
||||||
|
// colorStrongBorder 是需要格外醒目的描边(错误提示用)。
|
||||||
|
func colorStrongBorder() color.Color {
|
||||||
|
return lookupColor(theme.ColorNameForeground)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nrgba 把颜色转成不透明的 NRGBA,便于直接比较。
|
||||||
|
func nrgba(c color.Color) color.NRGBA {
|
||||||
|
return color.NRGBAModel.Convert(c).(color.NRGBA)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBubbleColorsFollowTheme(t *testing.T) {
|
||||||
|
test.NewApp()
|
||||||
|
|
||||||
|
applyThemeMode(ThemeContrast)
|
||||||
|
darkBot := nrgba(colorFor(bubbleBot))
|
||||||
|
darkErr := nrgba(colorFor(bubbleError))
|
||||||
|
darkBorder := nrgba(borderFor(bubbleUser))
|
||||||
|
darkStrong := nrgba(borderFor(bubbleError))
|
||||||
|
|
||||||
|
// 黑白主题:气泡底色应贴近纯黑,文字侧仍是纯白前景。
|
||||||
|
if darkBot.R > 0x30 {
|
||||||
|
t.Fatalf("黑白主题下助手气泡应贴近纯黑,实际 %v", darkBot)
|
||||||
|
}
|
||||||
|
if darkStrong.R != 0xFF {
|
||||||
|
t.Fatalf("黑白主题下错误气泡描边应为纯白,实际 %v", darkStrong)
|
||||||
|
}
|
||||||
|
if darkBorder.R >= darkStrong.R {
|
||||||
|
t.Fatalf("错误描边应比普通描边更亮:普通 %v,错误 %v", darkBorder, darkStrong)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyThemeMode(ThemeLight)
|
||||||
|
lightBot := nrgba(colorFor(bubbleBot))
|
||||||
|
lightErr := nrgba(colorFor(bubbleError))
|
||||||
|
lightStrong := nrgba(borderFor(bubbleError))
|
||||||
|
|
||||||
|
if lightBot.R < 0xD0 {
|
||||||
|
t.Fatalf("浅色主题下助手气泡应贴近纯白,实际 %v", lightBot)
|
||||||
|
}
|
||||||
|
if lightStrong.R != 0x00 {
|
||||||
|
t.Fatalf("浅色主题下错误气泡描边应为纯黑,实际 %v", lightStrong)
|
||||||
|
}
|
||||||
|
if lightBot.R == darkBot.R {
|
||||||
|
t.Fatal("切换主题后气泡底色应发生变化")
|
||||||
|
}
|
||||||
|
if lightErr.R == darkErr.R {
|
||||||
|
t.Fatal("切换主题后错误气泡底色应发生变化")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatViewRethemeRepaintsBubbles(t *testing.T) {
|
||||||
|
test.NewApp()
|
||||||
|
applyThemeMode(ThemeContrast)
|
||||||
|
|
||||||
|
c := newChatView()
|
||||||
|
b := newBubble("助手", bubbleBot)
|
||||||
|
c.appendBubble(b)
|
||||||
|
|
||||||
|
before := nrgba(b.bg.FillColor)
|
||||||
|
beforeStroke := nrgba(b.bg.StrokeColor)
|
||||||
|
|
||||||
|
applyThemeMode(ThemeLight)
|
||||||
|
c.retheme()
|
||||||
|
|
||||||
|
after := nrgba(b.bg.FillColor)
|
||||||
|
afterStroke := nrgba(b.bg.StrokeColor)
|
||||||
|
if before == after {
|
||||||
|
t.Fatalf("换肤后气泡底色应改变:%v -> %v", before, after)
|
||||||
|
}
|
||||||
|
if beforeStroke == afterStroke {
|
||||||
|
t.Fatalf("换肤后气泡描边应改变:%v -> %v", beforeStroke, afterStroke)
|
||||||
|
}
|
||||||
|
if after != nrgba(colorFor(bubbleBot)) {
|
||||||
|
t.Fatalf("换肤后底色应与当前主题一致:%v != %v", after, nrgba(colorFor(bubbleBot)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 主题模式。
|
||||||
|
const (
|
||||||
|
ThemeSystem = "system" // 跟随系统
|
||||||
|
ThemeContrast = "contrast" // 黑白高对比
|
||||||
|
ThemeLight = "light" // 浅色高对比
|
||||||
|
)
|
||||||
|
|
||||||
|
// themeLabel 返回主题模式的中文名。
|
||||||
|
func themeLabel(mode string) string {
|
||||||
|
switch mode {
|
||||||
|
case ThemeContrast:
|
||||||
|
return "黑白高对比"
|
||||||
|
case ThemeLight:
|
||||||
|
return "浅色高对比"
|
||||||
|
default:
|
||||||
|
return "跟随系统"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// themeOptions 返回设置页下拉里的可选项。
|
||||||
|
func themeOptions() []string {
|
||||||
|
return []string{
|
||||||
|
themeLabel(ThemeSystem),
|
||||||
|
themeLabel(ThemeContrast),
|
||||||
|
themeLabel(ThemeLight),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// themeFromLabel 由中文名反查主题模式。
|
||||||
|
func themeFromLabel(label string) string {
|
||||||
|
for _, mode := range []string{ThemeSystem, ThemeContrast, ThemeLight} {
|
||||||
|
if themeLabel(mode) == label {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ThemeSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyThemeMode 按主题模式切换界面主题。
|
||||||
|
func applyThemeMode(mode string) {
|
||||||
|
app := fyne.CurrentApp()
|
||||||
|
if app == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch mode {
|
||||||
|
case ThemeContrast:
|
||||||
|
app.Settings().SetTheme(newMonoTheme(darkPalette()))
|
||||||
|
case ThemeLight:
|
||||||
|
app.Settings().SetTheme(newMonoTheme(lightPalette()))
|
||||||
|
default:
|
||||||
|
app.Settings().SetTheme(theme.DefaultTheme())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// monoTheme 是一套纯灰度主题:所有颜色都去掉彩度,只靠明暗区分层级,
|
||||||
|
// 文字与背景的对比拉到最高。
|
||||||
|
type monoTheme struct {
|
||||||
|
base fyne.Theme
|
||||||
|
colors map[fyne.ThemeColorName]color.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMonoTheme 用给定配色造一个主题,其余项沿用默认主题。
|
||||||
|
func newMonoTheme(palette map[fyne.ThemeColorName]color.Color) fyne.Theme {
|
||||||
|
return &monoTheme{base: theme.DefaultTheme(), colors: palette}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *monoTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
|
||||||
|
if c, ok := t.colors[name]; ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return t.base.Color(name, variant)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *monoTheme) Font(style fyne.TextStyle) fyne.Resource { return t.base.Font(style) }
|
||||||
|
|
||||||
|
func (t *monoTheme) Icon(name fyne.ThemeIconName) fyne.Resource { return t.base.Icon(name) }
|
||||||
|
|
||||||
|
func (t *monoTheme) Size(name fyne.ThemeSizeName) float32 { return t.base.Size(name) }
|
||||||
|
|
||||||
|
// rgb 生成不透明灰度色。
|
||||||
|
func rgb(v uint8) color.NRGBA {
|
||||||
|
return color.NRGBA{R: v, G: v, B: v, A: 0xFF}
|
||||||
|
}
|
||||||
|
|
||||||
|
// darkPalette 是黑白高对比(纯黑底)的配色。
|
||||||
|
func darkPalette() map[fyne.ThemeColorName]color.Color {
|
||||||
|
return map[fyne.ThemeColorName]color.Color{
|
||||||
|
theme.ColorNameBackground: rgb(0x00),
|
||||||
|
theme.ColorNameForeground: rgb(0xFF),
|
||||||
|
theme.ColorNameDisabled: rgb(0x8C),
|
||||||
|
theme.ColorNameDisabledButton: rgb(0x1A),
|
||||||
|
theme.ColorNameButton: rgb(0x2E),
|
||||||
|
theme.ColorNameHover: rgb(0x4A),
|
||||||
|
theme.ColorNamePressed: rgb(0x1E),
|
||||||
|
theme.ColorNameFocus: rgb(0xFF),
|
||||||
|
theme.ColorNamePrimary: rgb(0xFF),
|
||||||
|
theme.ColorNameForegroundOnPrimary: rgb(0x00),
|
||||||
|
theme.ColorNameSelection: rgb(0xFF),
|
||||||
|
theme.ColorNameInputBackground: rgb(0x0B),
|
||||||
|
theme.ColorNameInputBorder: rgb(0xE6),
|
||||||
|
theme.ColorNamePlaceHolder: rgb(0xA6),
|
||||||
|
theme.ColorNameSeparator: rgb(0x5A),
|
||||||
|
theme.ColorNameOverlayBackground: rgb(0x11),
|
||||||
|
theme.ColorNameMenuBackground: rgb(0x16),
|
||||||
|
theme.ColorNameHeaderBackground: rgb(0x1C),
|
||||||
|
theme.ColorNameInnerWindowBorder: rgb(0x3A),
|
||||||
|
theme.ColorNameInnerWindowBorderInactive: rgb(0x26),
|
||||||
|
theme.ColorNameScrollBar: rgb(0x9A),
|
||||||
|
theme.ColorNameScrollBarBackground: rgb(0x1A),
|
||||||
|
theme.ColorNameShadow: rgb(0x00),
|
||||||
|
theme.ColorNameError: rgb(0xFF),
|
||||||
|
theme.ColorNameForegroundOnError: rgb(0x00),
|
||||||
|
theme.ColorNameSuccess: rgb(0xFF),
|
||||||
|
theme.ColorNameForegroundOnSuccess: rgb(0x00),
|
||||||
|
theme.ColorNameWarning: rgb(0xFF),
|
||||||
|
theme.ColorNameForegroundOnWarning: rgb(0x00),
|
||||||
|
theme.ColorNameHyperlink: rgb(0xFF),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lightPalette 是浅色高对比(纯白底)的配色。
|
||||||
|
func lightPalette() map[fyne.ThemeColorName]color.Color {
|
||||||
|
return map[fyne.ThemeColorName]color.Color{
|
||||||
|
theme.ColorNameBackground: rgb(0xFF),
|
||||||
|
theme.ColorNameForeground: rgb(0x00),
|
||||||
|
theme.ColorNameDisabled: rgb(0x70),
|
||||||
|
theme.ColorNameDisabledButton: rgb(0xF0),
|
||||||
|
theme.ColorNameButton: rgb(0xE0),
|
||||||
|
theme.ColorNameHover: rgb(0xC8),
|
||||||
|
theme.ColorNamePressed: rgb(0xD4),
|
||||||
|
theme.ColorNameFocus: rgb(0x00),
|
||||||
|
theme.ColorNamePrimary: rgb(0x00),
|
||||||
|
theme.ColorNameForegroundOnPrimary: rgb(0xFF),
|
||||||
|
theme.ColorNameSelection: rgb(0x00),
|
||||||
|
theme.ColorNameInputBackground: rgb(0xFF),
|
||||||
|
theme.ColorNameInputBorder: rgb(0x00),
|
||||||
|
theme.ColorNamePlaceHolder: rgb(0x70),
|
||||||
|
theme.ColorNameSeparator: rgb(0x9A),
|
||||||
|
theme.ColorNameOverlayBackground: rgb(0xF4),
|
||||||
|
theme.ColorNameMenuBackground: rgb(0xFA),
|
||||||
|
theme.ColorNameHeaderBackground: rgb(0xEF),
|
||||||
|
theme.ColorNameInnerWindowBorder: rgb(0xC0),
|
||||||
|
theme.ColorNameInnerWindowBorderInactive: rgb(0xD8),
|
||||||
|
theme.ColorNameScrollBar: rgb(0x55),
|
||||||
|
theme.ColorNameScrollBarBackground: rgb(0xED),
|
||||||
|
theme.ColorNameShadow: rgb(0x00),
|
||||||
|
theme.ColorNameError: rgb(0x00),
|
||||||
|
theme.ColorNameForegroundOnError: rgb(0xFF),
|
||||||
|
theme.ColorNameSuccess: rgb(0x00),
|
||||||
|
theme.ColorNameForegroundOnSuccess: rgb(0xFF),
|
||||||
|
theme.ColorNameWarning: rgb(0x00),
|
||||||
|
theme.ColorNameForegroundOnWarning: rgb(0xFF),
|
||||||
|
theme.ColorNameHyperlink: rgb(0x00),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/test"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isGray 判断颜色是否是无彩度的灰度色。
|
||||||
|
func isGray(c color.Color) bool {
|
||||||
|
n := color.NRGBAModel.Convert(c).(color.NRGBA)
|
||||||
|
return n.R == n.G && n.G == n.B
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThemeLabelRoundTrip(t *testing.T) {
|
||||||
|
for _, mode := range []string{ThemeSystem, ThemeContrast, ThemeLight} {
|
||||||
|
label := themeLabel(mode)
|
||||||
|
if got := themeFromLabel(label); got != mode {
|
||||||
|
t.Fatalf("主题 %q 的中文名 %q 反查应为 %q,实际 %q", mode, label, mode, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := themeFromLabel("不存在的名字"); got != ThemeSystem {
|
||||||
|
t.Fatalf("未知中文名应回落到 %q,实际 %q", ThemeSystem, got)
|
||||||
|
}
|
||||||
|
if len(themeOptions()) != 3 {
|
||||||
|
t.Fatalf("主题下拉应有 3 个选项,实际 %v", themeOptions())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDarkPaletteIsPureBlackOnWhite(t *testing.T) {
|
||||||
|
p := darkPalette()
|
||||||
|
|
||||||
|
black := color.NRGBA{R: 0, G: 0, B: 0, A: 0xFF}
|
||||||
|
white := color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}
|
||||||
|
if got := p[theme.ColorNameBackground]; got != black {
|
||||||
|
t.Fatalf("黑白主题背景应为纯黑,实际 %v", got)
|
||||||
|
}
|
||||||
|
if got := p[theme.ColorNameForeground]; got != white {
|
||||||
|
t.Fatalf("黑白主题文字应为纯白,实际 %v", got)
|
||||||
|
}
|
||||||
|
for name, c := range p {
|
||||||
|
if !isGray(c) {
|
||||||
|
t.Fatalf("黑白主题不应出现彩色:%s = %v", name, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLightPaletteIsPureWhiteOnBlack(t *testing.T) {
|
||||||
|
p := lightPalette()
|
||||||
|
|
||||||
|
black := color.NRGBA{R: 0, G: 0, B: 0, A: 0xFF}
|
||||||
|
white := color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}
|
||||||
|
if got := p[theme.ColorNameBackground]; got != white {
|
||||||
|
t.Fatalf("浅色主题背景应为纯白,实际 %v", got)
|
||||||
|
}
|
||||||
|
if got := p[theme.ColorNameForeground]; got != black {
|
||||||
|
t.Fatalf("浅色主题文字应为纯黑,实际 %v", got)
|
||||||
|
}
|
||||||
|
for name, c := range p {
|
||||||
|
if !isGray(c) {
|
||||||
|
t.Fatalf("浅色高对比主题不应出现彩色:%s = %v", name, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonoThemeFallsBackToDefault(t *testing.T) {
|
||||||
|
th := newMonoTheme(darkPalette())
|
||||||
|
|
||||||
|
black := color.NRGBA{R: 0, G: 0, B: 0, A: 0xFF}
|
||||||
|
// 调色板里有的走调色板。
|
||||||
|
if got := th.Color(theme.ColorNameBackground, theme.VariantDark); got != black {
|
||||||
|
t.Fatalf("背景色应取自调色板,实际 %v", got)
|
||||||
|
}
|
||||||
|
// 调色板里没有的沿用默认主题。
|
||||||
|
if got := th.Color(theme.ColorNameOverlayBackground, theme.VariantDark); got == nil {
|
||||||
|
t.Fatal("未覆盖的颜色名应回落到默认主题")
|
||||||
|
}
|
||||||
|
if th.Font(fyne.TextStyle{}) == nil || th.Size(theme.SizeNamePadding) <= 0 {
|
||||||
|
t.Fatal("字体与尺寸应沿用默认主题")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyThemeModeSwitchesTheme(t *testing.T) {
|
||||||
|
app := test.NewApp()
|
||||||
|
|
||||||
|
black := color.NRGBA{R: 0, G: 0, B: 0, A: 0xFF}
|
||||||
|
white := color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}
|
||||||
|
|
||||||
|
applyThemeMode(ThemeContrast)
|
||||||
|
mt, ok := app.Settings().Theme().(*monoTheme)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("黑白模式应切到 monoTheme,实际 %T", app.Settings().Theme())
|
||||||
|
}
|
||||||
|
if got := mt.Color(theme.ColorNameBackground, app.Settings().ThemeVariant()); got != black {
|
||||||
|
t.Fatalf("黑白模式背景应为纯黑,实际 %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyThemeMode(ThemeLight)
|
||||||
|
mt, ok = app.Settings().Theme().(*monoTheme)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("浅色模式应切到 monoTheme,实际 %T", app.Settings().Theme())
|
||||||
|
}
|
||||||
|
if got := mt.Color(theme.ColorNameBackground, app.Settings().ThemeVariant()); got != white {
|
||||||
|
t.Fatalf("浅色模式背景应为纯白,实际 %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyThemeMode(ThemeSystem)
|
||||||
|
if _, ok := app.Settings().Theme().(*monoTheme); ok {
|
||||||
|
t.Fatal("跟随系统应还原默认主题")
|
||||||
|
}
|
||||||
|
if got := app.Settings().Theme().Color(theme.ColorNameBackground, app.Settings().ThemeVariant()); got == nil {
|
||||||
|
t.Fatal("默认主题应能取到背景色")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
package win32
|
||||||
|
|
||||||
|
// 取景框:屏幕上的一个红框,常驻显示当前框选区域。
|
||||||
|
//
|
||||||
|
// 三个关键点:
|
||||||
|
// 1. 用 WS_EX_LAYERED 分层窗口自己画,框外像素 alpha=0,既不遮挡下方内容,
|
||||||
|
// 也不需要 Fyne 的透明窗口支持(OpenGL 窗口改颜色键并不可靠);
|
||||||
|
// 2. WS_EX_TRANSPARENT + WM_NCHITTEST 返回 HTTRANSPARENT,鼠标事件直接穿透,
|
||||||
|
// 不会挡住底下的点击;
|
||||||
|
// 3. SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE) 让它不出现在截屏和录屏
|
||||||
|
// 画面里,包括本程序自己抓屏用的 GDI BitBlt(Windows 10 2004+)。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
frameWNDClass = "ViewsAgentFrameWnd"
|
||||||
|
|
||||||
|
wsExLayered = 0x00080000
|
||||||
|
wsExTransparent = 0x00000020
|
||||||
|
wsExToolWindow = 0x00000080
|
||||||
|
wsExNoActivate = 0x08000000
|
||||||
|
|
||||||
|
wsPopup = 0x80000000
|
||||||
|
|
||||||
|
swShowNoActivate = 4
|
||||||
|
|
||||||
|
ulwAlpha = 0x02
|
||||||
|
|
||||||
|
acSrcOver = 0x00
|
||||||
|
acSrcAlpha = 0x01
|
||||||
|
|
||||||
|
biRgb = 0x00
|
||||||
|
dibRgbColors = 0x00
|
||||||
|
|
||||||
|
// wdaExcludeFromCapture 让窗口从截屏/录屏里消失(Windows 10 2004+)。
|
||||||
|
wdaExcludeFromCapture = 0x11
|
||||||
|
wdaNone = 0x00
|
||||||
|
|
||||||
|
wmDestroy = 0x0002
|
||||||
|
wmEraseBkgnd = 0x0014
|
||||||
|
wmNCHitTest = 0x0084
|
||||||
|
wmFrameRedraw = 0x8001 // WM_APP + 1
|
||||||
|
|
||||||
|
// htTransparent 让鼠标事件穿透到下层窗口。
|
||||||
|
htTransparent = ^uintptr(0)
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
|
||||||
|
|
||||||
|
procCreateCompatibleDC = gdi32.NewProc("CreateCompatibleDC")
|
||||||
|
procCreateDIBSection = gdi32.NewProc("CreateDIBSection")
|
||||||
|
procSelectObject = gdi32.NewProc("SelectObject")
|
||||||
|
procDeleteObject = gdi32.NewProc("DeleteObject")
|
||||||
|
procDeleteDC = gdi32.NewProc("DeleteDC")
|
||||||
|
|
||||||
|
procGetDC = user32.NewProc("GetDC")
|
||||||
|
procReleaseDC = user32.NewProc("ReleaseDC")
|
||||||
|
procCreateWindowExW = user32.NewProc("CreateWindowExW")
|
||||||
|
procRegisterClassExW = user32.NewProc("RegisterClassExW")
|
||||||
|
procDefWindowProcW = user32.NewProc("DefWindowProcW")
|
||||||
|
procTranslateMessage = user32.NewProc("TranslateMessage")
|
||||||
|
procDispatchMessageW = user32.NewProc("DispatchMessageW")
|
||||||
|
procPostMessageW = user32.NewProc("PostMessageW")
|
||||||
|
procDestroyWindow = user32.NewProc("DestroyWindow")
|
||||||
|
procShowWindow = user32.NewProc("ShowWindow")
|
||||||
|
procUpdateLayered = user32.NewProc("UpdateLayeredWindow")
|
||||||
|
procSetDisplayAffinity = user32.NewProc("SetWindowDisplayAffinity")
|
||||||
|
procGetDisplayAffinity = user32.NewProc("GetWindowDisplayAffinity")
|
||||||
|
procIsWindowVisible = user32.NewProc("IsWindowVisible")
|
||||||
|
)
|
||||||
|
|
||||||
|
type bitmapInfoHeader struct {
|
||||||
|
Size uint32
|
||||||
|
Width int32
|
||||||
|
Height int32
|
||||||
|
Planes uint16
|
||||||
|
BitCount uint16
|
||||||
|
Compression uint32
|
||||||
|
SizeImage uint32
|
||||||
|
XPelsPerMeter int32
|
||||||
|
YPelsPerMeter int32
|
||||||
|
ClrUsed uint32
|
||||||
|
ClrImportant uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type bitmapInfo struct {
|
||||||
|
Header bitmapInfoHeader
|
||||||
|
Colors [1]uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type blendFunction struct {
|
||||||
|
BlendOp byte
|
||||||
|
BlendFlags byte
|
||||||
|
SourceConstantAlpha byte
|
||||||
|
AlphaFormat byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type winPoint struct {
|
||||||
|
X, Y int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type winSize struct {
|
||||||
|
Cx, Cy int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type wndClassEx struct {
|
||||||
|
Size uint32
|
||||||
|
Style uint32
|
||||||
|
WndProc uintptr
|
||||||
|
ClsExtra int32
|
||||||
|
WndExtra int32
|
||||||
|
Instance windows.Handle
|
||||||
|
Icon windows.Handle
|
||||||
|
Cursor windows.Handle
|
||||||
|
Background windows.Handle
|
||||||
|
MenuName *uint16
|
||||||
|
ClassName *uint16
|
||||||
|
IconSmall windows.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame 是屏幕上的取景框。内部自带一条消息线程,
|
||||||
|
// 窗口消息必须有人泵,否则窗口不会响应。
|
||||||
|
type Frame struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
hwnd syscall.Handle
|
||||||
|
region Rect
|
||||||
|
shown bool
|
||||||
|
|
||||||
|
memDC syscall.Handle
|
||||||
|
dib syscall.Handle
|
||||||
|
oldBmp syscall.Handle
|
||||||
|
bits unsafe.Pointer
|
||||||
|
w, h int
|
||||||
|
buf []uint32
|
||||||
|
threadID uintptr
|
||||||
|
|
||||||
|
lastULW uintptr
|
||||||
|
lastErr uintptr
|
||||||
|
|
||||||
|
ready chan error
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// frames 记录窗口句柄到 Frame 的映射,窗口过程里要用。
|
||||||
|
var (
|
||||||
|
framesMu sync.Mutex
|
||||||
|
frames = map[syscall.Handle]*Frame{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewFrame 创建一个取景框。此时还没有区域,调用 SetRegion 才会显示。
|
||||||
|
func NewFrame() (*Frame, error) {
|
||||||
|
f := &Frame{
|
||||||
|
ready: make(chan error, 1),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go f.loop()
|
||||||
|
if err := <-f.ready; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loop 在独立线程上建窗口并跑消息循环。
|
||||||
|
func (f *Frame) loop() {
|
||||||
|
className, err := windows.UTF16PtrFromString(frameWNDClass)
|
||||||
|
if err != nil {
|
||||||
|
f.ready <- err
|
||||||
|
close(f.done)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wc := wndClassEx{
|
||||||
|
Size: uint32(unsafe.Sizeof(wndClassEx{})),
|
||||||
|
Style: 0x0008, // CS_DBLCLKS 无所谓,主要是别带 CS_HREDRAW/CS_VREDRAW
|
||||||
|
WndProc: windows.NewCallback(frameWndProc),
|
||||||
|
ClassName: className,
|
||||||
|
}
|
||||||
|
if atom, _, _ := procRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc))); atom == 0 {
|
||||||
|
// 1410 = ERROR_CLASS_ALREADY_EXISTS,重复注册不算错。
|
||||||
|
if code := lastErrorCode(); code != 1410 {
|
||||||
|
err = fmt.Errorf("RegisterClassEx 失败: %w", syscall.Errno(code))
|
||||||
|
f.ready <- err
|
||||||
|
close(f.done)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
style := uintptr(wsExLayered | wsExTransparent | wsExToolWindow | wsExNoActivate)
|
||||||
|
hwnd, _, _ := procCreateWindowExW.Call(
|
||||||
|
style,
|
||||||
|
uintptr(unsafe.Pointer(className)),
|
||||||
|
0,
|
||||||
|
uintptr(wsPopup),
|
||||||
|
0, 0, 1, 1,
|
||||||
|
0, 0, 0, 0,
|
||||||
|
)
|
||||||
|
if hwnd == 0 {
|
||||||
|
f.ready <- fmt.Errorf("CreateWindowEx 失败: %w", syscall.Errno(lastErrorCode()))
|
||||||
|
close(f.done)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f.mu.Lock()
|
||||||
|
f.hwnd = syscall.Handle(hwnd)
|
||||||
|
f.threadID, _, _ = procGetCurrentThreadID.Call()
|
||||||
|
f.mu.Unlock()
|
||||||
|
framesMu.Lock()
|
||||||
|
frames[syscall.Handle(hwnd)] = f
|
||||||
|
framesMu.Unlock()
|
||||||
|
|
||||||
|
// 关键:让窗口从截屏/录屏里排除出去。
|
||||||
|
// 开发自检时可用 VIEWSAGENT_FRAME_NO_EXCLUDE=1 临时关掉,方便截图核对。
|
||||||
|
if os.Getenv("VIEWSAGENT_FRAME_NO_EXCLUDE") == "" {
|
||||||
|
procSetDisplayAffinity.Call(hwnd, wdaExcludeFromCapture)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备离屏绘图用的 DC。
|
||||||
|
hdcScreen, _, _ := procGetDC.Call(0)
|
||||||
|
if hdcScreen != 0 {
|
||||||
|
memDC, _, _ := procCreateCompatibleDC.Call(hdcScreen)
|
||||||
|
f.mu.Lock()
|
||||||
|
f.memDC = syscall.Handle(memDC)
|
||||||
|
f.mu.Unlock()
|
||||||
|
procReleaseDC.Call(0, hdcScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.ready <- nil
|
||||||
|
|
||||||
|
var msg msg
|
||||||
|
for {
|
||||||
|
r, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0)
|
||||||
|
if int32(r) <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg)))
|
||||||
|
procDispatchMessageW.Call(uintptr(unsafe.Pointer(&msg)))
|
||||||
|
}
|
||||||
|
close(f.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegion 把取景框移到指定区域并重画。区域无效时隐藏。
|
||||||
|
func (f *Frame) SetRegion(r Rect) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.region = r
|
||||||
|
f.shown = r.Width() > 0 && r.Height() > 0
|
||||||
|
hwnd := f.hwnd
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
if hwnd == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Width() <= 0 || r.Height() <= 0 {
|
||||||
|
procShowWindow.Call(uintptr(hwnd), 0) // SW_HIDE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
procShowWindow.Call(uintptr(hwnd), swShowNoActivate)
|
||||||
|
procPostMessageW.Call(uintptr(hwnd), wmFrameRedraw, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide 隐藏取景框。
|
||||||
|
func (f *Frame) Hide() {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.shown = false
|
||||||
|
hwnd := f.hwnd
|
||||||
|
f.mu.Unlock()
|
||||||
|
if hwnd != 0 {
|
||||||
|
procShowWindow.Call(uintptr(hwnd), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCaptureExclusion 开关某个窗口的"不出现在截屏/录屏里"。
|
||||||
|
// 关掉之后窗口会被录进去,只用于自检和调试。
|
||||||
|
func SetCaptureExclusion(hwnd syscall.Handle, on bool) {
|
||||||
|
if hwnd == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
affinity := uintptr(wdaExcludeFromCapture)
|
||||||
|
if !on {
|
||||||
|
affinity = wdaNone
|
||||||
|
}
|
||||||
|
procSetDisplayAffinity.Call(uintptr(hwnd), affinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCaptureExclusion 开关"不出现在截屏/录屏里"。
|
||||||
|
// 关掉之后取景框会被录进去,只用于自检和调试。
|
||||||
|
func (f *Frame) SetCaptureExclusion(on bool) {
|
||||||
|
f.mu.Lock()
|
||||||
|
hwnd := f.hwnd
|
||||||
|
f.mu.Unlock()
|
||||||
|
SetCaptureExclusion(hwnd, on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindFrameWindow 按窗口类找到取景框窗口,给外部工具(如自检脚本)用。
|
||||||
|
func FindFrameWindow() (syscall.Handle, error) {
|
||||||
|
return FindWindowByClass(frameWNDClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWindowVisible 返回窗口是否可见。
|
||||||
|
func IsWindowVisible(hwnd syscall.Handle) bool {
|
||||||
|
r, _, _ := procIsWindowVisible.Call(uintptr(hwnd))
|
||||||
|
return r != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayAffinity 返回窗口的截屏亲和性设置。
|
||||||
|
func DisplayAffinity(hwnd syscall.Handle) uint32 {
|
||||||
|
var v uint32
|
||||||
|
r, _, _ := procGetDisplayAffinity.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&v)))
|
||||||
|
if r == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug 返回取景框的内部状态,用于自检排障。
|
||||||
|
func (f *Frame) Debug() string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
red := 0
|
||||||
|
if f.bits != nil && len(f.buf) > 0 {
|
||||||
|
for _, v := range unsafe.Slice((*uint32)(f.bits), len(f.buf)) {
|
||||||
|
if v == 0xFFFF0000 {
|
||||||
|
red++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("w=%d h=%d memDC=%v dib=%v bits=%v buf=%d 红像素=%d 区域=%+v ULW=%v err=%d",
|
||||||
|
f.w, f.h, f.memDC != 0, f.dib != 0, f.bits != nil, len(f.buf), red, f.region, f.lastULW, f.lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrameDebug 返回取景框的内部状态,用于自检排障。
|
||||||
|
func FrameDebug(hwnd syscall.Handle) string {
|
||||||
|
framesMu.Lock()
|
||||||
|
f := frames[hwnd]
|
||||||
|
framesMu.Unlock()
|
||||||
|
if f == nil {
|
||||||
|
return "找不到取景框实例"
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
red := 0
|
||||||
|
if f.bits != nil && len(f.buf) > 0 {
|
||||||
|
for _, v := range unsafe.Slice((*uint32)(f.bits), len(f.buf)) {
|
||||||
|
if v == 0xFFFF0000 {
|
||||||
|
red++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("w=%d h=%d memDC=%v dib=%v bits=%v buf=%d 红像素=%d",
|
||||||
|
f.w, f.h, f.memDC != 0, f.dib != 0, f.bits != nil, len(f.buf), red)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 销毁取景框。
|
||||||
|
func (f *Frame) Close() {
|
||||||
|
f.mu.Lock()
|
||||||
|
hwnd := f.hwnd
|
||||||
|
threadID := f.threadID
|
||||||
|
f.hwnd = 0
|
||||||
|
memDC := f.memDC
|
||||||
|
dib := f.dib
|
||||||
|
oldBmp := f.oldBmp
|
||||||
|
f.memDC, f.dib, f.oldBmp = 0, 0, 0
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
if hwnd != 0 {
|
||||||
|
framesMu.Lock()
|
||||||
|
delete(frames, syscall.Handle(hwnd))
|
||||||
|
framesMu.Unlock()
|
||||||
|
procDestroyWindow.Call(uintptr(hwnd))
|
||||||
|
// 让消息循环退出,否则这条线程会一直挂着。
|
||||||
|
if threadID != 0 {
|
||||||
|
procPostThreadMessageW.Call(threadID, wmQuit, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if memDC != 0 {
|
||||||
|
if oldBmp != 0 {
|
||||||
|
procSelectObject.Call(uintptr(memDC), uintptr(oldBmp))
|
||||||
|
}
|
||||||
|
if dib != 0 {
|
||||||
|
procDeleteObject.Call(uintptr(dib))
|
||||||
|
}
|
||||||
|
procDeleteDC.Call(uintptr(memDC))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redraw 重新绘制取景框(运行在窗口自己的线程上)。
|
||||||
|
func (f *Frame) redraw() {
|
||||||
|
f.mu.Lock()
|
||||||
|
r := f.region
|
||||||
|
memDC := f.memDC
|
||||||
|
hwnd := f.hwnd
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
w, h := r.Width(), r.Height()
|
||||||
|
if w <= 0 || h <= 0 || memDC == 0 || hwnd == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.ensureBitmap(w, h)
|
||||||
|
|
||||||
|
// 画框:四边实心红条,中间留空(全透明)。
|
||||||
|
const thickness = 2
|
||||||
|
red := uint32(0xFFFF0000) // DIB 是 BGRA 顺序,A=255 R=255 G=0 B=0
|
||||||
|
for i := range f.buf {
|
||||||
|
f.buf[i] = 0
|
||||||
|
}
|
||||||
|
fillRect(f.buf, w, h, 0, 0, w, thickness, red)
|
||||||
|
fillRect(f.buf, w, h, 0, h-thickness, w, h, red)
|
||||||
|
fillRect(f.buf, w, h, 0, 0, thickness, h, red)
|
||||||
|
fillRect(f.buf, w, h, w-thickness, 0, w, h, red)
|
||||||
|
copy(unsafe.Slice((*uint32)(f.bits), len(f.buf)), f.buf)
|
||||||
|
|
||||||
|
hdcScreen, _, _ := procGetDC.Call(0)
|
||||||
|
if hdcScreen == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer procReleaseDC.Call(0, hdcScreen)
|
||||||
|
|
||||||
|
blend := blendFunction{BlendOp: acSrcOver, SourceConstantAlpha: 255, AlphaFormat: acSrcAlpha}
|
||||||
|
dst := winPoint{X: int32(r.Left), Y: int32(r.Top)}
|
||||||
|
size := winSize{Cx: int32(w), Cy: int32(h)}
|
||||||
|
src := winPoint{}
|
||||||
|
ok, _, _ := procUpdateLayered.Call(
|
||||||
|
uintptr(hwnd),
|
||||||
|
hdcScreen,
|
||||||
|
uintptr(unsafe.Pointer(&dst)),
|
||||||
|
uintptr(unsafe.Pointer(&size)),
|
||||||
|
uintptr(memDC),
|
||||||
|
uintptr(unsafe.Pointer(&src)),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&blend)),
|
||||||
|
ulwAlpha,
|
||||||
|
)
|
||||||
|
f.mu.Lock()
|
||||||
|
f.lastULW = ok
|
||||||
|
f.lastErr = lastErrorCode()
|
||||||
|
f.mu.Unlock()
|
||||||
|
// 保持在最上层,但不抢焦点。
|
||||||
|
procSetWindowPos.Call(uintptr(hwnd), hwndTopMost, 0, 0, 0, 0, swpNoMove|swpNoSize|swpNoActivate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureBitmap 保证离屏位图尺寸与区域一致。
|
||||||
|
func (f *Frame) ensureBitmap(w, h int) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.dib != 0 && f.w == w && f.h == h {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f.dib != 0 {
|
||||||
|
procSelectObject.Call(uintptr(f.memDC), uintptr(f.oldBmp))
|
||||||
|
procDeleteObject.Call(uintptr(f.dib))
|
||||||
|
f.dib = 0
|
||||||
|
f.bits = nil
|
||||||
|
}
|
||||||
|
bmi := bitmapInfo{}
|
||||||
|
bmi.Header.Size = uint32(unsafe.Sizeof(bitmapInfoHeader{}))
|
||||||
|
bmi.Header.Width = int32(w)
|
||||||
|
bmi.Header.Height = -int32(h) // 负数表示自上而下
|
||||||
|
bmi.Header.Planes = 1
|
||||||
|
bmi.Header.BitCount = 32
|
||||||
|
bmi.Header.Compression = biRgb
|
||||||
|
|
||||||
|
var bits unsafe.Pointer
|
||||||
|
dib, _, _ := procCreateDIBSection.Call(
|
||||||
|
uintptr(f.memDC),
|
||||||
|
uintptr(unsafe.Pointer(&bmi)),
|
||||||
|
dibRgbColors,
|
||||||
|
uintptr(unsafe.Pointer(&bits)),
|
||||||
|
0, 0,
|
||||||
|
)
|
||||||
|
if dib == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
old, _, _ := procSelectObject.Call(uintptr(f.memDC), dib)
|
||||||
|
f.dib = syscall.Handle(dib)
|
||||||
|
f.oldBmp = syscall.Handle(old)
|
||||||
|
f.bits = bits
|
||||||
|
f.w, f.h = w, h
|
||||||
|
f.buf = make([]uint32, w*h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillRect 在像素缓冲里填一块矩形。
|
||||||
|
func fillRect(buf []uint32, w, h, x0, y0, x1, y1 int, color uint32) {
|
||||||
|
if x0 < 0 {
|
||||||
|
x0 = 0
|
||||||
|
}
|
||||||
|
if y0 < 0 {
|
||||||
|
y0 = 0
|
||||||
|
}
|
||||||
|
if x1 > w {
|
||||||
|
x1 = w
|
||||||
|
}
|
||||||
|
if y1 > h {
|
||||||
|
y1 = h
|
||||||
|
}
|
||||||
|
for y := y0; y < y1; y++ {
|
||||||
|
row := y * w
|
||||||
|
for x := x0; x < x1; x++ {
|
||||||
|
buf[row+x] = color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// frameWndProc 是取景框的窗口过程。
|
||||||
|
func frameWndProc(hwnd uintptr, msg uint32, wparam, lparam uintptr) uintptr {
|
||||||
|
switch msg {
|
||||||
|
case wmNCHitTest:
|
||||||
|
// 鼠标穿透:让下层窗口收到点击。
|
||||||
|
return htTransparent
|
||||||
|
case wmEraseBkgnd:
|
||||||
|
// 不擦背景:整个窗口由分层位图定义。
|
||||||
|
return 1
|
||||||
|
case wmFrameRedraw:
|
||||||
|
framesMu.Lock()
|
||||||
|
f := frames[syscall.Handle(hwnd)]
|
||||||
|
framesMu.Unlock()
|
||||||
|
if f != nil {
|
||||||
|
f.redraw()
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case wmDestroy:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
r, _, _ := procDefWindowProcW.Call(hwnd, uintptr(msg), wparam, lparam)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastErrorCode 取最近一次 Win32 调用的错误码。
|
||||||
|
func lastErrorCode() uintptr {
|
||||||
|
if err, ok := windows.GetLastError().(syscall.Errno); ok {
|
||||||
|
return uintptr(err)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package win32_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"viewsagent/internal/capture"
|
||||||
|
"viewsagent/internal/win32"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFrameVisibleButNotCaptured 验证取景框的两个关键性质:
|
||||||
|
// 1. 它确实显示在屏幕上(截屏能看到红框);
|
||||||
|
// 2. 打开"排除截屏"后,它不会被抓屏录进去(模型看到的画面里没有红框)。
|
||||||
|
//
|
||||||
|
// 这是个需要桌面会话的集成测试,默认跳过:
|
||||||
|
//
|
||||||
|
// VIEWSAGENT_UI_TEST=1 go test ./internal/win32/ -run Frame
|
||||||
|
func TestFrameVisibleButNotCaptured(t *testing.T) {
|
||||||
|
if os.Getenv("VIEWSAGENT_UI_TEST") == "" {
|
||||||
|
t.Skip("需要桌面会话,设置 VIEWSAGENT_UI_TEST=1 后运行")
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := win32.NewFrame()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("无法创建取景框窗口(可能没有桌面会话): %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
region := capture.Region{X: 120, Y: 120, W: 420, H: 300}
|
||||||
|
f.SetRegion(win32.Rect{
|
||||||
|
Left: region.X, Top: region.Y,
|
||||||
|
Right: region.X + region.W, Bottom: region.Y + region.H,
|
||||||
|
})
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
|
||||||
|
// 关闭排除:截屏里应该能看到红框,说明它真的贴在屏幕上。
|
||||||
|
f.SetCaptureExclusion(false)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
visible := grabRegion(t, region)
|
||||||
|
saveShot(t, "frame-visible.png", visible)
|
||||||
|
if n := countRedInBorder(visible, 3); n == 0 {
|
||||||
|
t.Fatal("取景框没有出现在屏幕上:关掉排除后截屏里也找不到红线")
|
||||||
|
} else {
|
||||||
|
t.Logf("边框窄带里有 %d 个红像素,取景框已显示在屏幕上", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开排除:截屏里不应再有红线。
|
||||||
|
f.SetCaptureExclusion(true)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
hidden := grabRegion(t, region)
|
||||||
|
saveShot(t, "frame-excluded.png", hidden)
|
||||||
|
if n := countRedInBorder(hidden, 3); n != 0 {
|
||||||
|
t.Fatalf("取景框被录进截屏了,边框窄带里有 %d 个红像素", n)
|
||||||
|
}
|
||||||
|
t.Log("取景框已被排除在截屏之外")
|
||||||
|
}
|
||||||
|
|
||||||
|
// grabRegion 抓取一块屏幕区域。
|
||||||
|
func grabRegion(t *testing.T, r capture.Region) image.Image {
|
||||||
|
t.Helper()
|
||||||
|
img, err := (capture.Screen{}).Grab(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("抓屏失败: %v", err)
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
// countRedInBorder 只统计区域四边 band 像素宽的窄带里的红色像素。
|
||||||
|
// 取景框只画在这一圈上,这样不会把页面里本来就有的红色(图标之类)算进来。
|
||||||
|
func countRedInBorder(img image.Image, band int) int {
|
||||||
|
b := img.Bounds()
|
||||||
|
isRed := func(x, y int) bool {
|
||||||
|
r, g, bl, _ := img.At(x, y).RGBA()
|
||||||
|
return r > 0xB000 && g < 0x5000 && bl < 0x5000
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||||
|
for x := b.Min.X; x < b.Max.X; x++ {
|
||||||
|
nearEdge := x < b.Min.X+band || x >= b.Max.X-band ||
|
||||||
|
y < b.Min.Y+band || y >= b.Max.Y-band
|
||||||
|
if nearEdge && isRed(x, y) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveShot 把截图写到 VIEWSAGENT_FRAME_SHOT_DIR 指定的目录,方便肉眼核对。
|
||||||
|
func saveShot(t *testing.T, name string, img image.Image) {
|
||||||
|
t.Helper()
|
||||||
|
dir := os.Getenv("VIEWSAGENT_FRAME_SHOT_DIR")
|
||||||
|
if dir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Logf("创建截图目录失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("创建截图文件失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := png.Encode(f, img); err != nil {
|
||||||
|
t.Logf("写入截图失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Logf("截图已保存: %s", path)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ var (
|
|||||||
shcore = windows.NewLazySystemDLL("shcore.dll")
|
shcore = windows.NewLazySystemDLL("shcore.dll")
|
||||||
|
|
||||||
procFindWindowW = user32.NewProc("FindWindowW")
|
procFindWindowW = user32.NewProc("FindWindowW")
|
||||||
|
procFindWindowExW = user32.NewProc("FindWindowExW")
|
||||||
procGetWindowRect = user32.NewProc("GetWindowRect")
|
procGetWindowRect = user32.NewProc("GetWindowRect")
|
||||||
procSetWindowPos = user32.NewProc("SetWindowPos")
|
procSetWindowPos = user32.NewProc("SetWindowPos")
|
||||||
procGetCursorPos = user32.NewProc("GetCursorPos")
|
procGetCursorPos = user32.NewProc("GetCursorPos")
|
||||||
@@ -200,6 +201,19 @@ func FindWindowByTitle(title string) (syscall.Handle, error) {
|
|||||||
return syscall.Handle(r), nil
|
return syscall.Handle(r), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindWindowByClass 按窗口类名查找顶层窗口句柄。
|
||||||
|
func FindWindowByClass(class string) (syscall.Handle, error) {
|
||||||
|
p, err := windows.UTF16PtrFromString(class)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
r, _, e := procFindWindowExW.Call(0, 0, uintptr(unsafe.Pointer(p)), 0)
|
||||||
|
if r == 0 {
|
||||||
|
return 0, fmt.Errorf("未找到类名为 %q 的窗口: %w", class, e)
|
||||||
|
}
|
||||||
|
return syscall.Handle(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
// WindowRect 返回窗口在屏幕上的物理像素矩形。
|
// WindowRect 返回窗口在屏幕上的物理像素矩形。
|
||||||
func WindowRect(hwnd syscall.Handle) (Rect, error) {
|
func WindowRect(hwnd syscall.Handle) (Rect, error) {
|
||||||
var r nativeRect
|
var r nativeRect
|
||||||
|
|||||||
File renamed without changes.
Reference in new issue
Block a user