wip chat sessions
This commit is contained in:
21 files changed
+2456
No files matched your search
@@ -0,0 +1,228 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ExportFormat is the text form of an export. The zero value is ExportMarkdown.
|
||||
type ExportFormat int
|
||||
|
||||
const (
|
||||
ExportMarkdown ExportFormat = iota
|
||||
ExportJSON
|
||||
ExportText
|
||||
)
|
||||
|
||||
// ExportRange is which messages an export holds. The zero value is ExportAll.
|
||||
type ExportRange int
|
||||
|
||||
const (
|
||||
ExportAll ExportRange = iota
|
||||
// ExportLast holds the last State.Last messages.
|
||||
ExportLast
|
||||
// ExportSelected holds the messages at Options.Selected.
|
||||
ExportSelected
|
||||
)
|
||||
|
||||
var exportRoles = map[SessionRole]string{SessionUser: "user", SessionAssistant: "assistant", SessionSystem: "system"}
|
||||
|
||||
// ExportConversation writes messages as Markdown, JSON or plain text; it does not parse their text.
|
||||
func ExportConversation(title string, messages []SessionMessage, format ExportFormat) string {
|
||||
var b strings.Builder
|
||||
switch format {
|
||||
case ExportMarkdown:
|
||||
fmt.Fprintf(&b, "# %s\n", title)
|
||||
for _, m := range messages {
|
||||
fmt.Fprintf(&b, "\n**%s**%s\n\n%s\n", exportAuthor(m), exportTime(m, " · "), m.Text)
|
||||
}
|
||||
case ExportJSON:
|
||||
type msgJSON struct {
|
||||
Role string `json:"role"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Text string `json:"text"`
|
||||
Time string `json:"time,omitempty"`
|
||||
}
|
||||
doc := struct {
|
||||
Title string `json:"title"`
|
||||
Messages []msgJSON `json:"messages"`
|
||||
}{Title: title, Messages: []msgJSON{}}
|
||||
for _, m := range messages {
|
||||
doc.Messages = append(doc.Messages, msgJSON{exportRoles[m.Role], m.Author, m.Text, exportTime(m, "")})
|
||||
}
|
||||
out, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
b.Write(out)
|
||||
b.WriteByte('\n')
|
||||
case ExportText:
|
||||
fmt.Fprintf(&b, "%s\n", title)
|
||||
for _, m := range messages {
|
||||
fmt.Fprintf(&b, "\n%s%s:\n%s\n", exportAuthor(m), exportTime(m, " "), m.Text)
|
||||
}
|
||||
default:
|
||||
panic("mujica: unknown ExportFormat")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func exportAuthor(m SessionMessage) string {
|
||||
if m.Author != "" {
|
||||
return m.Author
|
||||
}
|
||||
r, ok := exportRoles[m.Role]
|
||||
if !ok {
|
||||
panic("mujica: SessionMessage Role out of range")
|
||||
}
|
||||
return strings.ToUpper(r[:1]) + r[1:]
|
||||
}
|
||||
|
||||
func exportTime(m SessionMessage, sep string) string {
|
||||
if m.Time.IsZero() {
|
||||
return ""
|
||||
}
|
||||
if sep == "" {
|
||||
return m.Time.Format(time.RFC3339)
|
||||
}
|
||||
return sep + "(" + m.Time.Format("2006-01-02 15:04") + ")"
|
||||
}
|
||||
|
||||
// ConversationExportState is the chosen format and range.
|
||||
type ConversationExportState struct {
|
||||
Format ExportFormat
|
||||
Range ExportRange
|
||||
// Last is the count for ExportLast; zero is 10.
|
||||
Last float64
|
||||
cache conversationExportCache
|
||||
}
|
||||
|
||||
// conversationExportCache holds the last export text until its inputs change.
|
||||
type conversationExportCache struct {
|
||||
key conversationExportInputs
|
||||
content string
|
||||
}
|
||||
|
||||
type conversationExportInputs struct {
|
||||
title string
|
||||
format ExportFormat
|
||||
rng ExportRange
|
||||
last float64
|
||||
first *SessionMessage
|
||||
n int
|
||||
sel string
|
||||
built bool
|
||||
}
|
||||
|
||||
// conversationExportContent rebuilds the export only when its inputs change.
|
||||
func conversationExportContent(s *ConversationExportState, messages []SessionMessage, opts ConversationExportOptions) string {
|
||||
in := conversationExportInputs{title: opts.Title, format: s.Format, rng: s.Range, last: s.Last, n: len(messages), sel: fmt.Sprint(opts.Selected), built: true}
|
||||
if len(messages) > 0 {
|
||||
in.first = &messages[0]
|
||||
}
|
||||
if s.cache.key != in {
|
||||
s.cache = conversationExportCache{in, ExportConversation(opts.Title, conversationExportPick(s, messages, opts.Selected), s.Format)}
|
||||
}
|
||||
return s.cache.content
|
||||
}
|
||||
|
||||
// ConversationExportOptions configure a ConversationExport. Title is required.
|
||||
type ConversationExportOptions struct {
|
||||
Title string
|
||||
// Selected are message indices for ExportSelected.
|
||||
Selected []int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ConversationExportView is a built ConversationExport and the export asked for.
|
||||
type ConversationExportView struct {
|
||||
Element *ui.Element
|
||||
// Exported reports Content is ready for the caller to write, as Format.
|
||||
Exported bool
|
||||
Content string
|
||||
Format ExportFormat
|
||||
}
|
||||
|
||||
// ConversationExport picks a format and range, previews the result and hands the text to the caller to save.
|
||||
func ConversationExport(c *ui.Context, s *ConversationExportState, messages []SessionMessage, opts ConversationExportOptions) ConversationExportView {
|
||||
if s == nil || opts.Title == "" {
|
||||
panic("mujica: ConversationExport needs a state and a Title")
|
||||
}
|
||||
for _, i := range opts.Selected {
|
||||
if i < 0 || i >= len(messages) {
|
||||
panic("mujica: ConversationExport Selected index out of range")
|
||||
}
|
||||
}
|
||||
if s.Last == 0 {
|
||||
s.Last = 10
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
col := ui.Column(c).Gap(d.Unit() * 3).Disabled(opts.Disabled)
|
||||
v := ConversationExportView{Element: col, Format: s.Format}
|
||||
content := conversationExportContent(s, messages, opts)
|
||||
col.Children(func() {
|
||||
FormField(c, msg(c, "conversation_export.format"), FormFieldOptions{}, func() *ui.Element {
|
||||
return RadioGroup(c, &s.Format, []RadioItem[ExportFormat]{
|
||||
{Value: ExportMarkdown, Label: "Markdown"}, {Value: ExportJSON, Label: "JSON"},
|
||||
{Value: ExportText, Label: msg(c, "conversation_export.text")},
|
||||
}, RadioGroupOptions{Horizontal: true, Label: msg(c, "conversation_export.format"), Disabled: opts.Disabled}).Element
|
||||
})
|
||||
FormField(c, msg(c, "conversation_export.range"), FormFieldOptions{}, func() *ui.Element {
|
||||
return ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
|
||||
RadioGroup(c, &s.Range, []RadioItem[ExportRange]{
|
||||
{Value: ExportAll, Label: fmt.Sprintf(msg(c, "conversation_export.all"), len(messages))},
|
||||
{Value: ExportLast, Label: msg(c, "conversation_export.last")},
|
||||
{Value: ExportSelected, Label: fmt.Sprintf(msg(c, "conversation_export.selected"), len(opts.Selected)), Disabled: len(opts.Selected) == 0},
|
||||
}, RadioGroupOptions{Horizontal: true, Label: msg(c, "conversation_export.range"), Disabled: opts.Disabled})
|
||||
if s.Range == ExportLast {
|
||||
NumberInput(c, &s.Last, NumberInputOptions{Min: 1, Max: float64(max(1, len(messages))), Step: 1,
|
||||
Label: msg(c, "conversation_export.last"), Disabled: opts.Disabled}).Element.Width(120)
|
||||
}
|
||||
})
|
||||
})
|
||||
ui.Text(c, msg(c, "conversation_export.preview")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
ui.Scroll(c).MaxHeight(220).Padding(d.Unit()*3).Background(k.Background).Border(theme.BorderWidth, k.Border).
|
||||
Radius(theme.ControlRadius).Children(func() {
|
||||
ui.Text(c, content).Font(theme.MonoFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text).Selectable()
|
||||
})
|
||||
ui.Row(c).Justify(ui.End).Children(func() {
|
||||
v.Exported = Button(c, msg(c, "conversation_export.export"), ButtonOptions{Icon: icons.Must("upload"), Disabled: opts.Disabled}).Clicked()
|
||||
})
|
||||
})
|
||||
if v.Exported && !col.IsDisabled() {
|
||||
v.Content = content
|
||||
slog.Debug("mujica: conversation_export export", "format", s.Format, "bytes", len(content))
|
||||
} else {
|
||||
v.Exported = false
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// conversationExportPick returns the messages the range holds, in order.
|
||||
func conversationExportPick(s *ConversationExportState, messages []SessionMessage, selected []int) []SessionMessage {
|
||||
switch s.Range {
|
||||
case ExportLast:
|
||||
return messages[max(0, len(messages)-int(s.Last)):]
|
||||
case ExportSelected:
|
||||
var out []SessionMessage
|
||||
for i := range messages {
|
||||
for _, j := range selected {
|
||||
if i == j {
|
||||
out = append(out, messages[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return messages
|
||||
}
|
||||
Reference in new issue
Block a user