优化代码
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).
|
||||
# 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"
|
||||
|
||||
$mingw = "C:\msys64\mingw64\bin"
|
||||
$mingw = "E:\Tools\msys64\mingw64\bin"
|
||||
if (Test-Path $mingw) {
|
||||
$env:Path = "$mingw;" + $env:Path
|
||||
}
|
||||
@@ -24,7 +24,7 @@ go test ./...
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "tests failed, build aborted." }
|
||||
|
||||
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." }
|
||||
|
||||
$mb = [math]::Round((Get-Item "bin\viewsagent.exe").Length / 1MB, 1)
|
||||
|
||||
+76
-28
@@ -30,8 +30,9 @@ type State struct {
|
||||
TotalTokens int
|
||||
LastLatency time.Duration
|
||||
Region capture.Region
|
||||
Model string
|
||||
Profile string
|
||||
Model string // 生效接入点里的模型名
|
||||
Profile string // 生效接入点的名字
|
||||
Format string // 生效接入点的接口格式
|
||||
}
|
||||
|
||||
// Sink 是引擎向界面推送事件的回调集合。
|
||||
@@ -39,7 +40,7 @@ type State struct {
|
||||
type Sink interface {
|
||||
OnUserMessage(text string, withImage bool)
|
||||
OnAssistantStart(auto bool)
|
||||
OnAssistantDelta(delta string)
|
||||
OnAssistantDelta(delta llm.Delta)
|
||||
OnAssistantEnd(usage llm.Usage, err error)
|
||||
OnState(state State)
|
||||
}
|
||||
@@ -72,10 +73,12 @@ func New(cfg *config.Config, store *config.Store) *Engine {
|
||||
store: store,
|
||||
region: cfg.Region,
|
||||
}
|
||||
if prov, err := llm.New(toOptions(cfg.Active())); err != nil {
|
||||
slog.Warn("模型接入点不可用", "err", err)
|
||||
} else {
|
||||
e.provider = prov
|
||||
if active, ok := cfg.ActiveModel(); ok {
|
||||
if prov, err := llm.New(toOptions(active)); err != nil {
|
||||
slog.Warn("模型不可用", "err", err)
|
||||
} else {
|
||||
e.provider = prov
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
@@ -83,6 +86,7 @@ func New(cfg *config.Config, store *config.Store) *Engine {
|
||||
func toOptions(p config.Profile) llm.Options {
|
||||
return llm.Options{
|
||||
Name: p.Name,
|
||||
Format: p.Format,
|
||||
BaseURL: p.BaseURL,
|
||||
APIKey: p.APIKey,
|
||||
Model: p.Model,
|
||||
@@ -120,7 +124,7 @@ func (e *Engine) State() State {
|
||||
func (e *Engine) stateLocked() State {
|
||||
active := config.Profile{}
|
||||
if e.cfg != nil {
|
||||
active = e.cfg.Active()
|
||||
active, _ = e.cfg.ActiveModel()
|
||||
}
|
||||
return State{
|
||||
Watching: e.watching,
|
||||
@@ -131,6 +135,7 @@ func (e *Engine) stateLocked() State {
|
||||
Region: e.region,
|
||||
Model: active.Model,
|
||||
Profile: active.Name,
|
||||
Format: active.Format,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,16 +174,28 @@ func (e *Engine) SetRegion(r capture.Region) error {
|
||||
}
|
||||
|
||||
// ApplyConfig 在设置页保存后刷新引擎内部状态。
|
||||
// 接入点还没填完整时不算失败:配置照样生效,等用户补齐后自动恢复。
|
||||
func (e *Engine) ApplyConfig(cfg *config.Config) error {
|
||||
prov, err := llm.New(toOptions(cfg.Active()))
|
||||
if err != nil {
|
||||
return err
|
||||
if cfg == nil {
|
||||
return errors.New("配置为空")
|
||||
}
|
||||
e.mu.Lock()
|
||||
wasWatching := e.watching
|
||||
// 区域只由框选修改,不被设置页的旧副本覆盖。
|
||||
cfg.Region = e.region
|
||||
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.mu.Unlock()
|
||||
|
||||
@@ -192,8 +209,8 @@ func (e *Engine) ApplyConfig(cfg *config.Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PingProfile 用给定接入点做一次连通性测试。
|
||||
func (e *Engine) PingProfile(ctx context.Context, p config.Profile) error {
|
||||
// PingModel 用给定的模型配置做一次连通性测试。
|
||||
func (e *Engine) PingModel(ctx context.Context, p config.Profile) error {
|
||||
prov, err := llm.New(toOptions(p))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -201,6 +218,43 @@ func (e *Engine) PingProfile(ctx context.Context, p config.Profile) error {
|
||||
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 预览(用于界面缩略图)。
|
||||
func (e *Engine) Snapshot() ([]byte, error) {
|
||||
e.mu.Lock()
|
||||
@@ -229,6 +283,9 @@ func (e *Engine) Ask(text string, attach bool) error {
|
||||
|
||||
// StartWatch 开始按帧差自动监听。
|
||||
func (e *Engine) StartWatch() error {
|
||||
if err := e.EnsureReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
e.mu.Lock()
|
||||
if e.watching {
|
||||
e.mu.Unlock()
|
||||
@@ -264,18 +321,6 @@ func (e *Engine) StopWatch() {
|
||||
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 以低频抓屏 + 帧差判断是否需要提问。
|
||||
func (e *Engine) watchLoop(stop chan struct{}) {
|
||||
e.mu.Lock()
|
||||
@@ -378,7 +423,7 @@ func (e *Engine) run(text string, attach, auto bool) error {
|
||||
}
|
||||
if e.provider == nil {
|
||||
e.mu.Unlock()
|
||||
return errors.New("还没有可用的模型接入点,请到设置里填写")
|
||||
return errors.New("还没有接入模型,请到设置里填写")
|
||||
}
|
||||
e.busy = true
|
||||
prov := e.provider
|
||||
@@ -427,8 +472,11 @@ func (e *Engine) run(text string, attach, auto bool) error {
|
||||
|
||||
var reply strings.Builder
|
||||
start := time.Now()
|
||||
usage, err := prov.Chat(ctx, system, msgs, func(delta string) error {
|
||||
reply.WriteString(delta)
|
||||
usage, err := prov.Chat(ctx, system, msgs, func(delta llm.Delta) error {
|
||||
// 思考过程只往界面送,不进历史:历史里留正文就够了。
|
||||
if delta.Text != "" {
|
||||
reply.WriteString(delta.Text)
|
||||
}
|
||||
if sink != nil {
|
||||
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"
|
||||
|
||||
"viewsagent/internal/capture"
|
||||
"viewsagent/internal/llm"
|
||||
)
|
||||
|
||||
// Profile 是一个 OpenAI 兼容协议的模型接入点。
|
||||
// Profile 描述一份模型接入配置。Name 是用户自己起的名字,
|
||||
// 既是左侧列表的显示文本,也是 Config.Active 引用的键。
|
||||
type Profile struct {
|
||||
Name string `json:"name"`
|
||||
Format string `json:"format"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Model string `json:"model"`
|
||||
@@ -22,6 +25,20 @@ type Profile struct {
|
||||
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 是抓屏与触发策略参数。
|
||||
type Capture struct {
|
||||
FPS float64 `json:"fps"` // 监听帧率,1~5
|
||||
@@ -48,8 +65,11 @@ type Template struct {
|
||||
|
||||
// Config 是完整的应用配置。
|
||||
type Config struct {
|
||||
ActiveProfile string `json:"activeProfile"`
|
||||
Profiles []Profile `json:"profiles"`
|
||||
// Models 是全部已保存的模型配置,Active 指向其中生效的那一条。
|
||||
Models []Profile `json:"models"`
|
||||
Active string `json:"active"`
|
||||
// Theme 是界面主题:system 跟随系统、contrast 黑白高对比、light 浅色高对比。
|
||||
Theme string `json:"theme"`
|
||||
Capture Capture `json:"capture"`
|
||||
Hotkeys Hotkeys `json:"hotkeys"`
|
||||
PromptTemplate string `json:"promptTemplate"`
|
||||
@@ -113,32 +133,11 @@ func DefaultTemplates() []Template {
|
||||
}
|
||||
}
|
||||
|
||||
// Default 返回一份可直接使用的默认配置。
|
||||
// Default 返回一份默认配置。
|
||||
// Models 故意留空:由 Normalize 补一个空白条目,这样旧配置文件反序列化后
|
||||
// 仍能通过 len(Models)==0 判断出走迁移分支。
|
||||
func Default() *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{
|
||||
FPS: 2, MaxEdge: 1280, JPEGQuality: 75,
|
||||
DiffThreshold: 5, CooldownMs: 3000, StableFrames: 2, HistoryTurns: 6,
|
||||
@@ -153,25 +152,85 @@ func Default() *Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Profile 按名称查找接入点。
|
||||
func (c *Config) Profile(name string) (Profile, bool) {
|
||||
for _, p := range c.Profiles {
|
||||
if p.Name == name {
|
||||
return p, true
|
||||
// ActiveModel 返回当前生效的模型配置。
|
||||
func (c *Config) ActiveModel() (Profile, bool) {
|
||||
for i := range c.Models {
|
||||
if c.Models[i].Name == c.Active {
|
||||
return c.Models[i], true
|
||||
}
|
||||
}
|
||||
return Profile{}, false
|
||||
}
|
||||
|
||||
// Active 返回当前启用的接入点,找不到时回退到第一个。
|
||||
func (c *Config) Active() Profile {
|
||||
if p, ok := c.Profile(c.ActiveProfile); ok {
|
||||
return p
|
||||
// ModelNames 返回全部模型名字,顺序即列表顺序。
|
||||
func (c *Config) ModelNames() []string {
|
||||
names := make([]string, 0, len(c.Models))
|
||||
for _, m := range c.Models {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
if len(c.Profiles) > 0 {
|
||||
return c.Profiles[0]
|
||||
return names
|
||||
}
|
||||
|
||||
// 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 查找提示词模板。
|
||||
@@ -190,44 +249,66 @@ func (c *Config) Template(key string) Template {
|
||||
// Clone 返回一份深拷贝,避免设置窗口与引擎共享同一份可变配置。
|
||||
func (c *Config) Clone() *Config {
|
||||
if c == nil {
|
||||
return Default()
|
||||
cfg := Default()
|
||||
cfg.Normalize()
|
||||
return cfg
|
||||
}
|
||||
out := *c
|
||||
out.Profiles = append([]Profile(nil), c.Profiles...)
|
||||
for i := range out.Profiles {
|
||||
if c.Profiles[i].ExtraHeaders != nil {
|
||||
h := make(map[string]string, len(c.Profiles[i].ExtraHeaders))
|
||||
for k, v := range c.Profiles[i].ExtraHeaders {
|
||||
out.Templates = append([]Template(nil), c.Templates...)
|
||||
out.Models = make([]Profile, len(c.Models))
|
||||
for i, m := range c.Models {
|
||||
out.Models[i] = m
|
||||
if m.ExtraHeaders != nil {
|
||||
h := make(map[string]string, len(m.ExtraHeaders))
|
||||
for k, v := range m.ExtraHeaders {
|
||||
h[k] = v
|
||||
}
|
||||
out.Profiles[i].ExtraHeaders = h
|
||||
out.Models[i].ExtraHeaders = h
|
||||
}
|
||||
}
|
||||
out.Templates = append([]Template(nil), c.Templates...)
|
||||
return &out
|
||||
}
|
||||
|
||||
// Normalize 补全缺失字段,保证运行期不会出现零值。
|
||||
func (c *Config) Normalize() {
|
||||
def := Default()
|
||||
if len(c.Profiles) == 0 {
|
||||
c.Profiles = def.Profiles
|
||||
if len(c.Models) == 0 {
|
||||
c.Models = []Profile{NewProfile("接入点 1", llm.FormatOpenAI)}
|
||||
}
|
||||
if c.ActiveProfile == "" {
|
||||
c.ActiveProfile = c.Profiles[0].Name
|
||||
}
|
||||
for i := range c.Profiles {
|
||||
p := &c.Profiles[i]
|
||||
if p.MaxTokens <= 0 {
|
||||
p.MaxTokens = 1024
|
||||
for i := range c.Models {
|
||||
m := &c.Models[i]
|
||||
m.Name = strings.TrimSpace(m.Name)
|
||||
if m.Name == "" {
|
||||
m.Name = fmt.Sprintf("接入点 %d", i+1)
|
||||
}
|
||||
if p.Temperature <= 0 {
|
||||
p.Temperature = 0.3
|
||||
if m.Format != llm.FormatAnthropic {
|
||||
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 {
|
||||
c.Templates = def.Templates
|
||||
c.Templates = DefaultTemplates()
|
||||
}
|
||||
if c.Capture.FPS <= 0 {
|
||||
c.Capture.FPS = 2
|
||||
@@ -251,10 +332,15 @@ func (c *Config) Normalize() {
|
||||
c.Capture.HistoryTurns = 6
|
||||
}
|
||||
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 == "" {
|
||||
c.Hotkeys.ToggleWatch = def.Hotkeys.ToggleWatch
|
||||
c.Hotkeys.ToggleWatch = "Ctrl+Alt+Space"
|
||||
}
|
||||
if c.PromptTemplate == "" {
|
||||
c.PromptTemplate = c.Templates[0].Key
|
||||
@@ -300,11 +386,48 @@ func (s *Store) Load() *Config {
|
||||
fresh.Normalize()
|
||||
return fresh
|
||||
}
|
||||
var legacy legacyConfig
|
||||
_ = json.Unmarshal(raw, &legacy)
|
||||
cfg.migrateLegacy(legacy)
|
||||
cfg.Normalize()
|
||||
cfg.migrate()
|
||||
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 把旧版本的默认值升级到当前默认值,避免老配置文件一直沿用已知冲突的热键。
|
||||
func (c *Config) migrate() {
|
||||
if strings.EqualFold(strings.TrimSpace(c.Hotkeys.SelectRegion), "Ctrl+Alt+Q") {
|
||||
|
||||
+183
-19
@@ -1,16 +1,23 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"viewsagent/internal/llm"
|
||||
)
|
||||
|
||||
func TestDefaultIsUsable(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Normalize()
|
||||
|
||||
if len(cfg.Profiles) == 0 {
|
||||
t.Fatal("默认配置应包含接入点")
|
||||
if len(cfg.Models) == 0 {
|
||||
t.Fatal("Normalize 后应至少有一份接入点")
|
||||
}
|
||||
if cfg.Active().Name != "qwen" {
|
||||
t.Fatalf("默认接入点应为 qwen,实际 %s", cfg.Active().Name)
|
||||
if _, ok := cfg.ActiveModel(); !ok {
|
||||
t.Fatal("Normalize 后应有生效接入点")
|
||||
}
|
||||
if cfg.Capture.FPS <= 0 || cfg.Capture.MaxEdge <= 0 || cfg.Capture.CooldownMs <= 0 {
|
||||
t.Fatalf("抓屏参数不应为零值: %+v", cfg.Capture)
|
||||
@@ -27,8 +34,14 @@ func TestNormalizeFillsZeroValues(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
cfg.Normalize()
|
||||
|
||||
if cfg.ActiveProfile == "" {
|
||||
t.Fatal("Normalize 应补全 ActiveProfile")
|
||||
if len(cfg.Models) == 0 {
|
||||
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 {
|
||||
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) {
|
||||
cfg := Default()
|
||||
cfg.Normalize()
|
||||
|
||||
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.Capture.FPS = 9
|
||||
|
||||
if cfg.Profiles[0].Model == "changed" {
|
||||
t.Fatal("Clone 后修改接入点不应影响原配置")
|
||||
if cfg.Models[0].Model == "changed" {
|
||||
t.Fatal("Clone 后修改模型不应影响原配置")
|
||||
}
|
||||
if len(cfg.Models[0].ExtraHeaders) != 0 {
|
||||
t.Fatal("Clone 后修改请求头不应影响原配置")
|
||||
}
|
||||
if cfg.Templates[0].System == "changed" {
|
||||
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.Normalize()
|
||||
|
||||
if _, ok := cfg.Profile("qwen"); !ok {
|
||||
t.Fatal("应能找到 qwen 接入点")
|
||||
added := cfg.AddProfile(NewProfile("接入点", llm.FormatAnthropic))
|
||||
if cfg.ProfileIndex(added.Name) < 0 {
|
||||
t.Fatalf("新增的接入点应能在列表里找到: %+v", cfg.Models)
|
||||
}
|
||||
if _, ok := cfg.Profile("不存在"); ok {
|
||||
t.Fatal("不应找到不存在的接入点")
|
||||
}
|
||||
cfg.ActiveProfile = "不存在"
|
||||
if cfg.Active().Name != cfg.Profiles[0].Name {
|
||||
t.Fatal("接入点丢失时应回退到第一个")
|
||||
again := cfg.AddProfile(NewProfile("接入点", llm.FormatOpenAI))
|
||||
if again.Name == added.Name {
|
||||
t.Fatalf("重名接入点应自动改名: %s", again.Name)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// openAI 是 OpenAI 兼容协议(/chat/completions + SSE)的实现。
|
||||
@@ -25,19 +24,7 @@ func newOpenAI(opts Options) *openAI {
|
||||
if opts.Temperature <= 0 {
|
||||
opts.Temperature = 0.3
|
||||
}
|
||||
return &openAI{
|
||||
opts: opts,
|
||||
http: &http.Client{
|
||||
// 不设总超时:流式响应可能持续很久,由 ctx 控制。
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
MaxIdleConns: 8,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
return &openAI{opts: opts, http: newHTTPClient()}
|
||||
}
|
||||
|
||||
type reqMessage struct {
|
||||
@@ -72,6 +59,10 @@ type streamChunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
// 思考内容:DeepSeek-R1 / Qwen3 等推理模型走 reasoning_content,
|
||||
// 也有网关用 reasoning,两个字段都收。
|
||||
Reasoning string `json:"reasoning_content"`
|
||||
ReasoningAlt string `json:"reasoning"`
|
||||
} `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
@@ -186,6 +177,7 @@ func (o *openAI) Chat(ctx context.Context, system string, msgs []Message, fn Del
|
||||
|
||||
var usage Usage
|
||||
var got strings.Builder
|
||||
var gotReasoning strings.Builder
|
||||
err = readSSE(resp.Body, func(ev sseEvent) error {
|
||||
if len(ev.Data) == 0 || bytes.Equal(ev.Data, []byte("[DONE]")) {
|
||||
return nil
|
||||
@@ -205,12 +197,24 @@ func (o *openAI) Chat(ctx context.Context, system string, msgs []Message, fn Del
|
||||
}
|
||||
}
|
||||
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 == "" {
|
||||
continue
|
||||
}
|
||||
got.WriteString(c.Delta.Content)
|
||||
if fn != nil {
|
||||
if derr := fn(c.Delta.Content); derr != nil {
|
||||
if derr := fn(Delta{Text: c.Delta.Content}); derr != nil {
|
||||
return derr
|
||||
}
|
||||
}
|
||||
@@ -220,7 +224,7 @@ func (o *openAI) Chat(ctx context.Context, system string, msgs []Message, fn Del
|
||||
if err != nil {
|
||||
return usage, err
|
||||
}
|
||||
if got.Len() == 0 {
|
||||
if got.Len() == 0 && gotReasoning.Len() == 0 {
|
||||
return usage, fmt.Errorf("模型没有返回任何内容,请确认 %s 是支持图片输入的视觉模型", o.opts.Model)
|
||||
}
|
||||
return usage, nil
|
||||
@@ -236,6 +240,9 @@ func (o *openAI) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
// 非流式响应里思考内容通常挂在 message 上。
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage *struct {
|
||||
@@ -253,7 +260,11 @@ func (o *openAI) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) {
|
||||
if resp.Error != nil {
|
||||
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("模型返回内容为空")
|
||||
}
|
||||
var usage Usage
|
||||
@@ -264,10 +275,20 @@ func (o *openAI) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) {
|
||||
TotalTokens: resp.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
text := resp.Choices[0].Message.Content
|
||||
if fn != nil {
|
||||
if err := fn(text); err != nil {
|
||||
return usage, err
|
||||
reasoning := msg.ReasoningContent
|
||||
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
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
// Package llm 提供与 OpenAI 兼容接口的大模型通信能力(含视觉与流式)。
|
||||
// Package llm 提供大模型通信能力(含视觉与流式),
|
||||
// 支持 OpenAI Chat Completions 与 Anthropic Messages 两种接口格式。
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 是消息角色。
|
||||
type Role string
|
||||
|
||||
@@ -36,12 +61,21 @@ func (u *Usage) Add(o Usage) {
|
||||
u.TotalTokens += o.TotalTokens
|
||||
}
|
||||
|
||||
// DeltaFunc 接收流式增量文本,返回 error 可中断请求。
|
||||
type DeltaFunc func(delta string) error
|
||||
// Delta 是一次流式增量。
|
||||
// Text 是正式回答,Reasoning 是模型吐出来的思考过程;两者都按原样透传,
|
||||
// 服务端吐什么、界面就显示什么。
|
||||
type Delta struct {
|
||||
Text string
|
||||
Reasoning string
|
||||
}
|
||||
|
||||
// Options 描述一个模型接入点。
|
||||
// DeltaFunc 接收流式增量,返回 error 可中断请求。
|
||||
type DeltaFunc func(delta Delta) error
|
||||
|
||||
// Options 描述一个模型的接入参数。
|
||||
type Options struct {
|
||||
Name string
|
||||
Format string
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Model string
|
||||
@@ -51,7 +85,6 @@ type Options struct {
|
||||
}
|
||||
|
||||
// Provider 是大模型服务的最小抽象。
|
||||
// 目前只有 OpenAI 兼容实现,其它私有协议按此接口新增即可。
|
||||
type Provider interface {
|
||||
// Chat 发起一次流式对话,system 为系统提示词。
|
||||
Chat(ctx context.Context, system string, msgs []Message, fn DeltaFunc) (Usage, error)
|
||||
@@ -59,13 +92,33 @@ type Provider interface {
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
// New 根据接入点创建 Provider。
|
||||
// New 根据模型配置创建 Provider,按 Format 分派到对应协议实现。
|
||||
func New(opts Options) (Provider, error) {
|
||||
if opts.BaseURL == "" {
|
||||
return nil, fmt.Errorf("模型接入点缺少 BaseURL")
|
||||
return nil, fmt.Errorf("模型配置缺少 BaseURL")
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -45,16 +47,19 @@ type App struct {
|
||||
settings *settingsWindow
|
||||
card *floatCard
|
||||
overlay *overlay
|
||||
regionFrm *regionFrame
|
||||
hotkeyStop func()
|
||||
|
||||
// 流式文本合批,避免每个 delta 都触发一次布局
|
||||
pendMu sync.Mutex
|
||||
pending strings.Builder
|
||||
pendingText strings.Builder
|
||||
pendingThink strings.Builder
|
||||
pendScheduled bool
|
||||
|
||||
// 最近一条完整回答,用于同步到浮窗
|
||||
replyMu sync.Mutex
|
||||
lastReply strings.Builder
|
||||
replyMu sync.Mutex
|
||||
lastReply strings.Builder
|
||||
lastCardSync time.Time
|
||||
|
||||
quietModelSel bool
|
||||
}
|
||||
@@ -69,6 +74,8 @@ func Run() error {
|
||||
|
||||
fa := fyneapp.NewWithID("com.viewsagent.desktop")
|
||||
installTranslations()
|
||||
// 主题要在建界面之前定下来,否则首帧会用默认配色渲染。
|
||||
applyThemeMode(cfg.Theme)
|
||||
eng := agent.New(cfg, store)
|
||||
|
||||
a := &App{fyne: fa, eng: eng}
|
||||
@@ -81,6 +88,7 @@ func Run() error {
|
||||
if a.hotkeyStop != nil {
|
||||
a.hotkeyStop()
|
||||
}
|
||||
a.regionFrm.close()
|
||||
eng.Close()
|
||||
}()
|
||||
|
||||
@@ -88,11 +96,15 @@ func Run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeAutoShot 在设置了 VIEWSAGENT_SHOT 时,等界面稳定后把画布导出为 PNG 再退出。
|
||||
// maybeAutoShot 在设置了 VIEWSAGENT_SHOT / VIEWSAGENT_SHOT_SETTINGS / VIEWSAGENT_ASK 时,
|
||||
// 等界面稳定后把画布导出为 PNG 再退出。
|
||||
// VIEWSAGENT_SHOT_TAB 可指定设置窗口要停在哪个标签(如「其它」)。
|
||||
// 只用于开发期核对布局,正常运行不会触发。
|
||||
func (a *App) maybeAutoShot() {
|
||||
path := os.Getenv("VIEWSAGENT_SHOT")
|
||||
if path == "" {
|
||||
settingsPath := os.Getenv("VIEWSAGENT_SHOT_SETTINGS")
|
||||
ask := os.Getenv("VIEWSAGENT_ASK")
|
||||
if path == "" && settingsPath == "" && ask == "" {
|
||||
return
|
||||
}
|
||||
delay := 1500 * time.Millisecond
|
||||
@@ -103,18 +115,46 @@ func (a *App) maybeAutoShot() {
|
||||
}
|
||||
go func() {
|
||||
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() {
|
||||
if img := a.win.Canvas().Capture(); img != nil {
|
||||
if f, err := os.Create(path); err == nil {
|
||||
_ = png.Encode(f, img)
|
||||
_ = f.Close()
|
||||
}
|
||||
if path != "" {
|
||||
writePNG(path, a.win.Canvas().Capture())
|
||||
}
|
||||
if settingsPath != "" {
|
||||
writePNG(settingsPath, a.settings.win.Canvas().Capture())
|
||||
}
|
||||
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() {
|
||||
a.win = a.fyne.NewWindow(mainTitle)
|
||||
a.win.Resize(fyne.NewSize(520, 780))
|
||||
@@ -122,13 +162,16 @@ func (a *App) build() {
|
||||
|
||||
a.chat = newChatView()
|
||||
a.card = newFloatCard(a.fyne)
|
||||
a.regionFrm = newRegionFrame()
|
||||
a.settings = newSettingsWindow(a)
|
||||
|
||||
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.SetSelected(cfg.ActiveProfile)
|
||||
a.quietModelSel = true
|
||||
a.modelSel.SetSelected(cfg.Active)
|
||||
a.quietModelSel = false
|
||||
|
||||
cardBtn := widget.NewButtonWithIcon("", theme.InfoIcon(), a.toggleCard)
|
||||
cardBtn.Importance = widget.LowImportance
|
||||
@@ -136,7 +179,7 @@ func (a *App) build() {
|
||||
settingsBtn.Importance = widget.LowImportance
|
||||
|
||||
toolbar := container.NewHBox(
|
||||
widget.NewLabel("模型"), a.modelSel,
|
||||
widget.NewLabel("接入点"), a.modelSel,
|
||||
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.applyState(a.eng.State())
|
||||
}
|
||||
|
||||
func profileNames(cfg *config.Config) []string {
|
||||
names := make([]string, 0, len(cfg.Profiles))
|
||||
for _, p := range cfg.Profiles {
|
||||
names = append(names, p.Name)
|
||||
// 开发期自检:把取景框内部状态写到文件,方便排查。
|
||||
if p := os.Getenv("VIEWSAGENT_FRAME_DEBUG"); p != "" {
|
||||
go func() {
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
_ = os.WriteFile(p, []byte(a.regionFrm.Debug()), 0o644)
|
||||
}()
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ---------- 用户操作 ----------
|
||||
@@ -195,15 +238,46 @@ func (a *App) send() {
|
||||
return
|
||||
}
|
||||
a.entry.SetText("")
|
||||
if err := a.eng.Ask(text, a.attach.Checked); err != nil {
|
||||
a.chat.addNotice(err.Error())
|
||||
}
|
||||
// 请求必须放到后台:网络请求期间 UI 线程不能被占住,
|
||||
// 否则流式增量只能排队等全部返回后一次性刷新。
|
||||
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() {
|
||||
if err := a.eng.ToggleWatch(); err != nil {
|
||||
a.chat.addNotice(err.Error())
|
||||
if a.eng.State().Watching {
|
||||
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() {
|
||||
@@ -221,23 +295,23 @@ func (a *App) clearChat() {
|
||||
a.chat.addNotice("已清空对话上下文")
|
||||
}
|
||||
|
||||
// onModelChange 切换生效的接入点。
|
||||
func (a *App) onModelChange(name string) {
|
||||
if a.quietModelSel || name == "" {
|
||||
return
|
||||
}
|
||||
cfg := a.eng.Config()
|
||||
if _, ok := cfg.Profile(name); !ok {
|
||||
cfg := a.eng.Config().Clone()
|
||||
if !cfg.SetActive(name) {
|
||||
return
|
||||
}
|
||||
cfg.ActiveProfile = name
|
||||
if err := a.eng.ApplyConfig(cfg); err != nil {
|
||||
a.chat.addNotice("切换模型失败:" + err.Error())
|
||||
a.chat.addNotice("切换接入点失败:" + err.Error())
|
||||
return
|
||||
}
|
||||
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 把引擎里的配置落盘并立即生效。
|
||||
@@ -245,7 +319,7 @@ func (a *App) saveConfig() error {
|
||||
return a.applyAndSave(a.eng.Config())
|
||||
}
|
||||
|
||||
// applyAndSave 保存并应用一份配置(覆盖模型下拉选项)。
|
||||
// applyAndSave 保存并应用一份配置(覆盖接入点下拉选项)。
|
||||
func (a *App) applyAndSave(cfg *config.Config) error {
|
||||
cfg.Normalize()
|
||||
if err := a.eng.Store().Save(cfg); err != nil {
|
||||
@@ -255,9 +329,12 @@ func (a *App) applyAndSave(cfg *config.Config) error {
|
||||
return err
|
||||
}
|
||||
a.quietModelSel = true
|
||||
a.modelSel.Options = profileNames(cfg)
|
||||
a.modelSel.SetSelected(cfg.ActiveProfile)
|
||||
a.modelSel.Options = cfg.ModelNames()
|
||||
a.modelSel.SetSelected(cfg.Active)
|
||||
a.quietModelSel = false
|
||||
// 主题变了就立刻换肤,不用重启。
|
||||
applyThemeMode(cfg.Theme)
|
||||
a.chat.retheme()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -267,6 +344,8 @@ func (a *App) startSelection() {
|
||||
if a.overlay != nil {
|
||||
return
|
||||
}
|
||||
// 框选过程中整屏都是选区浮层,先把常驻红框收起来,免得两层边框打架。
|
||||
a.regionFrm.suspend()
|
||||
// 先隐藏主窗,避免它出现在冻结帧里挡住要框选的内容
|
||||
a.win.Hide()
|
||||
time.Sleep(220 * time.Millisecond)
|
||||
@@ -274,6 +353,7 @@ func (a *App) startSelection() {
|
||||
shot, err := (capture.Screen{}).Desktop()
|
||||
if err != nil {
|
||||
a.win.Show()
|
||||
a.regionFrm.resume(a.eng.Region())
|
||||
a.chat.addNotice("抓屏失败:" + err.Error())
|
||||
return
|
||||
}
|
||||
@@ -286,16 +366,19 @@ func (a *App) onRegionSelected(r capture.Region) {
|
||||
a.overlay = nil
|
||||
a.win.Show()
|
||||
if err := a.eng.SetRegion(r); err != nil {
|
||||
a.regionFrm.resume(a.eng.Region())
|
||||
a.chat.addNotice(err.Error())
|
||||
return
|
||||
}
|
||||
a.card.setRegion(r)
|
||||
a.regionFrm.resume(r)
|
||||
a.chat.addNotice(fmt.Sprintf("已框选监视区域 %s", r))
|
||||
}
|
||||
|
||||
func (a *App) onRegionCancel() {
|
||||
a.overlay = nil
|
||||
a.win.Show()
|
||||
a.regionFrm.resume(a.eng.Region())
|
||||
}
|
||||
|
||||
func (a *App) showPreview() {
|
||||
@@ -361,13 +444,21 @@ func (a *App) OnAssistantStart(auto bool) {
|
||||
}
|
||||
|
||||
// OnAssistantDelta 收到流式增量,40ms 合批后刷新界面。
|
||||
func (a *App) OnAssistantDelta(delta string) {
|
||||
a.replyMu.Lock()
|
||||
a.lastReply.WriteString(delta)
|
||||
a.replyMu.Unlock()
|
||||
// 思考过程与正式回答分两路合批,保证两边的顺序都不乱。
|
||||
func (a *App) OnAssistantDelta(delta llm.Delta) {
|
||||
if delta.Text != "" {
|
||||
a.replyMu.Lock()
|
||||
a.lastReply.WriteString(delta.Text)
|
||||
a.replyMu.Unlock()
|
||||
}
|
||||
|
||||
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 {
|
||||
a.pendMu.Unlock()
|
||||
return
|
||||
@@ -380,15 +471,42 @@ func (a *App) OnAssistantDelta(delta string) {
|
||||
|
||||
func (a *App) flushPending() {
|
||||
a.pendMu.Lock()
|
||||
text := a.pending.String()
|
||||
a.pending.Reset()
|
||||
text := a.pendingText.String()
|
||||
think := a.pendingThink.String()
|
||||
a.pendingText.Reset()
|
||||
a.pendingThink.Reset()
|
||||
a.pendScheduled = false
|
||||
a.pendMu.Unlock()
|
||||
|
||||
if text == "" {
|
||||
if text == "" && think == "" {
|
||||
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 结束一条助手回答。
|
||||
@@ -427,6 +545,8 @@ func (a *App) applyState(st agent.State) {
|
||||
a.regionLbl.SetText("未框选区域")
|
||||
}
|
||||
a.card.setRegion(st.Region)
|
||||
// 屏幕上的红框跟着选区走,始终提示当前盯的是哪一块。
|
||||
a.regionFrm.apply(st.Region)
|
||||
|
||||
if st.Busy {
|
||||
a.sendBtn.Disable()
|
||||
@@ -443,8 +563,16 @@ func (a *App) applyState(st agent.State) {
|
||||
case st.Busy:
|
||||
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",
|
||||
state, st.Model, st.Triggers, st.TotalTokens)
|
||||
state, label, st.Triggers, st.TotalTokens)
|
||||
if st.LastLatency > 0 {
|
||||
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 是一条消息气泡:圆角背景 + 角色标题 + 正文。
|
||||
// 流式阶段用 Label 追加文本,结束后换成 RichText 以获得 Markdown 排版。
|
||||
// 思考过程单独占一块(可折叠),不与正式回答混在一起。
|
||||
type bubble struct {
|
||||
root *fyne.Container
|
||||
bg *canvas.Rectangle
|
||||
body *fyne.Container
|
||||
head *widget.Label
|
||||
kind bubbleKind
|
||||
|
||||
thinkBtn *widget.Button
|
||||
thinkText *widget.RichText
|
||||
thinkBox *fyne.Container
|
||||
think strings.Builder
|
||||
|
||||
live *widget.Label
|
||||
text strings.Builder
|
||||
}
|
||||
@@ -45,7 +61,7 @@ type bubble struct {
|
||||
func newBubble(role string, kind bubbleKind) *bubble {
|
||||
bg := canvas.NewRectangle(colorFor(kind))
|
||||
bg.CornerRadius = 10
|
||||
bg.StrokeColor = colorBorder()
|
||||
bg.StrokeColor = borderFor(kind)
|
||||
bg.StrokeWidth = 1
|
||||
|
||||
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)
|
||||
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 追加流式文本。
|
||||
@@ -76,12 +147,27 @@ func (b *bubble) finish(err error) {
|
||||
}
|
||||
text := strings.TrimSpace(b.text.String())
|
||||
if text == "" {
|
||||
text = "(没有返回内容)"
|
||||
if b.think.Len() > 0 {
|
||||
text = "(模型只给出了思考过程,没有输出回答)"
|
||||
} else {
|
||||
text = "(没有返回内容)"
|
||||
}
|
||||
}
|
||||
rt := widget.NewRichTextFromMarkdown(text)
|
||||
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()
|
||||
// 思考过程默认折起来,把正文让到前面;点标题还能再展开。
|
||||
if b.thinkText != nil && b.thinkText.Visible() {
|
||||
b.toggleThinking()
|
||||
}
|
||||
}
|
||||
|
||||
// innerPadLayout 给气泡留内边距。
|
||||
@@ -113,6 +199,7 @@ type chatView struct {
|
||||
list *fyne.Container
|
||||
scroll *container.Scroll
|
||||
current *bubble
|
||||
bubbles []*bubble
|
||||
}
|
||||
|
||||
func newChatView() *chatView {
|
||||
@@ -127,10 +214,18 @@ func newChatView() *chatView {
|
||||
func (c *chatView) object() fyne.CanvasObject { return c.scroll }
|
||||
|
||||
func (c *chatView) appendBubble(b *bubble) {
|
||||
c.bubbles = append(c.bubbles, b)
|
||||
c.list.Add(b.root)
|
||||
c.scroll.ScrollToBottom()
|
||||
}
|
||||
|
||||
// retheme 主题切换后给所有气泡重新上色。
|
||||
func (c *chatView) retheme() {
|
||||
for _, b := range c.bubbles {
|
||||
b.retheme()
|
||||
}
|
||||
}
|
||||
|
||||
// addUser 添加一条用户消息。
|
||||
func (c *chatView) addUser(text string, withImage bool) {
|
||||
if withImage {
|
||||
@@ -161,6 +256,15 @@ func (c *chatView) appendDelta(delta string) {
|
||||
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 结束当前助手消息。
|
||||
func (c *chatView) endAssistant(err error) {
|
||||
if c.current == nil {
|
||||
@@ -182,6 +286,7 @@ func (c *chatView) addNotice(text string) {
|
||||
// clear 清空消息列表。
|
||||
func (c *chatView) clear() {
|
||||
c.list.Objects = nil
|
||||
c.bubbles = nil
|
||||
c.current = nil
|
||||
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])
|
||||
}
|
||||
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])
|
||||
}
|
||||
o.hint = canvas.NewText("拖拽框选区域 · Esc 取消", color.White)
|
||||
|
||||
+546
-148
@@ -2,8 +2,10 @@ package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,9 +13,11 @@ import (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"viewsagent/internal/config"
|
||||
"viewsagent/internal/llm"
|
||||
)
|
||||
|
||||
const settingsTitle = "viewsagent-settings"
|
||||
@@ -24,14 +28,21 @@ type settingsWindow struct {
|
||||
win fyne.Window
|
||||
cfg *config.Config
|
||||
|
||||
// 模型接入点
|
||||
profileSel *widget.Select
|
||||
pName *widget.Entry
|
||||
pBase *widget.Entry
|
||||
pModel *widget.Entry
|
||||
pKey *widget.Entry
|
||||
pMax *widget.Entry
|
||||
pTemp *widget.Entry
|
||||
// 模型接入:左边是接入点列表,右边是当前接入点的接口详情。
|
||||
profileList *widget.List
|
||||
selIndex int
|
||||
loading bool // 回填表单时抑制回写
|
||||
|
||||
formatSel *widget.Select
|
||||
nameEntry *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
|
||||
@@ -50,18 +61,24 @@ type settingsWindow struct {
|
||||
hkSelect *widget.Entry
|
||||
hkToggle *widget.Entry
|
||||
alwaysTop *widget.Check
|
||||
themeSel *widget.Select
|
||||
|
||||
// tabs 是设置窗口的标签容器,开发期截图时用来切页。
|
||||
tabs *container.AppTabs
|
||||
}
|
||||
|
||||
func newSettingsWindow(app *App) *settingsWindow {
|
||||
s := &settingsWindow{app: app}
|
||||
s.cfg = app.eng.Config().Clone()
|
||||
s.win = app.fyne.NewWindow(settingsTitle)
|
||||
s.win.Resize(fyne.NewSize(620, 620))
|
||||
s.win.SetContent(container.NewAppTabs(
|
||||
container.NewTabItem("模型接入", s.buildProfileTab()),
|
||||
s.win.Resize(fyne.NewSize(900, 700))
|
||||
s.tabs = container.NewAppTabs(
|
||||
container.NewTabItem("模型", s.buildModelTab()),
|
||||
container.NewTabItem("提示词", s.buildTemplateTab()),
|
||||
container.NewTabItem("抓屏与触发", s.buildCaptureTab()),
|
||||
container.NewTabItem("其它", s.buildMiscTab()),
|
||||
))
|
||||
)
|
||||
s.win.SetContent(s.tabs)
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -70,18 +87,32 @@ func (s *settingsWindow) 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 从引擎当前配置重新载入界面。
|
||||
func (s *settingsWindow) reload() {
|
||||
// 用副本编辑,避免与引擎并发读写同一份配置。
|
||||
s.cfg = s.app.eng.Config().Clone()
|
||||
|
||||
names := make([]string, 0, len(s.cfg.Profiles))
|
||||
for _, p := range s.cfg.Profiles {
|
||||
names = append(names, p.Name)
|
||||
s.loading = true
|
||||
s.profileList.Refresh()
|
||||
idx := s.cfg.ProfileIndex(s.cfg.Active)
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
s.profileSel.Options = names
|
||||
s.profileSel.SetSelected(s.cfg.ActiveProfile)
|
||||
s.applyProfile()
|
||||
s.selIndex = idx
|
||||
s.profileList.Select(idx)
|
||||
// 选中的项没变化时 List 不会再回调,这里显式回填一次表单。
|
||||
s.loadProfileToForm()
|
||||
s.loading = false
|
||||
|
||||
keys := make([]string, 0, len(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.hkToggle.SetText(s.cfg.Hotkeys.ToggleWatch)
|
||||
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 {
|
||||
s.profileSel = widget.NewSelect(nil, func(string) { s.applyProfile() })
|
||||
s.profileSel.PlaceHolder = "请选择接入点"
|
||||
s.pName = widget.NewEntry()
|
||||
s.pBase = widget.NewEntry()
|
||||
s.pBase.SetPlaceHolder("https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||
s.pModel = widget.NewEntry()
|
||||
s.pModel.SetPlaceHolder("qwen3-vl-plus")
|
||||
s.pKey = widget.NewPasswordEntry()
|
||||
s.pMax = widget.NewEntry()
|
||||
s.pTemp = widget.NewEntry()
|
||||
// buildModelFields 创建接口详情的输入控件。
|
||||
func (s *settingsWindow) buildModelFields() {
|
||||
s.formatSel = widget.NewSelect(formatLabels(), func(string) { s.onFormatChanged() })
|
||||
s.formatSel.PlaceHolder = "请选择接口格式"
|
||||
|
||||
s.nameEntry = widget.NewEntry()
|
||||
s.nameEntry.SetPlaceHolder("例如:本地 Ollama")
|
||||
s.nameEntry.OnChanged = func(string) { s.commitProfile() }
|
||||
|
||||
s.baseEntry = 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(
|
||||
entry("接入点", s.profileSel),
|
||||
entry("名称", s.pName),
|
||||
entry("BaseURL", s.pBase),
|
||||
entry("模型", s.pModel),
|
||||
entry("API Key", s.pKey),
|
||||
entry("max_tokens", s.pMax),
|
||||
entry("temperature", s.pTemp),
|
||||
entry("接口格式", s.formatSel),
|
||||
entry("配置名称", s.nameEntry),
|
||||
entry("BaseURL", s.baseEntry),
|
||||
entry("模型名", s.modelEntry),
|
||||
container.NewBorder(nil, nil, s.authLabel, nil, s.keyEntry),
|
||||
entry("max_tokens", s.maxEntry),
|
||||
entry("temperature", s.tempEntry),
|
||||
entry("额外请求头", s.headerEntry),
|
||||
widget.NewSeparator(),
|
||||
widget.NewCard("请求协议", "", s.previewHost),
|
||||
)
|
||||
|
||||
addBtn := widget.NewButton("新增接入点", func() {
|
||||
name := strings.TrimSpace(s.pName.Text)
|
||||
if name == "" {
|
||||
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() { s.testConnection() })
|
||||
saveBtn := widget.NewButton("保存并应用", func() { s.saveAll() })
|
||||
bottom := container.NewVBox(widget.NewSeparator(), container.NewHBox(testBtn, saveBtn))
|
||||
|
||||
testBtn := widget.NewButton("测试连接", func() {
|
||||
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),
|
||||
)
|
||||
return container.NewBorder(nil, bottom, 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 {
|
||||
s.tmplSel = widget.NewSelect(nil, func(string) { s.applyTemplate() })
|
||||
s.tmplSel.PlaceHolder = "请选择模板"
|
||||
@@ -193,12 +502,7 @@ func (s *settingsWindow) buildTemplateTab() fyne.CanvasObject {
|
||||
s.tmplSystem.Wrapping = fyne.TextWrapWord
|
||||
|
||||
saveBtn := widget.NewButton("保存提示词", func() {
|
||||
key := s.tmplSel.Selected
|
||||
for i := range s.cfg.Templates {
|
||||
if s.cfg.Templates[i].Key == key {
|
||||
s.cfg.Templates[i].System = s.tmplSystem.Text
|
||||
}
|
||||
}
|
||||
s.commitTemplate()
|
||||
if err := s.app.applyAndSave(s.cfg); err != nil {
|
||||
dialog.ShowError(err, s.win)
|
||||
return
|
||||
@@ -235,12 +539,7 @@ func (s *settingsWindow) buildCaptureTab() fyne.CanvasObject {
|
||||
)
|
||||
|
||||
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)
|
||||
s.saveAll()
|
||||
})
|
||||
|
||||
return container.NewBorder(nil,
|
||||
@@ -254,6 +553,8 @@ func (s *settingsWindow) buildMiscTab() fyne.CanvasObject {
|
||||
s.hkSelect = widget.NewEntry()
|
||||
s.hkToggle = widget.NewEntry()
|
||||
s.alwaysTop = widget.NewCheck("显示置顶答案浮窗", nil)
|
||||
s.themeSel = widget.NewSelect(themeOptions(), func(string) {})
|
||||
s.themeSel.PlaceHolder = "请选择主题"
|
||||
|
||||
openDir := widget.NewButton("打开配置目录", func() {
|
||||
dir := s.app.eng.Store().Dir()
|
||||
@@ -263,20 +564,17 @@ func (s *settingsWindow) buildMiscTab() fyne.CanvasObject {
|
||||
})
|
||||
|
||||
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)
|
||||
s.saveAll()
|
||||
})
|
||||
|
||||
form := container.NewVBox(
|
||||
entry("主题", s.themeSel),
|
||||
entry("框选热键", s.hkSelect),
|
||||
entry("监听开关热键", s.hkToggle),
|
||||
s.alwaysTop,
|
||||
container.NewHBox(openDir),
|
||||
widget.NewLabel("热键语法示例:Ctrl+Alt+Q、Ctrl+Shift+F2"),
|
||||
widget.NewLabel("黑白高对比:纯黑底纯白字,去掉所有彩色,文字与背景对比最强。"),
|
||||
)
|
||||
|
||||
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 把选中的模板填进编辑框。
|
||||
func (s *settingsWindow) applyTemplate() {
|
||||
if s.cfg == nil {
|
||||
@@ -316,35 +597,39 @@ func (s *settingsWindow) applyTemplate() {
|
||||
}
|
||||
}
|
||||
|
||||
// readProfile 用界面上的值构造一个接入点(不落盘)。
|
||||
func (s *settingsWindow) readProfile() config.Profile {
|
||||
return config.Profile{
|
||||
Name: strings.TrimSpace(s.pName.Text),
|
||||
BaseURL: strings.TrimSpace(s.pBase.Text),
|
||||
Model: strings.TrimSpace(s.pModel.Text),
|
||||
APIKey: strings.TrimSpace(s.pKey.Text),
|
||||
MaxTokens: atoiDefault(s.pMax.Text, 1024),
|
||||
Temperature: atofDefault(s.pTemp.Text, 0.3),
|
||||
// ---------- 提交 ----------
|
||||
|
||||
// saveAll 把界面上的所有值写回配置并落盘生效。
|
||||
func (s *settingsWindow) saveAll() {
|
||||
s.commitProfile()
|
||||
s.commitTemplate()
|
||||
s.commitCapture()
|
||||
s.commitMisc()
|
||||
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 把界面上的所有值写回配置对象。
|
||||
func (s *settingsWindow) commitAll() {
|
||||
p := s.readProfile()
|
||||
for i := range s.cfg.Profiles {
|
||||
if s.cfg.Profiles[i].Name == s.profileSel.Selected {
|
||||
p.Name = s.cfg.Profiles[i].Name
|
||||
s.cfg.Profiles[i] = p
|
||||
// commitTemplate 把模板编辑框的内容写回配置。
|
||||
func (s *settingsWindow) commitTemplate() {
|
||||
key := s.tmplSel.Selected
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
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.ActiveProfile = s.profileSel.Selected
|
||||
}
|
||||
|
||||
if key := s.tmplSel.Selected; key != "" {
|
||||
s.cfg.PromptTemplate = key
|
||||
}
|
||||
s.cfg.PromptTemplate = key
|
||||
}
|
||||
|
||||
// commitCapture 把抓屏与触发参数写回配置。
|
||||
func (s *settingsWindow) commitCapture() {
|
||||
s.cfg.Capture.FPS = atofDefault(s.fps.Text, 2)
|
||||
s.cfg.Capture.MaxEdge = atoiDefault(s.maxEdge.Text, 1280)
|
||||
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.StableFrames = atoiDefault(s.stable.Text, 2)
|
||||
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.ToggleWatch = strings.TrimSpace(s.hkToggle.Text)
|
||||
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 {
|
||||
|
||||
+31
-4
@@ -23,19 +23,46 @@ func withAlpha(c color.Color, a uint8) color.Color {
|
||||
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 {
|
||||
return withAlpha(lookupColor(theme.ColorNamePrimary), 0x3A)
|
||||
return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.18)
|
||||
}
|
||||
|
||||
// colorBotBubble 是助手气泡:贴着画布底色,只比背景亮一点点。
|
||||
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 {
|
||||
return withAlpha(lookupColor(theme.ColorNameError), 0x28)
|
||||
return mix(lookupColor(theme.ColorNameBackground), lookupColor(theme.ColorNameForeground), 0.10)
|
||||
}
|
||||
|
||||
// colorBorder 是气泡描边。
|
||||
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")
|
||||
|
||||
procFindWindowW = user32.NewProc("FindWindowW")
|
||||
procFindWindowExW = user32.NewProc("FindWindowExW")
|
||||
procGetWindowRect = user32.NewProc("GetWindowRect")
|
||||
procSetWindowPos = user32.NewProc("SetWindowPos")
|
||||
procGetCursorPos = user32.NewProc("GetCursorPos")
|
||||
@@ -200,6 +201,19 @@ func FindWindowByTitle(title string) (syscall.Handle, error) {
|
||||
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 返回窗口在屏幕上的物理像素矩形。
|
||||
func WindowRect(hwnd syscall.Handle) (Rect, error) {
|
||||
var r nativeRect
|
||||
|
||||
File renamed without changes.
Reference in new issue
Block a user