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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1 @@
|
||||
yunshu.exe
|
||||
yunshu.exe*
|
||||
|
||||
@@ -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 也一并清理。
|
||||
|
||||
@@ -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
|
||||
|
||||
| 工具名 | 作用 | 实现 |
|
||||
|
||||
@@ -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
|
||||
|
||||
### 发布说明
|
||||
|
||||
100
docs/taolun.md
100
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 单元测试通过
|
||||
- 构建成功,二进制运行正常
|
||||
|
||||
4
go.mod
4
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
|
||||
|
||||
146
main.go
Normal file
146
main.go
Normal 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
101
onboard.go
Normal 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
91
pkg/mdprint/inline.go
Normal 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
52
pkg/mdprint/mdprint.go
Normal 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
308
pkg/mdprint/mdprint_test.go
Normal 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
287
pkg/mdprint/parse.go
Normal 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
204
pkg/mdprint/render.go
Normal 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
132
pkg/style/style.go
Normal 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
1
pkg/termui/completer.go
Normal file
@@ -0,0 +1 @@
|
||||
package termui
|
||||
189
pkg/termui/input.go
Normal file
189
pkg/termui/input.go
Normal 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
67
pkg/termui/validate.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
85
src/main.go
85
src/main.go
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user