refactor: 项目结构重组,src/ 扁平化为根目录,提取 pkg/ 子包

- 模块名重命名 yunshu -> hub.gaomia.site/titor/YunShu
- Go 版本升级 1.21 -> 1.25
- src/ 目录删除,所有文件移至根目录
- 新增 pkg/mdprint/: Markdown AST 解析+ANSI 渲染
- 新增 pkg/style/: 终端颜色样式(8色 ANSI + 24位真彩色)
- 新增 pkg/termui/: 终端输入组件(交互式输入/密码/确认)
- 更新文档:AGENTS.md、architecture.md、changelog.md、taolun.md
- gitignore 通配符修复 yunshu.exe -> yunshu.exe*
This commit is contained in:
titor
2026-05-09 03:55:56 +08:00
parent 5f355a0d7c
commit d2b9b2c4bb
26 changed files with 1739 additions and 159 deletions

2
.gitignore vendored
View File

@@ -1 +1 @@
yunshu.exe
yunshu.exe*

View File

@@ -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. **项目正式命名为云枢·AgentYunShu / 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 也一并清理。

View File

@@ -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
| 工具名 | 作用 | 实现 |

View File

@@ -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
### 发布说明

View File

@@ -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 单元测试通过
- 构建成功,二进制运行正常

4
go.mod
View File

@@ -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

View File

146
main.go Normal file
View File

@@ -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()
}
}

101
onboard.go Normal file
View File

@@ -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✔ 连接成功!"))
}

91
pkg/mdprint/inline.go Normal file
View File

@@ -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
}

52
pkg/mdprint/mdprint.go Normal file
View File

@@ -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()
}

308
pkg/mdprint/mdprint_test.go Normal file
View File

@@ -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")
}
}

287
pkg/mdprint/parse.go Normal file
View File

@@ -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
}

204
pkg/mdprint/render.go Normal file
View File

@@ -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
}

132
pkg/style/style.go Normal file
View File

@@ -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()
)

1
pkg/termui/completer.go Normal file
View File

@@ -0,0 +1 @@
package termui

189
pkg/termui/input.go Normal file
View File

@@ -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
}

67
pkg/termui/validate.go Normal file
View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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))
}
}

View File

@@ -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")
}