first commit
This commit is contained in:
commit
3553790475
27 files changed
+3839
No files matched your search
@@ -0,0 +1,458 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
fyneapp "fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"viewsagent/internal/agent"
|
||||
"viewsagent/internal/capture"
|
||||
"viewsagent/internal/config"
|
||||
"viewsagent/internal/llm"
|
||||
"viewsagent/internal/win32"
|
||||
)
|
||||
|
||||
const mainTitle = "视界 Agent"
|
||||
|
||||
// App 是桌面端主程序,同时实现 agent.Sink 接收引擎事件。
|
||||
type App struct {
|
||||
fyne fyne.App
|
||||
win fyne.Window
|
||||
eng *agent.Engine
|
||||
|
||||
chat *chatView
|
||||
entry *widget.Entry
|
||||
attach *widget.Check
|
||||
sendBtn *widget.Button
|
||||
watchBtn *widget.Button
|
||||
modelSel *widget.Select
|
||||
regionLbl *widget.Label
|
||||
statusLbl *widget.Label
|
||||
settings *settingsWindow
|
||||
card *floatCard
|
||||
overlay *overlay
|
||||
hotkeyStop func()
|
||||
|
||||
// 流式文本合批,避免每个 delta 都触发一次布局
|
||||
pendMu sync.Mutex
|
||||
pending strings.Builder
|
||||
pendScheduled bool
|
||||
|
||||
// 最近一条完整回答,用于同步到浮窗
|
||||
replyMu sync.Mutex
|
||||
lastReply strings.Builder
|
||||
|
||||
quietModelSel bool
|
||||
}
|
||||
|
||||
// Run 启动整个桌面应用。
|
||||
func Run() error {
|
||||
store, err := config.NewStore()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := store.Load()
|
||||
|
||||
fa := fyneapp.NewWithID("com.viewsagent.desktop")
|
||||
installTranslations()
|
||||
eng := agent.New(cfg, store)
|
||||
|
||||
a := &App{fyne: fa, eng: eng}
|
||||
a.build()
|
||||
eng.SetSink(a)
|
||||
a.startHotkeys()
|
||||
a.maybeAutoShot()
|
||||
|
||||
defer func() {
|
||||
if a.hotkeyStop != nil {
|
||||
a.hotkeyStop()
|
||||
}
|
||||
eng.Close()
|
||||
}()
|
||||
|
||||
a.win.ShowAndRun()
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeAutoShot 在设置了 VIEWSAGENT_SHOT 时,等界面稳定后把画布导出为 PNG 再退出。
|
||||
// 只用于开发期核对布局,正常运行不会触发。
|
||||
func (a *App) maybeAutoShot() {
|
||||
path := os.Getenv("VIEWSAGENT_SHOT")
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
delay := 1500 * time.Millisecond
|
||||
if v := os.Getenv("VIEWSAGENT_SHOT_DELAY"); v != "" {
|
||||
if ms, err := strconv.Atoi(v); err == nil && ms > 0 {
|
||||
delay = time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
a.fyne.Quit()
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) build() {
|
||||
a.win = a.fyne.NewWindow(mainTitle)
|
||||
a.win.Resize(fyne.NewSize(520, 780))
|
||||
a.win.CenterOnScreen()
|
||||
|
||||
a.chat = newChatView()
|
||||
a.card = newFloatCard(a.fyne)
|
||||
a.settings = newSettingsWindow(a)
|
||||
|
||||
cfg := a.eng.Config()
|
||||
|
||||
a.modelSel = widget.NewSelect(profileNames(cfg), a.onModelChange)
|
||||
a.modelSel.PlaceHolder = "请选择接入点"
|
||||
a.modelSel.SetSelected(cfg.ActiveProfile)
|
||||
|
||||
cardBtn := widget.NewButtonWithIcon("", theme.InfoIcon(), a.toggleCard)
|
||||
cardBtn.Importance = widget.LowImportance
|
||||
settingsBtn := widget.NewButtonWithIcon("", theme.SettingsIcon(), a.openSettings)
|
||||
settingsBtn.Importance = widget.LowImportance
|
||||
|
||||
toolbar := container.NewHBox(
|
||||
widget.NewLabel("模型"), a.modelSel,
|
||||
layout.NewSpacer(), cardBtn, settingsBtn,
|
||||
)
|
||||
|
||||
a.regionLbl = widget.NewLabel("未框选区域")
|
||||
a.regionLbl.Importance = widget.LowImportance
|
||||
selectBtn := widget.NewButtonWithIcon("框选", theme.VisibilityOffIcon(), a.startSelection)
|
||||
previewBtn := widget.NewButtonWithIcon("预览", theme.SearchIcon(), a.showPreview)
|
||||
previewBtn.Importance = widget.LowImportance
|
||||
a.watchBtn = widget.NewButtonWithIcon("开始监听", theme.MediaPlayIcon(), a.toggleWatch)
|
||||
a.watchBtn.Importance = widget.HighImportance
|
||||
|
||||
regionRow := container.NewHBox(a.regionLbl, layout.NewSpacer(), previewBtn, selectBtn, a.watchBtn)
|
||||
|
||||
a.entry = widget.NewMultiLineEntry()
|
||||
a.entry.SetPlaceHolder("输入你的要求,例如:请帮我做练习题,实时给出答案")
|
||||
a.entry.SetMinRowsVisible(2)
|
||||
|
||||
a.attach = widget.NewCheck("附带区域截图", nil)
|
||||
a.attach.SetChecked(true)
|
||||
|
||||
a.sendBtn = widget.NewButtonWithIcon("发送", theme.MailSendIcon(), a.send)
|
||||
a.sendBtn.Importance = widget.HighImportance
|
||||
clearBtn := widget.NewButtonWithIcon("", theme.ContentClearIcon(), a.clearChat)
|
||||
clearBtn.Importance = widget.LowImportance
|
||||
|
||||
inputRow := container.NewBorder(nil, nil,
|
||||
container.NewHBox(a.attach),
|
||||
container.NewHBox(clearBtn, a.sendBtn),
|
||||
nil,
|
||||
)
|
||||
|
||||
a.statusLbl = widget.NewLabel("就绪")
|
||||
a.statusLbl.Importance = widget.LowImportance
|
||||
|
||||
top := container.NewVBox(toolbar, regionRow, widget.NewSeparator())
|
||||
bottom := container.NewVBox(widget.NewSeparator(), a.entry, inputRow, a.statusLbl)
|
||||
|
||||
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)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ---------- 用户操作 ----------
|
||||
|
||||
func (a *App) send() {
|
||||
text := strings.TrimSpace(a.entry.Text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
a.entry.SetText("")
|
||||
if err := a.eng.Ask(text, a.attach.Checked); err != nil {
|
||||
a.chat.addNotice(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) toggleWatch() {
|
||||
if err := a.eng.ToggleWatch(); err != nil {
|
||||
a.chat.addNotice(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) toggleCard() {
|
||||
a.card.setRegion(a.eng.Region())
|
||||
a.card.toggle()
|
||||
}
|
||||
|
||||
func (a *App) openSettings() {
|
||||
a.settings.show()
|
||||
}
|
||||
|
||||
func (a *App) clearChat() {
|
||||
a.eng.ResetHistory()
|
||||
a.chat.clear()
|
||||
a.chat.addNotice("已清空对话上下文")
|
||||
}
|
||||
|
||||
func (a *App) onModelChange(name string) {
|
||||
if a.quietModelSel || name == "" {
|
||||
return
|
||||
}
|
||||
cfg := a.eng.Config()
|
||||
if _, ok := cfg.Profile(name); !ok {
|
||||
return
|
||||
}
|
||||
cfg.ActiveProfile = name
|
||||
if err := a.eng.ApplyConfig(cfg); err != nil {
|
||||
a.chat.addNotice("切换模型失败:" + err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.eng.Store().Save(cfg); err != nil {
|
||||
slog.Warn("保存模型选择失败", "err", err)
|
||||
}
|
||||
a.chat.addNotice("已切换模型:" + name)
|
||||
}
|
||||
|
||||
// saveConfig 把引擎里的配置落盘并立即生效。
|
||||
func (a *App) saveConfig() error {
|
||||
return a.applyAndSave(a.eng.Config())
|
||||
}
|
||||
|
||||
// applyAndSave 保存并应用一份配置(覆盖模型下拉选项)。
|
||||
func (a *App) applyAndSave(cfg *config.Config) error {
|
||||
cfg.Normalize()
|
||||
if err := a.eng.Store().Save(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.eng.ApplyConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
a.quietModelSel = true
|
||||
a.modelSel.Options = profileNames(cfg)
|
||||
a.modelSel.SetSelected(cfg.ActiveProfile)
|
||||
a.quietModelSel = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 框选 ----------
|
||||
|
||||
func (a *App) startSelection() {
|
||||
if a.overlay != nil {
|
||||
return
|
||||
}
|
||||
// 先隐藏主窗,避免它出现在冻结帧里挡住要框选的内容
|
||||
a.win.Hide()
|
||||
time.Sleep(220 * time.Millisecond)
|
||||
|
||||
shot, err := (capture.Screen{}).Desktop()
|
||||
if err != nil {
|
||||
a.win.Show()
|
||||
a.chat.addNotice("抓屏失败:" + err.Error())
|
||||
return
|
||||
}
|
||||
vd := win32.VirtualScreen()
|
||||
a.overlay = newOverlay(a.fyne, shot, vd, a.onRegionSelected, a.onRegionCancel)
|
||||
a.overlay.show()
|
||||
}
|
||||
|
||||
func (a *App) onRegionSelected(r capture.Region) {
|
||||
a.overlay = nil
|
||||
a.win.Show()
|
||||
if err := a.eng.SetRegion(r); err != nil {
|
||||
a.chat.addNotice(err.Error())
|
||||
return
|
||||
}
|
||||
a.card.setRegion(r)
|
||||
a.chat.addNotice(fmt.Sprintf("已框选监视区域 %s", r))
|
||||
}
|
||||
|
||||
func (a *App) onRegionCancel() {
|
||||
a.overlay = nil
|
||||
a.win.Show()
|
||||
}
|
||||
|
||||
func (a *App) showPreview() {
|
||||
go func() {
|
||||
raw, err := a.eng.Snapshot()
|
||||
fyne.Do(func() {
|
||||
if err != nil {
|
||||
a.chat.addNotice("预览失败:" + err.Error())
|
||||
return
|
||||
}
|
||||
img := canvas.NewImageFromResource(fyne.NewStaticResource("preview.jpg", raw))
|
||||
img.FillMode = canvas.ImageFillContain
|
||||
img.SetMinSize(fyne.NewSize(520, 300))
|
||||
d := dialog.NewCustom("区域预览", "关闭", img, a.win)
|
||||
d.Resize(fyne.NewSize(560, 380))
|
||||
d.Show()
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// ---------- 热键 ----------
|
||||
|
||||
func (a *App) startHotkeys() {
|
||||
cfg := a.eng.Config()
|
||||
selectSpec := cfg.Hotkeys.SelectRegion
|
||||
toggleSpec := cfg.Hotkeys.ToggleWatch
|
||||
|
||||
stop, warns, err := win32.ListenHotkeys([]string{selectSpec, toggleSpec}, func(spec string) {
|
||||
fyne.Do(func() {
|
||||
switch spec {
|
||||
case selectSpec:
|
||||
a.startSelection()
|
||||
case toggleSpec:
|
||||
a.toggleWatch()
|
||||
}
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("全局热键注册失败", "err", err)
|
||||
a.chat.addNotice("全局热键不可用(请到设置页改快捷键):" + err.Error())
|
||||
return
|
||||
}
|
||||
if len(warns) > 0 {
|
||||
slog.Warn("部分全局热键注册失败", "warnings", warns)
|
||||
a.chat.addNotice("部分热键被其他程序占用,已跳过:" + strings.Join(warns, ";"))
|
||||
}
|
||||
a.hotkeyStop = stop
|
||||
}
|
||||
|
||||
// ---------- agent.Sink ----------
|
||||
|
||||
// OnUserMessage 收到手动提问。
|
||||
func (a *App) OnUserMessage(text string, withImage bool) {
|
||||
fyne.Do(func() { a.chat.addUser(text, withImage) })
|
||||
}
|
||||
|
||||
// OnAssistantStart 开始一条助手回答。
|
||||
func (a *App) OnAssistantStart(auto bool) {
|
||||
a.replyMu.Lock()
|
||||
a.lastReply.Reset()
|
||||
a.replyMu.Unlock()
|
||||
fyne.Do(func() { a.chat.beginAssistant(auto) })
|
||||
}
|
||||
|
||||
// OnAssistantDelta 收到流式增量,40ms 合批后刷新界面。
|
||||
func (a *App) OnAssistantDelta(delta string) {
|
||||
a.replyMu.Lock()
|
||||
a.lastReply.WriteString(delta)
|
||||
a.replyMu.Unlock()
|
||||
|
||||
a.pendMu.Lock()
|
||||
a.pending.WriteString(delta)
|
||||
if a.pendScheduled {
|
||||
a.pendMu.Unlock()
|
||||
return
|
||||
}
|
||||
a.pendScheduled = true
|
||||
a.pendMu.Unlock()
|
||||
|
||||
time.AfterFunc(40*time.Millisecond, a.flushPending)
|
||||
}
|
||||
|
||||
func (a *App) flushPending() {
|
||||
a.pendMu.Lock()
|
||||
text := a.pending.String()
|
||||
a.pending.Reset()
|
||||
a.pendScheduled = false
|
||||
a.pendMu.Unlock()
|
||||
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
fyne.Do(func() { a.chat.appendDelta(text) })
|
||||
}
|
||||
|
||||
// OnAssistantEnd 结束一条助手回答。
|
||||
func (a *App) OnAssistantEnd(usage llm.Usage, err error) {
|
||||
a.flushPending()
|
||||
|
||||
a.replyMu.Lock()
|
||||
text := a.lastReply.String()
|
||||
a.replyMu.Unlock()
|
||||
|
||||
fyne.Do(func() {
|
||||
a.chat.endAssistant(err)
|
||||
if err == nil && a.card.visibleNow() {
|
||||
a.card.update(text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// OnState 同步引擎状态到界面。
|
||||
func (a *App) OnState(st agent.State) {
|
||||
fyne.Do(func() { a.applyState(st) })
|
||||
}
|
||||
|
||||
func (a *App) applyState(st agent.State) {
|
||||
if st.Watching {
|
||||
a.watchBtn.SetText("暂停监听")
|
||||
a.watchBtn.SetIcon(theme.MediaPauseIcon())
|
||||
} else {
|
||||
a.watchBtn.SetText("开始监听")
|
||||
a.watchBtn.SetIcon(theme.MediaPlayIcon())
|
||||
}
|
||||
|
||||
if st.Region.Valid() {
|
||||
a.regionLbl.SetText(fmt.Sprintf("区域 %d × %d", st.Region.W, st.Region.H))
|
||||
} else {
|
||||
a.regionLbl.SetText("未框选区域")
|
||||
}
|
||||
a.card.setRegion(st.Region)
|
||||
|
||||
if st.Busy {
|
||||
a.sendBtn.Disable()
|
||||
} else {
|
||||
a.sendBtn.Enable()
|
||||
}
|
||||
|
||||
state := "就绪"
|
||||
switch {
|
||||
case st.Watching && st.Busy:
|
||||
state = "监听中 · 正在回答"
|
||||
case st.Watching:
|
||||
state = "监听中"
|
||||
case st.Busy:
|
||||
state = "正在回答"
|
||||
}
|
||||
status := fmt.Sprintf("%s · 模型 %s · 自动触发 %d 次 · 累计 %d tokens",
|
||||
state, st.Model, st.Triggers, st.TotalTokens)
|
||||
if st.LastLatency > 0 {
|
||||
status += fmt.Sprintf(" · 上次 %dms", st.LastLatency.Milliseconds())
|
||||
}
|
||||
a.statusLbl.SetText(status)
|
||||
|
||||
if st.Profile != "" && a.modelSel.Selected != st.Profile {
|
||||
a.quietModelSel = true
|
||||
a.modelSel.SetSelected(st.Profile)
|
||||
a.quietModelSel = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"viewsagent/internal/capture"
|
||||
"viewsagent/internal/win32"
|
||||
)
|
||||
|
||||
// cardTitle 是答案浮窗的标题,win32 靠它找 HWND。
|
||||
const cardTitle = "viewsagent-card"
|
||||
|
||||
// floatCard 是贴在选区旁边的置顶小窗,只显示最新答案。
|
||||
type floatCard struct {
|
||||
win fyne.Window
|
||||
body *widget.RichText
|
||||
region capture.Region
|
||||
visible bool
|
||||
}
|
||||
|
||||
func newFloatCard(a fyne.App) *floatCard {
|
||||
c := &floatCard{win: a.NewWindow(cardTitle)}
|
||||
rt := widget.NewRichText()
|
||||
rt.Wrapping = fyne.TextWrapWord
|
||||
c.body = rt
|
||||
|
||||
c.win.SetContent(container.NewBorder(
|
||||
nil, nil, nil, nil,
|
||||
container.NewPadded(rt),
|
||||
))
|
||||
c.win.Resize(fyne.NewSize(400, 240))
|
||||
return c
|
||||
}
|
||||
|
||||
// setRegion 记录当前监视区域,用于定位浮窗。
|
||||
func (c *floatCard) setRegion(r capture.Region) {
|
||||
c.region = r
|
||||
}
|
||||
|
||||
// update 刷新浮窗内容。
|
||||
func (c *floatCard) update(text string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
text = "(空)"
|
||||
}
|
||||
c.body.ParseMarkdown(text)
|
||||
if c.visible {
|
||||
c.place()
|
||||
}
|
||||
}
|
||||
|
||||
// show 显示浮窗。
|
||||
func (c *floatCard) show() {
|
||||
if c.visible {
|
||||
c.place()
|
||||
c.win.RequestFocus()
|
||||
return
|
||||
}
|
||||
c.visible = true
|
||||
c.win.Show()
|
||||
go func() {
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
fyne.Do(c.place)
|
||||
}()
|
||||
}
|
||||
|
||||
// hide 隐藏浮窗。
|
||||
func (c *floatCard) hide() {
|
||||
c.visible = false
|
||||
c.win.Hide()
|
||||
}
|
||||
|
||||
// toggle 切换显示状态。
|
||||
func (c *floatCard) toggle() {
|
||||
if c.visible {
|
||||
c.hide()
|
||||
return
|
||||
}
|
||||
c.show()
|
||||
}
|
||||
|
||||
// visibleNow 返回是否正在显示。
|
||||
func (c *floatCard) visibleNow() bool { return c.visible }
|
||||
|
||||
// place 把浮窗贴到选区下方(放不下就贴上方),并置顶。
|
||||
func (c *floatCard) place() {
|
||||
hwnd, err := win32.FindWindowByTitle(cardTitle)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = win32.SetTopMost(hwnd)
|
||||
|
||||
rect, err := win32.WindowRect(hwnd)
|
||||
if err != nil || rect.Width() <= 0 || rect.Height() <= 0 {
|
||||
return
|
||||
}
|
||||
if !c.region.Valid() {
|
||||
return
|
||||
}
|
||||
mon, _ := win32.MonitorAt(c.region.X+c.region.W/2, c.region.Y+c.region.H/2)
|
||||
work := mon.Work
|
||||
if work.Width() <= 0 {
|
||||
work = mon.Rect
|
||||
}
|
||||
gap := int(8 * max(mon.Scale, 1))
|
||||
|
||||
x := c.region.X
|
||||
y := c.region.Y + c.region.H + gap
|
||||
if y+rect.Height() > work.Bottom {
|
||||
y = c.region.Y - rect.Height() - gap
|
||||
}
|
||||
if y < work.Top {
|
||||
y = work.Bottom - rect.Height()
|
||||
}
|
||||
if x+rect.Width() > work.Right {
|
||||
x = work.Right - rect.Width()
|
||||
}
|
||||
if x < work.Left {
|
||||
x = work.Left
|
||||
}
|
||||
_ = win32.MoveTo(hwnd, win32.Rect{Left: x, Top: y, Right: x + rect.Width(), Bottom: y + rect.Height()})
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
// bubbleKind 区分气泡样式。
|
||||
type bubbleKind int
|
||||
|
||||
const (
|
||||
bubbleUser bubbleKind = iota
|
||||
bubbleBot
|
||||
bubbleError
|
||||
)
|
||||
|
||||
func colorFor(k bubbleKind) color.Color {
|
||||
switch k {
|
||||
case bubbleUser:
|
||||
return colorUserBubble()
|
||||
case bubbleError:
|
||||
return colorErrBubble()
|
||||
default:
|
||||
return colorBotBubble()
|
||||
}
|
||||
}
|
||||
|
||||
// bubble 是一条消息气泡:圆角背景 + 角色标题 + 正文。
|
||||
// 流式阶段用 Label 追加文本,结束后换成 RichText 以获得 Markdown 排版。
|
||||
type bubble struct {
|
||||
root *fyne.Container
|
||||
bg *canvas.Rectangle
|
||||
body *fyne.Container
|
||||
head *widget.Label
|
||||
live *widget.Label
|
||||
text strings.Builder
|
||||
}
|
||||
|
||||
func newBubble(role string, kind bubbleKind) *bubble {
|
||||
bg := canvas.NewRectangle(colorFor(kind))
|
||||
bg.CornerRadius = 10
|
||||
bg.StrokeColor = colorBorder()
|
||||
bg.StrokeWidth = 1
|
||||
|
||||
head := widget.NewLabelWithStyle(role, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
head.Importance = widget.LowImportance
|
||||
|
||||
live := widget.NewLabel("…")
|
||||
live.Wrapping = fyne.TextWrapWord
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
// append 追加流式文本。
|
||||
func (b *bubble) append(delta string) {
|
||||
b.text.WriteString(delta)
|
||||
b.live.SetText(b.text.String())
|
||||
}
|
||||
|
||||
// finish 结束流式状态;err 非空时展示错误。
|
||||
func (b *bubble) finish(err error) {
|
||||
if err != nil {
|
||||
b.live.SetText("⚠ " + err.Error())
|
||||
b.bg.FillColor = colorErrBubble()
|
||||
b.bg.Refresh()
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(b.text.String())
|
||||
if text == "" {
|
||||
text = "(没有返回内容)"
|
||||
}
|
||||
rt := widget.NewRichTextFromMarkdown(text)
|
||||
rt.Wrapping = fyne.TextWrapWord
|
||||
b.body.Objects = []fyne.CanvasObject{b.head, rt}
|
||||
b.body.Refresh()
|
||||
}
|
||||
|
||||
// innerPadLayout 给气泡留内边距。
|
||||
type innerPadLayout struct{}
|
||||
|
||||
func (innerPadLayout) MinSize(objs []fyne.CanvasObject) fyne.Size {
|
||||
// 宽度不参与最小尺寸计算:气泡宽度由滚动区的视口决定,
|
||||
// 否则长文本会把聊天区撑宽,导致换行宽度算大、右侧文字被截断。
|
||||
h := float32(0)
|
||||
for _, o := range objs {
|
||||
h = fyne.Max(h, o.MinSize().Height)
|
||||
}
|
||||
pad := theme.Padding() * 2
|
||||
return fyne.NewSize(0, h+pad)
|
||||
}
|
||||
|
||||
func (innerPadLayout) Layout(objs []fyne.CanvasObject, size fyne.Size) {
|
||||
pad := theme.Padding() * 2
|
||||
w := fyne.Max(size.Width-pad*2, 1)
|
||||
h := fyne.Max(size.Height-pad, 1)
|
||||
for _, o := range objs {
|
||||
o.Move(fyne.NewPos(pad, pad/2))
|
||||
o.Resize(fyne.NewSize(w, h))
|
||||
}
|
||||
}
|
||||
|
||||
// chatView 是消息列表:可滚动,支持流式追加与自动滚底。
|
||||
type chatView struct {
|
||||
list *fyne.Container
|
||||
scroll *container.Scroll
|
||||
current *bubble
|
||||
}
|
||||
|
||||
func newChatView() *chatView {
|
||||
list := container.NewVBox()
|
||||
scroll := container.NewScroll(list)
|
||||
scroll.Direction = container.ScrollVerticalOnly
|
||||
scroll.SetMinSize(fyne.NewSize(380, 300))
|
||||
return &chatView{list: list, scroll: scroll}
|
||||
}
|
||||
|
||||
// object 返回可嵌入界面的对象。
|
||||
func (c *chatView) object() fyne.CanvasObject { return c.scroll }
|
||||
|
||||
func (c *chatView) appendBubble(b *bubble) {
|
||||
c.list.Add(b.root)
|
||||
c.scroll.ScrollToBottom()
|
||||
}
|
||||
|
||||
// addUser 添加一条用户消息。
|
||||
func (c *chatView) addUser(text string, withImage bool) {
|
||||
if withImage {
|
||||
text += "\n(已附带区域截图)"
|
||||
}
|
||||
b := newBubble("你", bubbleUser)
|
||||
b.append(text)
|
||||
b.finish(nil)
|
||||
c.appendBubble(b)
|
||||
}
|
||||
|
||||
// beginAssistant 开始一条助手消息。
|
||||
func (c *chatView) beginAssistant(auto bool) {
|
||||
role := "助手"
|
||||
if auto {
|
||||
role = "助手 · 自动识别"
|
||||
}
|
||||
c.current = newBubble(role, bubbleBot)
|
||||
c.appendBubble(c.current)
|
||||
}
|
||||
|
||||
// appendDelta 追加流式文本。
|
||||
func (c *chatView) appendDelta(delta string) {
|
||||
if c.current == nil {
|
||||
c.beginAssistant(false)
|
||||
}
|
||||
c.current.append(delta)
|
||||
c.scroll.ScrollToBottom()
|
||||
}
|
||||
|
||||
// endAssistant 结束当前助手消息。
|
||||
func (c *chatView) endAssistant(err error) {
|
||||
if c.current == nil {
|
||||
return
|
||||
}
|
||||
c.current.finish(err)
|
||||
c.current = nil
|
||||
c.scroll.ScrollToBottom()
|
||||
}
|
||||
|
||||
// addNotice 插入一条系统提示。
|
||||
func (c *chatView) addNotice(text string) {
|
||||
b := newBubble("系统", bubbleError)
|
||||
b.append(text)
|
||||
b.finish(nil)
|
||||
c.appendBubble(b)
|
||||
}
|
||||
|
||||
// clear 清空消息列表。
|
||||
func (c *chatView) clear() {
|
||||
c.list.Objects = nil
|
||||
c.current = nil
|
||||
c.list.Refresh()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
)
|
||||
|
||||
// zhHansExtra 补上 Fyne 内置中文包里缺失的条目。
|
||||
// widget.NewSelect 在构造时就会取默认占位符,缺翻译会在日志里刷 Translation failure。
|
||||
const zhHansExtra = `{
|
||||
"(Select one)": {"other": "请选择"}
|
||||
}`
|
||||
|
||||
// installTranslations 在创建窗口前调用,注册补充翻译。
|
||||
func installTranslations() {
|
||||
_ = lang.AddTranslationsForLocale([]byte(zhHansExtra), fyne.Locale("zh-Hans"))
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/driver/desktop"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"viewsagent/internal/capture"
|
||||
"viewsagent/internal/win32"
|
||||
)
|
||||
|
||||
// overlayTitle 是框选窗口标题,win32 需要靠它找到 HWND。
|
||||
const overlayTitle = "viewsagent-overlay"
|
||||
|
||||
// overlay 是一个铺满当前显示器的冻结帧框选层。
|
||||
//
|
||||
// 之所以不依赖窗口透明:Windows 下的 OpenGL 窗口透明并不可靠,
|
||||
// 直接用"全屏截图做背景 + 遮罩"视觉等效,还能保证选区与截图完全对齐。
|
||||
type overlay struct {
|
||||
win fyne.Window
|
||||
|
||||
vd win32.Rect
|
||||
mapping capture.Mapping
|
||||
size fyne.Size
|
||||
calibrated bool
|
||||
|
||||
bg *canvas.Image
|
||||
dims [4]*canvas.Rectangle
|
||||
edges [4]*canvas.Rectangle
|
||||
hint *canvas.Text
|
||||
catcher *catcher
|
||||
|
||||
start, cur fyne.Position
|
||||
dragging bool
|
||||
closed bool
|
||||
|
||||
onDone func(capture.Region)
|
||||
onCancel func()
|
||||
}
|
||||
|
||||
// newOverlay 创建框选层。shot 应该是调用前抓好的整屏画面。
|
||||
func newOverlay(app fyne.App, shot image.Image, vd win32.Rect,
|
||||
onDone func(capture.Region), onCancel func()) *overlay {
|
||||
|
||||
o := &overlay{vd: vd, onDone: onDone, onCancel: onCancel}
|
||||
o.win = app.NewWindow(overlayTitle)
|
||||
o.win.SetPadded(false)
|
||||
o.win.SetFullScreen(true)
|
||||
|
||||
o.bg = canvas.NewImageFromImage(shot)
|
||||
o.bg.FillMode = canvas.ImageFillStretch
|
||||
|
||||
objs := []fyne.CanvasObject{o.bg}
|
||||
for i := range o.dims {
|
||||
o.dims[i] = canvas.NewRectangle(color.NRGBA{R: 0, G: 0, B: 0, A: 130})
|
||||
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})
|
||||
objs = append(objs, o.edges[i])
|
||||
}
|
||||
o.hint = canvas.NewText("拖拽框选区域 · Esc 取消", color.White)
|
||||
o.hint.TextStyle = fyne.TextStyle{Bold: true}
|
||||
o.hint.TextSize = 15
|
||||
|
||||
o.catcher = newCatcher(o)
|
||||
objs = append(objs, o.catcher, o.hint)
|
||||
|
||||
o.win.SetContent(container.NewWithoutLayout(objs...))
|
||||
o.win.Canvas().SetOnTypedKey(func(k *fyne.KeyEvent) {
|
||||
if k.Name == fyne.KeyEscape {
|
||||
o.cancel()
|
||||
}
|
||||
})
|
||||
return o
|
||||
}
|
||||
|
||||
// show 显示框选层并做一次坐标校准。
|
||||
func (o *overlay) show() {
|
||||
o.win.Show()
|
||||
// 窗口尺寸在 Show 之后才确定,稍等一拍再校准。
|
||||
go func() {
|
||||
time.Sleep(90 * time.Millisecond)
|
||||
fyne.Do(o.calibrate)
|
||||
}()
|
||||
}
|
||||
|
||||
// calibrate 反推"逻辑坐标 → 物理像素"的比例与原点。
|
||||
// 宁可依赖实际窗口尺寸,也不假设系统缩放,这样 125%/150% 屏都不会偏。
|
||||
func (o *overlay) calibrate() {
|
||||
size := o.win.Canvas().Size()
|
||||
if size.Width < 10 || size.Height < 10 {
|
||||
return
|
||||
}
|
||||
o.size = size
|
||||
|
||||
rect, err := windowRectByTitle(overlayTitle)
|
||||
if err != nil {
|
||||
mon, _ := win32.MonitorAtCursor()
|
||||
rect = mon.Rect
|
||||
}
|
||||
if rect.Width() <= 0 || rect.Height() <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
o.mapping = capture.Mapping{
|
||||
OffsetX: float64(rect.Left),
|
||||
OffsetY: float64(rect.Top),
|
||||
ScaleX: float64(rect.Width()) / float64(size.Width),
|
||||
ScaleY: float64(rect.Height()) / float64(size.Height),
|
||||
}
|
||||
if o.mapping.ScaleX <= 0 || o.mapping.ScaleY <= 0 {
|
||||
o.mapping.ScaleX, o.mapping.ScaleY = 1, 1
|
||||
}
|
||||
|
||||
// 把整张虚拟桌面按当前窗口的实际映射铺到画布上。
|
||||
bx, by, bw, bh := o.mapping.ToLogical(capture.Region{
|
||||
X: o.vd.Left, Y: o.vd.Top, W: o.vd.Width(), H: o.vd.Height(),
|
||||
})
|
||||
o.bg.Move(fyne.NewPos(float32(bx), float32(by)))
|
||||
o.bg.Resize(fyne.NewSize(float32(bw), float32(bh)))
|
||||
|
||||
o.catcher.Move(fyne.NewPos(0, 0))
|
||||
o.catcher.Resize(size)
|
||||
|
||||
o.calibrated = true
|
||||
o.updateSelection()
|
||||
}
|
||||
|
||||
func windowRectByTitle(title string) (win32.Rect, error) {
|
||||
hwnd, err := win32.FindWindowByTitle(title)
|
||||
if err != nil {
|
||||
return win32.Rect{}, err
|
||||
}
|
||||
return win32.WindowRect(hwnd)
|
||||
}
|
||||
|
||||
// selection 返回当前选区(逻辑坐标,已归一化)。
|
||||
func (o *overlay) selection() (x0, y0, x1, y1 float32) {
|
||||
x0, y0 = o.start.X, o.start.Y
|
||||
x1, y1 = o.cur.X, o.cur.Y
|
||||
if x1 < x0 {
|
||||
x0, x1 = x1, x0
|
||||
}
|
||||
if y1 < y0 {
|
||||
y0, y1 = y1, y0
|
||||
}
|
||||
// 限制在窗口内
|
||||
x0 = fyne.Max(x0, 0)
|
||||
y0 = fyne.Max(y0, 0)
|
||||
x1 = fyne.Min(x1, o.size.Width)
|
||||
y1 = fyne.Min(y1, o.size.Height)
|
||||
return
|
||||
}
|
||||
|
||||
// updateSelection 重排遮罩、边框与尺寸提示。
|
||||
func (o *overlay) updateSelection() {
|
||||
if o.size.Width <= 0 {
|
||||
return
|
||||
}
|
||||
w, h := o.size.Width, o.size.Height
|
||||
x0, y0, x1, y1 := o.selection()
|
||||
selW, selH := x1-x0, y1-y0
|
||||
|
||||
if selW < 2 || selH < 2 {
|
||||
// 尚未框选:整屏压暗
|
||||
o.dims[0].Move(fyne.NewPos(0, 0))
|
||||
o.dims[0].Resize(fyne.NewSize(w, h))
|
||||
for i := 1; i < 4; i++ {
|
||||
o.dims[i].Resize(fyne.NewSize(0, 0))
|
||||
}
|
||||
for _, e := range o.edges {
|
||||
e.Resize(fyne.NewSize(0, 0))
|
||||
}
|
||||
o.hint.Text = "拖拽框选区域 · Esc 取消"
|
||||
o.hint.Move(fyne.NewPos(theme.Padding()*2, theme.Padding()*2))
|
||||
o.hint.Refresh()
|
||||
return
|
||||
}
|
||||
|
||||
// 上下左右四块压暗区域,挖出选区
|
||||
o.dims[0].Move(fyne.NewPos(0, 0))
|
||||
o.dims[0].Resize(fyne.NewSize(w, y0))
|
||||
o.dims[1].Move(fyne.NewPos(0, y1))
|
||||
o.dims[1].Resize(fyne.NewSize(w, h-y1))
|
||||
o.dims[2].Move(fyne.NewPos(0, y0))
|
||||
o.dims[2].Resize(fyne.NewSize(x0, selH))
|
||||
o.dims[3].Move(fyne.NewPos(x1, y0))
|
||||
o.dims[3].Resize(fyne.NewSize(w-x1, selH))
|
||||
|
||||
// 选区描边
|
||||
const t = 2
|
||||
o.edges[0].Move(fyne.NewPos(x0, y0))
|
||||
o.edges[0].Resize(fyne.NewSize(selW, t))
|
||||
o.edges[1].Move(fyne.NewPos(x0, y1-t))
|
||||
o.edges[1].Resize(fyne.NewSize(selW, t))
|
||||
o.edges[2].Move(fyne.NewPos(x0, y0))
|
||||
o.edges[2].Resize(fyne.NewSize(t, selH))
|
||||
o.edges[3].Move(fyne.NewPos(x1-t, y0))
|
||||
o.edges[3].Resize(fyne.NewSize(t, selH))
|
||||
|
||||
phys := o.mapping.ToPhysical(float64(x0), float64(y0), float64(selW), float64(selH))
|
||||
o.hint.Text = fmt.Sprintf("%d × %d", phys.W, phys.H)
|
||||
hy := y0 - 26
|
||||
if hy < 4 {
|
||||
hy = y0 + 6
|
||||
}
|
||||
o.hint.Move(fyne.NewPos(x0+6, hy))
|
||||
o.hint.Refresh()
|
||||
}
|
||||
|
||||
// finish 确认选区。
|
||||
func (o *overlay) finish() {
|
||||
if !o.calibrated {
|
||||
o.calibrate()
|
||||
}
|
||||
x0, y0, x1, y1 := o.selection()
|
||||
if x1-x0 < 6 || y1-y0 < 6 {
|
||||
o.cancel()
|
||||
return
|
||||
}
|
||||
r := o.mapping.ToPhysical(float64(x0), float64(y0), float64(x1-x0), float64(y1-y0))
|
||||
if r.W < 8 || r.H < 8 {
|
||||
o.cancel()
|
||||
return
|
||||
}
|
||||
done := o.onDone
|
||||
o.close()
|
||||
if done != nil {
|
||||
done(r)
|
||||
}
|
||||
}
|
||||
|
||||
// cancel 取消框选。
|
||||
func (o *overlay) cancel() {
|
||||
cancel := o.onCancel
|
||||
o.close()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (o *overlay) close() {
|
||||
if o.closed {
|
||||
return
|
||||
}
|
||||
o.closed = true
|
||||
o.win.Close()
|
||||
}
|
||||
|
||||
// catcher 是铺满窗口的事件接收层,负责鼠标拖拽。
|
||||
type catcher struct {
|
||||
widget.BaseWidget
|
||||
o *overlay
|
||||
}
|
||||
|
||||
func newCatcher(o *overlay) *catcher {
|
||||
c := &catcher{o: o}
|
||||
c.ExtendBaseWidget(c)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *catcher) CreateRenderer() fyne.WidgetRenderer {
|
||||
return widget.NewSimpleRenderer(canvas.NewRectangle(color.Transparent))
|
||||
}
|
||||
|
||||
func (c *catcher) Dragged(e *fyne.DragEvent) {
|
||||
if !c.o.dragging {
|
||||
return
|
||||
}
|
||||
c.o.cur = e.Position
|
||||
c.o.updateSelection()
|
||||
}
|
||||
|
||||
func (c *catcher) DragEnd() {}
|
||||
|
||||
func (c *catcher) MouseDown(e *desktop.MouseEvent) {
|
||||
c.o.dragging = true
|
||||
c.o.start = e.Position
|
||||
c.o.cur = e.Position
|
||||
c.o.updateSelection()
|
||||
}
|
||||
|
||||
func (c *catcher) MouseUp(e *desktop.MouseEvent) {
|
||||
if !c.o.dragging {
|
||||
return
|
||||
}
|
||||
c.o.dragging = false
|
||||
c.o.cur = e.Position
|
||||
c.o.finish()
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"viewsagent/internal/config"
|
||||
)
|
||||
|
||||
const settingsTitle = "viewsagent-settings"
|
||||
|
||||
// settingsWindow 是模型、触发策略、提示词与热键的设置面板。
|
||||
type settingsWindow struct {
|
||||
app *App
|
||||
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
|
||||
|
||||
// 提示词
|
||||
tmplSel *widget.Select
|
||||
tmplSystem *widget.Entry
|
||||
|
||||
// 抓屏与触发
|
||||
fps *widget.Entry
|
||||
maxEdge *widget.Entry
|
||||
quality *widget.Entry
|
||||
diff *widget.Entry
|
||||
cooldown *widget.Entry
|
||||
stable *widget.Entry
|
||||
turns *widget.Entry
|
||||
|
||||
// 热键与其它
|
||||
hkSelect *widget.Entry
|
||||
hkToggle *widget.Entry
|
||||
alwaysTop *widget.Check
|
||||
}
|
||||
|
||||
func newSettingsWindow(app *App) *settingsWindow {
|
||||
s := &settingsWindow{app: app}
|
||||
s.win = app.fyne.NewWindow(settingsTitle)
|
||||
s.win.Resize(fyne.NewSize(620, 620))
|
||||
s.win.SetContent(container.NewAppTabs(
|
||||
container.NewTabItem("模型接入", s.buildProfileTab()),
|
||||
container.NewTabItem("提示词", s.buildTemplateTab()),
|
||||
container.NewTabItem("抓屏与触发", s.buildCaptureTab()),
|
||||
container.NewTabItem("其它", s.buildMiscTab()),
|
||||
))
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *settingsWindow) show() {
|
||||
s.reload()
|
||||
s.win.Show()
|
||||
}
|
||||
|
||||
// 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.profileSel.Options = names
|
||||
s.profileSel.SetSelected(s.cfg.ActiveProfile)
|
||||
s.applyProfile()
|
||||
|
||||
keys := make([]string, 0, len(s.cfg.Templates))
|
||||
for _, t := range s.cfg.Templates {
|
||||
keys = append(keys, t.Key)
|
||||
}
|
||||
s.tmplSel.Options = keys
|
||||
s.tmplSel.SetSelected(s.cfg.PromptTemplate)
|
||||
s.applyTemplate()
|
||||
|
||||
c := s.cfg.Capture
|
||||
s.fps.SetText(fmt.Sprintf("%g", c.FPS))
|
||||
s.maxEdge.SetText(strconv.Itoa(c.MaxEdge))
|
||||
s.quality.SetText(strconv.Itoa(c.JPEGQuality))
|
||||
s.diff.SetText(strconv.Itoa(c.DiffThreshold))
|
||||
s.cooldown.SetText(strconv.Itoa(c.CooldownMs))
|
||||
s.stable.SetText(strconv.Itoa(c.StableFrames))
|
||||
s.turns.SetText(strconv.Itoa(c.HistoryTurns))
|
||||
|
||||
s.hkSelect.SetText(s.cfg.Hotkeys.SelectRegion)
|
||||
s.hkToggle.SetText(s.cfg.Hotkeys.ToggleWatch)
|
||||
s.alwaysTop.SetChecked(s.cfg.AlwaysOnTopCard)
|
||||
}
|
||||
|
||||
func entry(label string, obj fyne.CanvasObject) fyne.CanvasObject {
|
||||
return container.NewBorder(nil, nil, widget.NewLabel(label), nil, obj)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
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() {
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *settingsWindow) buildTemplateTab() fyne.CanvasObject {
|
||||
s.tmplSel = widget.NewSelect(nil, func(string) { s.applyTemplate() })
|
||||
s.tmplSel.PlaceHolder = "请选择模板"
|
||||
s.tmplSystem = widget.NewMultiLineEntry()
|
||||
s.tmplSystem.SetMinRowsVisible(14)
|
||||
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
|
||||
}
|
||||
}
|
||||
if err := s.app.applyAndSave(s.cfg); err != nil {
|
||||
dialog.ShowError(err, s.win)
|
||||
return
|
||||
}
|
||||
dialog.ShowInformation("已保存", "提示词已更新", s.win)
|
||||
})
|
||||
|
||||
return container.NewBorder(
|
||||
container.NewVBox(entry("模板", s.tmplSel), widget.NewSeparator()),
|
||||
container.NewVBox(widget.NewSeparator(), container.NewHBox(saveBtn)),
|
||||
nil, nil,
|
||||
s.tmplSystem,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *settingsWindow) buildCaptureTab() fyne.CanvasObject {
|
||||
s.fps = widget.NewEntry()
|
||||
s.maxEdge = widget.NewEntry()
|
||||
s.quality = widget.NewEntry()
|
||||
s.diff = widget.NewEntry()
|
||||
s.cooldown = widget.NewEntry()
|
||||
s.stable = widget.NewEntry()
|
||||
s.turns = widget.NewEntry()
|
||||
|
||||
form := container.NewVBox(
|
||||
entry("监听帧率 (fps)", s.fps),
|
||||
entry("图片长边上限", s.maxEdge),
|
||||
entry("JPEG 质量", s.quality),
|
||||
entry("变化阈值", s.diff),
|
||||
entry("冷却时间 (ms)", s.cooldown),
|
||||
entry("稳定帧数", s.stable),
|
||||
entry("历史轮数", s.turns),
|
||||
widget.NewLabel("说明:变化阈值越小越灵敏(默认 5);冷却时间决定两次自动提问的最小间隔。"),
|
||||
)
|
||||
|
||||
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(saveBtn)),
|
||||
nil, nil,
|
||||
container.NewVScroll(form),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *settingsWindow) buildMiscTab() fyne.CanvasObject {
|
||||
s.hkSelect = widget.NewEntry()
|
||||
s.hkToggle = widget.NewEntry()
|
||||
s.alwaysTop = widget.NewCheck("显示置顶答案浮窗", nil)
|
||||
|
||||
openDir := widget.NewButton("打开配置目录", func() {
|
||||
dir := s.app.eng.Store().Dir()
|
||||
if err := exec.Command("explorer", dir).Start(); err != nil {
|
||||
dialog.ShowError(err, 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)
|
||||
})
|
||||
|
||||
form := container.NewVBox(
|
||||
entry("框选热键", s.hkSelect),
|
||||
entry("监听开关热键", s.hkToggle),
|
||||
s.alwaysTop,
|
||||
container.NewHBox(openDir),
|
||||
widget.NewLabel("热键语法示例:Ctrl+Alt+Q、Ctrl+Shift+F2"),
|
||||
)
|
||||
|
||||
return container.NewBorder(nil,
|
||||
container.NewVBox(widget.NewSeparator(), container.NewHBox(saveBtn)),
|
||||
nil, nil,
|
||||
form,
|
||||
)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
for _, t := range s.cfg.Templates {
|
||||
if t.Key == s.tmplSel.Selected {
|
||||
s.tmplSystem.SetText(t.System)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
if s.profileSel.Selected != "" {
|
||||
s.cfg.ActiveProfile = s.profileSel.Selected
|
||||
}
|
||||
|
||||
if key := s.tmplSel.Selected; key != "" {
|
||||
s.cfg.PromptTemplate = key
|
||||
}
|
||||
|
||||
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)
|
||||
s.cfg.Capture.DiffThreshold = atoiDefault(s.diff.Text, 5)
|
||||
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)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func atoiDefault(s string, def int) int {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func atofDefault(s string, def float64) float64 {
|
||||
v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
// lookupColor 从当前主题取色。
|
||||
func lookupColor(name fyne.ThemeColorName) color.Color {
|
||||
if app := fyne.CurrentApp(); app != nil {
|
||||
s := app.Settings()
|
||||
return s.Theme().Color(name, s.ThemeVariant())
|
||||
}
|
||||
return theme.DefaultTheme().Color(name, theme.VariantDark)
|
||||
}
|
||||
|
||||
// withAlpha 调整颜色透明度。
|
||||
func withAlpha(c color.Color, a uint8) color.Color {
|
||||
n := color.NRGBAModel.Convert(c).(color.NRGBA)
|
||||
n.A = a
|
||||
return n
|
||||
}
|
||||
|
||||
// 界面配色集中在这里,方便统一调整。
|
||||
func colorUserBubble() color.Color {
|
||||
return withAlpha(lookupColor(theme.ColorNamePrimary), 0x3A)
|
||||
}
|
||||
|
||||
func colorBotBubble() color.Color {
|
||||
return withAlpha(lookupColor(theme.ColorNameInputBackground), 0xF2)
|
||||
}
|
||||
|
||||
func colorErrBubble() color.Color {
|
||||
return withAlpha(lookupColor(theme.ColorNameError), 0x28)
|
||||
}
|
||||
|
||||
func colorBorder() color.Color {
|
||||
return withAlpha(lookupColor(theme.ColorNameInputBorder), 0xB0)
|
||||
}
|
||||
Reference in new issue
Block a user