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