Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4e5cd82c3 | |||
| 3f9443c14b |
14
changelog.md
14
changelog.md
@@ -2,7 +2,19 @@
|
||||
|
||||
## 版本记录
|
||||
|
||||
### v0.1.0 (规划中)
|
||||
### v0.2.0
|
||||
|
||||
- 新增 TTS 语音朗读功能
|
||||
- 集成 mimo-tts client 功能,通过 TCP 连接本地 daemon
|
||||
- 支持配置文件开关(tts.enabled)
|
||||
- 支持命令行切换(/tts on/off/status)
|
||||
- 支持临时 TTS 前缀(`T 消息` 临时开启)
|
||||
- 动态提示符显示 TTS 状态(👀 🔊)
|
||||
- 静默失败处理(网络异常时警告日志)
|
||||
|
||||
---
|
||||
|
||||
### v0.1.0
|
||||
|
||||
- 创建 hxclaw 项目
|
||||
- 实现流式输出功能
|
||||
|
||||
@@ -12,6 +12,7 @@ type ProjectConfig struct {
|
||||
Streaming StreamingConfig `yaml:"streaming"`
|
||||
Markdown MarkdownConfig `yaml:"markdown"`
|
||||
UI UIConfig `yaml:"ui"`
|
||||
TTS TTSConfig `yaml:"tts"`
|
||||
}
|
||||
|
||||
type StreamingConfig struct {
|
||||
@@ -29,6 +30,12 @@ type UIConfig struct {
|
||||
UserPrefix string `yaml:"user_prefix"`
|
||||
}
|
||||
|
||||
type TTSConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Port int `yaml:"port"`
|
||||
Auto bool `yaml:"auto"`
|
||||
}
|
||||
|
||||
var (
|
||||
defaultCfg = ProjectConfig{
|
||||
Streaming: StreamingConfig{
|
||||
@@ -43,6 +50,11 @@ var (
|
||||
Logo: "🦐",
|
||||
UserPrefix: "👀 ",
|
||||
},
|
||||
TTS: TTSConfig{
|
||||
Enabled: false,
|
||||
Port: 9876,
|
||||
Auto: true,
|
||||
},
|
||||
}
|
||||
projCfg *ProjectConfig
|
||||
projCfgLock sync.RWMutex
|
||||
@@ -90,6 +102,9 @@ func LoadProjectConfig() error {
|
||||
if cfg.UI.UserPrefix == "" {
|
||||
cfg.UI.UserPrefix = defaultCfg.UI.UserPrefix
|
||||
}
|
||||
if cfg.TTS.Port <= 0 {
|
||||
cfg.TTS.Port = defaultCfg.TTS.Port
|
||||
}
|
||||
|
||||
projCfg = &cfg
|
||||
return nil
|
||||
|
||||
@@ -49,7 +49,8 @@ func GetConfigPath() string {
|
||||
|
||||
// Readline 实例包装
|
||||
type Readline struct {
|
||||
rl *readline.Instance
|
||||
rl *readline.Instance
|
||||
basePrompt string
|
||||
}
|
||||
|
||||
// NewReadline 创建一个新的 Readline 实例
|
||||
@@ -68,7 +69,18 @@ func NewReadline(prompt string) (*Readline, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Readline{rl: rl}, nil
|
||||
return &Readline{rl: rl, basePrompt: prompt}, nil
|
||||
}
|
||||
|
||||
// SetPrompt 更新提示符
|
||||
func (r *Readline) SetPrompt(prompt string) {
|
||||
r.basePrompt = prompt
|
||||
r.rl.SetPrompt(prompt)
|
||||
}
|
||||
|
||||
// GetBasePrompt 返回基础提示符
|
||||
func (r *Readline) GetBasePrompt() string {
|
||||
return r.basePrompt
|
||||
}
|
||||
|
||||
// Readline 读取一行输入
|
||||
|
||||
@@ -66,7 +66,8 @@ func main() {
|
||||
}
|
||||
|
||||
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||
prompt := internal.GetProjectConfig().UI.UserPrefix
|
||||
basePrompt := internal.GetProjectConfig().UI.UserPrefix
|
||||
prompt := internal.GetTTSPrompt(basePrompt)
|
||||
|
||||
rl, err := internal.NewReadline(prompt)
|
||||
if err != nil {
|
||||
@@ -77,6 +78,11 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||
}
|
||||
defer rl.Close()
|
||||
|
||||
ttsCfg := internal.GetProjectConfig().TTS
|
||||
if ttsCfg.Enabled {
|
||||
internal.SetTTSEnabled(true)
|
||||
}
|
||||
|
||||
for {
|
||||
line, err := rl.Readline()
|
||||
if err != nil {
|
||||
@@ -98,14 +104,37 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||
return
|
||||
}
|
||||
|
||||
runWithStreaming(agentLoop, input, sessionKey)
|
||||
isTempTTS := false
|
||||
if len(input) > 0 && input[0] == 'T' && (len(input) == 1 || input[1] == ' ') {
|
||||
input = strings.TrimPrefix(input, "T")
|
||||
input = strings.TrimPrefix(input, " ")
|
||||
isTempTTS = true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(input, "/tts") {
|
||||
handleTTSCommand(input, rl, basePrompt)
|
||||
continue
|
||||
}
|
||||
|
||||
if isTempTTS {
|
||||
enabled := internal.ToggleTTS()
|
||||
if enabled {
|
||||
rl.SetPrompt(internal.GetTTSPrompt(basePrompt))
|
||||
}
|
||||
}
|
||||
|
||||
runWithStreaming(agentLoop, input, sessionKey, isTempTTS)
|
||||
}
|
||||
}
|
||||
|
||||
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||
reader := internal.NewSimpleReader()
|
||||
ttsCfg := internal.GetProjectConfig().TTS
|
||||
if ttsCfg.Enabled {
|
||||
internal.SetTTSEnabled(true)
|
||||
}
|
||||
for {
|
||||
fmt.Print(internal.GetProjectConfig().UI.UserPrefix)
|
||||
fmt.Print(internal.GetTTSPrompt(internal.GetProjectConfig().UI.UserPrefix))
|
||||
line, err := reader.ReadString()
|
||||
if err != nil {
|
||||
if err == internal.ErrEOF {
|
||||
@@ -126,12 +155,28 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||
return
|
||||
}
|
||||
|
||||
runWithStreaming(agentLoop, input, sessionKey)
|
||||
isTempTTS := false
|
||||
if len(input) > 0 && input[0] == 'T' && (len(input) == 1 || input[1] == ' ') {
|
||||
input = strings.TrimPrefix(input, "T")
|
||||
input = strings.TrimPrefix(input, " ")
|
||||
isTempTTS = true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(input, "/tts") {
|
||||
handleTTSCommandSimple(input)
|
||||
continue
|
||||
}
|
||||
|
||||
if isTempTTS {
|
||||
internal.ToggleTTS()
|
||||
}
|
||||
|
||||
runWithStreaming(agentLoop, input, sessionKey, isTempTTS)
|
||||
}
|
||||
}
|
||||
|
||||
// runWithStreaming 使用 ProcessDirect 处理请求,支持工具调用和结果显示
|
||||
func runWithStreaming(agentLoop *agent.AgentLoop, input, sessionKey string) {
|
||||
func runWithStreaming(agentLoop *agent.AgentLoop, input, sessionKey string, tempTTS bool) {
|
||||
startTime := time.Now()
|
||||
|
||||
spinner := internal.NewSpinner("思考中...")
|
||||
@@ -150,6 +195,11 @@ func runWithStreaming(agentLoop *agent.AgentLoop, input, sessionKey string) {
|
||||
clearSpinnerLine()
|
||||
outputLineByLine(rendered)
|
||||
|
||||
ttsCfg := internal.GetProjectConfig().TTS
|
||||
if ttsCfg.Enabled || tempTTS || internal.IsTTSEnabled() {
|
||||
go internal.SpeakText(resp)
|
||||
}
|
||||
|
||||
elapsed := time.Since(startTime)
|
||||
printElapsed(elapsed)
|
||||
}
|
||||
@@ -218,3 +268,66 @@ func formatDuration(s float64) string {
|
||||
}
|
||||
return fmt.Sprintf("%.1fs", s)
|
||||
}
|
||||
|
||||
func handleTTSCommand(input string, rl *internal.Readline, basePrompt string) {
|
||||
args := strings.Fields(input)
|
||||
if len(args) == 1 {
|
||||
enabled := internal.ToggleTTS()
|
||||
rl.SetPrompt(internal.GetTTSPrompt(basePrompt))
|
||||
status := "关闭"
|
||||
if enabled {
|
||||
status = "开启"
|
||||
}
|
||||
fmt.Printf("TTS 已%s\n", status)
|
||||
return
|
||||
}
|
||||
|
||||
switch args[1] {
|
||||
case "on":
|
||||
internal.SetTTSEnabled(true)
|
||||
rl.SetPrompt(internal.GetTTSPrompt(basePrompt))
|
||||
fmt.Println("TTS 已开启")
|
||||
case "off":
|
||||
internal.SetTTSEnabled(false)
|
||||
rl.SetPrompt(internal.GetTTSPrompt(basePrompt))
|
||||
fmt.Println("TTS 已关闭")
|
||||
case "status":
|
||||
status := "关闭"
|
||||
if internal.IsTTSEnabled() {
|
||||
status = "开启"
|
||||
}
|
||||
fmt.Printf("TTS 状态: %s\n", status)
|
||||
default:
|
||||
fmt.Println("用法: /tts [on|off|status]")
|
||||
}
|
||||
}
|
||||
|
||||
func handleTTSCommandSimple(input string) {
|
||||
args := strings.Fields(input)
|
||||
if len(args) == 1 {
|
||||
internal.ToggleTTS()
|
||||
status := "关闭"
|
||||
if internal.IsTTSEnabled() {
|
||||
status = "开启"
|
||||
}
|
||||
fmt.Printf("TTS 已%s\n", status)
|
||||
return
|
||||
}
|
||||
|
||||
switch args[1] {
|
||||
case "on":
|
||||
internal.SetTTSEnabled(true)
|
||||
fmt.Println("TTS 已开启")
|
||||
case "off":
|
||||
internal.SetTTSEnabled(false)
|
||||
fmt.Println("TTS 已关闭")
|
||||
case "status":
|
||||
status := "关闭"
|
||||
if internal.IsTTSEnabled() {
|
||||
status = "开启"
|
||||
}
|
||||
fmt.Printf("TTS 状态: %s\n", status)
|
||||
default:
|
||||
fmt.Println("用法: /tts [on|off|status]")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,10 @@ markdown:
|
||||
# UI 配置
|
||||
ui:
|
||||
logo: "🦐"
|
||||
user_prefix: "👀 "
|
||||
user_prefix: "👀 "
|
||||
|
||||
# TTS 语音配置
|
||||
tts:
|
||||
enabled: false # 全局开关(默认关闭)
|
||||
port: 9876 # mimo-tts daemon 端口
|
||||
auto: true # AI 回复后自动朗读
|
||||
178
taolun.md
178
taolun.md
@@ -205,4 +205,180 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
3. 换行控制:
|
||||
- "思考完成." 后需要两个换行符(一个换行 + 一个空行)
|
||||
- 流式输出完成后也需要空行分隔
|
||||
- 流式输出完成后也需要空行分隔
|
||||
|
||||
---
|
||||
|
||||
### 13. 重绘残留问题与新流程
|
||||
|
||||
#### 问题描述
|
||||
|
||||
之前的流程:
|
||||
1. 流式实时打印 token(边收边打)
|
||||
2. 完成后 Markdown 重绘
|
||||
3. 问题:重绘有残留
|
||||
|
||||
#### 解决方案:等待完整响应后输出
|
||||
|
||||
改进后的流程:
|
||||
1. AI 返回完整数据 ← 等待时间
|
||||
2. Markdown 转译
|
||||
3. 模拟流式输出(从配置读取速度)
|
||||
|
||||
效果更好,无残留问题。
|
||||
|
||||
#### 配置化
|
||||
|
||||
使用 `project.config.yml` 统一管理配置:
|
||||
```yaml
|
||||
streaming:
|
||||
line_delay_ms: 1000 # 每行输出后的延迟(毫秒)
|
||||
last_line_delay_ms: 600 # 最后一行延迟(毫秒)
|
||||
|
||||
markdown:
|
||||
glamour_style: dark
|
||||
wrap_width: 0 # 自动获取终端宽度
|
||||
|
||||
ui:
|
||||
logo: "🦐"
|
||||
user_prefix: "👀 " # 用户输入前缀
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 14. 按行延迟输出的实现
|
||||
|
||||
#### 核心逻辑
|
||||
|
||||
```go
|
||||
func outputLineByLine(text string) {
|
||||
lines := strings.Split(text, "\n")
|
||||
totalLines := len(lines)
|
||||
|
||||
cfg := internal.GetProjectConfig()
|
||||
lineDelay := time.Duration(cfg.Streaming.LineDelayMs) * time.Millisecond
|
||||
lastLineDelay := time.Duration(cfg.Streaming.LastLineDelayMs) * time.Millisecond
|
||||
|
||||
for i, line := range lines {
|
||||
if line == "" {
|
||||
fmt.Println()
|
||||
continue
|
||||
}
|
||||
fmt.Println(line)
|
||||
|
||||
if i < totalLines-1 {
|
||||
time.Sleep(lineDelay)
|
||||
} else {
|
||||
time.Sleep(lastLineDelay)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
```
|
||||
|
||||
特点:
|
||||
- 空行直接跳过
|
||||
- 每行输出后延迟可配置
|
||||
- 最后一行延迟可单独配置
|
||||
|
||||
---
|
||||
|
||||
### 15. 工具调用结果显示问题
|
||||
|
||||
#### 问题
|
||||
|
||||
使用 `ChatStream` 时,工具调用结果不显示。
|
||||
|
||||
#### 原因分析
|
||||
|
||||
1. 工具调用结果不在流式响应中返回,而是通过 `bus.PublishOutbound()` 单独发送
|
||||
2. `ChatStream` 的 `onChunk` 回调只处理文本内容,不处理工具调用
|
||||
3. 工具调用在 `runTurn` 循环中执行,结果通过消息总线发送
|
||||
|
||||
#### 解决方案
|
||||
|
||||
回退使用 `ProcessDirect`,因为它会正确处理:
|
||||
- 工具调用流程
|
||||
- 工具结果显示
|
||||
- Markdown 渲染和按行输出
|
||||
|
||||
---
|
||||
|
||||
### 16. 状态栏优化
|
||||
|
||||
#### 改动内容
|
||||
|
||||
- 图标:`▣`
|
||||
- 图标颜色:`#f0c75e`
|
||||
- 文字颜色:`#2b2e32`
|
||||
- 内容:只显示耗时,如 `▣ 耗时: 2.3s`
|
||||
|
||||
#### 之前 vs 之后
|
||||
|
||||
- 之前:`▣ Tokens: 120 · 耗时: 2.3s · 总Tokens: 350`
|
||||
- 之后:`▣ 耗时: 2.3s`
|
||||
|
||||
---
|
||||
|
||||
### 17. 项目配置文件详解
|
||||
|
||||
#### project.config.yml 结构
|
||||
|
||||
```yaml
|
||||
# hxclaw 项目配置文件
|
||||
|
||||
# 模拟流式输出配置
|
||||
streaming:
|
||||
line_delay_ms: 1000 # 每行输出后的延迟(毫秒)
|
||||
last_line_delay_ms: 600 # 最后一行延迟(毫秒)
|
||||
|
||||
# Markdown 渲染配置
|
||||
markdown:
|
||||
glamour_style: dark # 渲染主题:dark, light, dracula, tokyo-night 等
|
||||
wrap_width: 0 # 自动换行宽度(0=自动获取终端宽度)
|
||||
|
||||
# UI 配置
|
||||
ui:
|
||||
logo: "🦐" # Logo
|
||||
user_prefix: "👀 " # 用户输入前缀
|
||||
```
|
||||
|
||||
#### 配置加载优先级
|
||||
|
||||
1. 环境变量 `HXCLAW_CONFIG` 指定路径
|
||||
2. 项目根目录 `project.config.yml`
|
||||
|
||||
#### 代码实现
|
||||
|
||||
```go
|
||||
// internal/config.go
|
||||
type ProjectConfig struct {
|
||||
Streaming StreamingConfig `yaml:"streaming"`
|
||||
Markdown MarkdownConfig `yaml:"markdown"`
|
||||
UI UIConfig `yaml:"ui"`
|
||||
}
|
||||
|
||||
func getConfigPath() string {
|
||||
if path := os.Getenv("HXCLAW_CONFIG"); path != "" {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(".", "project.config.yml")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 18. 行业经验参考
|
||||
|
||||
#### CLI 动画最佳实践
|
||||
|
||||
- 帧率:75ms/帧(约 13fps)- GitHub Copilot CLI
|
||||
- Spinner 动画:70-120ms - ora 库
|
||||
- AI 流式输出:30-80ms/字符或行
|
||||
- 总动画时长:控制在 3 秒内 - Copilot CLI 原则
|
||||
|
||||
#### 关键结论
|
||||
|
||||
- 人眼需要约 30-50ms 才能感知单次视觉变化
|
||||
- 空白字符不应逐个输出,应批量处理
|
||||
- 终端宽度 100% 时 Markdown 渲染会显著增加行数和字符数
|
||||
Reference in New Issue
Block a user