293 lines
7.1 KiB
Go
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()
|
|
}
|