✨Feat: 构建对话终端

This commit is contained in:
2026-03-20 14:26:24 +08:00
parent 3f5c0bbae1
commit 355645efdf
17 changed files with 1338 additions and 2 deletions
+148
View File
@@ -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"))
)
+160
View File
@@ -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)
}
+185
View File
@@ -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)
}