diff --git a/go.mod b/go.mod index a01c745..0578e76 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 72014d1..1db1bdf 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 284c019..8bf8c40 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -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) diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..cb89a6d --- /dev/null +++ b/internal/cli/cli.go @@ -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 开启或关闭当前供应商的思考模式") + fmt.Println(" /effort 设置思考强度") + 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 ") + 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 ") + 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 +} diff --git a/internal/cli/complete.go b/internal/cli/complete.go new file mode 100644 index 0000000..b31d807 --- /dev/null +++ b/internal/cli/complete.go @@ -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 +} diff --git a/internal/cli/complete_test.go b/internal/cli/complete_test.go new file mode 100644 index 0000000..88765a2 --- /dev/null +++ b/internal/cli/complete_test.go @@ -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) + } + }) + } +} diff --git a/main.go b/main.go index edee974..11ceccf 100644 --- a/main.go +++ b/main.go @@ -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 开启或关闭当前供应商的思考模式") - fmt.Println(" /effort 设置思考强度") - 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 ") - 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 ") - 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 }