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