diff --git a/.gitignore b/.gitignore index d0ebb8b..81b297c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -yunshu.exe +yunshu.exe* diff --git a/src/catalog.go b/catalog.go similarity index 100% rename from src/catalog.go rename to catalog.go diff --git a/src/config.go b/config.go similarity index 100% rename from src/config.go rename to config.go diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 64b2a0e..8238ffe 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -2,7 +2,7 @@ ## 通用规范 -- 全程使用中文书写注释、文档和沟通 +- 全程使用中文书写注释、文档、沟通以及内部思考过程 - 所有代码必须包含详细的中文注释,说明函数功能、参数含义、关键逻辑 - Markdown 文件使用 `#` 标题层级,保持结构清晰 - 变量命名采用驼峰式,类型和函数首字母大写导出 @@ -16,6 +16,10 @@ - 导出函数(首字母大写)供包外调用,非导出函数(首字母小写)为内部实现 - HTTP 客户端设置超时(默认 15s),避免资源泄漏 - JSON 序列化/反序列化使用 `encoding/json` 标准库 +- 终端颜色输出优先使用 `pkg/style` 包: + - 8 色用 `Fg(ColorXxx)` / `Bg(ColorXxx)` + - 真彩色用 `FgHex("#RRGGBB")` / `BgHex("#RRGGBB")` + - 所有通配 `.Render(text)` 生成 ANSI 转义序列 ## Agent 定义规范(.md 文件) @@ -115,3 +119,11 @@ tools: 14. **geocode 工具用 Go 代码实现比让 LLM 自己调 http-get 解析 JSON 更可靠**。LLM 在构造 URL 和解析嵌套 JSON 时容易出错(尤其是中文编码问题)。注册为 tool 后,LLM 只需提供城市名参数,Go 代码处理所有细节。 15. **项目正式命名为云枢·Agent(YunShu / yunshu)**,配置目录从 `~/.config/weather-cli/` 迁移到 `~/.config/yunshu/`。旧目录在首次运行时会自动迁移并删除。二进制名称改为 `yunshu`。如果迁移失败,用户可手动复制旧目录内容后重新运行。 + +### 2026-05-09 + +1. **Windows raw mode + bufio.Reader 冲突**:在禁用 `ENABLE_LINE_INPUT` 的 raw mode 下,`bufio.NewReader(os.Stdin).ReadRune()` 会因内部预读缓冲(默认 4KB)导致读取阻塞。后续尝试用 `ReadConsoleInputW` + 手动回显也因光标位置计算不一致而放弃。**最终决定放弃自实现 Tab 补全**,后续如需补全功能,引入第三方库(如 `go-prompt` / `readline`)。 + +2. **ANSI 光标移动需要启用输出 VT 处理**:`\033[A`(光标上移)/ `\033[J`(清屏)等输出序列需要输出句柄设置 `ENABLE_VIRTUAL_TERMINAL_PROCESSING`(0x0004)才能生效。只设置输入句柄的 mode 不够,输入输出句柄是两个独立的控制台句柄。 + +3. **ReadLineWithCompletion / Completer 类型已移除**。`completer.go` 清空,`main.go` 回到 `termui.ReadLine()`。`cmdCompleter`、`commonPrefix`、相关测试一并移除。`input.go` 中 `ReadConsoleInputW` 相关的 `keyEventRecord`/`inputRecord` 结构和 proc 也一并清理。 diff --git a/docs/architecture.md b/docs/architecture.md index fa0f082..44f2c54 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,6 +47,19 @@ Tool (src/tool.go 注册) → 确定性执行(Go 代码,仅返回结果 | 知识加载 | 预置或直接塞入 | 按需加载,仅该轮存在 | | 工具执行 | 依赖 LLM 构造 URL 解析 JSON | Tool 用 Go 代码,100% 可靠 | +## 包结构 + +``` +pkg/ +├── mdprint/ Markdown → ANSI 终端渲染(AST 架构) +│ ├── mdprint.go Node 类型定义 + Print() 入口 +│ ├── parse.go 块级解析器(状态机) +│ ├── inline.go 行内解析器(递归) +│ └── render.go ANSI 渲染器(type switch) +├── style/ 终端颜色样式库(8 色 ANSI + 24-bit 真彩色) +└── termui/ 终端交互(行输入、模式设置) +``` + ## 当前 tools | 工具名 | 作用 | 实现 | diff --git a/docs/changelog.md b/docs/changelog.md index 61b9bcd..232fabe 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,34 @@ > 坐看云卷云舒,静听花开花落 +## [1.1.0] - 2026-05-09 + +### 新增 +- Markdown 渲染器重构:从"一行流"改为 AST 架构(`pkg/mdprint/`) + - 块级解析器:Heading / Paragraph / CodeBlock / Blockquote / List / Table / ThematicBreak + - 行内解析器:Bold / Italic / Code / Link + 递归嵌套 + - ANSI 渲染器:type switch 分发,标题按级别分色 +- 标题视觉系统:1-3 级 `▪` 符号 + 加粗,4-5 级 `▫` 符号 + 加粗,6 级纯加粗 + - 所有标题前插空行,1 级标题前后各插空行 +- 真彩色支持:`style.FgHex("#RRGGBB")` / `style.BgHex("#RRGGBB")`,兼容原有 8 色 ANSI +- 莫奈《睡莲》配色方案:H1 雾蓝灰 / H2 鼠尾草绿 / H3 薄荷青 / H4 淡紫粉 / H5 暖灰绿 / H6 浅灰 +- 排版间距优化:`---` 横线前后空行、输入与响应之间空行、输出末尾空行 + +### 修复 +- 行内解析器未闭合分隔符(`*` / `` ` ``)导致死循环 +- 代码 fence 检测不识别 ` ```go` 等带语言标识的写法 +- Windows 终端输入模式导致 `bufio.Scanner` 无法获取行输入 + +### 变更 +- 项目结构:`src/` → 根目录 + `pkg/` 子包 +- `pkg/style` 新增真彩色 API,向后兼容 + +### 技术栈 +- 语言:Go 1.25 +- 依赖:仅 `gopkg.in/yaml.v3` +- 默认 LLM:豆包(火山引擎)`doubao-seed-2-0-pro-260215` +- 数据源:MSN 天气非公开 API(`assets.msn.cn`) + ## [1.0.0] - 2026-05-08 ### 发布说明 diff --git a/docs/taolun.md b/docs/taolun.md index 44d4c9c..3c08627 100644 --- a/docs/taolun.md +++ b/docs/taolun.md @@ -142,3 +142,103 @@ weather/ ### 设计理念 "云枢"二字呼应了项目作为 AI 助理"中枢调度"的定位——云是分布式的、流转的,枢是枢纽、核心。后续主-从架构中,master 负责调度、subagent 各司其职,恰如云卷云舒。 + +--- + +## 2026-05-09 Markdown 渲染器重构 + 终端输入修复 + +### 背景 +原本的 `pkg/mdprint/mdprint.go` 是"一行流"渲染——读取 Markdown 文本后直接正则/字符串匹配输出 ANSI。问题:heading 和后续 paragraph 无法正确分离,`#### 标题\n正文` 导致正文也被染上 heading 颜色。 + +### 方案:OOP 风格 AST 架构 +将 `pkg/mdprint/` 拆分为多文件,各司其职: + +| 文件 | 职责 | +|------|------| +| `mdprint.go` | `Node` 接口 + 所有块级/行内类型定义 + `Print()` 入口 | +| `parse.go` | 状态机块级解析器 `parseBlocks` | +| `inline.go` | 递归行内解析器 `parseInline` | +| `render.go` | `renderNode` type switch 渲染器 | +| `mdprint_test.go` | 单元测试(待加) | + +### AST 类型设计 +``` +Node interface +├── Heading{Level, Content} → `#### 标题` +├── Paragraph{Content} → 普通文本块 +├── CodeBlock{Lang, Body} → ```fenced``` +├── Blockquote{Children} → > 引用 +├── List{Ordered, Items} → - / * / 1. 列表 +├── ListItem{Checked, Content} → 含 checkbox 支持 +├── Table{Headers, Rows} → `| H1 | H2 |` +├── ThematicBreak{} → --- +├── Text{Text} → "纯文本" +├── Bold{Content} → **bold** +├── Italic{Content} → *italic* +├── Code{Text} → `code` +└── Link{Content, URL} → [text](url) +``` + +### 解析器流程 +1. `Print(content)` → 按 `\n` 分割为 lines +2. `parseBlocks(lines)` → 逐行状态机,识别 heading / code fence / quote / list / table / thematic-break / paragraph → 输出 `[]Node` +3. 块内文本调 `parseInline()` → 递归扫描,识别 `**`/`*`/\`\`/`[]()` → 输出 `[]Node` +4. 遍历 `[]Node` 调 `renderNode()` → type switch 分发 → `strings.Builder` 拼接 +5. `fmt.Print` 输出 + +### 关键修复 +- Heading 和 Paragraph 是独立 AST 节点,heading 后的文本永远归 Paragraph,不会再染上 heading 颜色 +- 块级解析器在 heading 行后自动切 paragraph,无需 blank line 分隔 +- 代码 fence 用状态机追踪开闭 + +### 终端输入修复(2026-05-09) +- **Root cause**:Windows 控制台处于 character mode(`ENABLE_WINDOW_INPUT`),导致 `bufio.Scanner` 和 `ReadString` 均无法获取行输入 +- **修复**:`ensureLineMode()` 在每次读之前调 `GetConsoleMode` + `SetConsoleMode` 设置为 `ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT` +- `ReadLine()` 改为 `bufio.NewReader(os.Stdin).ReadString('\n')`(每次新建 reader),避免 Scanner 的缓冲区冲突 +- ANSI 样式在所有提示符和状态指示器上恢复正常显示 + +### 当前进度(后续完成) +- 2026-05-09 补充完成:`inline.go` + `render.go` + `mdprint_test.go` + - 行内解析器支持 `**bold**` / `*italic*` / `` `code` `` / `[link](url)`,递归嵌套 + - 修复:未闭合分隔符死循环、代码 fence 不识别语言标识 + +--- + +## 2026-05-09 标题视觉系统 + 真彩色支持 + +### 背景 +Markdown 渲染器完成 AST 解析后,需要确定标题的终端展示风格。最初方案是保留 Markdown 的 `#` 前缀,但用户反馈效果不好。 + +### 标题样式演进 +1. **最初**:`#` 前缀 + ANSI 颜色 → 用户觉得不好看 +2. **方案 A**:去掉 `#`,用 `▎` 色块 + 加粗文字 → 讨论中用户提出 `▪`/`▫` 符号更好 +3. **方案 B**:H1-H3 用 `▪` + 空格,H4-H5 用 `▫` + 空格,H6 纯加粗 → 用户确认 +4. **配色**:黄色/红色保留给重要信息,排除后从青/蓝/绿/品红/白中分配 +5. **最终使用真彩色**:用户要求莫奈(印象派)配色,现有 8 色 ANSI 无法满足 + +### 最终标题配置 +| 级别 | 符号 | 色值 | 色名 | +|------|------|------|------| +| H1 | `▪ ` | `#6B8E9B` | 雾蓝灰 | +| H2 | `▪ ` | `#89A894` | 鼠尾草绿 | +| H3 | `▪ ` | `#A6C0B5` | 薄荷青 | +| H4 | `▫ ` | `#C3B1BD` | 淡紫粉 | +| H5 | `▫ ` | `#7B8E8A` | 暖灰绿 | +| H6 | 无 | Dim | 浅灰 | + +### 排版规则 +- 所有标题前插一个空行 +- 1 级标题前后各插一个空行 +- `---` 横线前后各插一个空行 +- 输出末尾统一加空行 +- 交互模式:输入与响应之间插空行,响应与下一轮 `❯` 之间插空行 + +### 真彩色支持方案 +- `pkg/style/style.go` 新增 `FgHex()` / `BgHex()` 方法 +- 输出 `\033[38;2;R;G;Bm` 格式的 24-bit 真彩色序列 +- 底层复用 `codes []string`,`Render()` 零改动 +- 旧 `Fg(Color)` 8 色 API 完全兼容,不破坏已有代码 + +### 验证 +- 19/19 单元测试通过 +- 构建成功,二进制运行正常 diff --git a/go.mod b/go.mod index 57b3da9..359f2da 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ -module yunshu +module hub.gaomia.site/titor/YunShu -go 1.21 +go 1.25.0 require gopkg.in/yaml.v3 v3.0.1 diff --git a/src/llm.go b/llm.go similarity index 100% rename from src/llm.go rename to llm.go diff --git a/src/loader.go b/loader.go similarity index 100% rename from src/loader.go rename to loader.go diff --git a/main.go b/main.go new file mode 100644 index 0000000..687dfb3 --- /dev/null +++ b/main.go @@ -0,0 +1,146 @@ +package main + +import ( + "fmt" + "os" + "strings" + "syscall" + + "hub.gaomia.site/titor/YunShu/pkg/style" + "hub.gaomia.site/titor/YunShu/pkg/termui" +) + +const version = "1.0.0" + +func init() { + kernel32 := syscall.NewLazyDLL("kernel32.dll") + setConsoleCP := kernel32.NewProc("SetConsoleOutputCP") + setConsoleCP.Call(65001) +} + +func printHelp() { + fmt.Println() + fmt.Println(style.Cyan.Render("☁ 云枢·Agent"), style.Dim.Render("v"+version)) + fmt.Println() + fmt.Println(style.Bold.Render("用法:")) + fmt.Println(" yunshu [命令] [查询内容]") + fmt.Println() + fmt.Println(style.Bold.Render("命令:")) + fmt.Println(" onboard 交互式初始化配置") + fmt.Println(" help, -h 显示帮助信息") + fmt.Println(" version, -v 显示版本号") + fmt.Println() + fmt.Println(style.Bold.Render("示例:")) + fmt.Println(" yunshu \"北京今天天气\" ", style.Dim.Render("单次天气查询")) + fmt.Println(" yunshu ", style.Dim.Render("启动交互模式")) + fmt.Println(" yunshu onboard ", style.Dim.Render("重新初始化配置")) + fmt.Println() + fmt.Println(style.Bold.Render("环境变量:")) + fmt.Println(" LLM_API_KEY API Key(优先级高于配置文件)") + fmt.Println(" LLM_ENDPOINT API 端点") + fmt.Println(" LLM_MODEL 模型名") + fmt.Println() + fmt.Println(style.Bold.Render("配置文件:"), "~/.config/yunshu/config.yaml") + fmt.Println() +} + +func printVersion() { + fmt.Println("yunshu", version) +} + +func main() { + args := os.Args[1:] + + if len(args) > 0 { + switch args[0] { + case "onboard": + runOnboard() + return + case "help", "--help", "-h": + printHelp() + return + case "version", "--version", "-v": + printVersion() + return + default: + if strings.HasPrefix(args[0], "-") { + fmt.Fprintln(os.Stderr, style.Red.Render("未知选项: "+args[0])) + fmt.Fprintln(os.Stderr, "可用命令: onboard, help, version") + os.Exit(1) + } + } + } + + cfg, err := LoadConfig() + if err != nil { + fmt.Fprintln(os.Stderr, style.Red.Render("未找到配置文件。请先运行:")) + fmt.Fprintln(os.Stderr, " yunshu onboard") + os.Exit(1) + } + _ = cfg + + GenerateToolsYAML() + + agentPath := SearchFile("agents/weather-agent.md") + def, err := LoadAgent(agentPath) + if err != nil { + fmt.Fprintln(os.Stderr, style.Red.Render("加载 agent 失败: "+err.Error())) + os.Exit(1) + } + + if len(args) > 0 { + ClearSession() + query := strings.Join(args, " ") + if err := RunAgent(def, query); err != nil { + fmt.Fprintln(os.Stderr, style.Red.Render("错误: "+err.Error())) + os.Exit(1) + } + return + } + + fmt.Println() + fmt.Println(style.Cyan.Render("☁ 云枢·Agent"), style.Dim.Render("· 天气情报官")) + fmt.Println(style.Dim.Render(" /exit 退出,// 开头的行不发给 LLM")) + fmt.Println() + ClearSession() + + for { + fmt.Print(style.Cyan.Render("❯ ")) + input := termui.ReadLine() + input = strings.TrimSpace(input) + if input == "" { + continue + } + + if strings.HasPrefix(input, "//") { + continue + } + + switch input { + case "/exit", "exit", "quit": + fmt.Println("再见!") + fmt.Println() + return + case "/clear": + ClearSession() + fmt.Print("\033[2J\033[H") + fmt.Println(style.Dim.Render("会话已清空")) + fmt.Println() + continue + case "/help": + fmt.Println("可用命令:") + fmt.Println(" /exit 退出") + fmt.Println(" /clear 清空会话") + fmt.Println(" /help 显示帮助") + fmt.Println(" // 不发给 LLM 的注释行") + fmt.Println() + continue + } + + if err := RunAgent(def, input); err != nil { + fmt.Fprintln(os.Stderr, style.Red.Render("错误: "+err.Error())) + } + fmt.Println() + } +} + diff --git a/onboard.go b/onboard.go new file mode 100644 index 0000000..6b933d2 --- /dev/null +++ b/onboard.go @@ -0,0 +1,101 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "hub.gaomia.site/titor/YunShu/pkg/style" + "hub.gaomia.site/titor/YunShu/pkg/termui" +) + +func runOnboard() { + fmt.Println() + fmt.Println(style.Cyan.Render("☁ 云枢·Agent · 初始化配置")) + fmt.Println() + fmt.Println("配置说明 · 以下信息将保存在", style.Dim.Render(ConfigDir())) + fmt.Println() + + host := termui.TextInput("LLM 接口地址", + termui.WithRequired(true), + termui.WithHelp("支持 OpenAI 兼容格式"), + termui.WithDefault("https://ark.cn-beijing.volces.com/api/v3/chat/completions"), + termui.WithValidator(termui.And(termui.NonEmpty, termui.IsURL)), + ) + + key := termui.PasswordInput("API Key", + termui.WithRequired(true), + termui.WithHelp("输入已隐藏"), + termui.WithValidator(termui.NonEmpty), + ) + + model := termui.TextInput("模型名称", + termui.WithHelp("默认: doubao-seed-2-0-pro-260215"), + termui.WithDefault("doubao-seed-2-0-pro-260215"), + ) + + fmt.Println() + ok := termui.Confirm("保存配置?", true) + if !ok { + fmt.Println(style.Yellow.Render("取消配置")) + return + } + + cfg := &Config{ + LLM: LLMConfig{ + Host: host, + Model: model, + Key: key, + }, + } + + if err := SaveConfig(cfg); err != nil { + fmt.Fprintln(os.Stderr, style.Red.Render("保存配置失败: "+err.Error())) + os.Exit(1) + } + + CopyDefaultDir("agents", "agents") + CopyDefaultDir("skills", "skills") + CopyDefaultDir("data", "data") + + fmt.Println() + + testOk := termui.Confirm("测试连通性?", true) + if testOk { + testLLM() + } + + fmt.Println() + fmt.Println(style.Green.Render("✔ 配置完成!")) + fmt.Println(" 配置文件:", style.Dim.Render(filepath.Join(ConfigDir(), "config.yaml"))) + fmt.Println() + fmt.Println(" 运行示例:") + fmt.Println(" " + style.Cyan.Render("yunshu \"北京今天天气\"")) + fmt.Println(" " + style.Cyan.Render("yunshu")) +} + +func testLLM() { + fmt.Print(style.Dim.Render("测试中 ...")) + + oldKey, oldHost, oldModel := llmKey, llmHost, llmModel + defer func() { + llmKey, llmHost, llmModel = oldKey, oldHost, oldModel + }() + + cfg, err := LoadConfig() + if err != nil { + fmt.Println(style.Red.Render("\r⚠ 读取配置失败: " + err.Error())) + return + } + + llmKey, llmHost, llmModel = cfg.LLM.Key, cfg.LLM.Host, cfg.LLM.Model + + msg := Message{Role: RoleUser, Content: "ping"} + _, err = CallLLM([]Message{msg}, nil) + if err != nil { + fmt.Println(style.Red.Render("\r⚠ 连接失败: " + err.Error())) + return + } + + fmt.Println(style.Green.Render("\r✔ 连接成功!")) +} diff --git a/pkg/mdprint/inline.go b/pkg/mdprint/inline.go new file mode 100644 index 0000000..2f56157 --- /dev/null +++ b/pkg/mdprint/inline.go @@ -0,0 +1,91 @@ +package mdprint + +func parseInline(text string) []Node { + var nodes []Node + runes := []rune(text) + i := 0 + + for i < len(runes) { + rem := string(runes[i:]) + + if len(rem) >= 2 && rem[:2] == "**" { + end := findDelim(runes, i+2, "**") + if end >= 0 { + inner := string(runes[i+2 : end]) + nodes = append(nodes, Bold{Content: parseInline(inner)}) + i = end + 2 + continue + } + nodes = append(nodes, Text{Text: "**"}) + i += 2 + continue + } + + if len(rem) >= 1 && rem[:1] == "*" { + end := findDelim(runes, i+1, "*") + if end >= 0 { + inner := string(runes[i+1 : end]) + nodes = append(nodes, Italic{Content: parseInline(inner)}) + i = end + 1 + continue + } + nodes = append(nodes, Text{Text: "*"}) + i++ + continue + } + + if len(rem) >= 1 && rem[:1] == "`" { + end := findDelim(runes, i+1, "`") + if end >= 0 { + nodes = append(nodes, Code{Text: string(runes[i+1 : end])}) + i = end + 1 + continue + } + nodes = append(nodes, Text{Text: "`"}) + i++ + continue + } + + if len(rem) >= 1 && rem[:1] == "[" { + closeB := findDelim(runes, i+1, "]") + if closeB >= 0 && closeB+1 < len(runes) && runes[closeB+1] == '(' { + closeP := findDelim(runes, closeB+2, ")") + if closeP >= 0 { + linkText := string(runes[i+1 : closeB]) + url := string(runes[closeB+2 : closeP]) + nodes = append(nodes, Link{Content: parseInline(linkText), URL: url}) + i = closeP + 1 + continue + } + } + } + + textStart := i + for i < len(runes) { + ch := string(runes[i]) + if ch == "*" || ch == "`" || ch == "[" { + break + } + if ch == "\\" && i+1 < len(runes) { + i += 2 + continue + } + i++ + } + if i > textStart { + nodes = append(nodes, Text{Text: string(runes[textStart:i])}) + } + } + + return nodes +} + +func findDelim(runes []rune, start int, delim string) int { + for j := start; j < len(runes); j++ { + rem := string(runes[j:]) + if len(rem) >= len(delim) && rem[:len(delim)] == delim { + return j + } + } + return -1 +} diff --git a/pkg/mdprint/mdprint.go b/pkg/mdprint/mdprint.go new file mode 100644 index 0000000..e65e75e --- /dev/null +++ b/pkg/mdprint/mdprint.go @@ -0,0 +1,52 @@ +package mdprint + +import "fmt" + +// Node is the interface for all AST nodes. +type Node interface{ isNode() } + +// ---- block-level ---- + +type Heading struct{ Level int; Content []Node } +type Paragraph struct{ Content []Node } +type CodeBlock struct{ Lang, Body string } +type Blockquote struct{ Children []Node } +type List struct{ Ordered bool; Items []ListItem } +type ListItem struct{ Checked *bool; Content []Node } +type Table struct{ Headers []string; Rows [][]string } +type ThematicBreak struct{} + +// ---- inline-level ---- + +type Text struct{ Text string } +type Bold struct{ Content []Node } +type Italic struct{ Content []Node } +type Code struct{ Text string } +type Link struct{ Content []Node; URL string } + +func (Heading) isNode() {} +func (Paragraph) isNode() {} +func (CodeBlock) isNode() {} +func (Blockquote) isNode() {} +func (List) isNode() {} +func (ListItem) isNode() {} +func (Table) isNode() {} +func (ThematicBreak) isNode() {} + +func (Text) isNode() {} +func (Bold) isNode() {} +func (Italic) isNode() {} +func (Code) isNode() {} +func (Link) isNode() {} + +// Print parses Markdown content and renders it to the terminal. +func Print(content string) { + blocks := parseBlocks(content) + for i, b := range blocks { + if i > 0 { + fmt.Println() + } + fmt.Print(renderNode(b)) + } + fmt.Println() +} diff --git a/pkg/mdprint/mdprint_test.go b/pkg/mdprint/mdprint_test.go new file mode 100644 index 0000000..b2c14e3 --- /dev/null +++ b/pkg/mdprint/mdprint_test.go @@ -0,0 +1,308 @@ +package mdprint + +import ( + "strings" + "testing" +) + +func stripANSI(s string) string { + var b strings.Builder + i := 0 + for i < len(s) { + if s[i] == '\033' && i+1 < len(s) && s[i+1] == '[' { + j := i + 2 + for j < len(s) && s[j] != 'm' { + j++ + } + if j < len(s) { + i = j + 1 + continue + } + } + b.WriteByte(s[i]) + i++ + } + return b.String() +} + +func TestParseInline_Text(t *testing.T) { + nodes := parseInline("hello world") + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + _, ok := nodes[0].(Text) + if !ok { + t.Fatalf("expected Text, got %T", nodes[0]) + } +} + +func TestParseInline_Bold(t *testing.T) { + nodes := parseInline("**bold**") + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + b, ok := nodes[0].(Bold) + if !ok { + t.Fatalf("expected Bold, got %T", nodes[0]) + } + if len(b.Content) != 1 { + t.Fatalf("expected 1 child, got %d", len(b.Content)) + } + txt, ok := b.Content[0].(Text) + if !ok { + t.Fatalf("expected Text in Bold, got %T", b.Content[0]) + } + if txt.Text != "bold" { + t.Fatalf("expected 'bold', got '%s'", txt.Text) + } +} + +func TestParseInline_Italic(t *testing.T) { + nodes := parseInline("*italic*") + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + _, ok := nodes[0].(Italic) + if !ok { + t.Fatalf("expected Italic, got %T", nodes[0]) + } +} + +func TestParseInline_Code(t *testing.T) { + nodes := parseInline("`code`") + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + c, ok := nodes[0].(Code) + if !ok { + t.Fatalf("expected Code, got %T", nodes[0]) + } + if c.Text != "code" { + t.Fatalf("expected 'code', got '%s'", c.Text) + } +} + +func TestParseInline_Link(t *testing.T) { + nodes := parseInline("[text](url)") + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + link, ok := nodes[0].(Link) + if !ok { + t.Fatalf("expected Link, got %T", nodes[0]) + } + if link.URL != "url" { + t.Fatalf("expected URL 'url', got '%s'", link.URL) + } + if len(link.Content) != 1 { + t.Fatalf("expected 1 child in link, got %d", len(link.Content)) + } + txt, ok := link.Content[0].(Text) + if !ok { + t.Fatalf("expected Text in Link, got %T", link.Content[0]) + } + if txt.Text != "text" { + t.Fatalf("expected 'text', got '%s'", txt.Text) + } +} + +func TestParseInline_Mixed(t *testing.T) { + nodes := parseInline("a **b** c") + if len(nodes) != 3 { + t.Fatalf("expected 3 nodes, got %d: %#v", len(nodes), nodes) + } + t1, ok := nodes[0].(Text) + if !ok || t1.Text != "a " { + t.Fatalf("expected 'a ' Text, got %#v", nodes[0]) + } + _, ok = nodes[1].(Bold) + if !ok { + t.Fatalf("expected Bold at index 1, got %T", nodes[1]) + } + t2, ok := nodes[2].(Text) + if !ok || t2.Text != " c" { + t.Fatalf("expected ' c' Text, got %#v", nodes[2]) + } +} + +func TestParseBlocks_Heading(t *testing.T) { + blocks := parseBlocks("# Title") + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + h, ok := blocks[0].(Heading) + if !ok { + t.Fatalf("expected Heading, got %T", blocks[0]) + } + if h.Level != 1 { + t.Fatalf("expected level 1, got %d", h.Level) + } + if stripANSI(renderNode(h)) != "\n▪ Title\n" { + t.Fatalf("got '%s'", stripANSI(renderNode(h))) + } +} + +func TestParseBlocks_CodeFence(t *testing.T) { + blocks := parseBlocks("```go\npackage main\n```") + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + cb, ok := blocks[0].(CodeBlock) + if !ok { + t.Fatalf("expected CodeBlock, got %T", blocks[0]) + } + if cb.Lang != "go" { + t.Fatalf("expected lang 'go', got '%s'", cb.Lang) + } + body := stripANSI(renderNode(cb)) + if !strings.Contains(body, "package main") { + t.Fatalf("expected 'package main' in body") + } +} + +func TestParseBlocks_Blockquote(t *testing.T) { + blocks := parseBlocks("> hello\n> world") + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + _, ok := blocks[0].(Blockquote) + if !ok { + t.Fatalf("expected Blockquote, got %T", blocks[0]) + } +} + +func TestParseBlocks_UnorderedList(t *testing.T) { + blocks := parseBlocks("- a\n- b\n- c") + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + l, ok := blocks[0].(List) + if !ok { + t.Fatalf("expected List, got %T", blocks[0]) + } + if len(l.Items) != 3 { + t.Fatalf("expected 3 items, got %d", len(l.Items)) + } +} + +func TestParseBlocks_OrderedList(t *testing.T) { + blocks := parseBlocks("1. a\n2. b") + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + l, ok := blocks[0].(List) + if !ok { + t.Fatalf("expected List, got %T", blocks[0]) + } + if !l.Ordered { + t.Fatalf("expected ordered list") + } +} + +func TestParseBlocks_Table(t *testing.T) { + blocks := parseBlocks("| H1 | H2 |\n|---|----|\n| A | B |") + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + tbl, ok := blocks[0].(Table) + if !ok { + t.Fatalf("expected Table, got %T", blocks[0]) + } + if len(tbl.Headers) != 2 { + t.Fatalf("expected 2 headers, got %d", len(tbl.Headers)) + } + if len(tbl.Rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(tbl.Rows)) + } +} + +func TestParseBlocks_ThematicBreak(t *testing.T) { + blocks := parseBlocks("---") + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + _, ok := blocks[0].(ThematicBreak) + if !ok { + t.Fatalf("expected ThematicBreak, got %T", blocks[0]) + } +} + +func TestParseBlocks_Paragraph(t *testing.T) { + blocks := parseBlocks("hello\nworld") + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + _, ok := blocks[0].(Paragraph) + if !ok { + t.Fatalf("expected Paragraph, got %T", blocks[0]) + } +} + +func TestParseBlocks_MultiBlock(t *testing.T) { + content := "# Title\n\nhello **world**\n\n> quote" + blocks := parseBlocks(content) + if len(blocks) != 3 { + t.Fatalf("expected 3 blocks, got %d", len(blocks)) + } +} + +func TestRender_HeadingLevels(t *testing.T) { + for level := 1; level <= 6; level++ { + prefix := strings.Repeat("#", level) + blocks := parseBlocks(prefix + " test") + if len(blocks) != 1 { + t.Fatalf("heading level %d: expected 1 block", level) + } + h, ok := blocks[0].(Heading) + if !ok { + t.Fatalf("heading level %d: expected Heading", level) + } + if h.Level != level { + t.Fatalf("heading level %d: expected level=%d", level, level) + } + result := stripANSI(renderNode(h)) + var expected string + switch level { + case 1: + expected = "\n▪ test\n" + case 2, 3: + expected = "\n▪ test" + case 4, 5: + expected = "\n▫ test" + case 6: + expected = "\ntest" + } + if result != expected { + t.Fatalf("heading level %d: got '%q', expected '%q'", level, result, expected) + } + } +} + +func TestRender_BoldItalic(t *testing.T) { + nodes := parseInline("**bold** and *italic*") + r := stripANSI(renderInline(nodes)) + if r != "bold and italic" { + t.Fatalf("got '%s'", r) + } +} + +func TestRender_Link(t *testing.T) { + nodes := parseInline("[click](https://example.com)") + r := stripANSI(renderInline(nodes)) + if !strings.Contains(r, "click") || !strings.Contains(r, "https://example.com") { + t.Fatalf("got '%s'", r) + } +} + +func TestRender_FullDocument(t *testing.T) { + content := "# 天气报告\n\n今天**晴**,温度 *适中*。\n\n> 温馨提示:出门带伞\n\n- 温度:25°C\n- 湿度:40%\n\n代码:`fmt.Println(\"hello\")`\n\n| 项目 | 值 |\n|------|----|\n| AQI | 42 |\n" + + blocks := parseBlocks(content) + if len(blocks) == 0 { + t.Fatal("expected blocks") + } + _, ok := blocks[0].(Heading) + if !ok { + t.Fatal("first block should be Heading") + } +} diff --git a/pkg/mdprint/parse.go b/pkg/mdprint/parse.go new file mode 100644 index 0000000..756f68f --- /dev/null +++ b/pkg/mdprint/parse.go @@ -0,0 +1,287 @@ +package mdprint + +import ( + "strings" +) + +func parseBlocks(content string) []Node { + lines := strings.Split(content, "\n") + var blocks []Node + i := 0 + + for i < len(lines) { + line := lines[i] + trim := strings.TrimSpace(line) + + if trim == "" { + i++ + continue + } + + if level := headingLevel(trim); level > 0 { + rest := strings.TrimSpace(trim[level:]) + blocks = append(blocks, Heading{Level: level, Content: parseInline(rest)}) + i++ + continue + } + + if trim == "---" || trim == "***" || trim == "___" { + blocks = append(blocks, ThematicBreak{}) + i++ + continue + } + + if isFence(trim) { + b, lang, consumed := parseFence(lines, i) + blocks = append(blocks, CodeBlock{Lang: lang, Body: b}) + i += consumed + continue + } + + if strings.HasPrefix(trim, ">") { + q, consumed := parseQuote(lines, i) + blocks = append(blocks, q) + i += consumed + continue + } + + if mark, ordered, ok := listMarker(trim); ok { + items, consumed := parseListItems(lines, i, mark) + blocks = append(blocks, List{Ordered: ordered, Items: items}) + i += consumed + continue + } + + if strings.HasPrefix(trim, "|") && i+1 < len(lines) && isTableSep(strings.TrimSpace(lines[i+1])) { + t, consumed := parseTable(lines, i) + blocks = append(blocks, t) + i += consumed + continue + } + + p, consumed := parseParagraph(lines, i) + blocks = append(blocks, p) + i += consumed + } + + return blocks +} + +func headingLevel(s string) int { + n := 0 + for _, c := range s { + if c == '#' { + n++ + } else { + break + } + } + if n > 0 && n <= 6 && len(s) > n && s[n] == ' ' { + return n + } + return 0 +} + +func isFence(s string) bool { + count := 0 + for _, c := range s { + if c == '`' || c == '~' { + count++ + } else { + break + } + } + return count >= 3 +} + +func fenceChar(s string) rune { + for _, c := range s { + return c + } + return '`' +} + +func parseFence(lines []string, start int) (body, lang string, consumed int) { + first := strings.TrimSpace(lines[start]) + ch := fenceChar(first) + lang = strings.TrimSpace(first[3:]) + var buf strings.Builder + consumed = 1 + for i := start + 1; i < len(lines); i++ { + consumed++ + if strings.TrimSpace(lines[i]) == strings.Repeat(string(ch), 3) { + break + } + if buf.Len() > 0 { + buf.WriteString("\n") + } + buf.WriteString(lines[i]) + } + body = buf.String() + return +} + +func parseQuote(lines []string, start int) (Blockquote, int) { + var contents []Node + var buf []string + flush := func() { + if len(buf) > 0 { + text := strings.Join(buf, "\n") + contents = append(contents, Paragraph{Content: parseInline(text)}) + buf = nil + } + } + + i := start + for i < len(lines) { + trim := strings.TrimSpace(lines[i]) + if !strings.HasPrefix(trim, ">") { + break + } + rest := trim[1:] + if strings.HasPrefix(rest, " ") { + rest = rest[1:] + } + if rest == "" { + flush() + } else { + buf = append(buf, rest) + } + i++ + } + flush() + return Blockquote{Children: contents}, i - start +} + +func listMarker(s string) (mark string, ordered bool, ok bool) { + trim := strings.TrimSpace(s) + if len(trim) >= 2 && (trim[0] == '-' || trim[0] == '*') && trim[1] == ' ' { + return string(trim[0]), false, true + } + if len(trim) >= 3 && trim[0] >= '1' && trim[0] <= '9' && trim[1] == '.' && trim[2] == ' ' { + return ".", true, true + } + return "", false, false +} + +func parseListItems(lines []string, start int, mark string) ([]ListItem, int) { + var items []ListItem + consumed := 0 + for i := start; i < len(lines); i++ { + trim := strings.TrimSpace(lines[i]) + m, _, ok := listMarker(trim) + if !ok || m != mark { + break + } + consumed++ + rest := strings.TrimSpace(trim[len(mark)+1:]) + + var checked *bool + if strings.HasPrefix(rest, "[ ] ") { + f := false + checked = &f + rest = rest[4:] + } else if strings.HasPrefix(rest, "[x] ") { + t := true + checked = &t + rest = rest[4:] + } else if strings.HasPrefix(rest, "[X] ") { + t := true + checked = &t + rest = rest[4:] + } + + items = append(items, ListItem{ + Checked: checked, + Content: parseInline(rest), + }) + } + return items, consumed +} + +func isTableSep(s string) bool { + if !strings.HasPrefix(s, "|") || !strings.HasSuffix(s, "|") { + return false + } + s = s[1 : len(s)-1] + cells := strings.Split(s, "|") + if len(cells) == 0 { + return false + } + for _, c := range cells { + c = strings.TrimSpace(c) + if c == "" { + return false + } + for _, ch := range c { + if ch != '-' && ch != ':' { + return false + } + } + } + return true +} + +func parseTable(lines []string, start int) (Table, int) { + headerLine := strings.TrimSpace(lines[start]) + headers := splitTableRow(headerLine) + + consumed := 2 + var rows [][]string + for i := start + 2; i < len(lines); i++ { + trim := strings.TrimSpace(lines[i]) + if !strings.HasPrefix(trim, "|") { + break + } + rows = append(rows, splitTableRow(trim)) + consumed++ + } + + return Table{Headers: headers, Rows: rows}, consumed +} + +func splitTableRow(s string) []string { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "|") { + s = s[1:] + } + if strings.HasSuffix(s, "|") { + s = s[:len(s)-1] + } + cells := strings.Split(s, "|") + result := make([]string, len(cells)) + for i, c := range cells { + result[i] = strings.TrimSpace(c) + } + return result +} + +func parseParagraph(lines []string, start int) (Paragraph, int) { + var contentLines []string + consumed := 0 + for i := start; i < len(lines); i++ { + trim := strings.TrimSpace(lines[i]) + if trim == "" { + break + } + if headingLevel(trim) > 0 { + break + } + if trim == "---" || trim == "***" || trim == "___" { + break + } + if isFence(trim) { + break + } + if strings.HasPrefix(trim, ">") { + break + } + if _, _, ok := listMarker(trim); ok { + break + } + contentLines = append(contentLines, lines[i]) + consumed++ + } + text := strings.Join(contentLines, "\n") + return Paragraph{Content: parseInline(text)}, consumed +} diff --git a/pkg/mdprint/render.go b/pkg/mdprint/render.go new file mode 100644 index 0000000..70f34f4 --- /dev/null +++ b/pkg/mdprint/render.go @@ -0,0 +1,204 @@ +package mdprint + +import ( + "fmt" + "strings" + + "hub.gaomia.site/titor/YunShu/pkg/style" +) + +func renderNode(node Node) string { + switch n := node.(type) { + case Heading: + return renderHeading(n) + case Paragraph: + return renderParagraph(n) + case CodeBlock: + return renderCodeBlock(n) + case Blockquote: + return renderBlockquote(n) + case List: + return renderList(n) + case Table: + return renderTable(n) + case ThematicBreak: + return "\n" + style.Dim.Render("────────────────────────────") + "\n" + case Text: + return style.New().Render(n.Text) + case Bold: + return renderBold(n) + case Italic: + return renderItalic(n) + case Code: + return renderInlineCode(n) + case Link: + return renderLink(n) + default: + return fmt.Sprintf("%v", n) + } +} + +func renderInline(nodes []Node) string { + var b strings.Builder + for _, child := range nodes { + b.WriteString(renderNode(child)) + } + return b.String() +} + +var headingConfig = []struct { + symbol string + style *style.Style +}{ + {"▪ ", style.New().Bold().FgHex("#6B8E9B")}, + {"▪ ", style.New().Bold().FgHex("#89A894")}, + {"▪ ", style.New().Bold().FgHex("#A6C0B5")}, + {"▫ ", style.New().Bold().FgHex("#C3B1BD")}, + {"▫ ", style.New().Bold().FgHex("#7B8E8A")}, + {"", style.New().Bold().Dim()}, +} + +func renderHeading(h Heading) string { + cfg := headingConfig[0] + if h.Level-1 < len(headingConfig) { + cfg = headingConfig[h.Level-1] + } + suffix := "" + if h.Level == 1 { + suffix = "\n" + } + return "\n" + cfg.style.Render(cfg.symbol+renderInline(h.Content)) + suffix +} + +func renderParagraph(p Paragraph) string { + return renderInline(p.Content) +} + +func renderCodeBlock(c CodeBlock) string { + var b strings.Builder + if c.Lang != "" { + b.WriteString(style.Dim.Render(c.Lang + " ") + "\n") + } + for _, line := range strings.Split(c.Body, "\n") { + b.WriteString(" " + style.New().Fg(style.ColorYellow).Render(line) + "\n") + } + return strings.TrimRight(b.String(), "\n") +} + +func renderBlockquote(q Blockquote) string { + var b strings.Builder + for i, child := range q.Children { + if i > 0 { + b.WriteString("\n") + } + rendered := renderNode(child) + for _, line := range strings.Split(rendered, "\n") { + b.WriteString(style.Dim.Render("│ ") + line + "\n") + } + } + return strings.TrimRight(b.String(), "\n") +} + +func renderList(l List) string { + var b strings.Builder + for i, item := range l.Items { + if i > 0 { + b.WriteString("\n") + } + prefix := "- " + if l.Ordered { + prefix = fmt.Sprintf("%d. ", i+1) + } + if item.Checked != nil { + check := " " + if *item.Checked { + check = "x" + } + prefix = fmt.Sprintf("- [%s] ", check) + } + b.WriteString(style.Dim.Render(prefix) + renderInline(item.Content)) + } + return b.String() +} + +func renderTable(t Table) string { + if len(t.Headers) == 0 { + return "" + } + var b strings.Builder + + colWidths := make([]int, len(t.Headers)) + for i, h := range t.Headers { + colWidths[i] = len(h) + } + for _, row := range t.Rows { + for i, cell := range row { + if i < len(colWidths) && len(cell) > colWidths[i] { + colWidths[i] = len(cell) + } + } + } + + renderRow := func(cells []string, isHeader bool) { + b.WriteString("| ") + for i, cell := range cells { + if i > 0 { + b.WriteString(" | ") + } + if i < len(colWidths) { + cell = fmt.Sprintf("%-*s", colWidths[i], cell) + } + if isHeader { + b.WriteString(style.Bold.Render(cell)) + } else { + b.WriteString(cell) + } + } + b.WriteString(" |") + } + + renderRow(t.Headers, true) + + sep := "| " + for i, w := range colWidths { + if i > 0 { + sep += " | " + } + sep += strings.Repeat("-", w) + } + sep += " |" + b.WriteString("\n" + style.Dim.Render(sep)) + + for _, row := range t.Rows { + b.WriteString("\n") + renderRow(row, false) + } + + return b.String() +} + +func renderBold(b Bold) string { + return style.New().Bold().Render(renderInline(b.Content)) +} + +func renderItalic(i Italic) string { + return style.New().Italic().Render(renderInline(i.Content)) +} + +func renderInlineCode(c Code) string { + return style.New().Fg(style.ColorYellow).Render("`" + c.Text + "`") +} + +func renderLink(l Link) string { + text := renderInline(l.Content) + url := l.URL + if text == "" { + text = url + url = "" + } + styled := style.New().Underline().Fg(style.ColorBlue).Render(text) + if url != "" { + styled += style.Dim.Render(" (" + url + ")") + } + return styled +} diff --git a/pkg/style/style.go b/pkg/style/style.go new file mode 100644 index 0000000..465c0f7 --- /dev/null +++ b/pkg/style/style.go @@ -0,0 +1,132 @@ +package style + +import ( + "fmt" + "os" + "strings" +) + +var noColor = os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" + +type Style struct { + codes []string +} + +func New() *Style { + return &Style{} +} + +func (s *Style) clone() *Style { + c := &Style{codes: make([]string, len(s.codes))} + copy(c.codes, s.codes) + return c +} + +func (s *Style) Bold() *Style { + c := s.clone() + c.codes = append(c.codes, "1") + return c +} + +func (s *Style) Dim() *Style { + c := s.clone() + c.codes = append(c.codes, "2") + return c +} + +func (s *Style) Italic() *Style { + c := s.clone() + c.codes = append(c.codes, "3") + return c +} + +func (s *Style) Underline() *Style { + c := s.clone() + c.codes = append(c.codes, "4") + return c +} + +func (s *Style) Fg(c Color) *Style { + n := s.clone() + n.codes = append(n.codes, fmt.Sprintf("%d", c)) + return n +} + +func (s *Style) Bg(c Color) *Style { + n := s.clone() + n.codes = append(n.codes, fmt.Sprintf("%d", c+10)) + return n +} + +func (s *Style) FgHex(hex string) *Style { + n := s.clone() + r, g, b := hexToRGB(hex) + n.codes = append(n.codes, fmt.Sprintf("38;2;%d;%d;%d", r, g, b)) + return n +} + +func (s *Style) BgHex(hex string) *Style { + n := s.clone() + r, g, b := hexToRGB(hex) + n.codes = append(n.codes, fmt.Sprintf("48;2;%d;%d;%d", r, g, b)) + return n +} + +func hexToRGB(hex string) (r, g, b int) { + hex = strings.TrimPrefix(hex, "#") + if len(hex) != 6 { + return 255, 255, 255 + } + r = parseHex(hex[0:2]) + g = parseHex(hex[2:4]) + b = parseHex(hex[4:6]) + return +} + +func parseHex(s string) int { + v := 0 + for _, c := range s { + v *= 16 + switch { + case c >= '0' && c <= '9': + v += int(c - '0') + case c >= 'a' && c <= 'f': + v += int(c - 'a' + 10) + case c >= 'A' && c <= 'F': + v += int(c - 'A' + 10) + } + } + return v +} + +func (s *Style) Render(text string) string { + if noColor || len(s.codes) == 0 { + return text + } + return "\033[" + strings.Join(s.codes, ";") + "m" + text + "\033[0m" +} + +type Color int + +const ( + ColorBlack Color = 30 + ColorRed Color = 31 + ColorGreen Color = 32 + ColorYellow Color = 33 + ColorBlue Color = 34 + ColorMagenta Color = 35 + ColorCyan Color = 36 + ColorWhite Color = 37 +) + +var ( + Red = New().Fg(ColorRed) + Green = New().Fg(ColorGreen) + Yellow = New().Fg(ColorYellow) + Cyan = New().Fg(ColorCyan) + Blue = New().Fg(ColorBlue) + Magenta = New().Fg(ColorMagenta) + White = New().Fg(ColorWhite) + Bold = New().Bold() + Dim = New().Dim() +) diff --git a/pkg/termui/completer.go b/pkg/termui/completer.go new file mode 100644 index 0000000..6cb5d8d --- /dev/null +++ b/pkg/termui/completer.go @@ -0,0 +1 @@ +package termui diff --git a/pkg/termui/input.go b/pkg/termui/input.go new file mode 100644 index 0000000..41e999b --- /dev/null +++ b/pkg/termui/input.go @@ -0,0 +1,189 @@ +package termui + +import ( + "bufio" + "fmt" + "os" + "strings" + "syscall" + "unsafe" + + "hub.gaomia.site/titor/YunShu/pkg/style" +) + +const ( + stdInputHandle = ^uintptr(9) + stdOutputHandle = ^uintptr(10) + enableProcessed = 0x0001 + enableLineInput = 0x0002 + enableEchoInput = 0x0004 + enableVtProcessing = 0x0004 +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetStdHandle = kernel32.NewProc("GetStdHandle") + procGetConsoleMode = kernel32.NewProc("GetConsoleMode") + procSetConsoleMode = kernel32.NewProc("SetConsoleMode") +) + +func ensureLineMode() { + h, _, _ := procGetStdHandle.Call(stdInputHandle) + if h == 0 || h == uintptr(syscall.InvalidHandle) { + return + } + var mode uint32 + ret, _, _ := procGetConsoleMode.Call(h, uintptr(unsafe.Pointer(&mode))) + if ret == 0 { + return + } + need := uint32(enableProcessed | enableLineInput | enableEchoInput) + if mode&need != need { + procSetConsoleMode.Call(h, uintptr(need)) + } +} + +type InputConfig struct { + Label string + Help string + Default string + Required bool + Validator Validator +} + +type InputOption func(*InputConfig) + +func WithDefault(v string) InputOption { + return func(c *InputConfig) { c.Default = v } +} + +func WithHelp(v string) InputOption { + return func(c *InputConfig) { c.Help = v } +} + +func WithRequired(v bool) InputOption { + return func(c *InputConfig) { c.Required = v } +} + +func WithValidator(v Validator) InputOption { + return func(c *InputConfig) { c.Validator = v } +} + +func applyOpts(opts []InputOption) InputConfig { + c := InputConfig{} + for _, o := range opts { + o(&c) + } + return c +} + +func printLabel(label string, required bool) { + s := style.New().Bold().Render(label) + if required { + s += "(必填)" + } + fmt.Println(s) +} + +func ReadLine() string { + ensureLineMode() + r := bufio.NewReader(os.Stdin) + s, err := r.ReadString('\n') + if err != nil { + return "" + } + return strings.TrimRight(s, "\r\n") +} + +func TextInput(label string, opts ...InputOption) string { + cfg := applyOpts(opts) + printLabel(label, cfg.Required) + + for { + fmt.Print(style.Cyan.Render("? ")) + + line := ReadLine() + + if line == "" { + line = cfg.Default + } + + var err error + if cfg.Required && line == "" { + err = fmt.Errorf("不能为空") + } else if cfg.Validator != nil { + err = cfg.Validator(line) + } + + if err != nil { + fmt.Println(style.Red.Render("⚠ " + err.Error())) + continue + } + + fmt.Println(style.Green.Render("✔ " + line)) + return line + } +} + +func PasswordInput(label string, opts ...InputOption) string { + cfg := applyOpts(opts) + printLabel(label, cfg.Required) + + for { + fmt.Print(style.Cyan.Render("? ")) + + line := ReadLine() + + fmt.Print("\033[A\r\033[K") + + if line == "" { + line = cfg.Default + } + + var err error + if cfg.Required && line == "" { + err = fmt.Errorf("不能为空") + } else if cfg.Validator != nil { + err = cfg.Validator(line) + } + + if err != nil { + fmt.Println(style.Red.Render("⚠ " + err.Error())) + continue + } + + masked := strings.Repeat("*", len(line)) + fmt.Println(style.Green.Render("✔ " + masked)) + return line + } +} + +func Confirm(label string, defaultValue bool) bool { + hint := "Y/n" + if !defaultValue { + hint = "y/N" + } + + fmt.Print(style.Cyan.Render("?"), label, "(", hint, ")") + + line := ReadLine() + line = strings.ToLower(line) + + fmt.Print("\033[A\r\033[K") + + ok := defaultValue + if line == "y" || line == "yes" { + ok = true + } else if line == "n" || line == "no" { + ok = false + } else if line != "" { + ok = false + } + + if ok { + fmt.Println(style.Green.Render("✔ " + label)) + } else { + fmt.Println(style.Red.Render("✘ " + label)) + } + return ok +} diff --git a/pkg/termui/validate.go b/pkg/termui/validate.go new file mode 100644 index 0000000..9ed80f2 --- /dev/null +++ b/pkg/termui/validate.go @@ -0,0 +1,67 @@ +package termui + +import ( + "fmt" + "strings" +) + +type Validator func(string) error + +var ( + NonEmpty Validator = func(v string) error { + if v == "" { + return fmt.Errorf("不能为空") + } + return nil + } + + IsURL Validator = func(v string) error { + if !strings.HasPrefix(v, "http://") && !strings.HasPrefix(v, "https://") { + return fmt.Errorf("必须以 http:// 或 https:// 开头") + } + return nil + } +) + +func MaxLength(n int) Validator { + return func(v string) error { + if len(v) > n { + return fmt.Errorf("不能超过 %d 个字符", n) + } + return nil + } +} + +func MinLength(n int) Validator { + return func(v string) error { + if len(v) < n { + return fmt.Errorf("至少需要 %d 个字符", n) + } + return nil + } +} + +func And(vv ...Validator) Validator { + return func(v string) error { + for _, fn := range vv { + if err := fn(v); err != nil { + return err + } + } + return nil + } +} + +func Or(vv ...Validator) Validator { + return func(v string) error { + var lastErr error + for _, fn := range vv { + if err := fn(v); err == nil { + return nil + } else { + lastErr = err + } + } + return lastErr + } +} diff --git a/src/runtime.go b/runtime.go similarity index 95% rename from src/runtime.go rename to runtime.go index 47f8c73..2bab810 100644 --- a/src/runtime.go +++ b/runtime.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "path/filepath" + + "hub.gaomia.site/titor/YunShu/pkg/mdprint" ) func sessionPath() string { @@ -94,7 +96,8 @@ func RunAgent(def *AgentDef, userInput string) error { fullMessages = append(fullMessages, assistantMsg) AppendToSession(assistantMsg) - fmt.Println(content) + fmt.Println() + mdprint.Print(content) return nil } } diff --git a/src/main.go b/src/main.go deleted file mode 100644 index 412583e..0000000 --- a/src/main.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "os" - "strings" - "syscall" -) - -func init() { - // Windows 控制台 UTF-8 编码 - kernel32 := syscall.NewLazyDLL("kernel32.dll") - setConsoleCP := kernel32.NewProc("SetConsoleOutputCP") - setConsoleCP.Call(65001) -} - -func main() { - args := os.Args[1:] - - // onboard 子命令:交互式初始化 - if len(args) > 0 && args[0] == "onboard" { - runOnboard() - return - } - - // 加载配置 - cfg, err := LoadConfig() - if err != nil { - fmt.Fprintf(os.Stderr, "❌ 未找到配置文件。请先运行:\n") - fmt.Fprintf(os.Stderr, " yunshu onboard\n") - os.Exit(1) - } - - // 配置已通过 init() 加载到 llmHost/llmModel/llmKey 中 - _ = cfg - - // 生成工具目录(启动时覆写 auto 节,保留 manual 节) - GenerateToolsYAML() - - // 查找并加载 agent 定义 - agentPath := SearchFile("agents/weather-agent.md") - def, err := LoadAgent(agentPath) - if err != nil { - fmt.Fprintf(os.Stderr, "加载 agent 失败: %v\n", err) - os.Exit(1) - } - - // 单次查询模式 - if len(args) > 0 { - ClearSession() - query := strings.Join(args, " ") - if err := RunAgent(def, query); err != nil { - fmt.Fprintf(os.Stderr, "错误: %v\n", err) - os.Exit(1) - } - return - } - - // 交互模式 - fmt.Println("☁️ 云枢 Agent — 天气情报官,输入 exit 退出") - fmt.Println(strings.Repeat("─", 50)) - ClearSession() - - scanner := bufio.NewScanner(os.Stdin) - for { - fmt.Print("> ") - if !scanner.Scan() { - break - } - input := strings.TrimSpace(scanner.Text()) - if input == "" { - continue - } - if input == "exit" || input == "quit" { - fmt.Println("再见!") - break - } - - if err := RunAgent(def, input); err != nil { - fmt.Fprintf(os.Stderr, "错误: %v\n", err) - } - fmt.Println(strings.Repeat("─", 50)) - } -} diff --git a/src/onboard.go b/src/onboard.go deleted file mode 100644 index f1cb0e3..0000000 --- a/src/onboard.go +++ /dev/null @@ -1,69 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "os" - "path/filepath" - "strings" -) - -// runOnboard 交互式初始化向导 -func runOnboard() { - fmt.Println(strings.Repeat("=", 50)) - fmt.Println(" ☁️ 云枢 Agent 初始化配置") - fmt.Println(strings.Repeat("=", 50)) - - reader := bufio.NewReader(os.Stdin) - - // 读取 LLM 地址 - defaultHost := "https://ark.cn-beijing.volces.com/api/v3/chat/completions" - fmt.Printf("\nLLM 接口地址\n 默认: %s\n > ", defaultHost) - host, _ := reader.ReadString('\n') - host = strings.TrimSpace(host) - if host == "" { - host = defaultHost - } - - // 读取 API Key - fmt.Print("API Key\n > ") - key, _ := reader.ReadString('\n') - key = strings.TrimSpace(key) - - // 读取模型名称 - defaultModel := "doubao-seed-2-0-pro-260215" - fmt.Printf("模型名称\n 默认: %s\n > ", defaultModel) - model, _ := reader.ReadString('\n') - model = strings.TrimSpace(model) - if model == "" { - model = defaultModel - } - - // 保存配置 - cfg := &Config{ - LLM: LLMConfig{ - Host: host, - Model: model, - Key: key, - }, - } - - if err := SaveConfig(cfg); err != nil { - fmt.Fprintf(os.Stderr, "保存配置失败: %v\n", err) - os.Exit(1) - } - - // 复制默认 agents / skills / data 到全局配置目录 - CopyDefaultDir("agents", "agents") - CopyDefaultDir("skills", "skills") - CopyDefaultDir("data", "data") - - configPath := filepath.Join(ConfigDir(), "config.yaml") - fmt.Printf("\n✅ 配置完成!\n") - fmt.Printf(" 配置文件: %s\n", configPath) - fmt.Printf(" Agent 目录: %s\n", filepath.Join(ConfigDir(), "agents")) - fmt.Println() - fmt.Println("运行示例:") - fmt.Println(" yunshu \"北京今天天气\"") - fmt.Println(" yunshu") -} diff --git a/src/tool.go b/tool.go similarity index 100% rename from src/tool.go rename to tool.go diff --git a/src/types.go b/types.go similarity index 100% rename from src/types.go rename to types.go