Files
deskview/internal/ui/chat.go
T
2026-10-27 17:58:28 +08:00

188 lines
4.4 KiB
Go

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()
}