Files
2026-10-28 08:47:09 +08:00

293 lines
7.1 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()
}
}
// 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
}
func newBubble(role string, kind bubbleKind) *bubble {
bg := canvas.NewRectangle(colorFor(kind))
bg.CornerRadius = 10
bg.StrokeColor = borderFor(kind)
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, 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 追加流式文本。
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 == "" {
if b.think.Len() > 0 {
text = "(模型只给出了思考过程,没有输出回答)"
} else {
text = "(没有返回内容)"
}
}
rt := widget.NewRichTextFromMarkdown(text)
rt.Wrapping = fyne.TextWrapWord
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 给气泡留内边距。
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
bubbles []*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.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 {
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()
}
// 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 {
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.bubbles = nil
c.current = nil
c.list.Refresh()
}