188 lines
4.4 KiB
Go
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()
|
|
}
|