Merge Chat Input (as built)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 15:52:37 -04:00
commit 098c00486a
70 files changed
+2599 -228

No files matched your search

+64 -1
View File
@@ -1,3 +1,66 @@
package main
var chatinputPages = []page{}
const chatinputCategory = "Chat Input"
var chatinputPages = []page{
{ID: "221", Name: "PromptComposer", ZhName: "提示编辑器", Category: chatinputCategory, Demo: chatinputDemoPromptComposer,
Keys: "Enter 发送,Shift+Enter 换行;CmdEnterSends 下使用 Command/Ctrl+Enter;组合保护期间 Enter 留给输入法。",
Code: "var prompt string\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tr := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{Placeholder: \"Ask the herald\"})\n\tif r.Submitted() {\n\t\tfmt.Println(\"send\", prompt)\n\t\tprompt = \"\"\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "222", Name: "AttachmentChip", ZhName: "附件标签", Category: chatinputCategory, Demo: chatinputDemoAttachmentChip,
Keys: "Tab 到移除与重试;Enter、Space 激活。",
Code: "view := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tr := mujica.AttachmentChip(c, mujica.Attachment{Name: \"guests.csv\", Size: 4096, Uploading: true, Progress: 0.4})\n\tif r.Removed() {\n\t\tfmt.Println(\"cancel the upload\")\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "223", Name: "PasteImagePreview", ZhName: "图片预览", Category: chatinputCategory, Demo: chatinputDemoPasteImagePreview,
Keys: "Tab 到移除按钮;Enter、Space 移除。",
Code: "pic := ui.MustParseSVG([]byte(`<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"4\" height=\"3\"/>`))\nimages := []mujica.PastedImage{{ID: \"p1\", Source: pic, Name: \"screenshot\"}}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif id, ok := mujica.PasteImagePreview(c, images).Removed(); ok {\n\t\tfmt.Println(\"remove\", id)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "224", Name: "DragDropOverlay", ZhName: "拖放遮罩", Category: chatinputCategory, Demo: chatinputDemoDragDropOverlay,
Keys: "内容中的操作按其自身键盘规则访问;文件拖入由操作系统驱动。",
Code: "view := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\timages := func(path string) bool { return strings.HasSuffix(path, \".png\") }\n\tr := mujica.DragDropOverlay(c, mujica.DragDropOverlayOptions{Accept: images}, func() {\n\t\tui.Text(c, \"Drag images into the chat\")\n\t})\n\tfor _, path := range r.Accepted() {\n\t\tfmt.Println(\"attach\", path)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "225", Name: "VoiceInputButton", ZhName: "语音输入按钮", Category: chatinputCategory, Demo: chatinputDemoVoiceInputButton,
Keys: "Tab 到按钮;Enter、Space 开始或停止。",
Code: "var rec mujica.VoiceInputOptions\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif mujica.VoiceInputButton(c, rec).Changed() {\n\t\trec = mujica.VoiceInputOptions{Recording: !rec.Recording, Started: time.Now()}\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "226", Name: "VoiceWaveform", ZhName: "输入波形", Category: chatinputCategory, Demo: chatinputDemoVoiceWaveform,
Keys: "展示组件,沿用外层 Tab 顺序。",
Code: "levels := []float32{0.2, 0.6, 0.9, 0.4, 0.1}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tmujica.VoiceWaveform(c, levels, mujica.VoiceWaveformOptions{})\n}\nui.Render(view, 400, 300, 1)"},
{ID: "227", Name: "SlashCommandMenu", ZhName: "斜杠命令菜单", Category: chatinputCategory, Demo: chatinputDemoSlashCommandMenu,
Keys: "编辑器内 Up/Down 选择,Enter 选中,Tab 补全,Escape 关闭。",
Code: "var prompt string\ncommands := []mujica.SlashCommand{{Name: \"summarize\", Description: \"Summarize the thread\"}}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tcomposer := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{})\n\tif cmd, ok := mujica.SlashCommandMenu(c, composer, &prompt, commands).Chosen(); ok {\n\t\tfmt.Println(\"run\", cmd.Name)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "228", Name: "ContextMentionMenu", ZhName: "上下文提及菜单", Category: chatinputCategory, Demo: chatinputDemoContextMentionMenu,
Keys: "编辑器内 Up/Down 选择,Enter 或 Tab 附加,Escape 关闭。",
Code: "var prompt string\nvar found []mujica.ContextItem\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tcomposer := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{})\n\tr := mujica.ContextMentionMenu(c, composer, &prompt, found, mujica.ContextMentionOptions{})\n\tif r.QueryChanged() {\n\t\tfound = []mujica.ContextItem{{ID: \"f1\", Label: r.Query() + \".md\", Kind: mujica.ContextFile}}\n\t}\n\tif item, ok := r.Chosen(); ok {\n\t\tfmt.Println(\"attach\", item.ID)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "229", Name: "ContextChips", ZhName: "上下文标签", Category: chatinputCategory, Demo: chatinputDemoContextChips,
Keys: "Tab 到移除与展开;Enter、Space 激活。",
Code: "items := []mujica.ContextItem{{ID: \"f1\", Label: \"guests.csv\", Kind: mujica.ContextFile}}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif id, ok := mujica.ContextChips(c, items, mujica.ContextChipsOptions{}).Removed(); ok {\n\t\tfmt.Println(\"detach\", id)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "230", Name: "ModelSelector", ZhName: "模型选择器", Category: chatinputCategory, Demo: chatinputDemoModelSelector,
Keys: "Tab 到选择器;Up/Down、Home/End 选择,Enter/Space 确认,Escape 关闭。",
Code: "model := \"herald\"\nmodels := []mujica.ModelOption{{ID: \"herald\", Name: \"Herald 2\", Capabilities: []string{\"Vision\"}, ContextTokens: 200000}}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif mujica.ModelSelector(c, &model, models, mujica.ModelSelectorOptions{}).Changed() {\n\t\tfmt.Println(\"model\", model)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "231", Name: "ModeSelector", ZhName: "模式选择器", Category: chatinputCategory, Demo: chatinputDemoModeSelector,
Keys: "Tab 进入模式组;Left/Right 切换模式。",
Code: "mode := mujica.ModeChat\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif mujica.ModeSelector(c, &mode, mujica.ModeSelectorOptions{}).Changed() {\n\t\tfmt.Println(\"mode\", mode)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "232", Name: "ToolToggleMenu", ZhName: "工具开关菜单", Category: chatinputCategory, Demo: chatinputDemoToolToggleMenu,
Keys: "Tab 到菜单按钮;Enter/Space 打开;Tab 到开关与授权;Escape 关闭。",
Code: "web := true\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\ttools := []mujica.ToolItem{{ID: \"web\", Label: \"Web search\", On: &web}}\n\tif id, ok := mujica.ToolToggleMenu(c, tools).Changed(); ok {\n\t\tfmt.Println(\"toggled\", id)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "233", Name: "ParameterPanel", ZhName: "参数面板", Category: chatinputCategory, Demo: chatinputDemoParameterPanel,
Keys: "Tab 到滑块与重置;方向键按 Step 调整,Home/End 到范围端点;Enter/Space 重置。",
Code: "temperature := 1.0\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tparams := []mujica.ModelParam{{Label: \"Temperature\", Value: &temperature, Min: 0, Max: 2, Step: 0.1, Default: 1}}\n\tmujica.ParameterPanel(c, params)\n}\nui.Render(view, 400, 300, 1)"},
{ID: "234", Name: "TokenCounter", ZhName: "Token计数", Category: chatinputCategory, Demo: chatinputDemoTokenCounter,
Keys: "展示组件,沿用外层 Tab 顺序。",
Code: "view := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tmujica.TokenCounter(c, 1234, 8000)\n}\nui.Render(view, 400, 300, 1)"},
{ID: "235", Name: "ContextWindowMeter", ZhName: "上下文容量", Category: chatinputCategory, Demo: chatinputDemoContextWindowMeter,
Keys: "展示组件,沿用外层 Tab 顺序。",
Code: "segments := []mujica.ContextSegment{{Label: \"System\", Tokens: 1200}, {Label: \"History\", Tokens: 58000}}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tmujica.ContextWindowMeter(c, segments, 128000, mujica.ContextWindowMeterOptions{})\n}\nui.Render(view, 400, 300, 1)"},
{ID: "236", Name: "SendButton", ZhName: "发送按钮", Category: chatinputCategory, Demo: chatinputDemoSendButton,
Keys: "Tab 到按钮;Enter、Space 激活;快捷键提示由调用方绑定。",
Code: "streaming := false\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tr := mujica.SendButton(c, mujica.SendButtonOptions{Streaming: streaming, Shortcut: \"Enter\"})\n\tif r.Sent() || r.Stopped() {\n\t\tstreaming = r.Sent()\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "237", Name: "CitationBadge", ZhName: "引用编号", Category: chatinputCategory, Demo: chatinputDemoCitationBadge,
Keys: "Tab 到引用;Enter、Space 定位;悬停或键盘焦点 500 毫秒后预览;Escape 关闭预览。",
Code: "var cites mujica.CitationState\nsrc := mujica.Source{Title: \"The Palace of Versailles\", URL: \"https://example.com/versailles\"}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif mujica.CitationBadge(c, 1, src, &cites).Changed() {\n\t\tfmt.Println(\"show source\", cites.Active)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "238", Name: "SourceCard", ZhName: "来源卡片", Category: chatinputCategory, Demo: chatinputDemoSourceCard,
Keys: "Tab 到卡片;Enter、Space 请求打开。",
Code: "var cites mujica.CitationState\nsrc := mujica.Source{Title: \"Hall of Mirrors\", URL: \"https://example.org/hall\", Snippet: \"Seventeen arches.\"}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif mujica.SourceCard(c, 1, src, &cites).Clicked() {\n\t\tfmt.Println(\"open\", src.URL)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "239", Name: "SourcesPanel", ZhName: "来源面板", Category: chatinputCategory, Demo: chatinputDemoSourcesPanel,
Keys: "Tab 到可见来源;Enter、Space 请求打开;滚轮滚动;Active 变化定位来源。",
Code: "var cites mujica.CitationState\nsources := []mujica.Source{{Title: \"Hall of Mirrors\", URL: \"https://example.org/hall\"}}\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tif n, ok := mujica.SourcesPanel(c, sources, &cites).Opened(); ok {\n\t\tfmt.Println(\"open\", sources[n-1].URL)\n\t}\n}\nui.Render(view, 400, 300, 1)"},
{ID: "240", Name: "SearchProgress", ZhName: "搜索进度", Category: chatinputCategory, Demo: chatinputDemoSearchProgress,
Keys: "Tab 到折叠标题;Enter、Space 展开或收起。",
Code: "view := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tstages := []mujica.SearchStage{{Label: \"Searching the web\", Done: true}, {Label: \"Reading sources\"}}\n\tmujica.SearchProgress(c, mujica.SearchProgressOptions{Query: \"Versailles\", Read: 3, Total: 8, Stages: stages})\n}\nui.Render(view, 400, 300, 1)"},
}
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func chatinputDemoPromptComposer(c *ui.Context, log func(string)) {
type state struct {
text string
disabled, cmd, composing, attached, web, streaming bool
}
s := ui.Local(c.Root(), "221", func() state { return state{attached: true} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
choiceFlag(c, &s.cmd, "Cmd+Enter sends", log)
choiceFlag(c, &s.composing, "Composition guard", log)
choiceFlag(c, &s.attached, "Attachment", log)
}, func() {
ui.Column(c).Width(520).Children(func() {
r := mujica.PromptComposer(c, &s.text, mujica.PromptComposerOptions{Label: "Prompt", Placeholder: "Ask the herald", Disabled: s.disabled, CmdEnterSends: s.cmd, Composing: s.composing,
Attachments: func() {
if s.attached && mujica.AttachmentChip(c, mujica.Attachment{Name: "guests.csv", Size: 4096}).Removed() {
s.attached = false
log("remove attachment")
}
},
Tools: func() {
if _, ok := mujica.ToolToggleMenu(c, []mujica.ToolItem{{ID: "web", Label: "Web search", On: &s.web}}).Changed(); ok {
log("toggle web search")
}
},
Actions: func() {
r := mujica.SendButton(c, mujica.SendButtonOptions{Streaming: s.streaming, Disabled: strings.TrimSpace(s.text) == "" || s.composing, Shortcut: "Enter"})
if r.Sent() {
s.streaming = true
s.text = ""
log("send")
}
if r.Stopped() {
s.streaming = false
log("stop")
}
},
})
if r.Changed() {
log(fmt.Sprintf("edit %d characters", len(s.text)))
}
if r.Submitted() {
s.text = ""
s.streaming = true
log("submit")
}
})
})
}
func chatinputDemoAttachmentChip(c *ui.Context, log func(string)) {
type state struct {
upload, fail, disabled, removed bool
progress float64
}
s := ui.Local(c.Root(), "222", func() state { return state{upload: true, progress: 0.4} })
choiceDemo(c, func() {
choiceFlag(c, &s.upload, "Uploading", log)
choiceFlag(c, &s.fail, "Failed", log)
choiceFlag(c, &s.disabled, "Disabled", log)
if mujica.Slider(c, &s.progress, mujica.SliderOptions{Min: 0, Max: 1, Step: 0.1, Label: "Upload progress"}).Changed() {
log(fmt.Sprint("progress ", s.progress))
}
if mujica.Button(c, "Restore attachment", mujica.ButtonOptions{}).Clicked() {
s.removed = false
log("restore")
}
}, func() {
ui.Column(c).Disabled(s.disabled).Children(func() {
if s.removed {
ui.Text(c, "Attachment removed")
return
}
a := mujica.Attachment{Name: "guests.csv", Size: 4096, Uploading: s.upload, Progress: s.progress}
if s.fail {
a.Err = errors.New("upload rejected")
}
r := mujica.AttachmentChip(c, a)
if r.Removed() {
s.removed = true
log("remove")
}
if r.Retried() {
s.fail = false
s.upload = true
s.progress = 0
log("retry")
}
})
})
}
func chatinputPictures() []mujica.PastedImage {
var out []mujica.PastedImage
for i, col := range []string{"#7D2034", "#355E80"} {
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72"><rect width="72" height="72" fill="%s"/><circle cx="36" cy="36" r="18" fill="#F2EBDD"/></svg>`, col)
out = append(out, mujica.PastedImage{ID: fmt.Sprint(i), Name: fmt.Sprintf("Picture %d", i+1), Source: ui.MustParseSVG([]byte(svg))})
}
return out
}
func chatinputDemoPasteImagePreview(c *ui.Context, log func(string)) {
type state struct {
images []mujica.PastedImage
disabled bool
}
s := ui.Local(c.Root(), "223", func() state { return state{images: chatinputPictures()} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
if mujica.Button(c, "Restore images", mujica.ButtonOptions{}).Clicked() {
s.images = chatinputPictures()
log("restore images")
}
}, func() {
ui.Column(c).Padding(12).Disabled(s.disabled).Children(func() {
if id, ok := mujica.PasteImagePreview(c, s.images).Removed(); ok {
for i, im := range s.images {
if im.ID == id {
s.images = append(s.images[:i], s.images[i+1:]...)
break
}
}
log("remove image " + id)
}
})
ui.Text(c, "Decoded images arrive from the caller's paste or drop handler.")
})
}
func chatinputDemoDragDropOverlay(c *ui.Context, log func(string)) {
type state struct {
imagesOnly, disabled bool
accepted, rejected int
}
s := ui.Local(c.Root(), "224", func() state { return state{imagesOnly: true} })
choiceDemo(c, func() {
choiceFlag(c, &s.imagesOnly, "PNG only", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
ui.Column(c).Width(520).Height(180).Disabled(s.disabled).Children(func() {
r := mujica.DragDropOverlay(c, mujica.DragDropOverlayOptions{Accept: func(p string) bool { return !s.imagesOnly || strings.EqualFold(filepath.Ext(p), ".png") }}, func() {
ui.Box(c).Fill().Border(1, mujica.Tokens(c).ControlBorder).Center().Children(func() { ui.Text(c, "Drop files here") })
})
r.Element.Fill()
if n := len(r.Accepted()); n > 0 {
s.accepted += n
log(fmt.Sprintf("accept %d files", n))
}
if n := len(r.Rejected()); n > 0 {
s.rejected += n
log(fmt.Sprintf("reject %d files", n))
}
})
ui.Text(c, fmt.Sprintf("Accepted %d · Rejected %d", s.accepted, s.rejected))
})
}
func chatinputDemoVoiceInputButton(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "225", func() mujica.VoiceInputOptions { return mujica.VoiceInputOptions{} })
choiceDemo(c, func() {
choiceFlag(c, &s.Disabled, "Disabled", log)
if mujica.Button(c, "Permission failure", mujica.ButtonOptions{}).Clicked() {
s.Recording = false
s.Denied = true
log("permission denied")
}
}, func() {
if mujica.VoiceInputButton(c, *s).Changed() {
s.Recording = !s.Recording
s.Denied = false
s.Started = c.Now()
log(fmt.Sprintf("recording %v", s.Recording))
}
ui.Text(c, "The caller records audio; this demo changes recording state.")
})
}
func chatinputDemoVoiceWaveform(c *ui.Context, log func(string)) {
type state struct {
silent bool
level float64
}
s := ui.Local(c.Root(), "226", func() state { return state{level: 0.6} })
choiceDemo(c, func() {
choiceFlag(c, &s.silent, "Silent", log)
if mujica.Slider(c, &s.level, mujica.SliderOptions{Min: 0, Max: 1, Step: 0.1, Label: "Input level"}).Changed() {
log(fmt.Sprint("level ", s.level))
}
}, func() {
levels := make([]float32, 40)
for i := range levels {
if !s.silent {
levels[i] = float32(s.level) * float32(i%7+1) / 7
}
}
mujica.VoiceWaveform(c, levels, mujica.VoiceWaveformOptions{})
})
}
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"strings"
)
func chatinputDemoSlashCommandMenu(c *ui.Context, log func(string)) {
type state struct {
text string
disabled, empty bool
}
s := ui.Local(c.Root(), "227", func() state { return state{text: "/"} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
choiceFlag(c, &s.empty, "Empty commands", log)
}, func() {
ui.Column(c).Width(520).Children(func() {
p := mujica.PromptComposer(c, &s.text, mujica.PromptComposerOptions{Label: "Command prompt", Disabled: s.disabled})
commands := []mujica.SlashCommand{{Name: "summarize", Description: "Summarize the thread"}, {Name: "search", Description: "Search the web"}, {Name: "translate", Description: "Translate the message"}}
if s.empty {
commands = nil
}
before := s.text
if cmd, ok := mujica.SlashCommandMenu(c, p, &s.text, commands).Chosen(); ok {
log("command " + cmd.Name)
} else if before != s.text {
log("complete command")
}
if p.Changed() {
log("edit command query")
}
if p.Submitted() {
log("submit prompt")
}
})
})
}
func chatinputContextItems() []mujica.ContextItem {
return []mujica.ContextItem{{ID: "file", Label: "guests.csv", Kind: mujica.ContextFile}, {ID: "doc", Label: "Court protocol", Kind: mujica.ContextDoc}, {ID: "web", Label: "Versailles", Detail: "example.com", Kind: mujica.ContextWeb}}
}
func chatinputDemoContextMentionMenu(c *ui.Context, log func(string)) {
type state struct {
text, query string
items []mujica.ContextItem
loading, disabled bool
}
s := ui.Local(c.Root(), "228", func() state { return state{text: "@", items: chatinputContextItems()} })
choiceDemo(c, func() {
choiceFlag(c, &s.loading, "Loading results", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
ui.Column(c).Width(520).Children(func() {
p := mujica.PromptComposer(c, &s.text, mujica.PromptComposerOptions{Label: "Context prompt", Disabled: s.disabled})
items := s.items
if s.loading {
items = nil
}
r := mujica.ContextMentionMenu(c, p, &s.text, items, mujica.ContextMentionOptions{Loading: s.loading})
if r.QueryChanged() {
s.query = r.Query()
s.items = nil
for _, it := range chatinputContextItems() {
if strings.Contains(strings.ToLower(it.Label), strings.ToLower(s.query)) {
s.items = append(s.items, it)
}
}
c.Invalidate()
log("context query changed")
}
if it, ok := r.Chosen(); ok {
log("attach context " + it.ID)
}
if p.Changed() {
log("edit context prompt")
}
if p.Submitted() {
log("submit prompt")
}
})
})
}
func chatinputDemoContextChips(c *ui.Context, log func(string)) {
type state struct {
items []mujica.ContextItem
max float64
disabled bool
}
s := ui.Local(c.Root(), "229", func() state { return state{items: chatinputContextItems(), max: 2} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
if mujica.NumberInput(c, &s.max, mujica.NumberInputOptions{Min: 1, Max: 5, Step: 1, Label: "Visible chips"}).Changed() {
log(fmt.Sprint("max ", s.max))
}
if mujica.Button(c, "Restore context", mujica.ButtonOptions{}).Clicked() {
s.items = chatinputContextItems()
log("restore context")
}
}, func() {
ui.Column(c).Width(520).Disabled(s.disabled).Children(func() {
if id, ok := mujica.ContextChips(c, s.items, mujica.ContextChipsOptions{Max: int(s.max)}).Removed(); ok {
for i, it := range s.items {
if it.ID == id {
s.items = append(s.items[:i], s.items[i+1:]...)
break
}
}
log("remove context " + id)
}
})
})
}
func chatinputDemoModelSelector(c *ui.Context, log func(string)) {
type state struct {
model string
disabled, unavailable bool
}
s := ui.Local(c.Root(), "230", func() state { return state{model: "herald", unavailable: true} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
choiceFlag(c, &s.unavailable, "Restricted model", log)
}, func() {
models := []mujica.ModelOption{{ID: "herald", Name: "Herald 2", Capabilities: []string{"Vision", "Tools"}, ContextTokens: 200000}, {ID: "page", Name: "Page Mini", Capabilities: []string{"Fast"}, ContextTokens: 32000}, {ID: "oracle", Name: "Oracle", ContextTokens: 1000000}}
if s.unavailable {
models[2].Unavailable = "Needs the Court plan"
}
if mujica.ModelSelector(c, &s.model, models, mujica.ModelSelectorOptions{Disabled: s.disabled}).Changed() {
log("model " + s.model)
}
})
}
func chatinputDemoModeSelector(c *ui.Context, log func(string)) {
type state struct {
mode mujica.ChatMode
icons, disabled bool
}
s := ui.Local(c.Root(), "231", func() state { return state{} })
choiceDemo(c, func() { choiceFlag(c, &s.icons, "Icons only", log); choiceFlag(c, &s.disabled, "Disabled", log) }, func() {
if mujica.ModeSelector(c, &s.mode, mujica.ModeSelectorOptions{IconsOnly: s.icons, Disabled: s.disabled}).Changed() {
log(fmt.Sprint("mode ", s.mode))
}
})
}
func chatinputDemoToolToggleMenu(c *ui.Context, log func(string)) {
type state struct{ web, code, mail, authorized, disabled bool }
s := ui.Local(c.Root(), "232", func() state { return state{web: true} })
choiceDemo(c, func() { choiceFlag(c, &s.authorized, "Authorized", log); choiceFlag(c, &s.disabled, "Disabled", log) }, func() {
ui.Column(c).Disabled(s.disabled).Children(func() {
r := mujica.ToolToggleMenu(c, []mujica.ToolItem{{ID: "web", Label: "Web search", On: &s.web}, {ID: "code", Label: "Code execution", On: &s.code}, {ID: "mail", Label: "Mail", On: &s.mail, NeedsAuth: true, Authorized: s.authorized}})
if id, ok := r.Changed(); ok {
log("toggle " + id)
}
if id, ok := r.Authorize(); ok {
s.authorized = true
log("authorize " + id)
}
})
})
}
func chatinputDemoParameterPanel(c *ui.Context, log func(string)) {
type state struct {
temp, top, length float64
disabled bool
}
s := ui.Local(c.Root(), "233", func() state { return state{temp: 0.7, top: 1, length: 2048} })
choiceDemo(c, func() { choiceFlag(c, &s.disabled, "Disabled", log) }, func() {
ui.Column(c).Width(420).Disabled(s.disabled).Children(func() {
if mujica.ParameterPanel(c, []mujica.ModelParam{{Label: "Temperature", Value: &s.temp, Min: 0, Max: 2, Step: 0.1, Default: 1}, {Label: "Top-p", Value: &s.top, Min: 0, Max: 1, Step: 0.05, Default: 1}, {Label: "Max tokens", Value: &s.length, Min: 256, Max: 8192, Step: 256, Default: 2048}}).Changed() {
log(fmt.Sprintf("parameters %.1f / %.2f / %.0f", s.temp, s.top, s.length))
}
})
})
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"time"
)
func chatinputDemoTokenCounter(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "234", func() float64 { return 1234 })
choiceDemo(c, func() {
if mujica.NumberInput(c, s, mujica.NumberInputOptions{Min: 0, Max: 12000, Step: 100, Label: "Estimated tokens"}).Changed() {
log(fmt.Sprint("estimate ", *s))
}
}, func() {
mujica.TokenCounter(c, int(*s), 8000)
mujica.TokenCounter(c, 7500, 8000)
mujica.TokenCounter(c, 9100, 8000)
})
}
func chatinputDemoContextWindowMeter(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "235", func() float64 { return 58000 })
choiceDemo(c, func() {
if mujica.NumberInput(c, s, mujica.NumberInputOptions{Min: 0, Max: 150000, Step: 1000, Label: "History tokens"}).Changed() {
log(fmt.Sprint("history ", *s))
}
}, func() {
ui.Column(c).Width(520).Children(func() {
mujica.ContextWindowMeter(c, []mujica.ContextSegment{{Label: "System", Tokens: 1200}, {Label: "History", Tokens: int(*s)}, {Label: "Attachments", Tokens: 24000}}, 128000, mujica.ContextWindowMeterOptions{})
})
})
}
func chatinputDemoSendButton(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "236", func() mujica.SendButtonOptions { return mujica.SendButtonOptions{Shortcut: "Enter"} })
choiceDemo(c, func() {
choiceFlag(c, &s.Streaming, "Streaming", log)
choiceFlag(c, &s.Disabled, "Disabled send", log)
}, func() {
r := mujica.SendButton(c, *s)
if r.Sent() {
s.Streaming = true
log("send")
}
if r.Stopped() {
s.Streaming = false
log("stop")
}
})
}
func chatinputSources() []mujica.Source {
return []mujica.Source{{Title: "The Palace of Versailles", URL: "https://example.com/versailles", Site: "example.com", Snippet: "Seat of the court from 1682.", Published: time.Date(2025, 5, 4, 0, 0, 0, 0, time.UTC)}, {Title: "Hall of Mirrors", URL: "https://example.org/hall", Site: "example.org", Snippet: "Seventeen arches face the garden."}, {Title: "Court etiquette", URL: "https://example.net/etiquette", Snippet: "The ranks of the court."}}
}
func chatinputDemoCitationBadge(c *ui.Context, log func(string)) {
type state struct {
cites mujica.CitationState
disabled bool
}
s := ui.Local(c.Root(), "237", func() state { return state{} })
choiceDemo(c, func() { choiceFlag(c, &s.disabled, "Disabled", log) }, func() {
ui.Row(c).Disabled(s.disabled).Gap(8).AlignItems(ui.Center).Children(func() {
ui.Text(c, "The court moved in 1682")
for i, src := range chatinputSources() {
if mujica.CitationBadge(c, i+1, src, &s.cites).Changed() {
log(fmt.Sprint("locate source ", s.cites.Active))
}
}
})
ui.Column(c).Width(360).Height(260).Disabled(s.disabled).Children(func() {
if n, ok := mujica.SourcesPanel(c, chatinputSources(), &s.cites).Opened(); ok {
log(fmt.Sprint("open source ", n))
}
})
})
}
func chatinputDemoSourceCard(c *ui.Context, log func(string)) {
type state struct {
cites mujica.CitationState
selected, disabled bool
}
s := ui.Local(c.Root(), "238", func() state { return state{} })
choiceDemo(c, func() { choiceFlag(c, &s.selected, "Selected", log); choiceFlag(c, &s.disabled, "Disabled", log) }, func() {
s.cites.Active = 0
if s.selected {
s.cites.Active = 1
}
ui.Column(c).Width(400).Disabled(s.disabled).Children(func() {
if mujica.SourceCard(c, 1, chatinputSources()[0], &s.cites).Clicked() {
log("open source 1")
}
})
})
}
func chatinputDemoSourcesPanel(c *ui.Context, log func(string)) {
type state struct {
cites mujica.CitationState
sources []mujica.Source
disabled, empty, many bool
}
s := ui.Local(c.Root(), "239", func() state { return state{sources: chatinputSources()} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
choiceFlag(c, &s.empty, "Empty", log)
if mujica.Switch(c, &s.many, "100,000 sources", mujica.SwitchOptions{}).Changed() {
s.sources = chatinputSources()
s.cites.Active = 0
if s.many {
s.sources = make([]mujica.Source, 100000)
for i := range s.sources {
s.sources[i] = mujica.Source{Title: fmt.Sprintf("Archive %d", i+1), URL: "https://example.com/archive"}
}
}
log(fmt.Sprint("sources ", len(s.sources)))
}
if mujica.Button(c, "Locate last source", mujica.ButtonOptions{Disabled: s.empty}).Clicked() {
s.cites.Active = len(s.sources)
log("locate last source")
}
}, func() {
sources := s.sources
if s.empty {
sources = nil
s.cites.Active = 0
}
ui.Column(c).Width(400).Height(320).Disabled(s.disabled).Children(func() {
if n, ok := mujica.SourcesPanel(c, sources, &s.cites).Opened(); ok {
log(fmt.Sprint("open source ", n))
}
})
})
}
func chatinputDemoSearchProgress(c *ui.Context, log func(string)) {
type state struct {
done, disabled bool
read float64
}
s := ui.Local(c.Root(), "240", func() state { return state{read: 3} })
choiceDemo(c, func() {
choiceFlag(c, &s.done, "Done", log)
choiceFlag(c, &s.disabled, "Disabled", log)
if mujica.NumberInput(c, &s.read, mujica.NumberInputOptions{Min: 0, Max: 8, Step: 1, Label: "Read sources"}).Changed() {
log(fmt.Sprint("read ", s.read))
}
}, func() {
ui.Column(c).Width(440).Disabled(s.disabled).Children(func() {
if mujica.SearchProgress(c, mujica.SearchProgressOptions{Query: "Versailles 1682", Read: int(s.read), Total: 8, Done: s.done, Stages: []mujica.SearchStage{{Label: "Planning queries", Done: true}, {Label: "Searching the web", Done: true}, {Label: "Reading sources", Done: s.done}}}).Changed() {
log("toggle search details")
}
})
})
}
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"image/png"
"os"
"path/filepath"
"testing"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func chatinputDemoTester(t *testing.T, id string, mode mujica.Mode) (*ui.Tester, *[]string) {
t.Helper()
var p *page
for i := range chatinputPages {
if chatinputPages[i].ID == id {
p = &chatinputPages[i]
break
}
}
if p == nil {
t.Fatalf("missing page %s", id)
}
events := new([]string)
tt := ui.NewTester(func(c *ui.Context) {
mujica.Use(c, mujica.Settings{Mode: mode})
ui.Scroll(c).Fill().Padding(24).Gap(16).Children(func() { p.Demo(c, func(s string) { *events = append(*events, s) }) })
}, 900, 800)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
return tt, events
}
func TestChatinputDemosRender(t *testing.T) {
if len(chatinputPages) != 20 {
t.Fatalf("pages %d, want 20", len(chatinputPages))
}
for _, p := range chatinputPages {
if p.Category != "Chat Input" || p.Code == "" || p.Keys == "" {
t.Errorf("incomplete page %s", p.ID)
}
for _, mode := range []mujica.Mode{mujica.Light, mujica.Dark} {
tt, _ := chatinputDemoTester(t, p.ID, mode)
if tt.HasText("panic") {
t.Errorf("%s: panic shown", p.ID)
}
if dir := os.Getenv("CHATINPUT_DEMO_DIR"); dir != "" {
f, err := os.Create(filepath.Join(dir, p.ID+"-"+mode.String()+".png"))
if err != nil {
t.Fatal(err)
}
if err := png.Encode(f, tt.Image()); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
}
}
}
}
func TestChatinputDemosEvents(t *testing.T) {
for _, tc := range []struct{ id, click string }{
{"221", "Prompt"}, {"222", "Remove guests.csv"}, {"223", "Remove image Picture 1"}, {"225", "Start voice input"},
{"227", "Command prompt"}, {"228", "Context prompt"}, {"229", "Remove guests.csv"}, {"230", "Model"},
{"231", "Deep research"}, {"232", "Tools, 1 on"}, {"233", "Reset Temperature"}, {"236", "Send"},
{"237", "Source 1: The Palace of Versailles"}, {"238", "Open The Palace of Versailles"},
{"239", "Locate last source"}, {"240", "Searching for “Versailles 1682”"},
} {
tt, events := chatinputDemoTester(t, tc.id, mujica.Light)
if err := tt.Click(tc.click); err != nil {
t.Fatalf("%s: %v", tc.id, err)
}
switch tc.id {
case "221":
tt.Type("hello")
tt.Key(0, ui.KeyEnter)
case "227":
tt.Type("s")
tt.Key(0, ui.KeyTab)
case "228":
tt.Key(0, ui.KeyEnter)
case "230":
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
case "232":
if err := tt.Click("Code execution"); err != nil {
t.Fatal(err)
}
}
if len(*events) == 0 {
t.Errorf("%s: action logged no event", tc.id)
}
}
}