wip chat sessions
This commit is contained in:
21 files changed
+2456
No files matched your search
@@ -0,0 +1,84 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/icons"
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtifactKind is what an artifact holds. The zero value is ArtifactCode.
|
||||||
|
type ArtifactKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ArtifactCode ArtifactKind = iota
|
||||||
|
ArtifactDocument
|
||||||
|
ArtifactWeb
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtifactCardOptions configure an ArtifactCard. Title is required.
|
||||||
|
type ArtifactCardOptions struct {
|
||||||
|
Kind ArtifactKind
|
||||||
|
Title string
|
||||||
|
// Detail is a short fact, such as "Go · 120 lines".
|
||||||
|
Detail string
|
||||||
|
// Streaming shows the artifact is still being written.
|
||||||
|
Streaming bool
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var artifactKinds = [3]struct{ icon, name string }{
|
||||||
|
{"file", "artifact_card.code"}, {"file-text", "artifact_card.document"}, {"layout-grid", "artifact_card.web"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactCard stands for an artifact inside a message; a click or Enter asks to open the artifact panel.
|
||||||
|
func ArtifactCard(c *ui.Context, opts ArtifactCardOptions) ChoiceResult {
|
||||||
|
if opts.Title == "" {
|
||||||
|
panic("mujica: ArtifactCard needs a Title")
|
||||||
|
}
|
||||||
|
if opts.Kind < ArtifactCode || opts.Kind > ArtifactWeb {
|
||||||
|
panic("mujica: ArtifactCard Kind out of range")
|
||||||
|
}
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
kind := artifactKinds[opts.Kind]
|
||||||
|
var b *ui.Element
|
||||||
|
ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() {
|
||||||
|
b = ui.ButtonBase(c).MinWidth(260).MaxWidth(420).MinHeight(56).Padding(d.Unit()*2, d.Unit()*3).Gap(d.Unit()*3).
|
||||||
|
Justify(ui.Start).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).
|
||||||
|
Label(msg(c, kind.name) + ": " + opts.Title)
|
||||||
|
})
|
||||||
|
internal.Ring(b, k.Focus, theme.CardRadius)
|
||||||
|
if b.Hovered() {
|
||||||
|
b.Background(k.SurfaceHover)
|
||||||
|
}
|
||||||
|
b.Transition(colorFade)
|
||||||
|
b.Children(func() {
|
||||||
|
ui.Box(c).Size(36, 36).Shrink(0).Center().Radius(theme.ControlRadius).Background(k.Selection).Children(func() {
|
||||||
|
ui.Icon(c, icons.Must(kind.icon)).Size(18, 18).TextColor(k.AccentText)
|
||||||
|
})
|
||||||
|
ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() {
|
||||||
|
ui.Text(c, opts.Title).SingleLine().Bold().TextColor(k.Text)
|
||||||
|
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() {
|
||||||
|
detail := msg(c, kind.name)
|
||||||
|
if opts.Detail != "" {
|
||||||
|
detail += " · " + opts.Detail
|
||||||
|
}
|
||||||
|
ui.Text(c, detail).SingleLine().FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||||
|
if opts.Streaming {
|
||||||
|
Spinner(c, SpinnerOptions{Size: 12, Label: msg(c, "artifact_card.writing")})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
ui.Text(c, msg(c, "artifact_card.open")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText).Shrink(0)
|
||||||
|
ui.Icon(c, icons.Must("chevron-right")).Size(14, 14).TextColor(k.AccentText)
|
||||||
|
})
|
||||||
|
open := b.Clicked() && !b.IsDisabled()
|
||||||
|
if open {
|
||||||
|
slog.Debug("mujica: artifact_card open", "kind", opts.Kind)
|
||||||
|
}
|
||||||
|
return ChoiceResult{Element: b, changed: open}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CapabilityCard explains one thing the assistant can do, with example prompts.
|
||||||
|
type CapabilityCard struct {
|
||||||
|
Title, Description string
|
||||||
|
Icon *ui.SVG
|
||||||
|
Examples []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapabilityCardsOptions configure CapabilityCards.
|
||||||
|
type CapabilityCardsOptions struct {
|
||||||
|
// Columns is the most cards per row; zero is 3.
|
||||||
|
Columns int
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapabilityCardsView is built CapabilityCards and the example clicked this frame.
|
||||||
|
type CapabilityCardsView struct {
|
||||||
|
Element *ui.Element
|
||||||
|
// Example is the example prompt chosen, empty for none.
|
||||||
|
Example string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapabilityCards shows what the assistant can do; clicking an example hands it to the caller.
|
||||||
|
func CapabilityCards(c *ui.Context, cards []CapabilityCard, opts CapabilityCardsOptions) CapabilityCardsView {
|
||||||
|
if opts.Columns < 0 {
|
||||||
|
panic("mujica: CapabilityCards Columns is negative")
|
||||||
|
}
|
||||||
|
cols := opts.Columns
|
||||||
|
if cols == 0 {
|
||||||
|
cols = 3
|
||||||
|
}
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
g := ui.Grid(c).Columns(cols).Gap(d.Unit() * 3).Disabled(opts.Disabled)
|
||||||
|
v := CapabilityCardsView{Element: g}
|
||||||
|
g.Children(func() {
|
||||||
|
for i, card := range cards {
|
||||||
|
if card.Title == "" {
|
||||||
|
panic("mujica: CapabilityCard needs a Title")
|
||||||
|
}
|
||||||
|
box := ui.Column(c).Key(i).Gap(d.Unit()*2).Padding(d.Unit()*4).Background(k.Surface).
|
||||||
|
Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).Role(ui.RoleGroup).Label(card.Title)
|
||||||
|
box.Children(func() {
|
||||||
|
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||||
|
if card.Icon != nil {
|
||||||
|
ui.Icon(c, card.Icon).Size(20, 20).TextColor(k.AccentText)
|
||||||
|
}
|
||||||
|
ui.Text(c, card.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||||
|
})
|
||||||
|
if card.Description != "" {
|
||||||
|
ui.Text(c, card.Description).TextColor(k.TextMuted)
|
||||||
|
}
|
||||||
|
ui.Column(c).Gap(d.Unit()).FocusGroup(ui.Vertical).Children(func() {
|
||||||
|
for _, ex := range card.Examples {
|
||||||
|
if capabilityExample(c, k, ex) {
|
||||||
|
v.Example = ex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if v.Example != "" {
|
||||||
|
slog.Debug("mujica: capability_cards example", "len", len(v.Example))
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// capabilityExample is a quoted example prompt button; it reports a click.
|
||||||
|
func capabilityExample(c *ui.Context, k theme.Tokens, text string) bool {
|
||||||
|
d := Density(c)
|
||||||
|
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*3).Justify(ui.Start).
|
||||||
|
Radius(theme.ControlRadius).Background(k.Background).Border(theme.BorderWidth, k.Border).Label(text)
|
||||||
|
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||||
|
switch {
|
||||||
|
case b.Pressed():
|
||||||
|
b.Background(k.Selection)
|
||||||
|
case b.Hovered():
|
||||||
|
b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.ControlBorder)
|
||||||
|
}
|
||||||
|
b.Transition(colorFade)
|
||||||
|
b.Children(func() { ui.Text(c, "“"+text+"”").TextColor(k.Text) })
|
||||||
|
return b.Clicked() && !b.IsDisabled()
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/icons"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatEmptyKind is why a chat view is empty. The zero value is ChatEmptyNew.
|
||||||
|
type ChatEmptyKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ChatEmptyNew: a new conversation with no messages.
|
||||||
|
ChatEmptyNew ChatEmptyKind = iota
|
||||||
|
// ChatEmptyNoResults: a conversation search matched nothing.
|
||||||
|
ChatEmptyNoResults
|
||||||
|
// ChatEmptyOffline: the connection is down.
|
||||||
|
ChatEmptyOffline
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatEmptyStateOptions configure a ChatEmptyState; empty texts use the kind's built-in copy.
|
||||||
|
type ChatEmptyStateOptions struct {
|
||||||
|
Kind ChatEmptyKind
|
||||||
|
// Query, for ChatEmptyNoResults, is named in the description.
|
||||||
|
Query string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
// Action replaces the kind's button text: Start, Clear search or Retry.
|
||||||
|
Action string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatEmptyState explains an empty chat (new, no results, offline) and offers the next step.
|
||||||
|
func ChatEmptyState(c *ui.Context, opts ChatEmptyStateOptions) EmptyStateResult {
|
||||||
|
switch opts.Kind {
|
||||||
|
case ChatEmptyNew:
|
||||||
|
return EmptyState(c, EmptyStateOptions{
|
||||||
|
Title: cmp.Or(opts.Title, msg(c, "chat_empty_state.new.title")),
|
||||||
|
Description: cmp.Or(opts.Description, msg(c, "chat_empty_state.new.description")),
|
||||||
|
Icon: icons.Must("star"),
|
||||||
|
Action: cmp.Or(opts.Action, msg(c, "chat_empty_state.new.action")),
|
||||||
|
})
|
||||||
|
case ChatEmptyNoResults:
|
||||||
|
return EmptyState(c, EmptyStateOptions{
|
||||||
|
Kind: EmptyNoResults, Query: opts.Query, Title: opts.Title, Description: opts.Description,
|
||||||
|
Action: cmp.Or(opts.Action, msg(c, "chat_empty_state.no_results.action")),
|
||||||
|
})
|
||||||
|
case ChatEmptyOffline:
|
||||||
|
return EmptyState(c, EmptyStateOptions{
|
||||||
|
Title: cmp.Or(opts.Title, msg(c, "chat_empty_state.offline.title")),
|
||||||
|
Description: cmp.Or(opts.Description, msg(c, "chat_empty_state.offline.description")),
|
||||||
|
Icon: icons.Must("wifi-off"),
|
||||||
|
Action: cmp.Or(opts.Action, msg(c, "chat_empty_state.offline.action")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
panic("mujica: ChatEmptyState Kind out of range")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/icons"
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConversationItemOptions configure a ConversationItem.
|
||||||
|
type ConversationItemOptions struct {
|
||||||
|
// Today decides the time stamp's form; zero uses the window clock.
|
||||||
|
Today Date
|
||||||
|
Selected bool
|
||||||
|
Disabled bool
|
||||||
|
// Draggable lets the row be dragged onto a ProjectList as a ConversationDrag.
|
||||||
|
Draggable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationDrag is the value a dragged conversation carries.
|
||||||
|
type ConversationDrag struct{ ID string }
|
||||||
|
|
||||||
|
// ConversationItemView is a built ConversationItem and what the user asked for this frame.
|
||||||
|
type ConversationItemView struct {
|
||||||
|
Element *ui.Element
|
||||||
|
// Opened, Deleted, Exported and PinToggled are requests for the caller to carry out.
|
||||||
|
Opened, Deleted, Exported, PinToggled bool
|
||||||
|
// Renamed reports a confirmed new title, already in conv.Title.
|
||||||
|
Renamed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type conversationItemState struct {
|
||||||
|
editing, fresh bool
|
||||||
|
draft string
|
||||||
|
}
|
||||||
|
|
||||||
|
type conversationItemKey struct{}
|
||||||
|
|
||||||
|
// ConversationItem is a conversation row: title, time, and a menu to rename in place, pin, export or delete.
|
||||||
|
func ConversationItem(c *ui.Context, conv *ChatConversation, opts ConversationItemOptions) ConversationItemView {
|
||||||
|
if conv == nil || conv.ID == "" {
|
||||||
|
panic("mujica: ConversationItem needs a conversation with an ID")
|
||||||
|
}
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
today := opts.Today
|
||||||
|
if today.IsZero() {
|
||||||
|
today = DateOf(c.Now())
|
||||||
|
}
|
||||||
|
row := ui.Row(c).Key(conv.ID).FillWidth().MinHeight(d.ControlHeight()).Gap(d.Unit()).AlignItems(ui.Center).
|
||||||
|
Radius(theme.ControlRadius).Disabled(opts.Disabled)
|
||||||
|
v := ConversationItemView{Element: row}
|
||||||
|
st := ui.Local(row, conversationItemKey{}, func() conversationItemState { return conversationItemState{} })
|
||||||
|
if row.IsDisabled() {
|
||||||
|
st.editing = false
|
||||||
|
}
|
||||||
|
if opts.Selected {
|
||||||
|
row.Background(k.Selection)
|
||||||
|
}
|
||||||
|
title := cmp.Or(conv.Title, msg(c, "conversation_list.untitled"))
|
||||||
|
rename := false
|
||||||
|
row.Children(func() {
|
||||||
|
if st.editing {
|
||||||
|
conversationItemEditor(c, k, conv, st, &v)
|
||||||
|
} else {
|
||||||
|
main := ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).
|
||||||
|
Justify(ui.Start).Radius(theme.ControlRadius).Label(title)
|
||||||
|
internal.Ring(main, k.Focus, theme.ControlRadius)
|
||||||
|
if opts.Draggable {
|
||||||
|
main.Drag(ConversationDrag{ID: conv.ID})
|
||||||
|
}
|
||||||
|
if main.Hovered() && !opts.Selected {
|
||||||
|
main.Background(k.SurfaceHover)
|
||||||
|
}
|
||||||
|
main.Transition(colorFade)
|
||||||
|
main.Children(func() {
|
||||||
|
if conv.Pinned {
|
||||||
|
ui.Icon(c, icons.Must("star")).Size(14, 14).Shrink(0).TextColor(k.AccentText).Label(msg(c, "conversation_item.pinned"))
|
||||||
|
}
|
||||||
|
t := ui.Text(c, title).SingleLine().Grow(1).MinWidth(0)
|
||||||
|
if opts.Selected {
|
||||||
|
t.TextColor(k.AccentText)
|
||||||
|
}
|
||||||
|
ui.Text(c, dateSeparatorWhen(c, conv.Updated, today)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Shrink(0)
|
||||||
|
})
|
||||||
|
if !main.IsDisabled() {
|
||||||
|
v.Opened = main.Clicked()
|
||||||
|
rename = main.Shortcut(0, ui.KeyF2)
|
||||||
|
v.Deleted = main.Shortcut(0, ui.KeyDelete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menu := DropdownMenu(c, "", DropdownMenuOptions{Icon: icons.Must("ellipsis"), Label: msg(c, "conversation_item.actions"), Disabled: opts.Disabled}, func(m *PopupMenu) {
|
||||||
|
rename = m.Item(msg(c, "conversation_item.rename"), PopupMenuItemOptions{}) || rename
|
||||||
|
pin := msg(c, "conversation_item.pin")
|
||||||
|
if conv.Pinned {
|
||||||
|
pin = msg(c, "conversation_item.unpin")
|
||||||
|
}
|
||||||
|
v.PinToggled = m.Item(pin, PopupMenuItemOptions{})
|
||||||
|
v.Exported = m.Item(msg(c, "conversation_item.export"), PopupMenuItemOptions{})
|
||||||
|
m.Separator()
|
||||||
|
v.Deleted = m.Item(msg(c, "conversation_item.delete"), PopupMenuItemOptions{}) || v.Deleted
|
||||||
|
})
|
||||||
|
menu.Shrink(0).MinWidth(theme.MinHitSize).MinHeight(theme.MinHitSize)
|
||||||
|
})
|
||||||
|
if rename && !row.IsDisabled() {
|
||||||
|
st.editing, st.fresh, st.draft = true, true, conv.Title
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case v.Opened:
|
||||||
|
slog.Debug("mujica: conversation_item open", "id", conv.ID)
|
||||||
|
case v.Deleted, v.Exported, v.PinToggled:
|
||||||
|
slog.Debug("mujica: conversation_item action", "id", conv.ID, "delete", v.Deleted, "export", v.Exported, "pin", v.PinToggled)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// conversationItemEditor is the in-place title field: Enter keeps a non-blank title, Escape cancels.
|
||||||
|
func conversationItemEditor(c *ui.Context, k theme.Tokens, conv *ChatConversation, st *conversationItemState, v *ConversationItemView) {
|
||||||
|
d := Density(c)
|
||||||
|
in := ui.TextInputBase(c, &st.draft).Grow(1).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*2).
|
||||||
|
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.AccentText).
|
||||||
|
Label(msg(c, "conversation_item.rename"))
|
||||||
|
internal.Ring(in, k.Focus, theme.ControlRadius)
|
||||||
|
switch {
|
||||||
|
case st.fresh:
|
||||||
|
st.fresh = false
|
||||||
|
in.Focus()
|
||||||
|
case in.IsDisabled():
|
||||||
|
case in.Submitted():
|
||||||
|
if t := strings.TrimSpace(st.draft); t != "" {
|
||||||
|
v.Renamed = t != conv.Title
|
||||||
|
conv.Title = t
|
||||||
|
}
|
||||||
|
st.editing = false
|
||||||
|
slog.Debug("mujica: conversation_item rename", "id", conv.ID, "changed", v.Renamed)
|
||||||
|
case in.Shortcut(0, ui.KeyEscape):
|
||||||
|
st.editing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatConversation is one saved conversation, as the caller stores it.
|
||||||
|
type ChatConversation struct {
|
||||||
|
ID, Title string
|
||||||
|
Updated time.Time
|
||||||
|
Pinned bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationListOptions configure a ConversationList.
|
||||||
|
type ConversationListOptions struct {
|
||||||
|
// Now dates the groups; zero uses the window clock.
|
||||||
|
Now time.Time
|
||||||
|
// Label names the list for assistive technology.
|
||||||
|
Label string
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationListState is the chosen conversation and the list's place.
|
||||||
|
type ConversationListState struct {
|
||||||
|
// Selected is the chosen conversation's ID; empty for none.
|
||||||
|
Selected string
|
||||||
|
rows ui.ListState
|
||||||
|
cursor int
|
||||||
|
plan conversationListPlan
|
||||||
|
}
|
||||||
|
|
||||||
|
// conversationListPlan is the grouped rows, rebuilt only when the data or day changes.
|
||||||
|
type conversationListPlan struct {
|
||||||
|
src []ChatConversation
|
||||||
|
day Date
|
||||||
|
rows []conversationListRow
|
||||||
|
built bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// conversationListRow is group header g (item < 0) or an index into the items.
|
||||||
|
type conversationListRow struct {
|
||||||
|
group, item int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationListView is a built ConversationList and this frame's events.
|
||||||
|
type ConversationListView struct {
|
||||||
|
Element *ui.Element
|
||||||
|
changed bool
|
||||||
|
submitted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed reports a newly chosen conversation, in State.Selected.
|
||||||
|
func (v ConversationListView) Changed() bool { return v.changed }
|
||||||
|
|
||||||
|
// Submitted reports Enter or a double click on the chosen conversation.
|
||||||
|
func (v ConversationListView) Submitted() bool { return v.submitted }
|
||||||
|
|
||||||
|
// ConversationList groups conversations into pinned, today, yesterday, past 7 days and older, building only rows in view.
|
||||||
|
func ConversationList(c *ui.Context, s *ConversationListState, items []ChatConversation, opts ConversationListOptions, row func(conv ChatConversation)) ConversationListView {
|
||||||
|
if s == nil {
|
||||||
|
panic("mujica: ConversationList needs a state")
|
||||||
|
}
|
||||||
|
now := opts.Now
|
||||||
|
if now.IsZero() {
|
||||||
|
now = c.Now()
|
||||||
|
}
|
||||||
|
conversationListPlanRows(&s.plan, items, DateOf(now))
|
||||||
|
rows := s.plan.rows
|
||||||
|
s.cursor = slices.IndexFunc(rows, func(r conversationListRow) bool { return r.item >= 0 && items[r.item].ID == s.Selected })
|
||||||
|
before := s.cursor
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
cursor := &s.cursor
|
||||||
|
if opts.Disabled {
|
||||||
|
// A disabled list moves a copy of the cursor.
|
||||||
|
cp := s.cursor
|
||||||
|
cursor = &cp
|
||||||
|
}
|
||||||
|
s.rows.Key = func(i int) any {
|
||||||
|
if r := rows[i]; r.item >= 0 {
|
||||||
|
return items[r.item].ID
|
||||||
|
}
|
||||||
|
return conversationListGroups[rows[i].group]
|
||||||
|
}
|
||||||
|
s.rows.Header = func(i int) bool { return rows[i].item < 0 }
|
||||||
|
s.rows.Label = func(i int) string { return conversationListTitle(c, items, rows[i]) }
|
||||||
|
s.rows.Selected = cursor
|
||||||
|
var list *ui.Element
|
||||||
|
wrap := ui.Column(c).Grow(1).MinHeight(0).Disabled(opts.Disabled)
|
||||||
|
wrap.Children(func() {
|
||||||
|
restore := dataSelectTheme(c, 0)
|
||||||
|
list = ui.List(c, &s.rows, len(rows), func(i int) {
|
||||||
|
r := rows[i]
|
||||||
|
if r.item < 0 {
|
||||||
|
conversationListHeader(c, k, msg(c, conversationListGroups[r.group]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conv := items[r.item]
|
||||||
|
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||||
|
ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, b ui.Rect) {
|
||||||
|
if conv.ID == s.Selected {
|
||||||
|
p.FillPath(internal.Diamond(b.X+b.W/2, b.Y+b.H/2, 8), k.AccentText)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if row != nil {
|
||||||
|
ui.Row(c).Grow(1).MinWidth(0).Children(func() { row(conv) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.Text(c, conversationListTitle(c, items, r)).SingleLine().Grow(1).MinWidth(0)
|
||||||
|
})
|
||||||
|
}).Grow(1).MinHeight(0).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||||
|
Radius(theme.ControlRadius).Label(opts.Label)
|
||||||
|
restore()
|
||||||
|
internal.Ring(list, k.Focus, theme.ControlRadius)
|
||||||
|
})
|
||||||
|
v := ConversationListView{Element: wrap}
|
||||||
|
if !wrap.IsDisabled() {
|
||||||
|
if s.cursor != before && s.cursor >= 0 && rows[s.cursor].item >= 0 {
|
||||||
|
s.Selected, v.changed = items[rows[s.cursor].item].ID, true
|
||||||
|
slog.Debug("mujica: conversation_list select", "id", s.Selected)
|
||||||
|
}
|
||||||
|
v.submitted = list.Submitted() && s.Selected != ""
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func conversationListTitle(c *ui.Context, items []ChatConversation, r conversationListRow) string {
|
||||||
|
if r.item < 0 {
|
||||||
|
return msg(c, conversationListGroups[r.group])
|
||||||
|
}
|
||||||
|
return cmp.Or(items[r.item].Title, msg(c, "conversation_list.untitled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func conversationListHeader(c *ui.Context, k theme.Tokens, title string) {
|
||||||
|
d := Density(c)
|
||||||
|
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).AlignItems(ui.Center).Background(k.Surface).Children(func() {
|
||||||
|
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).SingleLine()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// conversationListPlanRows regroups items when the slice or the day changed.
|
||||||
|
func conversationListPlanRows(p *conversationListPlan, items []ChatConversation, today Date) {
|
||||||
|
same := p.built && p.day == today && len(p.src) == len(items) && (len(items) == 0 || &p.src[0] == &items[0])
|
||||||
|
if same {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.src, p.day, p.built = items, today, true
|
||||||
|
order := make([]int, len(items))
|
||||||
|
for i := range order {
|
||||||
|
order[i] = i
|
||||||
|
}
|
||||||
|
slices.SortStableFunc(order, func(a, b int) int { return items[b].Updated.Compare(items[a].Updated) })
|
||||||
|
groups := [5][]int{}
|
||||||
|
for _, i := range order {
|
||||||
|
g := conversationListGroup(items[i], today)
|
||||||
|
groups[g] = append(groups[g], i)
|
||||||
|
}
|
||||||
|
p.rows = p.rows[:0]
|
||||||
|
for g, idx := range groups {
|
||||||
|
if len(idx) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.rows = append(p.rows, conversationListRow{group: g, item: -1})
|
||||||
|
for _, i := range idx {
|
||||||
|
p.rows = append(p.rows, conversationListRow{item: i})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Debug("mujica: conversation_list regroup", "count", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
var conversationListGroups = [5]string{
|
||||||
|
"conversation_list.pinned", "conversation_list.today", "conversation_list.yesterday",
|
||||||
|
"conversation_list.week", "conversation_list.older",
|
||||||
|
}
|
||||||
|
|
||||||
|
// conversationListGroup is the group index of conv: pinned, today, yesterday, 7 days, older.
|
||||||
|
func conversationListGroup(conv ChatConversation, today Date) int {
|
||||||
|
day := DateOf(conv.Updated)
|
||||||
|
switch {
|
||||||
|
case conv.Pinned:
|
||||||
|
return 0
|
||||||
|
case day.Compare(today) >= 0:
|
||||||
|
return 1
|
||||||
|
case day == today.AddDays(-1):
|
||||||
|
return 2
|
||||||
|
case day.Compare(today.AddDays(-7)) >= 0:
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return 4
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/icons"
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConversationSearchHit is one conversation the caller found for the query.
|
||||||
|
type ConversationSearchHit struct {
|
||||||
|
ID, Title string
|
||||||
|
// Snippet is matching message text, shown under the title.
|
||||||
|
Snippet string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationSearchOptions configure a ConversationSearch.
|
||||||
|
type ConversationSearchOptions struct {
|
||||||
|
// Loading shows the caller's search for the query is under way.
|
||||||
|
Loading bool
|
||||||
|
// Error is the caller's failed search; it shows with a retry button.
|
||||||
|
Error string
|
||||||
|
Placeholder string
|
||||||
|
Label string
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversationSearchView is a built ConversationSearch and this frame's events.
|
||||||
|
type ConversationSearchView struct {
|
||||||
|
Element *ui.Element
|
||||||
|
// QueryChanged asks the caller to search again for the new query.
|
||||||
|
QueryChanged bool
|
||||||
|
// Chosen is the hit opened by a click or Enter, empty for none.
|
||||||
|
Chosen string
|
||||||
|
Retry bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type conversationSearchState struct {
|
||||||
|
cursor int
|
||||||
|
rows ui.ListState
|
||||||
|
enter bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type conversationSearchKey struct{}
|
||||||
|
|
||||||
|
// ConversationSearch searches titles and content; the caller supplies hits for the query and the matches are highlighted.
|
||||||
|
func ConversationSearch(c *ui.Context, query *string, hits []ConversationSearchHit, opts ConversationSearchOptions) ConversationSearchView {
|
||||||
|
if query == nil {
|
||||||
|
panic("mujica: ConversationSearch needs a query")
|
||||||
|
}
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
col := ui.Column(c).Gap(d.Unit() * 2).MinHeight(0).Disabled(opts.Disabled)
|
||||||
|
v := ConversationSearchView{Element: col}
|
||||||
|
st := ui.Local(col, conversationSearchKey{}, func() *conversationSearchState { return &conversationSearchState{cursor: -1} })
|
||||||
|
s := *st
|
||||||
|
s.cursor = min(s.cursor, len(hits)-1)
|
||||||
|
col.Children(func() {
|
||||||
|
in := SearchInput(c, query, SearchInputOptions{Placeholder: opts.Placeholder, Label: opts.Label, Disabled: opts.Disabled})
|
||||||
|
v.QueryChanged = in.Changed()
|
||||||
|
if in.Input.Shortcut(0, ui.KeyDown) && !in.Input.IsDisabled() && len(hits) > 0 {
|
||||||
|
s.enter, s.cursor = true, max(s.cursor, 0)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case opts.Error != "":
|
||||||
|
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).TextColor(k.Danger).Children(func() {
|
||||||
|
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16)
|
||||||
|
ui.Text(c, opts.Error).Role(ui.RoleStatus).Grow(1)
|
||||||
|
v.Retry = Button(c, msg(c, "conversation_search.retry"), ButtonOptions{Variant: Secondary, Icon: icons.Must("refresh-cw")}).Clicked()
|
||||||
|
})
|
||||||
|
case opts.Loading && len(hits) == 0:
|
||||||
|
Spinner(c, SpinnerOptions{Label: msg(c, "conversation_search.searching"), ShowLabel: true})
|
||||||
|
case len(hits) == 0 && *query != "":
|
||||||
|
ui.Text(c, msg(c, "conversation_search.no_results")).TextColor(k.TextMuted).Role(ui.RoleStatus)
|
||||||
|
case len(hits) > 0:
|
||||||
|
v.Chosen = conversationSearchList(c, s, *query, hits, opts.Loading)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if v.QueryChanged {
|
||||||
|
s.cursor = -1
|
||||||
|
slog.Debug("mujica: conversation_search query", "len", len(*query))
|
||||||
|
}
|
||||||
|
if v.Chosen != "" {
|
||||||
|
slog.Debug("mujica: conversation_search choose", "id", v.Chosen)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func conversationSearchList(c *ui.Context, s *conversationSearchState, q string, hits []ConversationSearchHit, loading bool) string {
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
chosen := ""
|
||||||
|
s.rows.Key = func(i int) any { return hits[i].ID }
|
||||||
|
s.rows.Label = func(i int) string { return hits[i].Title }
|
||||||
|
s.rows.Selected = &s.cursor
|
||||||
|
restore := dataSelectTheme(c, 0)
|
||||||
|
list := ui.List(c, &s.rows, len(hits), func(i int) {
|
||||||
|
h := hits[i]
|
||||||
|
row := ui.Column(c).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*2).Gap(2).Justify(ui.Center)
|
||||||
|
if i == s.cursor {
|
||||||
|
row.DrawOver(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
p.FillPath(internal.Diamond(r.X+3, r.Y+r.H/2, 6), k.AccentText)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
row.Children(func() {
|
||||||
|
ui.RichText(c, conversationSearchSpans(h.Title, q, k)...).SingleLine()
|
||||||
|
if h.Snippet != "" {
|
||||||
|
ui.RichText(c, conversationSearchSpans(h.Snippet, q, k)...).MaxLines(2).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !row.IsDisabled() && row.Clicked() {
|
||||||
|
chosen = h.ID
|
||||||
|
}
|
||||||
|
}).MaxHeight(360).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||||
|
Radius(theme.ControlRadius).Label(msg(c, "conversation_search.results"))
|
||||||
|
restore()
|
||||||
|
internal.Ring(list, k.Focus, theme.ControlRadius)
|
||||||
|
if s.enter {
|
||||||
|
s.enter = false
|
||||||
|
list.Focus()
|
||||||
|
}
|
||||||
|
if loading {
|
||||||
|
Spinner(c, SpinnerOptions{Size: 12, Label: msg(c, "conversation_search.searching")})
|
||||||
|
}
|
||||||
|
if list.Submitted() && !list.IsDisabled() && s.cursor >= 0 && s.cursor < len(hits) {
|
||||||
|
chosen = hits[s.cursor].ID
|
||||||
|
}
|
||||||
|
return chosen
|
||||||
|
}
|
||||||
|
|
||||||
|
// conversationSearchSpans splits text into spans, marking each case-insensitive match of q.
|
||||||
|
func conversationSearchSpans(text, q string, k theme.Tokens) []ui.Span {
|
||||||
|
r := []rune(text)
|
||||||
|
var spans []ui.Span
|
||||||
|
at := 0
|
||||||
|
for _, m := range conversationSearchMatches(text, q) {
|
||||||
|
if m[0] > at {
|
||||||
|
spans = append(spans, ui.Span{Text: string(r[at:m[0]])})
|
||||||
|
}
|
||||||
|
spans = append(spans, ui.Span{Text: string(r[m[0]:m[1]]), Weight: 700, Background: k.Selection, Underline: true})
|
||||||
|
at = m[1]
|
||||||
|
}
|
||||||
|
if at < len(r) || len(spans) == 0 {
|
||||||
|
spans = append(spans, ui.Span{Text: string(r[at:])})
|
||||||
|
}
|
||||||
|
return spans
|
||||||
|
}
|
||||||
|
|
||||||
|
// conversationSearchMatches returns rune ranges of case-insensitive matches of q in text.
|
||||||
|
func conversationSearchMatches(text, q string) [][2]int {
|
||||||
|
t, p := []rune(text), []rune(q)
|
||||||
|
if len(p) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out [][2]int
|
||||||
|
for i := 0; i+len(p) <= len(t); {
|
||||||
|
ok := true
|
||||||
|
for j := range p {
|
||||||
|
if unicode.ToLower(t[i+j]) != unicode.ToLower(p[j]) {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
out = append(out, [2]int{i, i + len(p)})
|
||||||
|
i += len(p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CostEstimatorOptions configure a CostEstimator. Prices are per million tokens, from the caller.
|
||||||
|
type CostEstimatorOptions struct {
|
||||||
|
InputTokens, OutputTokens int
|
||||||
|
InputPrice, OutputPrice float64
|
||||||
|
// Currency prefixes amounts; empty is "$".
|
||||||
|
Currency string
|
||||||
|
// Digits is the decimals shown for amounts; zero is 4.
|
||||||
|
Digits int
|
||||||
|
}
|
||||||
|
|
||||||
|
// CostEstimate is the cost of the input, the output and both.
|
||||||
|
type CostEstimate struct{ Input, Output, Total float64 }
|
||||||
|
|
||||||
|
// EstimateCost prices token counts at per-million rates.
|
||||||
|
func EstimateCost(inputTokens, outputTokens int, inputPrice, outputPrice float64) CostEstimate {
|
||||||
|
in := float64(inputTokens) * inputPrice / 1e6
|
||||||
|
out := float64(outputTokens) * outputPrice / 1e6
|
||||||
|
return CostEstimate{in, out, in + out}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CostEstimator shows input and output tokens and their estimated cost at the caller's prices.
|
||||||
|
func CostEstimator(c *ui.Context, opts CostEstimatorOptions) *ui.Element {
|
||||||
|
if opts.InputTokens < 0 || opts.OutputTokens < 0 {
|
||||||
|
panic("mujica: CostEstimator token counts must not be negative")
|
||||||
|
}
|
||||||
|
for _, p := range []float64{opts.InputPrice, opts.OutputPrice} {
|
||||||
|
if p < 0 || math.IsNaN(p) || math.IsInf(p, 0) {
|
||||||
|
panic("mujica: CostEstimator prices must be finite and not negative")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.Digits < 0 {
|
||||||
|
panic("mujica: CostEstimator Digits is negative")
|
||||||
|
}
|
||||||
|
digits := opts.Digits
|
||||||
|
if digits == 0 {
|
||||||
|
digits = 4
|
||||||
|
}
|
||||||
|
cur := opts.Currency
|
||||||
|
if cur == "" {
|
||||||
|
cur = "$"
|
||||||
|
}
|
||||||
|
money := func(v float64) string { return cur + strconv.FormatFloat(v, 'f', digits, 64) }
|
||||||
|
est := EstimateCost(opts.InputTokens, opts.OutputTokens, opts.InputPrice, opts.OutputPrice)
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
total := fmt.Sprintf(msg(c, "cost_estimator.total_label"), money(est.Total))
|
||||||
|
g := ui.Grid(c).ColumnTracks(ui.Fr(1), ui.FitContent(), ui.FitContent()).GapX(d.Unit()*4).GapY(d.Unit()).
|
||||||
|
Padding(d.Unit()*3).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).
|
||||||
|
Role(ui.RoleGroup).Label(total)
|
||||||
|
cell := func(s string, ink ui.Color, right bool) {
|
||||||
|
t := ui.Text(c, s).TextColor(ink).SingleLine().FontFeatures("tnum")
|
||||||
|
if right {
|
||||||
|
t.JustifySelf(ui.End)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.Children(func() {
|
||||||
|
cell(msg(c, "cost_estimator.kind"), k.TextMuted, false)
|
||||||
|
cell(msg(c, "cost_estimator.tokens"), k.TextMuted, true)
|
||||||
|
cell(msg(c, "cost_estimator.cost"), k.TextMuted, true)
|
||||||
|
cell(msg(c, "cost_estimator.input"), k.Text, false)
|
||||||
|
cell(strconv.Itoa(opts.InputTokens), k.Text, true)
|
||||||
|
cell(money(est.Input), k.Text, true)
|
||||||
|
cell(msg(c, "cost_estimator.output"), k.Text, false)
|
||||||
|
cell(strconv.Itoa(opts.OutputTokens), k.Text, true)
|
||||||
|
cell(money(est.Output), k.Text, true)
|
||||||
|
ui.Box(c).ColumnSpan(3).Height(9).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
internal.Rule(p, r.X, r.X+r.W, r.Y+r.H/2, k.Ornament)
|
||||||
|
})
|
||||||
|
ui.Text(c, msg(c, "cost_estimator.total")).Font(theme.SerifFont).TextColor(k.Text)
|
||||||
|
cell(strconv.Itoa(opts.InputTokens+opts.OutputTokens), k.Text, true)
|
||||||
|
ui.Text(c, money(est.Total)).Bold().TextColor(k.AccentText).JustifySelf(ui.End).FontFeatures("tnum")
|
||||||
|
ui.Text(c, fmt.Sprintf(msg(c, "cost_estimator.rates"), cur+strconv.FormatFloat(opts.InputPrice, 'f', -1, 64),
|
||||||
|
cur+strconv.FormatFloat(opts.OutputPrice, 'f', -1, 64))).
|
||||||
|
ColumnSpan(3).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||||
|
})
|
||||||
|
return g
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DateSeparatorOptions configure a DateSeparator.
|
||||||
|
type DateSeparatorOptions struct {
|
||||||
|
// Today decides "Today" and "Yesterday"; zero uses the window clock.
|
||||||
|
Today Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// DateSeparator marks where a day's messages begin: Today, Yesterday, or the full date in the window's language.
|
||||||
|
func DateSeparator(c *ui.Context, day Date, opts DateSeparatorOptions) *ui.Element {
|
||||||
|
if !day.Valid() {
|
||||||
|
panic("mujica: DateSeparator needs a valid date")
|
||||||
|
}
|
||||||
|
today := opts.Today
|
||||||
|
if today.IsZero() {
|
||||||
|
today = DateOf(c.Now())
|
||||||
|
}
|
||||||
|
k := Tokens(c)
|
||||||
|
u := Density(c).Unit()
|
||||||
|
label := dateSeparatorLabel(c, day, today)
|
||||||
|
row := ui.Row(c).FillWidth().Gap(u*3).AlignItems(ui.Center).Padding(u*2, 0).Role(ui.RoleText).Label(label)
|
||||||
|
line := func() {
|
||||||
|
ui.Box(c).Grow(1).Height(9).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
internal.Rule(p, r.X, r.X+r.W, r.Y+r.H/2, k.Border)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
row.Children(func() {
|
||||||
|
line()
|
||||||
|
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Shrink(0).Children(func() {
|
||||||
|
ui.Box(c).Size(6, 6).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
p.FillPath(internal.Diamond(r.X+3, r.Y+3, 6), k.Ornament)
|
||||||
|
})
|
||||||
|
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine()
|
||||||
|
})
|
||||||
|
line()
|
||||||
|
})
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// dateSeparatorLabel names day relative to today, in the window's language.
|
||||||
|
func dateSeparatorLabel(c *ui.Context, day, today Date) string {
|
||||||
|
switch day {
|
||||||
|
case today:
|
||||||
|
return msg(c, "date_separator.today")
|
||||||
|
case today.AddDays(-1):
|
||||||
|
return msg(c, "date_separator.yesterday")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(msg(c, "calendar.day_label"), msg(c, fmt.Sprintf("calendar.weekday_long_%d", day.Weekday())),
|
||||||
|
msg(c, fmt.Sprintf("calendar.month_%d", day.Month)), day.Day, day.Year)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dateSeparatorWhen is a short time stamp: the clock today, else the relative day or date.
|
||||||
|
func dateSeparatorWhen(c *ui.Context, t time.Time, today Date) string {
|
||||||
|
day := DateOf(t)
|
||||||
|
if day == today {
|
||||||
|
return t.Format("15:04")
|
||||||
|
}
|
||||||
|
if day == today.AddDays(-1) {
|
||||||
|
return msg(c, "date_separator.yesterday")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(msg(c, "date_separator.short"), msg(c, fmt.Sprintf("calendar.month_%d", day.Month)), day.Day)
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/icons"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedbackRating is the thumb the user gave. The zero value is FeedbackUp.
|
||||||
|
type FeedbackRating int
|
||||||
|
|
||||||
|
const (
|
||||||
|
FeedbackUp FeedbackRating = iota
|
||||||
|
FeedbackDown
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedbackValue is what the user wrote: chosen reasons and a comment.
|
||||||
|
type FeedbackValue struct {
|
||||||
|
Reasons []string
|
||||||
|
Comment string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedbackFormOptions configure a FeedbackForm.
|
||||||
|
type FeedbackFormOptions struct {
|
||||||
|
Rating FeedbackRating
|
||||||
|
// Reasons replaces the built-in reasons for the rating.
|
||||||
|
Reasons []string
|
||||||
|
// Submitting shows the caller sending the feedback and blocks the form.
|
||||||
|
Submitting bool
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedbackFormView is a built FeedbackForm and this frame's outcome.
|
||||||
|
type FeedbackFormView struct {
|
||||||
|
Element *ui.Element
|
||||||
|
Submitted, Cancelled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var feedbackReasons = [2][]string{
|
||||||
|
{"feedback_form.up.accurate", "feedback_form.up.helpful", "feedback_form.up.clear"},
|
||||||
|
{"feedback_form.down.wrong", "feedback_form.down.unhelpful", "feedback_form.down.unsafe", "feedback_form.down.style"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedbackForm asks why after a thumbs up or down: reason chips and a comment, then Submit or Cancel.
|
||||||
|
func FeedbackForm(c *ui.Context, value *FeedbackValue, opts FeedbackFormOptions) FeedbackFormView {
|
||||||
|
if value == nil {
|
||||||
|
panic("mujica: FeedbackForm needs a value")
|
||||||
|
}
|
||||||
|
if opts.Rating != FeedbackUp && opts.Rating != FeedbackDown {
|
||||||
|
panic("mujica: FeedbackForm Rating out of range")
|
||||||
|
}
|
||||||
|
reasons := opts.Reasons
|
||||||
|
if reasons == nil {
|
||||||
|
for _, key := range feedbackReasons[opts.Rating] {
|
||||||
|
reasons = append(reasons, msg(c, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
blocked := opts.Disabled || opts.Submitting
|
||||||
|
col := ui.Column(c).Gap(d.Unit()*3).Padding(d.Unit()*4).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||||
|
Radius(theme.CardRadius).Role(ui.RoleGroup).Disabled(blocked)
|
||||||
|
v := FeedbackFormView{Element: col}
|
||||||
|
title := map[FeedbackRating]string{FeedbackUp: "feedback_form.up.title", FeedbackDown: "feedback_form.down.title"}[opts.Rating]
|
||||||
|
col.Label(msg(c, title))
|
||||||
|
col.Children(func() {
|
||||||
|
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||||
|
ui.Text(c, msg(c, title)).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||||
|
})
|
||||||
|
ui.Row(c).Wrap().Gap(d.Unit() * 2).FocusGroup(ui.Horizontal).Children(func() {
|
||||||
|
for _, r := range reasons {
|
||||||
|
on := slices.Contains(value.Reasons, r)
|
||||||
|
was := on
|
||||||
|
Toggle(c, &on, r, ToggleOptions{Disabled: blocked})
|
||||||
|
if on != was && !col.IsDisabled() {
|
||||||
|
if on {
|
||||||
|
value.Reasons = append(slices.Clone(value.Reasons), r)
|
||||||
|
} else {
|
||||||
|
value.Reasons = slices.DeleteFunc(slices.Clone(value.Reasons), func(x string) bool { return x == r })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
TextArea(c, &value.Comment, TextAreaOptions{Placeholder: msg(c, "feedback_form.comment"), Label: msg(c, "feedback_form.comment"),
|
||||||
|
MinHeight: 72, Disabled: blocked})
|
||||||
|
ui.Row(c).Gap(d.Unit() * 2).Justify(ui.End).Children(func() {
|
||||||
|
v.Cancelled = Button(c, msg(c, "feedback_form.cancel"), ButtonOptions{Variant: Ghost, Disabled: blocked}).Clicked()
|
||||||
|
v.Submitted = Button(c, msg(c, "feedback_form.submit"), ButtonOptions{Icon: icons.Must("check"), Loading: opts.Submitting, Disabled: opts.Disabled}).Clicked()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if !col.IsDisabled() && col.Shortcut(0, ui.KeyEscape) {
|
||||||
|
v.Cancelled = true
|
||||||
|
}
|
||||||
|
if col.IsDisabled() {
|
||||||
|
v.Submitted, v.Cancelled = false, false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case v.Submitted:
|
||||||
|
slog.Debug("mujica: feedback_form submit", "rating", opts.Rating, "reasons", len(value.Reasons))
|
||||||
|
case v.Cancelled:
|
||||||
|
slog.Debug("mujica: feedback_form cancel")
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
addMessages(map[string][2]string{
|
||||||
|
"conversation_list.pinned": {"Pinned", "置顶"},
|
||||||
|
"conversation_list.today": {"Today", "今天"},
|
||||||
|
"conversation_list.yesterday": {"Yesterday", "昨天"},
|
||||||
|
"conversation_list.week": {"Previous 7 days", "7 天内"},
|
||||||
|
"conversation_list.older": {"Older", "更早"},
|
||||||
|
"conversation_list.untitled": {"New conversation", "新对话"},
|
||||||
|
|
||||||
|
"conversation_item.actions": {"Conversation actions", "对话操作"},
|
||||||
|
"conversation_item.rename": {"Rename", "重命名"},
|
||||||
|
"conversation_item.pin": {"Pin", "置顶"},
|
||||||
|
"conversation_item.unpin": {"Unpin", "取消置顶"},
|
||||||
|
"conversation_item.pinned": {"Pinned", "已置顶"},
|
||||||
|
"conversation_item.export": {"Export", "导出"},
|
||||||
|
"conversation_item.delete": {"Delete", "删除"},
|
||||||
|
|
||||||
|
"conversation_search.searching": {"Searching", "正在搜索"},
|
||||||
|
"conversation_search.no_results": {"No conversations match", "没有匹配的对话"},
|
||||||
|
"conversation_search.results": {"Search results", "搜索结果"},
|
||||||
|
"conversation_search.retry": {"Retry", "重试"},
|
||||||
|
|
||||||
|
"project_list.expanded": {"expanded", "已展开"},
|
||||||
|
"project_list.collapsed": {"collapsed", "已收起"},
|
||||||
|
"project_list.expand": {"Show conversations in %s", "展开 %s 的对话"},
|
||||||
|
"project_list.collapse": {"Hide conversations in %s", "收起 %s 的对话"},
|
||||||
|
"project_list.move_to": {"Move %s", "移动 %s"},
|
||||||
|
"project_list.move_group": {"Move to project", "移入项目"},
|
||||||
|
|
||||||
|
"project_knowledge_panel.title": {"Project knowledge", "项目知识"},
|
||||||
|
"project_knowledge_panel.add": {"Add files", "添加文件"},
|
||||||
|
"project_knowledge_panel.empty": {"No files yet", "暂无文件"},
|
||||||
|
"project_knowledge_panel.tokens": {"%d tokens", "%d tokens"},
|
||||||
|
"project_knowledge_panel.remove": {"Remove %s", "移除 %s"},
|
||||||
|
"project_knowledge_panel.usage": {"%d of %d tokens (%d%%)", "已用 %d / %d tokens(%d%%)"},
|
||||||
|
"project_knowledge_panel.near": {"Nearly full", "即将用满"},
|
||||||
|
"project_knowledge_panel.over": {"Over capacity", "超出容量"},
|
||||||
|
|
||||||
|
"prompt_library.all": {"All", "全部"},
|
||||||
|
"prompt_library.search": {"Search prompts", "搜索提示"},
|
||||||
|
"prompt_library.no_results": {"No prompts match", "没有匹配的提示"},
|
||||||
|
"prompt_library.pick": {"Choose a prompt to preview", "选择一个提示以预览"},
|
||||||
|
"prompt_library.insert": {"Insert", "插入"},
|
||||||
|
|
||||||
|
"feedback_form.up.title": {"What did you like?", "哪里令你满意?"},
|
||||||
|
"feedback_form.down.title": {"What went wrong?", "哪里出了问题?"},
|
||||||
|
"feedback_form.up.accurate": {"Accurate", "准确"},
|
||||||
|
"feedback_form.up.helpful": {"Helpful", "有帮助"},
|
||||||
|
"feedback_form.up.clear": {"Clear", "清晰"},
|
||||||
|
"feedback_form.down.wrong": {"Incorrect", "内容错误"},
|
||||||
|
"feedback_form.down.unhelpful": {"Not helpful", "没有帮助"},
|
||||||
|
"feedback_form.down.unsafe": {"Harmful or unsafe", "有害或不安全"},
|
||||||
|
"feedback_form.down.style": {"Too long or unclear", "冗长或不清楚"},
|
||||||
|
"feedback_form.comment": {"Tell us more (optional)", "补充说明(可选)"},
|
||||||
|
"feedback_form.submit": {"Submit", "提交"},
|
||||||
|
"feedback_form.cancel": {"Cancel", "取消"},
|
||||||
|
|
||||||
|
"date_separator.today": {"Today", "今天"},
|
||||||
|
"date_separator.yesterday": {"Yesterday", "昨天"},
|
||||||
|
"date_separator.short": {"%s %d", "%s%d日"},
|
||||||
|
|
||||||
|
"thinking_indicator.thinking": {"Thinking", "正在思考"},
|
||||||
|
|
||||||
|
"stop_generating_button.stop": {"Stop generating", "停止生成"},
|
||||||
|
|
||||||
|
"system_prompt_editor.title": {"System prompt", "系统提示"},
|
||||||
|
"system_prompt_editor.unsaved": {"Unsaved changes", "未保存"},
|
||||||
|
"system_prompt_editor.count": {"%d characters", "%d 字"},
|
||||||
|
"system_prompt_editor.count_of": {"%d / %d characters", "%d / %d 字"},
|
||||||
|
"system_prompt_editor.restore": {"Restore default", "恢复默认"},
|
||||||
|
"system_prompt_editor.save": {"Save", "保存"},
|
||||||
|
|
||||||
|
"cost_estimator.kind": {"Kind", "类别"},
|
||||||
|
"cost_estimator.tokens": {"Tokens", "Tokens"},
|
||||||
|
"cost_estimator.cost": {"Cost", "费用"},
|
||||||
|
"cost_estimator.input": {"Input", "输入"},
|
||||||
|
"cost_estimator.output": {"Output", "输出"},
|
||||||
|
"cost_estimator.total": {"Total", "合计"},
|
||||||
|
"cost_estimator.total_label": {"Estimated cost %s", "预计费用 %s"},
|
||||||
|
"cost_estimator.rates": {"Rates per million tokens: input %s, output %s", "每百万 tokens 单价:输入 %s,输出 %s"},
|
||||||
|
|
||||||
|
"shared_conversation_view.shared_by": {"Shared by %s", "分享者:%s"},
|
||||||
|
"shared_conversation_view.someone": {"someone", "未知"},
|
||||||
|
"shared_conversation_view.read_only": {"Read only", "只读"},
|
||||||
|
"shared_conversation_view.copy_link": {"Copy link", "复制链接"},
|
||||||
|
"shared_conversation_view.user": {"You", "你"},
|
||||||
|
"shared_conversation_view.assistant": {"Assistant", "助手"},
|
||||||
|
"shared_conversation_view.system": {"System", "系统"},
|
||||||
|
|
||||||
|
"conversation_export.format": {"Format", "格式"},
|
||||||
|
"conversation_export.text": {"Plain text", "纯文本"},
|
||||||
|
"conversation_export.range": {"Messages", "消息范围"},
|
||||||
|
"conversation_export.all": {"All (%d)", "全部(%d)"},
|
||||||
|
"conversation_export.last": {"Last", "最近"},
|
||||||
|
"conversation_export.selected": {"Selected (%d)", "已选(%d)"},
|
||||||
|
"conversation_export.preview": {"Preview", "预览"},
|
||||||
|
"conversation_export.export": {"Export", "导出"},
|
||||||
|
|
||||||
|
"regenerate_menu.regenerate": {"Regenerate", "重新生成"},
|
||||||
|
"regenerate_menu.again": {"Try again", "重试"},
|
||||||
|
"regenerate_menu.model": {"Try another model", "换模型重试"},
|
||||||
|
"regenerate_menu.variants": {"Rewrite", "改写"},
|
||||||
|
"regenerate_menu.longer": {"Longer", "更长"},
|
||||||
|
"regenerate_menu.shorter": {"Shorter", "更短"},
|
||||||
|
"regenerate_menu.simpler": {"Simpler", "更简单"},
|
||||||
|
"regenerate_menu.detailed": {"More detailed", "更详细"},
|
||||||
|
|
||||||
|
"artifact_card.code": {"Code", "代码"},
|
||||||
|
"artifact_card.document": {"Document", "文档"},
|
||||||
|
"artifact_card.web": {"Web page", "网页"},
|
||||||
|
"artifact_card.open": {"Open", "打开"},
|
||||||
|
"artifact_card.writing": {"Writing", "正在生成"},
|
||||||
|
|
||||||
|
"chat_empty_state.new.title": {"Start a conversation", "开始新对话"},
|
||||||
|
"chat_empty_state.new.description": {"Ask a question or pick a suggestion.", "提出问题,或选择一个建议。"},
|
||||||
|
"chat_empty_state.new.action": {"New conversation", "新建对话"},
|
||||||
|
"chat_empty_state.no_results.action": {"Clear search", "清除搜索"},
|
||||||
|
"chat_empty_state.offline.title": {"You are offline", "当前离线"},
|
||||||
|
"chat_empty_state.offline.description": {"Messages will send when the connection returns.", "恢复连接后即可发送消息。"},
|
||||||
|
"chat_empty_state.offline.action": {"Retry", "重试"},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/icons"
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KnowledgeFile is a file in a project's knowledge, sized in tokens by the caller.
|
||||||
|
type KnowledgeFile struct {
|
||||||
|
ID, Name string
|
||||||
|
Tokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectKnowledgePanelOptions configure a ProjectKnowledgePanel. Capacity is required.
|
||||||
|
type ProjectKnowledgePanelOptions struct {
|
||||||
|
// Capacity is how many tokens the project's knowledge holds.
|
||||||
|
Capacity int
|
||||||
|
// Title replaces the built-in "Project knowledge".
|
||||||
|
Title string
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectKnowledgePanelView is a built panel and what the user asked for.
|
||||||
|
type ProjectKnowledgePanelView struct {
|
||||||
|
Element *ui.Element
|
||||||
|
// Add asks the caller to pick files to add.
|
||||||
|
Add bool
|
||||||
|
// Removed is the ID of a file to remove, empty for none.
|
||||||
|
Removed string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectKnowledgePanel lists a project's knowledge files with add, remove and how full it is.
|
||||||
|
func ProjectKnowledgePanel(c *ui.Context, files []KnowledgeFile, opts ProjectKnowledgePanelOptions) ProjectKnowledgePanelView {
|
||||||
|
if opts.Capacity <= 0 {
|
||||||
|
panic("mujica: ProjectKnowledgePanel needs a positive Capacity")
|
||||||
|
}
|
||||||
|
used := 0
|
||||||
|
for _, f := range files {
|
||||||
|
if f.Tokens < 0 {
|
||||||
|
panic("mujica: KnowledgeFile with negative Tokens")
|
||||||
|
}
|
||||||
|
used += f.Tokens
|
||||||
|
}
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
col := ui.Column(c).Gap(d.Unit()*2).Padding(d.Unit()*3).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||||
|
Radius(theme.CardRadius).Disabled(opts.Disabled)
|
||||||
|
v := ProjectKnowledgePanelView{Element: col}
|
||||||
|
col.Children(func() {
|
||||||
|
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||||
|
title := opts.Title
|
||||||
|
if title == "" {
|
||||||
|
title = msg(c, "project_knowledge_panel.title")
|
||||||
|
}
|
||||||
|
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1)
|
||||||
|
v.Add = Button(c, msg(c, "project_knowledge_panel.add"), ButtonOptions{Variant: Secondary, Icon: icons.Must("plus"), Disabled: opts.Disabled}).Clicked()
|
||||||
|
})
|
||||||
|
ui.Box(c).FillWidth().Height(9).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
internal.RuleDiamond(p, r, 6, 5, k.Ornament)
|
||||||
|
})
|
||||||
|
projectKnowledgeMeter(c, k, used, opts.Capacity)
|
||||||
|
if len(files) == 0 {
|
||||||
|
ui.Text(c, msg(c, "project_knowledge_panel.empty")).TextColor(k.TextMuted).Padding(d.Unit()*2, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Children(func() {
|
||||||
|
for _, f := range files {
|
||||||
|
ui.Row(c).Key(f.ID).MinHeight(d.ControlHeight()).Gap(d.Unit()*2).AlignItems(ui.Center).Padding(0, d.Unit()).Children(func() {
|
||||||
|
ui.Icon(c, icons.Must("file-text")).Size(16, 16).TextColor(k.TextMuted)
|
||||||
|
ui.Text(c, f.Name).SingleLine().Grow(1).MinWidth(0)
|
||||||
|
ui.Text(c, fmt.Sprintf(msg(c, "project_knowledge_panel.tokens"), f.Tokens)).
|
||||||
|
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||||
|
x := iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "project_knowledge_panel.remove"), f.Name))
|
||||||
|
if x.Clicked() && !x.IsDisabled() {
|
||||||
|
v.Removed = f.ID
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if v.Add || v.Removed != "" {
|
||||||
|
slog.Debug("mujica: project_knowledge_panel action", "add", v.Add, "remove", v.Removed)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectKnowledgeMeter shows used of capacity; near and over the limit add a word and an icon, not only color.
|
||||||
|
func projectKnowledgeMeter(c *ui.Context, k theme.Tokens, used, capacity int) {
|
||||||
|
d := Density(c)
|
||||||
|
frac := float64(used) / float64(capacity)
|
||||||
|
ink, note, icon := k.AccentText, "", ""
|
||||||
|
switch {
|
||||||
|
case used > capacity:
|
||||||
|
ink, note, icon = k.Danger, msg(c, "project_knowledge_panel.over"), "circle-alert"
|
||||||
|
case frac >= 0.8:
|
||||||
|
ink, note, icon = k.Warning, msg(c, "project_knowledge_panel.near"), "triangle-alert"
|
||||||
|
}
|
||||||
|
pct := int(frac * 100)
|
||||||
|
label := fmt.Sprintf(msg(c, "project_knowledge_panel.usage"), used, capacity, pct)
|
||||||
|
ui.Column(c).Gap(d.Unit()).Role(ui.RoleMeter).Label(label).Children(func() {
|
||||||
|
ui.Box(c).FillWidth().Height(6).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
p.Fill(r, k.Border, 3)
|
||||||
|
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(min(frac, 1)), H: r.H}, ink, 3)
|
||||||
|
})
|
||||||
|
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() {
|
||||||
|
if icon != "" {
|
||||||
|
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(ink)
|
||||||
|
}
|
||||||
|
t := ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||||
|
if note != "" {
|
||||||
|
t.TextColor(ink)
|
||||||
|
ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).TextColor(ink).Role(ui.RoleStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/icons"
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatProject is a project that holds conversations; Folder groups projects, empty for none.
|
||||||
|
type ChatProject struct {
|
||||||
|
ID, Name, Folder string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectListOptions configure a ProjectList.
|
||||||
|
type ProjectListOptions struct {
|
||||||
|
Label string
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectListState is the chosen project and which folders and projects are open.
|
||||||
|
type ProjectListState struct {
|
||||||
|
// Selected is the chosen project's ID; empty for none.
|
||||||
|
Selected string
|
||||||
|
closed map[string]bool
|
||||||
|
open map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectListView is a built ProjectList and this frame's events.
|
||||||
|
type ProjectListView struct {
|
||||||
|
Element *ui.Element
|
||||||
|
// Changed reports a newly chosen project, in State.Selected.
|
||||||
|
Changed bool
|
||||||
|
// Opened is a conversation the user opened, empty for none.
|
||||||
|
Opened string
|
||||||
|
// Moved asks the caller to move conversation MoveID into project MoveTo.
|
||||||
|
Moved bool
|
||||||
|
MoveID, MoveTo string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectList shows folders of projects and their conversations; drop a ConversationDrag on a project, or use Move to, to file it.
|
||||||
|
func ProjectList(c *ui.Context, s *ProjectListState, projects []ChatProject, conversations func(projectID string) []ChatConversation, opts ProjectListOptions) ProjectListView {
|
||||||
|
if s == nil || conversations == nil {
|
||||||
|
panic("mujica: ProjectList needs a state and conversations")
|
||||||
|
}
|
||||||
|
if s.closed == nil {
|
||||||
|
s.closed, s.open = map[string]bool{}, map[string]bool{}
|
||||||
|
}
|
||||||
|
d := Density(c)
|
||||||
|
col := ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Role(ui.RoleTree).Label(opts.Label).Disabled(opts.Disabled)
|
||||||
|
v := ProjectListView{Element: col}
|
||||||
|
col.Children(func() {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, p := range projects {
|
||||||
|
if p.Folder == "" || seen[p.Folder] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[p.Folder] = true
|
||||||
|
open := !s.closed[p.Folder]
|
||||||
|
ui.Column(c).Key("folder:" + p.Folder).Children(func() { projectListFolder(c, p.Folder, &open) })
|
||||||
|
s.closed[p.Folder] = !open
|
||||||
|
if !open {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, q := range projects {
|
||||||
|
if q.Folder == p.Folder {
|
||||||
|
projectListProject(c, s, q, projects, conversations, d.Unit()*6, &v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range projects {
|
||||||
|
if p.Folder == "" {
|
||||||
|
projectListProject(c, s, p, projects, conversations, 0, &v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if col.IsDisabled() {
|
||||||
|
v.Changed, v.Opened, v.Moved = false, "", false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case v.Moved:
|
||||||
|
slog.Debug("mujica: project_list move", "conversation", v.MoveID, "project", v.MoveTo)
|
||||||
|
case v.Changed:
|
||||||
|
slog.Debug("mujica: project_list select", "project", s.Selected)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectListFolder is a folder heading on a CollapsibleBase trigger, which reports expanded.
|
||||||
|
func projectListFolder(c *ui.Context, title string, open *bool) *ui.Element {
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
parts := ui.CollapsibleBase(c, open)
|
||||||
|
tr := parts.Trigger.MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Center).
|
||||||
|
Radius(theme.ControlRadius).Label(title)
|
||||||
|
internal.Ring(tr, k.Focus, theme.ControlRadius)
|
||||||
|
if tr.Hovered() {
|
||||||
|
tr.Background(k.SurfaceHover)
|
||||||
|
}
|
||||||
|
chevron := "chevron-right"
|
||||||
|
if *open {
|
||||||
|
chevron = "chevron-down"
|
||||||
|
}
|
||||||
|
tr.Children(func() {
|
||||||
|
ui.Icon(c, icons.Must(chevron)).Size(14, 14).TextColor(k.TextMuted)
|
||||||
|
ui.Icon(c, icons.Must("folder")).Size(16, 16).TextColor(k.Ornament.Mix(k.Text, 0.3))
|
||||||
|
ui.Text(c, title).Font(theme.SerifFont).SingleLine()
|
||||||
|
})
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectListProject(c *ui.Context, s *ProjectListState, p ChatProject, all []ChatProject, conversations func(string) []ChatConversation, indent float32, v *ProjectListView) {
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
convs := conversations(p.ID)
|
||||||
|
chosen := s.Selected == p.ID
|
||||||
|
var b *ui.Element
|
||||||
|
line := ui.Row(c).Key("project:" + p.ID).AlignItems(ui.Center)
|
||||||
|
line.Children(func() { b = ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()) })
|
||||||
|
b.Padding(0, d.Unit()*2, 0, d.Unit()*2+indent).Padding(0, d.Unit()*2, 0, d.Unit()*2+indent).
|
||||||
|
Gap(d.Unit() * 2).Justify(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleTreeItem).Label(p.Name).Selected(chosen)
|
||||||
|
b.Background(ui.Transparent).TextColor(k.Text)
|
||||||
|
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||||
|
_, over := ui.DragOver[ConversationDrag](b)
|
||||||
|
switch {
|
||||||
|
case chosen:
|
||||||
|
b.Background(k.Selection).TextColor(k.AccentText)
|
||||||
|
case b.Hovered():
|
||||||
|
b.Background(k.SurfaceHover)
|
||||||
|
}
|
||||||
|
if over {
|
||||||
|
b.DrawOver(func(pt *ui.Painter, r ui.Rect) { internal.Frame(pt, r, 1, 5, k.AccentText) })
|
||||||
|
}
|
||||||
|
if drop, ok := ui.Drop[ConversationDrag](b); ok && !b.IsDisabled() {
|
||||||
|
v.Moved, v.MoveID, v.MoveTo = true, drop.ID, p.ID
|
||||||
|
}
|
||||||
|
if b.Clicked() && !chosen {
|
||||||
|
s.Selected, v.Changed = p.ID, true
|
||||||
|
}
|
||||||
|
open := s.open[p.ID]
|
||||||
|
if !b.IsDisabled() && len(convs) > 0 {
|
||||||
|
switch {
|
||||||
|
case b.Shortcut(0, ui.KeyRight):
|
||||||
|
s.open[p.ID] = true
|
||||||
|
case b.Shortcut(0, ui.KeyLeft):
|
||||||
|
s.open[p.ID] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(convs) > 0 {
|
||||||
|
b.Description(msg(c, map[bool]string{true: "project_list.expanded", false: "project_list.collapsed"}[open]))
|
||||||
|
}
|
||||||
|
b.Children(func() {
|
||||||
|
ui.Box(c).Size(8, 8).Shrink(0).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||||
|
if chosen {
|
||||||
|
pt.FillPath(internal.Diamond(r.X+4, r.Y+4, 8), k.AccentText)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ui.Text(c, p.Name).SingleLine().Grow(1).MinWidth(0)
|
||||||
|
ui.Text(c, fmt.Sprint(len(convs))).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||||
|
})
|
||||||
|
if len(convs) > 0 {
|
||||||
|
chevron := map[bool]string{true: "chevron-down", false: "chevron-right"}[open]
|
||||||
|
line.Children(func() {
|
||||||
|
t := iconAction(c, icons.Must(chevron), fmt.Sprintf(msg(c, map[bool]string{true: "project_list.collapse", false: "project_list.expand"}[open]), p.Name))
|
||||||
|
if t.Clicked() && !t.IsDisabled() {
|
||||||
|
s.open[p.ID] = !open
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !open {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, conv := range convs {
|
||||||
|
projectListConversation(c, conv, p, all, indent+d.Unit()*6, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectListConversation(c *ui.Context, conv ChatConversation, home ChatProject, all []ChatProject, indent float32, v *ProjectListView) {
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
ui.Row(c).Key("conv:" + conv.ID).AlignItems(ui.Center).Children(func() {
|
||||||
|
b := ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2, 0, d.Unit()*2+indent).
|
||||||
|
Justify(ui.Start).Radius(theme.ControlRadius).Label(conv.Title).Drag(ConversationDrag{ID: conv.ID})
|
||||||
|
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||||
|
if b.Dragging() {
|
||||||
|
b.Opacity(0.4)
|
||||||
|
}
|
||||||
|
if b.Hovered() {
|
||||||
|
b.Background(k.SurfaceHover)
|
||||||
|
}
|
||||||
|
b.Children(func() {
|
||||||
|
ui.Text(c, cmp.Or(conv.Title, msg(c, "conversation_list.untitled"))).SingleLine().TextColor(k.TextMuted)
|
||||||
|
})
|
||||||
|
if b.Clicked() {
|
||||||
|
v.Opened = conv.ID
|
||||||
|
}
|
||||||
|
DropdownMenu(c, "", DropdownMenuOptions{Icon: icons.Must("ellipsis"), Label: fmt.Sprintf(msg(c, "project_list.move_to"), conv.Title)}, func(m *PopupMenu) {
|
||||||
|
m.Group(msg(c, "project_list.move_group"), func() {
|
||||||
|
for _, p := range all {
|
||||||
|
if p.ID != home.ID && m.Item(p.Name, PopupMenuItemOptions{}) {
|
||||||
|
v.Moved, v.MoveID, v.MoveTo = true, conv.ID, p.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}).Shrink(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/icons"
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PromptTemplate is a reusable prompt; {{name}} in Body is a variable to fill before inserting.
|
||||||
|
type PromptTemplate struct {
|
||||||
|
ID, Category, Title, Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptLibraryOptions configure a PromptLibrary.
|
||||||
|
type PromptLibraryOptions struct {
|
||||||
|
Label string
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptLibraryState is the library's category, search, chosen template and variable values.
|
||||||
|
type PromptLibraryState struct {
|
||||||
|
// Category filters templates; empty shows all.
|
||||||
|
Category string
|
||||||
|
Query string
|
||||||
|
Selected string
|
||||||
|
Values map[string]string
|
||||||
|
shown promptLibraryShown
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptLibraryShown caches the filtered templates until the data, query or category changes.
|
||||||
|
type promptLibraryShown struct {
|
||||||
|
src []PromptTemplate
|
||||||
|
query, category string
|
||||||
|
ids []int
|
||||||
|
cats []string
|
||||||
|
built bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptLibraryView is a built PromptLibrary and the text inserted this frame.
|
||||||
|
type PromptLibraryView struct {
|
||||||
|
Element *ui.Element
|
||||||
|
// Inserted is the chosen template with its variables filled, empty for none.
|
||||||
|
Inserted string
|
||||||
|
}
|
||||||
|
|
||||||
|
var promptLibraryVar = regexp.MustCompile(`\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}`)
|
||||||
|
|
||||||
|
// PromptLibraryVariables lists a body's {{name}} variables in order, once each.
|
||||||
|
func PromptLibraryVariables(body string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, m := range promptLibraryVar.FindAllStringSubmatch(body, -1) {
|
||||||
|
if !slices.Contains(out, m[1]) {
|
||||||
|
out = append(out, m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptLibraryFill replaces each {{name}} in body with values[name].
|
||||||
|
func PromptLibraryFill(body string, values map[string]string) string {
|
||||||
|
return promptLibraryVar.ReplaceAllStringFunc(body, func(m string) string {
|
||||||
|
return values[promptLibraryVar.FindStringSubmatch(m)[1]]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptLibrary browses templates by category and search, previews one, fills its variables and inserts it.
|
||||||
|
func PromptLibrary(c *ui.Context, s *PromptLibraryState, templates []PromptTemplate, opts PromptLibraryOptions) PromptLibraryView {
|
||||||
|
if s == nil {
|
||||||
|
panic("mujica: PromptLibrary needs a state")
|
||||||
|
}
|
||||||
|
if s.Values == nil {
|
||||||
|
s.Values = map[string]string{}
|
||||||
|
}
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
promptLibraryFilter(&s.shown, templates, s.Query, s.Category)
|
||||||
|
row := ui.Row(c).Gap(d.Unit() * 4).AlignItems(ui.Stretch).Role(ui.RoleGroup).Label(opts.Label).Disabled(opts.Disabled)
|
||||||
|
v := PromptLibraryView{Element: row}
|
||||||
|
row.Children(func() {
|
||||||
|
ui.Column(c).Width(150).Shrink(0).Gap(2).FocusGroup(ui.Vertical).Children(func() {
|
||||||
|
promptLibraryCategory(c, k, s, "", msg(c, "prompt_library.all"))
|
||||||
|
for _, cat := range s.shown.cats {
|
||||||
|
promptLibraryCategory(c, k, s, cat, cat)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ui.Column(c).Width(240).Shrink(0).Gap(d.Unit() * 2).Children(func() {
|
||||||
|
SearchInput(c, &s.Query, SearchInputOptions{Placeholder: msg(c, "prompt_library.search"), Disabled: opts.Disabled})
|
||||||
|
ui.Scroll(c).MaxHeight(320).Children(func() {
|
||||||
|
ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Children(func() {
|
||||||
|
if len(s.shown.ids) == 0 {
|
||||||
|
ui.Text(c, msg(c, "prompt_library.no_results")).TextColor(k.TextMuted).Padding(d.Unit() * 2)
|
||||||
|
}
|
||||||
|
for _, i := range s.shown.ids {
|
||||||
|
t := templates[i]
|
||||||
|
if selectableRow(c, k, t.ID, t.Title, s.Selected == t.ID) && s.Selected != t.ID {
|
||||||
|
s.Selected = t.ID
|
||||||
|
slog.Debug("mujica: prompt_library select", "id", t.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
ui.Column(c).Grow(1).MinWidth(200).Gap(d.Unit() * 2).Children(func() {
|
||||||
|
i := slices.IndexFunc(templates, func(t PromptTemplate) bool { return t.ID == s.Selected })
|
||||||
|
if i < 0 {
|
||||||
|
ui.Text(c, msg(c, "prompt_library.pick")).TextColor(k.TextMuted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.Inserted = promptLibraryPreview(c, k, s, templates[i], opts.Disabled)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if v.Inserted != "" {
|
||||||
|
slog.Debug("mujica: prompt_library insert", "id", s.Selected)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptLibraryFilter(sh *promptLibraryShown, templates []PromptTemplate, query, category string) {
|
||||||
|
same := sh.built && sh.query == query && sh.category == category && len(sh.src) == len(templates) &&
|
||||||
|
(len(templates) == 0 || &sh.src[0] == &templates[0])
|
||||||
|
if same {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*sh = promptLibraryShown{src: templates, query: query, category: category, built: true}
|
||||||
|
q := strings.ToLower(strings.TrimSpace(query))
|
||||||
|
for i, t := range templates {
|
||||||
|
if t.ID == "" || t.Title == "" {
|
||||||
|
panic("mujica: PromptTemplate needs an ID and a Title")
|
||||||
|
}
|
||||||
|
if t.Category != "" && !slices.Contains(sh.cats, t.Category) {
|
||||||
|
sh.cats = append(sh.cats, t.Category)
|
||||||
|
}
|
||||||
|
if category != "" && t.Category != category {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q == "" || strings.Contains(strings.ToLower(t.Title+"\n"+t.Body), q) {
|
||||||
|
sh.ids = append(sh.ids, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptLibraryCategory(c *ui.Context, k theme.Tokens, s *PromptLibraryState, cat, label string) {
|
||||||
|
if selectableRow(c, k, "cat:"+cat, label, s.Category == cat) && s.Category != cat {
|
||||||
|
s.Category = cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectableRow is a focusable row chosen by click, Enter or Space, marked with Selection and a diamond.
|
||||||
|
func selectableRow(c *ui.Context, k theme.Tokens, key, label string, chosen bool) bool {
|
||||||
|
d := Density(c)
|
||||||
|
b := ui.ButtonBase(c).Key(key).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).Justify(ui.Start).
|
||||||
|
Radius(theme.ControlRadius).Label(label).Selected(chosen)
|
||||||
|
b.Background(ui.Transparent).TextColor(k.Text)
|
||||||
|
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||||
|
switch {
|
||||||
|
case chosen:
|
||||||
|
b.Background(k.Selection).TextColor(k.AccentText)
|
||||||
|
case b.Hovered():
|
||||||
|
b.Background(k.SurfaceHover)
|
||||||
|
}
|
||||||
|
b.Children(func() {
|
||||||
|
ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
if chosen {
|
||||||
|
p.FillPath(internal.Diamond(r.X+4, r.Y+4, 8), k.AccentText)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ui.Text(c, label).SingleLine()
|
||||||
|
})
|
||||||
|
return b.Clicked() && !b.IsDisabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptLibraryPreview shows the filled body, a field per variable, and Insert once all are filled.
|
||||||
|
func promptLibraryPreview(c *ui.Context, k theme.Tokens, s *PromptLibraryState, t PromptTemplate, disabled bool) string {
|
||||||
|
d := Density(c)
|
||||||
|
vars := PromptLibraryVariables(t.Body)
|
||||||
|
ui.Text(c, t.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||||
|
missing := false
|
||||||
|
var spans []ui.Span
|
||||||
|
at := 0
|
||||||
|
for _, m := range promptLibraryVar.FindAllStringSubmatchIndex(t.Body, -1) {
|
||||||
|
spans = append(spans, ui.Span{Text: t.Body[at:m[0]]})
|
||||||
|
name := t.Body[m[2]:m[3]]
|
||||||
|
if val := s.Values[name]; val != "" {
|
||||||
|
spans = append(spans, ui.Span{Text: val, Weight: 600, Color: k.AccentText})
|
||||||
|
} else {
|
||||||
|
missing = true
|
||||||
|
spans = append(spans, ui.Span{Text: "[" + name + "]", Background: k.Selection, Color: k.AccentText, Underline: true})
|
||||||
|
}
|
||||||
|
at = m[1]
|
||||||
|
}
|
||||||
|
spans = append(spans, ui.Span{Text: t.Body[at:]})
|
||||||
|
ui.Column(c).Padding(d.Unit()*3).Background(k.Background).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius).
|
||||||
|
Children(func() { ui.RichText(c, spans...).Selectable() })
|
||||||
|
for _, name := range vars {
|
||||||
|
val := s.Values[name]
|
||||||
|
ui.Column(c).Key("var:" + name).Children(func() {
|
||||||
|
FormField(c, name, FormFieldOptions{Required: true}, func() *ui.Element {
|
||||||
|
r := TextInput(c, &val, TextInputOptions{Label: name, Disabled: disabled})
|
||||||
|
if r.Changed() {
|
||||||
|
s.Values[name] = val
|
||||||
|
}
|
||||||
|
return r.Element
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
insert := Button(c, msg(c, "prompt_library.insert"), ButtonOptions{Icon: icons.Must("plus"), Disabled: missing || disabled}).Clicked()
|
||||||
|
if insert && !missing {
|
||||||
|
return PromptLibraryFill(t.Body, s.Values)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/icons"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegenerateKind is what a RegenerateMenu choice asks for.
|
||||||
|
type RegenerateKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RegenerateAgain retries with the same model.
|
||||||
|
RegenerateAgain RegenerateKind = iota
|
||||||
|
// RegenerateModel retries with Choice.Model.
|
||||||
|
RegenerateModel
|
||||||
|
// RegenerateVariant retries asking for Choice.Variant, such as longer or shorter.
|
||||||
|
RegenerateVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegenerateChoice is the retry the user picked.
|
||||||
|
type RegenerateChoice struct {
|
||||||
|
Kind RegenerateKind
|
||||||
|
Model string
|
||||||
|
Variant string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegenerateMenuOptions configure a RegenerateMenu.
|
||||||
|
type RegenerateMenuOptions struct {
|
||||||
|
// Models lists models to retry with; Current is marked and left out.
|
||||||
|
Models []string
|
||||||
|
Current string
|
||||||
|
// Variants replaces the built-in Longer, Shorter, Simpler and More detailed.
|
||||||
|
Variants []string
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegenerateMenuView is a built menu and the retry chosen this frame.
|
||||||
|
type RegenerateMenuView struct {
|
||||||
|
Element *ui.Element
|
||||||
|
Chosen bool
|
||||||
|
Choice RegenerateChoice
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegenerateMenu offers regenerate, retry with another model, and variants such as longer or shorter.
|
||||||
|
func RegenerateMenu(c *ui.Context, opts RegenerateMenuOptions) RegenerateMenuView {
|
||||||
|
variants := opts.Variants
|
||||||
|
if variants == nil {
|
||||||
|
for _, key := range []string{"regenerate_menu.longer", "regenerate_menu.shorter", "regenerate_menu.simpler", "regenerate_menu.detailed"} {
|
||||||
|
variants = append(variants, msg(c, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var v RegenerateMenuView
|
||||||
|
pick := func(ch RegenerateChoice) { v.Chosen, v.Choice = true, ch }
|
||||||
|
v.Element = DropdownMenu(c, msg(c, "regenerate_menu.regenerate"), DropdownMenuOptions{Icon: icons.Must("refresh-cw"), Disabled: opts.Disabled}, func(m *PopupMenu) {
|
||||||
|
if m.Item(msg(c, "regenerate_menu.again"), PopupMenuItemOptions{}) {
|
||||||
|
pick(RegenerateChoice{Kind: RegenerateAgain})
|
||||||
|
}
|
||||||
|
others := 0
|
||||||
|
for _, model := range opts.Models {
|
||||||
|
if model != opts.Current {
|
||||||
|
others++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.Submenu(msg(c, "regenerate_menu.model"), PopupMenuItemOptions{Disabled: others == 0}, func(m *PopupMenu) {
|
||||||
|
for _, model := range opts.Models {
|
||||||
|
if model != opts.Current && m.Item(model, PopupMenuItemOptions{}) {
|
||||||
|
pick(RegenerateChoice{Kind: RegenerateModel, Model: model})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
m.Separator()
|
||||||
|
m.Group(msg(c, "regenerate_menu.variants"), func() {
|
||||||
|
for _, variant := range variants {
|
||||||
|
if m.Item(variant, PopupMenuItemOptions{}) {
|
||||||
|
pick(RegenerateChoice{Kind: RegenerateVariant, Variant: variant})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if v.Element.IsDisabled() {
|
||||||
|
v.Chosen = false
|
||||||
|
}
|
||||||
|
if v.Chosen {
|
||||||
|
slog.Debug("mujica: regenerate_menu choose", "kind", v.Choice.Kind, "model", v.Choice.Model, "variant", v.Choice.Variant)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/icons"
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionRole is who wrote a message. The zero value is SessionUser.
|
||||||
|
type SessionRole int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionUser SessionRole = iota
|
||||||
|
SessionAssistant
|
||||||
|
SessionSystem
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionMessage is one message of a saved conversation, as plain text.
|
||||||
|
type SessionMessage struct {
|
||||||
|
Role SessionRole
|
||||||
|
// Author names the writer; empty uses the role's name.
|
||||||
|
Author string
|
||||||
|
Text string
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharedConversationViewOptions configure a SharedConversationView.
|
||||||
|
type SharedConversationViewOptions struct {
|
||||||
|
Title string
|
||||||
|
SharedBy string
|
||||||
|
SharedAt time.Time
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharedConversationView shows a shared conversation read-only, with who shared it and when; it reports Copy link.
|
||||||
|
func SharedConversationView(c *ui.Context, messages []SessionMessage, opts SharedConversationViewOptions) ChoiceResult {
|
||||||
|
if opts.Title == "" {
|
||||||
|
panic("mujica: SharedConversationView needs a Title")
|
||||||
|
}
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
col := ui.Column(c).Grow(1).MinHeight(0).Gap(d.Unit() * 3).Disabled(opts.Disabled)
|
||||||
|
rows := *ui.Local(col, sharedConversationKey{}, func() *ui.ListState { return &ui.ListState{} })
|
||||||
|
copied := false
|
||||||
|
col.Children(func() {
|
||||||
|
ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
|
||||||
|
ui.Column(c).Grow(1).Gap(2).Children(func() {
|
||||||
|
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H2Size)).TextColor(k.Text)
|
||||||
|
by := fmt.Sprintf(msg(c, "shared_conversation_view.shared_by"), cmp.Or(opts.SharedBy, msg(c, "shared_conversation_view.someone")))
|
||||||
|
if !opts.SharedAt.IsZero() {
|
||||||
|
by += " · " + dateSeparatorLabel(c, DateOf(opts.SharedAt), DateOf(c.Now()))
|
||||||
|
}
|
||||||
|
ui.Text(c, by).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||||
|
})
|
||||||
|
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() {
|
||||||
|
ui.Icon(c, icons.Must("lock")).Size(14, 14).TextColor(k.TextMuted)
|
||||||
|
ui.Text(c, msg(c, "shared_conversation_view.read_only")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||||
|
})
|
||||||
|
copied = Button(c, msg(c, "shared_conversation_view.copy_link"), ButtonOptions{Variant: Secondary, Icon: icons.Must("copy"),
|
||||||
|
Disabled: opts.Disabled}).Clicked()
|
||||||
|
})
|
||||||
|
ui.Box(c).FillWidth().Height(9).Draw(func(p *ui.Painter, r ui.Rect) { internal.RuleDiamond(p, r, 6, 5, k.Ornament) })
|
||||||
|
rows.Key = func(i int) any { return i }
|
||||||
|
ui.List(c, rows, len(messages), func(i int) { sessionMessageView(c, k, messages[i]) }).Grow(1).MinHeight(0).Gap(d.Unit() * 3)
|
||||||
|
})
|
||||||
|
if copied {
|
||||||
|
slog.Debug("mujica: shared_conversation_view copy_link")
|
||||||
|
}
|
||||||
|
return ChoiceResult{Element: col, changed: copied}
|
||||||
|
}
|
||||||
|
|
||||||
|
type sharedConversationKey struct{}
|
||||||
|
|
||||||
|
// sessionMessageView is one read-only message: author, time and selectable plain text.
|
||||||
|
func sessionMessageView(c *ui.Context, k theme.Tokens, m SessionMessage) {
|
||||||
|
d := Density(c)
|
||||||
|
if m.Role < SessionUser || m.Role > SessionSystem {
|
||||||
|
panic("mujica: SessionMessage Role out of range")
|
||||||
|
}
|
||||||
|
author := cmp.Or(m.Author, msg(c, sessionRoleKeys[m.Role]))
|
||||||
|
box := ui.Column(c).Gap(d.Unit()).Padding(d.Unit()*2, d.Unit()*3).Radius(theme.CardRadius)
|
||||||
|
if m.Role == SessionUser {
|
||||||
|
box.Background(k.Background).Border(theme.BorderWidth, k.Border)
|
||||||
|
}
|
||||||
|
box.Children(func() {
|
||||||
|
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||||
|
ui.Text(c, author).Bold().TextColor(k.Text)
|
||||||
|
if !m.Time.IsZero() {
|
||||||
|
ui.Text(c, m.Time.Format("15:04")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Plain text: Markdown is not rendered here.
|
||||||
|
Text(c, m.Text, TextOptions{Selectable: true})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionRoleKeys = map[SessionRole]string{
|
||||||
|
SessionUser: "shared_conversation_view.user", SessionAssistant: "shared_conversation_view.assistant",
|
||||||
|
SessionSystem: "shared_conversation_view.system",
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StopGeneratingButtonOptions configure a StopGeneratingButton.
|
||||||
|
type StopGeneratingButtonOptions struct {
|
||||||
|
// Generating shows the button; it hides once generation ends.
|
||||||
|
Generating bool
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopGeneratingButton asks to stop a reply while one is generating; Cmd+. (Ctrl+. elsewhere) does too.
|
||||||
|
func StopGeneratingButton(c *ui.Context, opts StopGeneratingButtonOptions) ChoiceResult {
|
||||||
|
if !opts.Generating {
|
||||||
|
return ChoiceResult{}
|
||||||
|
}
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
mods := ui.Ctrl
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
mods = ui.Cmd
|
||||||
|
}
|
||||||
|
var b *ui.Element
|
||||||
|
wrap := ui.Row(c).Shrink(0).Disabled(opts.Disabled)
|
||||||
|
wrap.Children(func() {
|
||||||
|
b = ui.ButtonBase(c).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3).Gap(d.Unit()*2).
|
||||||
|
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Label(msg(c, "stop_generating_button.stop"))
|
||||||
|
})
|
||||||
|
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||||
|
if b.Hovered() {
|
||||||
|
b.Background(k.SurfaceHover)
|
||||||
|
}
|
||||||
|
b.Transition(colorFade)
|
||||||
|
b.Children(func() {
|
||||||
|
ui.Box(c).Size(10, 10).Shrink(0).Background(k.Text).Radius(1)
|
||||||
|
ui.Text(c, msg(c, "stop_generating_button.stop")).SingleLine()
|
||||||
|
Kbd(c, "Mod", ".")
|
||||||
|
})
|
||||||
|
stop := b.Clicked()
|
||||||
|
if c.Shortcut(mods, ui.KeyPeriod) {
|
||||||
|
stop = true
|
||||||
|
}
|
||||||
|
stop = stop && !b.IsDisabled()
|
||||||
|
if stop {
|
||||||
|
slog.Debug("mujica: stop_generating_button stop")
|
||||||
|
}
|
||||||
|
return ChoiceResult{Element: b, changed: stop}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/icons"
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SuggestionChipsOptions configure SuggestionChips.
|
||||||
|
type SuggestionChipsOptions struct {
|
||||||
|
// Send makes a click send the suggestion; by default it fills the input.
|
||||||
|
Send bool
|
||||||
|
// Label names the group, such as "Follow-up questions".
|
||||||
|
Label string
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestionChipsView is built chips and the suggestion used this frame.
|
||||||
|
type SuggestionChipsView struct {
|
||||||
|
Element *ui.Element
|
||||||
|
// Chosen is the suggestion clicked, empty for none.
|
||||||
|
Chosen string
|
||||||
|
// Sent reports that Chosen should be sent now; otherwise it is already in the input.
|
||||||
|
Sent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestionChips offers starter prompts or follow-ups; a click fills *input, or with Send asks to send.
|
||||||
|
func SuggestionChips(c *ui.Context, input *string, suggestions []string, opts SuggestionChipsOptions) SuggestionChipsView {
|
||||||
|
if input == nil {
|
||||||
|
panic("mujica: SuggestionChips needs an input")
|
||||||
|
}
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
row := ui.Row(c).Wrap().Gap(d.Unit() * 2).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(opts.Label).Disabled(opts.Disabled)
|
||||||
|
v := SuggestionChipsView{Element: row}
|
||||||
|
icon := icons.Must("chevron-right")
|
||||||
|
if opts.Send {
|
||||||
|
icon = icons.Must("arrow-up")
|
||||||
|
}
|
||||||
|
row.Children(func() {
|
||||||
|
for i, s := range suggestions {
|
||||||
|
if s == "" {
|
||||||
|
panic("mujica: SuggestionChips with an empty suggestion")
|
||||||
|
}
|
||||||
|
b := ui.ButtonBase(c).Key(i).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3).
|
||||||
|
Gap(d.Unit()*2).Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Label(s)
|
||||||
|
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||||
|
switch {
|
||||||
|
case b.Pressed():
|
||||||
|
b.Background(k.Selection)
|
||||||
|
case b.Hovered():
|
||||||
|
b.Background(k.SurfaceHover)
|
||||||
|
}
|
||||||
|
b.Transition(colorFade)
|
||||||
|
b.Children(func() {
|
||||||
|
ui.Text(c, s).SingleLine()
|
||||||
|
ui.Icon(c, icon).Size(14, 14).TextColor(k.TextMuted)
|
||||||
|
})
|
||||||
|
if b.Clicked() && !b.IsDisabled() {
|
||||||
|
v.Chosen = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if v.Chosen != "" {
|
||||||
|
v.Sent = opts.Send
|
||||||
|
if !opts.Send {
|
||||||
|
*input = v.Chosen
|
||||||
|
}
|
||||||
|
slog.Debug("mujica: suggestion_chips choose", "send", v.Sent)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"runtime"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/icons"
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SystemPromptEditorOptions configure a SystemPromptEditor.
|
||||||
|
type SystemPromptEditorOptions struct {
|
||||||
|
// Default is what Restore default puts back.
|
||||||
|
Default string
|
||||||
|
// Saved is the text last saved; a different value shows as unsaved.
|
||||||
|
Saved string
|
||||||
|
// MaxLength caps the characters; zero is no cap.
|
||||||
|
MaxLength int
|
||||||
|
// Saving shows the caller's save in progress and blocks editing.
|
||||||
|
Saving bool
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemPromptEditorView is a built editor and what the user asked for.
|
||||||
|
type SystemPromptEditorView struct {
|
||||||
|
Element *ui.Element
|
||||||
|
// Save asks the caller to save *value; Restored reports the default was put back.
|
||||||
|
Save, Restored bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemPromptEditor edits a system prompt with a character count, restore default, an unsaved mark and Mod+S to save.
|
||||||
|
func SystemPromptEditor(c *ui.Context, value *string, opts SystemPromptEditorOptions) SystemPromptEditorView {
|
||||||
|
if value == nil {
|
||||||
|
panic("mujica: SystemPromptEditor needs a value")
|
||||||
|
}
|
||||||
|
if opts.MaxLength < 0 {
|
||||||
|
panic("mujica: SystemPromptEditor MaxLength is negative")
|
||||||
|
}
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
blocked := opts.Disabled || opts.Saving
|
||||||
|
col := ui.Column(c).Gap(d.Unit() * 2).Disabled(blocked)
|
||||||
|
v := SystemPromptEditorView{Element: col}
|
||||||
|
dirty := *value != opts.Saved
|
||||||
|
col.Children(func() {
|
||||||
|
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||||
|
ui.Text(c, msg(c, "system_prompt_editor.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1)
|
||||||
|
if dirty {
|
||||||
|
ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Role(ui.RoleStatus).Children(func() {
|
||||||
|
ui.Box(c).Size(8, 8).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
p.FillPath(internal.Diamond(r.X+4, r.Y+4, 8), k.Warning)
|
||||||
|
})
|
||||||
|
ui.Text(c, msg(c, "system_prompt_editor.unsaved")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Warning)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
TextArea(c, value, TextAreaOptions{Label: msg(c, "system_prompt_editor.title"), MinHeight: 120, MaxHeight: 320,
|
||||||
|
MaxLength: opts.MaxLength, Disabled: blocked})
|
||||||
|
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||||
|
n := utf8.RuneCountInString(*value)
|
||||||
|
count := fmt.Sprintf(msg(c, "system_prompt_editor.count"), n)
|
||||||
|
if opts.MaxLength > 0 {
|
||||||
|
count = fmt.Sprintf(msg(c, "system_prompt_editor.count_of"), n, opts.MaxLength)
|
||||||
|
}
|
||||||
|
ui.Text(c, count).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1)
|
||||||
|
if Button(c, msg(c, "system_prompt_editor.restore"), ButtonOptions{Variant: Ghost, Icon: icons.Must("refresh-cw"),
|
||||||
|
Disabled: blocked || *value == opts.Default}).Clicked() && !col.IsDisabled() {
|
||||||
|
*value, v.Restored = opts.Default, true
|
||||||
|
}
|
||||||
|
v.Save = Button(c, msg(c, "system_prompt_editor.save"), ButtonOptions{Loading: opts.Saving, Disabled: opts.Disabled || !dirty}).Clicked()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mods := ui.Ctrl
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
mods = ui.Cmd
|
||||||
|
}
|
||||||
|
if col.Shortcut(mods, ui.KeyS) && dirty {
|
||||||
|
v.Save = true
|
||||||
|
}
|
||||||
|
if col.IsDisabled() {
|
||||||
|
v.Save = false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case v.Save:
|
||||||
|
slog.Debug("mujica: system_prompt_editor save", "len", len(*value))
|
||||||
|
case v.Restored:
|
||||||
|
slog.Debug("mujica: system_prompt_editor restore")
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ThinkingIndicatorOptions configure a ThinkingIndicator.
|
||||||
|
type ThinkingIndicatorOptions struct {
|
||||||
|
// Phase is the caller's stage text, such as "Searching the web"; empty uses "Thinking".
|
||||||
|
Phase string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThinkingIndicator shows the assistant is working: three diamonds rising in turn and the phase; still under reduced motion.
|
||||||
|
func ThinkingIndicator(c *ui.Context, opts ThinkingIndicatorOptions) *ui.Element {
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
phase := opts.Phase
|
||||||
|
if phase == "" {
|
||||||
|
phase = msg(c, "thinking_indicator.thinking")
|
||||||
|
}
|
||||||
|
still := c.Preferences().ReduceMotion
|
||||||
|
row := ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Role(ui.RoleStatus).Label(phase)
|
||||||
|
row.Children(func() {
|
||||||
|
ui.Box(c).Size(30, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
t := 0.0
|
||||||
|
if !still {
|
||||||
|
t = float64(p.Now().UnixMilli()%1200) / 1200
|
||||||
|
p.AnimationFrame()
|
||||||
|
}
|
||||||
|
for i := range 3 {
|
||||||
|
lift := float32(0)
|
||||||
|
alpha := float32(1)
|
||||||
|
if !still {
|
||||||
|
phase := math.Mod(t-float64(i)*0.18+1, 1)
|
||||||
|
wave := float32(math.Max(0, math.Sin(phase*2*math.Pi)))
|
||||||
|
lift, alpha = wave*3, 0.45+0.55*wave
|
||||||
|
}
|
||||||
|
x := internal.Snap(p, r.X+5+float32(i)*10)
|
||||||
|
y := internal.Snap(p, r.Y+r.H/2-lift)
|
||||||
|
p.FillPath(internal.Diamond(x, y, 7), k.AccentText.Alpha(alpha))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
text := phase
|
||||||
|
if still {
|
||||||
|
text += "…"
|
||||||
|
}
|
||||||
|
ui.Text(c, text).FontSize(FontSize(c, theme.BodySize)).TextColor(k.TextMuted).Italic()
|
||||||
|
})
|
||||||
|
return row
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package mujica
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/internal"
|
||||||
|
"mujicaui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WelcomeScreenOptions configure a WelcomeScreen. Greeting is required.
|
||||||
|
type WelcomeScreenOptions struct {
|
||||||
|
Greeting string
|
||||||
|
Subtitle string
|
||||||
|
Cards []CapabilityCard
|
||||||
|
// Prompts is the slot for starter prompts, such as SuggestionChips.
|
||||||
|
Prompts func()
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WelcomeScreen greets a new conversation with capability cards and a starter-prompt slot; it reports a chosen example.
|
||||||
|
func WelcomeScreen(c *ui.Context, opts WelcomeScreenOptions) CapabilityCardsView {
|
||||||
|
if opts.Greeting == "" {
|
||||||
|
panic("mujica: WelcomeScreen needs a Greeting")
|
||||||
|
}
|
||||||
|
k := Tokens(c)
|
||||||
|
d := Density(c)
|
||||||
|
var v CapabilityCardsView
|
||||||
|
col := ui.Column(c).FillWidth().MaxWidth(880).AlignSelf(ui.Center).AlignItems(ui.Center).Gap(d.Unit()*4).
|
||||||
|
Padding(d.Unit()*8, d.Unit()*4).Disabled(opts.Disabled)
|
||||||
|
col.Children(func() {
|
||||||
|
ui.Text(c, opts.Greeting).Font(theme.SerifFont).FontSize(FontSize(c, theme.H1Size)).TextColor(k.Text).
|
||||||
|
TextAlign(ui.Center).Role(ui.RoleText)
|
||||||
|
ui.Box(c).Width(220).Height(9).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
|
internal.RuleDiamond(p, r, 8, 6, k.Ornament)
|
||||||
|
})
|
||||||
|
if opts.Subtitle != "" {
|
||||||
|
ui.Text(c, opts.Subtitle).TextColor(k.TextMuted).TextAlign(ui.Center)
|
||||||
|
}
|
||||||
|
if len(opts.Cards) > 0 {
|
||||||
|
v = CapabilityCards(c, opts.Cards, CapabilityCardsOptions{Columns: min(3, len(opts.Cards)), Disabled: opts.Disabled})
|
||||||
|
v.Element.FillWidth()
|
||||||
|
}
|
||||||
|
if opts.Prompts != nil {
|
||||||
|
ui.Column(c).FillWidth().AlignItems(ui.Center).Children(opts.Prompts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
v.Element = col
|
||||||
|
return v
|
||||||
|
}
|
||||||
Reference in new issue
Block a user