diff --git a/README.md b/README.md index f771cb4..ccb764c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,204 @@ -# agent-chain +# 学习助手 (Learning Assistant) -👑 多智能体协同调用 \ No newline at end of file +一个基于AI知识的交互式学习工具,通过生成多种形式的内容帮助你快速掌握知识点。 + +## 功能特性 + +- 🎓 **多形式学习内容**:为知识点生成5种不同形式的学习内容 + - 📊 **Mermaid图表**:知识结构可视化,自动复制到剪切板 + - 🎭 **郭德纲式讲解**:幽默风趣的知识讲解 + - ✍️ **十道考核题**:带解析的选择题,检验学习效果 + - 📝 **记忆卡片**:结构化的关键知识点总结 + - 🏗️ **工程场景**:实际应用案例和代码示例 + +- 🖥️ **交互式TUI界面**:使用Bubble Tea构建的终端用户界面 + - Tab切换查看不同内容 + - 键盘导航(←/→翻页,↑/↓滚动) + - 流畅的用户体验 + +- 🤖 **AI驱动**:基于硅基流动AI服务,使用DeepSeek模型 + +## 安装 + +### 前置要求 + +- Go 1.25.6 或更高版本 +- 硅基流动API密钥 + +### 安装步骤 + +1. 克隆仓库 +```bash +git clone https://github.com/yourusername/learning-assistant.git +cd learning-assistant +``` + +2. 安装依赖 +```bash +go mod tidy +``` + +3. 构建程序 +```bash +go build -o learning.exe cmd/main.go +``` + +4. 设置API密钥 +```bash +export SILICON_API_KEY=your_api_key_here +``` + +## 使用方法 + +### 基本使用 + +```bash +./learning.exe +``` + +### 操作流程 + +1. 程序启动后,输入你想学习的知识点 +2. 按 Enter 键开始生成 +3. 等待AI生成完成(后台并发生成5种内容) +4. 使用 ←/→ 键切换不同的Tab查看内容 +5. 使用 ↑/↓ 键滚动长内容 +6. 按 q 或 Ctrl+C 退出程序 + +### 键盘快捷键 + +| 按键 | 功能 | +|------|------| +| Enter | 确认输入/开始生成 | +| ← | 切换到上一个Tab | +| → | 切换到下一个Tab | +| ↑ | 向上滚动内容 | +| ↓ | 向下滚动内容 | +| q | 退出程序 | +| Ctrl+C | 退出程序 | + +## 配置文件 + +配置文件位于 `config/config.yaml`: + +```yaml +api: + endpoint: "https://api.siliconflow.cn/v1" + model: "deepseek-ai/DeepSeek-V3" + timeout: 120 + max_retries: 3 + +ui: + width: 100 + height: 30 + colors: + primary: "#7D56F4" + secondary: "#04B575" + text: "#FAFAFA" + background: "#232323" + border: "#586069" +``` + +## 项目结构 + +``` +learning-assistant/ +├── cmd/ +│ └── main.go # 程序入口 +├── internal/ +│ ├── ai/ # AI集成层 +│ │ ├── client.go # 硅基流动API客户端 +│ │ ├── prompt.go # 提示词管理 +│ │ ├── prompts/ # 提示词模板 +│ │ └── generators/ # 内容生成器 +│ ├── clipboard/ # 剪切板操作 +│ └── tui/ # TUI界面层 +│ ├── model.go # TUI模型 +│ ├── view.go # 视图渲染 +│ └── update.go # 事件处理 +├── config/ +│ └── config.yaml # 配置文件 +├── go.mod +├── go.sum +└── README.md +``` + +## 技术栈 + +- **语言**:Go 1.25.6 +- **TUI框架**:[Bubble Tea](https://github.com/charmbracelet/bubbletea) +- **样式库**:[Lip Gloss](https://github.com/charmbracelet/lipgloss) +- **剪切板**:[clipboard](https://github.com/atotto/clipboard) +- **AI服务**:硅基流动(DeepSeek模型) + +## 示例 + +### 输入示例 + +``` +🎓 学习助手 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +请输入您想学习的知识点: Redis缓存策略 +``` + +### 输出示例 + +程序会生成以下内容: + +1. Mermaid图表 - 显示Redis缓存策略的思维导图 +2. 郭德纲式讲解 - 用幽默的方式讲解缓存策略 +3. 十道考核题 - 测试对Redis缓存的理解 +4. 记忆卡片 - 总结核心知识点 +5. 工程场景 - 展示实际应用案例 + +## 常见问题 + +### Q: 如何获取硅基流动API密钥? + +A: 访问 [硅基流动官网](https://cloud.siliconflow.cn) 注册并获取API密钥。 + +### Q: 生成失败怎么办? + +A: 检查以下几点: +1. API密钥是否正确设置 +2. 网络连接是否正常 +3. API额度是否充足 + +### Q: 如何更改UI主题颜色? + +A: 编辑 `config/config.yaml` 文件中的 `ui.colors` 配置项。 + +## 开发 + +### 运行开发版本 + +```bash +go run cmd/main.go +``` + +### 构建 + +```bash +go build -o learning.exe cmd/main.go +``` + +### 依赖管理 + +```bash +go mod tidy +go mod verify +``` + +## 许可证 + +MIT License + +## 贡献 + +欢迎提交Issue和Pull Request! + +## 致谢 + +- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - 强大的TUI框架 +- [硅基流动](https://cloud.siliconflow.cn) - AI服务提供商 diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..5a56137 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "bufio" + "fmt" + "learning-assistant/internal/ai" + "learning-assistant/internal/tui" + "os" + "os/signal" + "syscall" + + tea "github.com/charmbracelet/bubbletea" + "gopkg.in/yaml.v3" +) + +func main() { + // 加载配置 + config, err := loadConfig("../config/config.yaml") + if err != nil { + fmt.Printf("加载配置文件失败: %v\n", err) + os.Exit(1) + } + + // 获取API密钥 + // apiKey := getAPIKey() + // if apiKey == "" { + // fmt.Println("错误: 未设置API密钥") + // fmt.Println("请设置环境变量 SILICON_API_KEY 或在配置文件中指定") + // os.Exit(1) + // } + + // 创建AI客户端 + client := ai.NewClient(config.API) + + // 创建提示词管理器 + promptManager := ai.NewPromptManager() + + // 读取用户输入 + fmt.Println("🎓 学习助手") + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Println() + fmt.Print("请输入您想学习的知识点: ") + + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return + } + + knowledge := scanner.Text() + if knowledge == "" { + fmt.Println("输入不能为空") + return + } + + // 创建模型 + model := tui.NewModel(client, promptManager) + model.SetInput(knowledge) + + // 设置信号处理 + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // 创建程序 + p := tea.NewProgram(&model, tea.WithAltScreen()) + + // 启动程序 + if _, err := p.Run(); err != nil { + fmt.Printf("启动失败: %v\n", err) + os.Exit(1) + } +} + +// Config 配置 +type Config struct { + API ai.Config +} + +func loadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, err + } + + return &config, nil +} + +// getAPIKey 获取API密钥 +func getAPIKey() string { + // 优先从环境变量获取 + if key := os.Getenv("SILICON_API_KEY"); key != "" { + return key + } + + return "" +} diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..43659f6 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,19 @@ +api: + endpoint: "https://api.siliconflow.cn/v1" + model: "Pro/deepseek-ai/DeepSeek-V3.2" + timeout: 120 + max_retries: 3 + api_key: sk-rrizhcztbcpqvzszpsosxbylpuaajjropmzrcdzlqynhopzm + +ui: + width: 100 + height: 30 + colors: + primary: "#7D56F4" + secondary: "#04B575" + text: "#FAFAFA" + background: "#232323" + border: "#586069" + styles: + bold: true + rounded: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..06e55fc --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module learning-assistant + +go 1.25.6 + +require ( + github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/bubbletea v1.3.2 + github.com/charmbracelet/lipgloss v1.0.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6829fd8 --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.2 h1:nc+gDivH0P8ii8CUcf3zCN/PiUz7LKbp3Iz+vYPScNY= +github.com/charmbracelet/bubbletea v1.3.2/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +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= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/ai/client.go b/internal/ai/client.go new file mode 100644 index 0000000..258fe06 --- /dev/null +++ b/internal/ai/client.go @@ -0,0 +1,155 @@ +package ai + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// SiliconFlowClient 硅基流动API客户端 +type SiliconFlowClient struct { + apiKey string + baseURL string + model string + timeout time.Duration + maxRetries int +} + +// Config AI配置 +type Config struct { + Endpoint string `yaml:"endpoint"` + Model string `yaml:"model"` + Timeout int `yaml:"timeout"` + MaxRetries int `yaml:"max_retries"` + ApiKey string `yaml:"api_key"` +} + +// ChatMessage 聊天消息 +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// ChatRequest 聊天请求 +type ChatRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + Temperature float64 `json:"temperature"` + Stream bool `json:"stream"` +} + +// ChatResponse 聊天响应 +type ChatResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []Choice `json:"choices"` + Usage Usage `json:"usage"` +} + +// Choice 选择项 +type Choice struct { + Index int `json:"index"` + Message ChatMessage `json:"message"` + FinishReason string `json:"finish_reason"` +} + +// Usage 使用情况 +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// NewClient 创建新的AI客户端 +func NewClient(config Config) *SiliconFlowClient { + return &SiliconFlowClient{ + apiKey: config.ApiKey, + baseURL: config.Endpoint, + model: config.Model, + timeout: time.Duration(config.Timeout) * time.Second, + maxRetries: config.MaxRetries, + } +} + +// Generate 生成内容 +func (c *SiliconFlowClient) Generate(prompt string) (string, error) { + reqBody := ChatRequest{ + Model: c.model, + Messages: []ChatMessage{ + { + Role: "user", + Content: prompt, + }, + }, + Temperature: 0.7, + Stream: false, + } + + reqJSON, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + var lastErr error + for attempt := 0; attempt < c.maxRetries; attempt++ { + result, err := c.sendRequest(reqJSON) + if err == nil { + return result, nil + } + + lastErr = err + + if attempt < c.maxRetries-1 { + time.Sleep(time.Duration(attempt+1) * time.Second) + } + } + + return "", fmt.Errorf("failed after %d attempts: %w", c.maxRetries, lastErr) +} + +func (c *SiliconFlowClient) sendRequest(reqJSON []byte) (string, error) { + url := c.baseURL + "/chat/completions" + + req, err := http.NewRequest("POST", url, bytes.NewReader(reqJSON)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Timeout: c.timeout, + } + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("API returned status code %d: %s", resp.StatusCode, string(body)) + } + + var chatResp ChatResponse + if err := json.Unmarshal(body, &chatResp); err != nil { + return "", fmt.Errorf("failed to unmarshal response: %w", err) + } + + if len(chatResp.Choices) == 0 { + return "", fmt.Errorf("no choices in response") + } + + return chatResp.Choices[0].Message.Content, nil +} diff --git a/internal/ai/generators/generator.go b/internal/ai/generators/generator.go new file mode 100644 index 0000000..329a90a --- /dev/null +++ b/internal/ai/generators/generator.go @@ -0,0 +1,63 @@ +package generators + +import ( + "learning-assistant/internal/ai" +) + +// GeneratorType 生成器类型 +type GeneratorType string + +const ( + GeneratorMermaid GeneratorType = "mermaid" + GeneratorGuodegang GeneratorType = "guodegang" + GeneratorQuiz GeneratorType = "quiz" + GeneratorCard GeneratorType = "card" + GeneratorScenario GeneratorType = "scenario" +) + +// GenerateResult 生成结果 +type GenerateResult struct { + Type GeneratorType + Content string + Error error +} + +// BaseGenerator 基础生成器 +type BaseGenerator struct { + client *ai.SiliconFlowClient + promptManager *ai.PromptManager +} + +// NewBaseGenerator 创建基础生成器 +func NewBaseGenerator(client *ai.SiliconFlowClient, promptManager *ai.PromptManager) *BaseGenerator { + return &BaseGenerator{ + client: client, + promptManager: promptManager, + } +} + +// Generate 生成内容 +func (g *BaseGenerator) Generate(genType GeneratorType, knowledge string, resultChan chan<- GenerateResult) { + prompt, err := g.promptManager.GeneratePrompt(string(genType), knowledge) + if err != nil { + resultChan <- GenerateResult{ + Type: genType, + Error: err, + } + return + } + + content, err := g.client.Generate(prompt) + if err != nil { + resultChan <- GenerateResult{ + Type: genType, + Error: err, + } + return + } + + resultChan <- GenerateResult{ + Type: genType, + Content: content, + } +} diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go new file mode 100644 index 0000000..3eec391 --- /dev/null +++ b/internal/ai/prompt.go @@ -0,0 +1,71 @@ +package ai + +import ( + "embed" + "fmt" + "strings" + "text/template" +) + +//go:embed prompts/*.txt +var promptFS embed.FS + +// PromptManager 提示词管理器 +type PromptManager struct { + templates map[string]*template.Template +} + +// NewPromptManager 创建提示词管理器 +func NewPromptManager() *PromptManager { + pm := &PromptManager{ + templates: make(map[string]*template.Template), + } + + pm.loadTemplates() + return pm +} + +func (pm *PromptManager) loadTemplates() { + templateFiles := map[string]string{ + "mermaid": "prompts/mermaid.txt", + "guodegang": "prompts/guodegang.txt", + "quiz": "prompts/quiz.txt", + "card": "prompts/card.txt", + "scenario": "prompts/scenario.txt", + } + + for name, path := range templateFiles { + content, err := promptFS.ReadFile(path) + if err != nil { + continue + } + + tmpl, err := template.New(name).Parse(string(content)) + if err != nil { + continue + } + + pm.templates[name] = tmpl + } +} + +// GeneratePrompt 生成提示词 +func (pm *PromptManager) GeneratePrompt(templateType string, knowledge string) (string, error) { + tmpl, ok := pm.templates[templateType] + if !ok { + return "", fmt.Errorf("template type %s not found", templateType) + } + + var buf strings.Builder + data := struct { + Knowledge string + }{ + Knowledge: knowledge, + } + + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + + return buf.String(), nil +} diff --git a/internal/ai/prompts/card.txt b/internal/ai/prompts/card.txt new file mode 100644 index 0000000..30bce65 --- /dev/null +++ b/internal/ai/prompts/card.txt @@ -0,0 +1,23 @@ +为"{{.Knowledge}}"制作知识记忆卡片,帮助用户快速掌握关键知识点,要求: +1. 结构清晰,重点突出 +2. 使用emoji图标增强可读性 +3. 每个部分简洁明了,方便记忆 + +输出格式: +📌 核心概念:[精确的知识点定义,50字以内] + +🔑 关键要点: + - 要点1(具体说明,20字以内) + - 要点2(具体说明,20字以内) + - 要点3(具体说明,20字以内) + - 要点4(具体说明,20字以内) + +⚠️ 常见误区: + - 误区1:[说明] + - 误区2:[说明] + +✅ 最佳实践: + - 实践1:[具体建议] + - 实践2:[具体建议] + +🎯 记忆口诀:[朗朗上口的口诀,帮助记忆] diff --git a/internal/ai/prompts/guodegang.txt b/internal/ai/prompts/guodegang.txt new file mode 100644 index 0000000..d52982d --- /dev/null +++ b/internal/ai/prompts/guodegang.txt @@ -0,0 +1,12 @@ +用北京话、郭德纲相声演员的口吻讲解"{{.Knowledge}}",要求: +1. 开头用"咳咳,咱今儿个来说说这个{{.Knowledge}}..."作为开场 +2. 中间穿插一些相声评书风格的生动比喻和例子 +3. 语气轻松诙谐、接地气,但内容要准确专业 +4. 偶尔使用相声常用语,比如"您听好了"、"这就好比"、"想当年"、"您猜怎么着" +5. 每讲完一个要点,可以用"这话怎么说呢?"来过渡 +6. 最后用"您记住了吗?下次要是有人问起来,您就把这么一通摆出来,准保让他对您刮目相看!"收尾 + +注意: +- 不要过度夸张,保持内容的准确性和专业性 +- 语言要通俗易懂,避免过于晦涩的专业术语 +- 字数控制在300-500字 diff --git a/internal/ai/prompts/mermaid.txt b/internal/ai/prompts/mermaid.txt new file mode 100644 index 0000000..b1e4abe --- /dev/null +++ b/internal/ai/prompts/mermaid.txt @@ -0,0 +1,19 @@ +请为"{{.Knowledge}}"生成一个Mermaid思维导图或流程图,要求: +1. 使用graph TD或flowchart TD格式 +2. 清晰展示知识点的结构层次和关系 +3. 关键节点使用中文标签 +4. 适当使用不同的节点样式区分概念类型 +5. 保持代码简洁,便于复制使用 + +输出格式(只输出代码,不要其他说明文字): +```mermaid +graph TD + A[{{.Knowledge}}] --> B(核心概念1) + A --> C(核心概念2) + B --> B1(子概念1.1) + B --> B2(子概念1.2) + C --> C1(子概念2.1) + style A fill:#f9f,stroke:#333,stroke-width:4px + style B fill:#bbf,stroke:#333,stroke-width:2px + style C fill:#bbf,stroke:#333,stroke-width:2px +``` diff --git a/internal/ai/prompts/quiz.txt b/internal/ai/prompts/quiz.txt new file mode 100644 index 0000000..cc17e56 --- /dev/null +++ b/internal/ai/prompts/quiz.txt @@ -0,0 +1,22 @@ +关于"{{.Knowledge}}",生成10道单选题,要求: +1. 题目类型包括:概念理解、原理分析、应用场景、最佳实践等 +2. 难度分布:3道基础题、4道中级题、3道进阶题 +3. 每道题都要提供详细的解析说明 +4. 选项之间要有明显的区别,避免模棱两可 + +必须严格按照以下JSON格式输出(只输出JSON,不要其他说明文字): +{ + "questions": [ + { + "question": "题目文字,准确描述问题", + "options": [ + "A. 选项内容", + "B. 选项内容", + "C. 选项内容", + "D. 选项内容" + ], + "answer": "A", + "explanation": "详细解析为什么选这个答案,以及为什么其他选项不对" + } + ] +} diff --git a/internal/ai/prompts/scenario.txt b/internal/ai/prompts/scenario.txt new file mode 100644 index 0000000..7a03c8b --- /dev/null +++ b/internal/ai/prompts/scenario.txt @@ -0,0 +1,48 @@ +为"{{.Knowledge}}"提供3个实际的工程应用场景,要求: +1. 每个场景都要具体、真实、有代表性 +2. 包含完整的问题背景、解决方案、代码示例 +3. 代码使用伪代码或简化的实现,注重逻辑清晰 +4. 说明使用该知识点解决了什么核心问题 +5. 强调注意事项和最佳实践 + +输出格式: + +场景1:[场景名称,如"高并发缓存更新"] +背景: + [描述实际业务场景和遇到的问题] + +方案: + [说明如何使用{{.Knowledge}}解决问题,分步骤说明] + +代码示例: + [使用伪代码或简化代码展示实现方式] + +注意事项: + - 关键点1 + - 关键点2 + +场景2:[场景名称] +背景: + ... + +方案: + ... + +代码示例: + ... + +注意事项: + - ... + +场景3:[场景名称] +背景: + ... + +方案: + ... + +代码示例: + ... + +注意事项: + - ... diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go new file mode 100644 index 0000000..3a91ebe --- /dev/null +++ b/internal/clipboard/clipboard.go @@ -0,0 +1,39 @@ +package clipboard + +import ( + "fmt" + "runtime" + + "github.com/atotto/clipboard" +) + +var isClipboardAvailable bool + +func init() { + // 检测剪切板是否可用 + // Linux需要xclip或xsel + if runtime.GOOS == "linux" { + isClipboardAvailable = isXClipAvailable() + } else { + isClipboardAvailable = true + } +} + +func isXClipAvailable() bool { + // 简单检查,实际使用时会在调用时检测 + return true +} + +// Write 将文本复制到剪切板 +func Write(text string) error { + if !isClipboardAvailable { + return fmt.Errorf("clipboard not available on this system") + } + return clipboard.WriteAll(text) +} + +// WriteSilent 静默复制到剪切板,不返回错误 +func WriteSilent(text string) bool { + err := Write(text) + return err == nil +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..30b196c --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,148 @@ +package tui + +import ( + "learning-assistant/internal/ai" + "learning-assistant/internal/ai/generators" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// State TUI状态 +type State string + +const ( + StateInput State = "input" + StateLoading State = "loading" + StateResult State = "result" + StateError State = "error" +) + +// TabType Tab类型 +type TabType string + +const ( + TabGuodegang TabType = "guodegang" + TabQuiz TabType = "quiz" + TabCard TabType = "card" + TabScenario TabType = "scenario" +) + +// TabItem Tab项 +type TabItem struct { + Type TabType + Label string + Icon string +} + +// Model TUI模型 +type Model struct { + state State + + // AI组件 + client *ai.SiliconFlowClient + promptManager *ai.PromptManager + generator *generators.BaseGenerator + + // 用户输入 + inputText string + + // 生成结果 + results map[TabType]string + + // 加载状态 + loadingStatus map[generators.GeneratorType]bool + + // Tab管理 + tabs []TabItem + activeTab int + + // 错误信息 + errorMsg string +} + +// NewModel 创建模型 +func NewModel(client *ai.SiliconFlowClient, promptManager *ai.PromptManager) Model { + return Model{ + state: StateResult, + client: client, + promptManager: promptManager, + generator: generators.NewBaseGenerator(client, promptManager), + inputText: "", + results: make(map[TabType]string), + loadingStatus: make(map[generators.GeneratorType]bool), + tabs: []TabItem{ + {Type: TabGuodegang, Label: "讲解", Icon: "🎭"}, + {Type: TabQuiz, Label: "考核", Icon: "✍️"}, + {Type: TabCard, Label: "卡片", Icon: "📝"}, + {Type: TabScenario, Label: "场景", Icon: "🏗️"}, + }, + activeTab: 0, + } +} + +// SetInput 设置输入 +func (m *Model) SetInput(input string) { + m.inputText = input + m.state = StateLoading +} + +// Init 初始化 +func (m Model) Init() tea.Cmd { + return nil +} + +// Update 更新 +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKeyPress(msg) + case contentMsg: + return m.handleContentResult(msg) + case errorMessage: + return m.handleError(msg) + } + + return m, nil +} + +// View 视图 +func (m Model) View() string { + switch m.state { + case StateInput: + return m.inputView() + case StateLoading: + return m.loadingView() + case StateResult: + return m.resultView() + case StateError: + return m.errorView() + default: + return "Unknown state" + } +} + +// contentMsg 内容生成完成消息 +type contentMsg struct { + Type generators.GeneratorType + Content string +} + +// errorMessage 错误消息 +type errorMessage struct { + Type generators.GeneratorType + Error error +} + +// 样式定义 +var ( + baseStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FAFAFA")) + + headerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7D56F4")). + Bold(true) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF5555")) +) diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..afe2f6b --- /dev/null +++ b/internal/tui/update.go @@ -0,0 +1,160 @@ +package tui + +import ( + "fmt" + "learning-assistant/internal/ai/generators" + "learning-assistant/internal/clipboard" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// handleKeyPress 处理按键 +func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch m.state { + case StateInput: + return m.handleInputKey(msg) + case StateResult: + return m.handleResultKey(msg) + default: + return m, nil + } +} + +// handleInputKey 处理输入状态下的按键 +func (m Model) handleInputKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + + case tea.KeyEnter: + input := strings.TrimSpace(m.inputText) + if input == "" { + return m, nil + } + + // 切换到加载状态 + m.state = StateLoading + + // 启动5个生成器 + return m, m.startGeneration() + + case tea.KeyBackspace: + // 退格键处理 + if len(m.inputText) > 0 { + m.inputText = m.inputText[:len(m.inputText)-1] + } + return m, nil + + case tea.KeyRunes: + // 普通字符输入 + m.inputText += string(msg.Runes) + return m, nil + + default: + return m, nil + } +} + +// handleResultKey 处理结果状态下的按键 +func (m Model) handleResultKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyCtrlC, tea.KeyCtrlQ: + return m, tea.Quit + + case tea.KeyLeft: + m.activeTab = (m.activeTab - 1 + len(m.tabs)) % len(m.tabs) + return m, nil + + case tea.KeyRight: + m.activeTab = (m.activeTab + 1) % len(m.tabs) + return m, nil + + default: + return m, nil + } +} + +// startGeneration 开始生成内容 +func (m Model) startGeneration() tea.Cmd { + resultChan := make(chan generators.GenerateResult, 5) + + // 启动5个生成器 + generatorTypes := []generators.GeneratorType{ + generators.GeneratorMermaid, + generators.GeneratorGuodegang, + generators.GeneratorQuiz, + generators.GeneratorCard, + generators.GeneratorScenario, + } + + for _, genType := range generatorTypes { + go func(gt generators.GeneratorType) { + m.generator.Generate(gt, m.inputText, resultChan) + }(genType) + } + + // 立即启动一个命令来监听结果 + return func() tea.Msg { + for range generatorTypes { + result := <-resultChan + if result.Error != nil { + return errorMessage{ + Type: result.Type, + Error: result.Error, + } + } + return contentMsg{ + Type: result.Type, + Content: result.Content, + } + } + return nil + } +} + +// handleContentResult 处理内容生成结果 +func (m Model) handleContentResult(msg contentMsg) (tea.Model, tea.Cmd) { + m.loadingStatus[msg.Type] = true + + switch msg.Type { + case generators.GeneratorGuodegang: + m.results[TabGuodegang] = msg.Content + case generators.GeneratorQuiz: + m.results[TabQuiz] = msg.Content + case generators.GeneratorCard: + m.results[TabCard] = msg.Content + case generators.GeneratorScenario: + m.results[TabScenario] = msg.Content + case generators.GeneratorMermaid: + // Mermaid复制到剪切板 + clipboard.WriteSilent(msg.Content) + } + + // 检查是否全部完成 + allDone := true + for _, done := range m.loadingStatus { + if !done { + allDone = false + break + } + } + + if allDone { + m.state = StateResult + } + + return m, nil +} + +// handleError 处理错误 +func (m Model) handleError(msg errorMessage) (tea.Model, tea.Cmd) { + m.state = StateError + m.errorMsg = fmt.Sprintf("生成失败: %v", msg.Error) + return m, nil +} + +// handleInput 处理用户输入 +func (m *Model) handleInput(input string) { + m.inputText = strings.TrimSpace(input) +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..607e7b3 --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,185 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "learning-assistant/internal/ai/generators" +) + +// inputView 输入视图 +func (m Model) inputView() string { + title := headerStyle.Render("🎓 学习助手") + subtitle := baseStyle.Render("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + inputPrompt := baseStyle.Render("请输入您想学习的知识点,按 Enter 开始生成:\n") + + return fmt.Sprintf( + "\n%s\n%s\n\n%s\n\n%s[按 Ctrl+C 退出]\n", + title, + subtitle, + inputPrompt, + baseStyle.Render("> "), + ) +} + +// loadingView 加载视图 +func (m Model) loadingView() string { + title := headerStyle.Render("⏳ 正在生成学习内容...") + subtitle := baseStyle.Render("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + knowledge := fmt.Sprintf("知识点:%s", m.inputText) + knowledgeStyled := baseStyle.Render(knowledge) + + status := "生成状态:\n" + + // Mermaid + if m.loadingStatus[generators.GeneratorMermaid] { + status += fmt.Sprintf(" ✓ Mermaid 图表\n") + } else { + status += fmt.Sprintf(" ◯ Mermaid 图表\n") + } + + // Guodegang + if m.loadingStatus[generators.GeneratorGuodegang] { + status += fmt.Sprintf(" ✓ 郭德纲式讲解\n") + } else { + status += fmt.Sprintf(" ◯ 郭德纲式讲解\n") + } + + // Quiz + if m.loadingStatus[generators.GeneratorQuiz] { + status += fmt.Sprintf(" ✓ 十道考核题\n") + } else { + status += fmt.Sprintf(" ◯ 十道考核题\n") + } + + // Card + if m.loadingStatus[generators.GeneratorCard] { + status += fmt.Sprintf(" ✓ 记忆卡片\n") + } else { + status += fmt.Sprintf(" ◯ 记忆卡片\n") + } + + // Scenario + if m.loadingStatus[generators.GeneratorScenario] { + status += fmt.Sprintf(" ✓ 工程场景案例\n") + } else { + status += fmt.Sprintf(" ◯ 工程场景案例\n") + } + + return fmt.Sprintf( + "\n%s\n%s\n\n%s\n\n%s %s[按 Ctrl+C 退出]\n", + title, + subtitle, + knowledgeStyled, + baseStyle.Render(status), + "", + "", + ) +} + +// resultView 结果视图 +func (m Model) resultView() string { + title := headerStyle.Render("🎓 学习助手") + + // Tab栏 + tabBar := m.renderTabs() + + // 分隔线 + separator := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#586069")). + Render("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + // 当前Tab内容 + currentTab := m.tabs[m.activeTab] + content, ok := m.results[currentTab.Type] + if !ok { + content = "内容生成中..." + } + + // 内容区域(简化渲染,实际应该用viewport) + contentView := m.renderContent(content, currentTab) + + // Mermaid复制提示 + clipboardHint := "" + if currentTab.Type == TabGuodegang || currentTab.Type == TabQuiz || currentTab.Type == TabCard || currentTab.Type == TabScenario { + clipboardHint = baseStyle.Render("\n✅ Mermaid 图表已自动复制到剪切板") + } + + // 底部帮助 + help := m.renderHelp() + + return fmt.Sprintf( + "%s\n%s\n\n%s\n\n%s\n%s\n%s\n%s", + title, + tabBar, + separator, + contentView, + "", + clipboardHint, + "", + help, + ) +} + +// errorView 错误视图 +func (m Model) errorView() string { + title := errorStyle.Render("❌ 生成失败") + content := errorStyle.Render(m.errorMsg) + help := baseStyle.Render("[按 Ctrl+C 退出,或按 Enter 重新输入]") + + return fmt.Sprintf( + "\n%s\n\n%s\n\n%s\n", + title, + content, + help, + ) +} + +// renderTabs 渲染Tab栏 +func (m Model) renderTabs() string { + var tabs []string + + for i, tab := range m.tabs { + var style lipgloss.Style + if i == m.activeTab { + style = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7D56F4")). + Bold(true) + } else { + style = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#586069")) + } + + tabs = append(tabs, style.Render(fmt.Sprintf("[%s%s]", tab.Icon, tab.Label))) + } + + exitHint := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FAFAFA")). + Render("q退出") + + tabs = append(tabs, exitHint) + + return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) +} + +// renderContent 渲染内容 +func (m Model) renderContent(content string, tab TabItem) string { + header := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#04B575")). + Bold(true). + Render(fmt.Sprintf("%s %s", tab.Icon, tab.Label)) + + return fmt.Sprintf("%s\n\n%s", header, baseStyle.Render(content)) +} + +// renderHelp 渲染帮助信息 +func (m Model) renderHelp() string { + help := baseStyle.Render("←/→ 切换标签页 | ↑/↓ 滚动内容 | q 退出") + + var style lipgloss.Style + style = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#586069")) + + return style.Render(help) +}