增加/context命令、Tab补全与输入历史,命令处理提取到internal/cli

This commit is contained in:
2026-08-14 16:31:03 +08:00
parent 068c5fccf1
commit ce49bb9c1e
7 changed files with 203 additions and 77 deletions
+3
View File
@@ -4,12 +4,15 @@ go 1.26.4
require (
github.com/openai/openai-go v1.12.0
github.com/peterh/liner v1.2.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/mattn/go-runewidth v0.0.3 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
golang.org/x/sys v0.29.0 // indirect
)
+7
View File
@@ -1,5 +1,9 @@
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw=
github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -10,6 +14,9 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+22
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
@@ -83,6 +84,27 @@ func (b *Bot) ThinkingConfig() (string, string) {
return b.provider.Thinking, b.provider.ReasoningEffort
}
func (b *Bot) ContextDump() string {
var sb strings.Builder
sb.WriteString("[系统] " + b.systemPrompt + "\n")
for i, msg := range b.history {
role, content := "", ""
switch {
case msg.OfUser != nil:
role, content = "用户", msg.OfUser.Content.OfString.Value
case msg.OfAssistant != nil:
role, content = "机器人", msg.OfAssistant.Content.OfString.Value
case msg.OfSystem != nil:
role, content = "系统", msg.OfSystem.Content.OfString.Value
}
if content == "" {
content = "[多模态内容]"
}
sb.WriteString(fmt.Sprintf("[%d] %s: %s\n", i+1, role, content))
}
return sb.String()
}
func (b *Bot) Chat(ctx context.Context, userMsg string, onReasoning, onContent func(string)) (string, error) {
if b.provider.APIKey == "" {
return "", fmt.Errorf("供应商 %s 未配置 api_key,请编辑 data/config.yaml", b.provider.Name)
+86
View File
@@ -0,0 +1,86 @@
package cli
import (
"fmt"
"strings"
"myaibot/internal/bot"
)
type Handler struct {
bot *bot.Bot
}
func New(b *bot.Bot) *Handler {
return &Handler{bot: b}
}
func (h *Handler) Handle(input string) bool {
fields := strings.Fields(input)
cmd, args := fields[0], fields[1:]
switch cmd {
case "/exit", "/quit":
fmt.Println("再见!")
return false
case "/help":
fmt.Println("命令列表:")
fmt.Println(" /models 列出所有供应商和模型")
fmt.Println(" /use <模型> 切换模型,如 /use deepseek-chat 或 /use deepseek/deepseek-chat")
fmt.Println(" /think <on|off> 开启或关闭当前供应商的思考模式")
fmt.Println(" /effort <low|high|max> 设置思考强度")
fmt.Println(" /context 打印当前聊天上下文")
fmt.Println(" /info 显示当前供应商、模型和思考配置")
fmt.Println(" /exit 退出")
case "/models":
for _, m := range h.bot.Models() {
fmt.Println(" " + m)
}
case "/use":
if len(args) == 0 {
fmt.Println("用法: /use <模型>,如 /use deepseek-chat")
return true
}
if err := h.bot.SwitchModel(args[0]); err != nil {
fmt.Printf("⚠️ %v\n", err)
return true
}
provider, model := h.bot.Current()
fmt.Printf("已切换到 %s/%s (对话历史已保留)\n", provider, model)
case "/think":
if len(args) == 0 {
fmt.Println("用法: /think <on|off>")
return true
}
v := map[string]string{"on": "enabled", "off": "disabled"}[args[0]]
if err := h.bot.SetThinking(v); err != nil {
fmt.Printf("⚠️ %v\n", err)
return true
}
fmt.Printf("思考模式已%s\n", map[string]string{"enabled": "开启", "disabled": "关闭"}[v])
case "/effort":
if len(args) == 0 {
fmt.Println("用法: /effort <low|high|max>")
return true
}
if err := h.bot.SetEffort(args[0]); err != nil {
fmt.Printf("⚠️ %v\n", err)
return true
}
fmt.Printf("思考强度已设置为 %s\n", args[0])
case "/context":
fmt.Print(h.bot.ContextDump())
case "/info":
provider, model := h.bot.Current()
thinking, effort := h.bot.ThinkingConfig()
if thinking == "" {
thinking = "enabled(默认)"
}
if effort == "" {
effort = "high(默认)"
}
fmt.Printf("供应商: %s, 模型: %s, 思考模式: %s, 思考强度: %s\n", provider, model, thinking, effort)
default:
fmt.Printf("未知命令: %s,输入 /help 查看命令列表\n", cmd)
}
return true
}
+35
View File
@@ -0,0 +1,35 @@
package cli
import "strings"
var commands = []string{"/exit", "/quit", "/help", "/models", "/use", "/think", "/effort", "/context", "/info"}
func Complete(line string, models []string) []string {
fields := strings.Fields(line)
switch len(fields) {
case 0:
return commands
case 1:
return prefixMatch(commands, fields[0])
}
arg := fields[1]
switch fields[0] {
case "/use":
return prefixMatch(models, arg)
case "/think":
return prefixMatch([]string{"on", "off"}, arg)
case "/effort":
return prefixMatch([]string{"low", "high", "max"}, arg)
}
return nil
}
func prefixMatch(list []string, prefix string) []string {
var out []string
for _, s := range list {
if strings.HasPrefix(s, prefix) {
out = append(out, s)
}
}
return out
}
+31
View File
@@ -0,0 +1,31 @@
package cli
import (
"reflect"
"testing"
)
func TestComplete(t *testing.T) {
models := []string{"deepseek-v4-flash", "deepseek-v4-pro", "gpt-4o"}
cases := []struct {
name string
line string
want []string
}{
{"空行返回全部命令", "", commands},
{"命令前缀", "/us", []string{"/use"}},
{"模型补全", "/use deepseek", []string{"deepseek-v4-flash", "deepseek-v4-pro"}},
{"模型无匹配", "/use claude", nil},
{"think 补全", "/think o", []string{"on", "off"}},
{"effort 补全", "/effort h", []string{"high"}},
{"未知命令不补全", "/foo a", nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := Complete(c.line, models)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("Complete(%q) = %v, want %v", c.line, got, c.want)
}
})
}
}
+19 -77
View File
@@ -1,14 +1,17 @@
package main
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log"
"os"
"strings"
"github.com/peterh/liner"
"myaibot/internal/bot"
"myaibot/internal/cli"
"myaibot/internal/config"
)
@@ -22,18 +25,27 @@ func main() {
fmt.Printf("🤖 %s 已启动 (供应商: %s, 模型: %s)。输入问题开始对话,输入 /help 查看命令。\n",
cfg.BotName, provider, model)
scanner := bufio.NewScanner(os.Stdin)
line := liner.NewLiner()
defer line.Close()
line.SetCtrlCAborts(true)
line.SetCompleter(func(s string) []string {
return cli.Complete(s, b.Models())
})
h := cli.New(b)
for {
fmt.Print("你: ")
if !scanner.Scan() {
input, err := line.Prompt("你: ")
if errors.Is(err, io.EOF) || errors.Is(err, liner.ErrPromptAborted) {
fmt.Println("再见!")
break
}
input := strings.TrimSpace(scanner.Text())
input = strings.TrimSpace(input)
if input == "" {
continue
}
line.AppendHistory(input)
if strings.HasPrefix(input, "/") {
if !handleCommand(b, input) {
if !h.Handle(input) {
break
}
continue
@@ -62,74 +74,4 @@ func main() {
continue
}
}
if err := scanner.Err(); err != nil {
log.Fatal(err)
}
}
func handleCommand(b *bot.Bot, input string) bool {
fields := strings.Fields(input)
cmd, args := fields[0], fields[1:]
switch cmd {
case "/exit", "/quit":
fmt.Println("再见!")
return false
case "/help":
fmt.Println("命令列表:")
fmt.Println(" /models 列出所有供应商和模型")
fmt.Println(" /use <模型> 切换模型,如 /use deepseek-chat 或 /use deepseek/deepseek-chat")
fmt.Println(" /think <on|off> 开启或关闭当前供应商的思考模式")
fmt.Println(" /effort <low|high|max> 设置思考强度")
fmt.Println(" /info 显示当前供应商、模型和思考配置")
fmt.Println(" /exit 退出")
case "/models":
for _, m := range b.Models() {
fmt.Println(" " + m)
}
case "/use":
if len(args) == 0 {
fmt.Println("用法: /use <模型>,如 /use deepseek-chat")
return true
}
if err := b.SwitchModel(args[0]); err != nil {
fmt.Printf("⚠️ %v\n", err)
return true
}
provider, model := b.Current()
fmt.Printf("已切换到 %s/%s (对话历史已保留)\n", provider, model)
case "/think":
if len(args) == 0 {
fmt.Println("用法: /think <on|off>")
return true
}
v := map[string]string{"on": "enabled", "off": "disabled"}[args[0]]
if err := b.SetThinking(v); err != nil {
fmt.Printf("⚠️ %v\n", err)
return true
}
fmt.Printf("思考模式已%s\n", map[string]string{"enabled": "开启", "disabled": "关闭"}[v])
case "/effort":
if len(args) == 0 {
fmt.Println("用法: /effort <low|high|max>")
return true
}
if err := b.SetEffort(args[0]); err != nil {
fmt.Printf("⚠️ %v\n", err)
return true
}
fmt.Printf("思考强度已设置为 %s\n", args[0])
case "/info":
provider, model := b.Current()
thinking, effort := b.ThinkingConfig()
if thinking == "" {
thinking = "enabled(默认)"
}
if effort == "" {
effort = "high(默认)"
}
fmt.Printf("供应商: %s, 模型: %s, 思考模式: %s, 思考强度: %s\n", provider, model, thinking, effort)
default:
fmt.Printf("未知命令: %s,输入 /help 查看命令列表\n", cmd)
}
return true
}