Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9936dbce52 | ||
|
|
24d5e833ad | ||
|
|
91e7e18e85 | ||
|
|
f3f7ca7851 | ||
|
|
ac945fa6f3 | ||
|
|
2df60b2fa3 | ||
|
|
07e624c8da | ||
|
|
f7d6a9ca43 | ||
|
|
5baae33911 | ||
|
|
b599a022be |
39
README.md
39
README.md
@@ -81,9 +81,11 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
### 🐜 Innovative Low-Footprint Deploy
|
### 🐜 Innovative Low-Footprint Deploy
|
||||||
1. Minimal 10$ Home Assitant
|
PicoClaw can be deployed on almost any Linux device!
|
||||||
2. NanoKVM Automated Maintenance
|
|
||||||
3. MaixCAM2 Smart Monitoring
|
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assitant
|
||||||
|
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance
|
||||||
|
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring
|
||||||
|
|
||||||
https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
|
https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
|
||||||
|
|
||||||
@@ -158,14 +160,10 @@ picoclaw onboard
|
|||||||
|
|
||||||
**3. Get API Keys**
|
**3. Get API Keys**
|
||||||
|
|
||||||
- **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com)
|
|
||||||
- **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
|
||||||
|
|
||||||
> **Note**: See `config.example.json` for a complete configuration template.
|
|
||||||
|
|
||||||
- **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
- **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
- **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
- **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
||||||
|
|
||||||
|
> **Note**: See `config.example.json` for a complete configuration template.
|
||||||
|
|
||||||
**3. Chat**
|
**3. Chat**
|
||||||
|
|
||||||
@@ -280,7 +278,7 @@ Config file: `~/.picoclaw/config.json`
|
|||||||
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||||
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||||
| `groq(To be tested)` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -380,31 +378,14 @@ picoclaw agent -m "Hello"
|
|||||||
| `picoclaw agent` | Interactive chat mode |
|
| `picoclaw agent` | Interactive chat mode |
|
||||||
| `picoclaw gateway` | Start the gateway |
|
| `picoclaw gateway` | Start the gateway |
|
||||||
| `picoclaw status` | Show status |
|
| `picoclaw status` | Show status |
|
||||||
| `picoclaw channels login` | Link WhatsApp (scan QR) |
|
|
||||||
| `picoclaw channels status` | Show channel status |
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Scheduled Tasks (Cron)</b></summary>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add a job
|
|
||||||
picoclaw cron add --name "daily" --message "Good morning!" --cron "0 9 * * *"
|
|
||||||
picoclaw cron add --name "hourly" --message "Check status" --every 3600
|
|
||||||
|
|
||||||
# List jobs
|
|
||||||
picoclaw cron list
|
|
||||||
|
|
||||||
# Remove a job
|
|
||||||
picoclaw cron remove <job_id>
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## 🤝 Contribute & Roadmap
|
## 🤝 Contribute & Roadmap
|
||||||
|
|
||||||
PRs welcome! The codebase is intentionally small and readable. 🤗
|
PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||||
|
|
||||||
**Roadmap** — Pick an item and [open a PR](https://github.com/HKUDS/picoclaw/pulls)!
|
discord: https://discord.gg/V4sAZ9XWpN
|
||||||
|
|
||||||
|
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|||||||
BIN
assets/wechat.png
Normal file
BIN
assets/wechat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
@@ -142,7 +142,8 @@ func main() {
|
|||||||
|
|
||||||
func printHelp() {
|
func printHelp() {
|
||||||
fmt.Printf("%s picoclaw - Personal AI Assistant v%s\n\n", logo, version)
|
fmt.Printf("%s picoclaw - Personal AI Assistant v%s\n\n", logo, version)
|
||||||
fmt.Println("Usage: picoclaw <command>\n")
|
fmt.Println("Usage: picoclaw <command>")
|
||||||
|
fmt.Println()
|
||||||
fmt.Println("Commands:")
|
fmt.Println("Commands:")
|
||||||
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
|
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
|
||||||
fmt.Println(" agent Interact with the agent directly")
|
fmt.Println(" agent Interact with the agent directly")
|
||||||
@@ -450,8 +451,8 @@ func agentCmd() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
bus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, bus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
if message != "" {
|
if message != "" {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -472,7 +473,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
|||||||
|
|
||||||
rl, err := readline.NewEx(&readline.Config{
|
rl, err := readline.NewEx(&readline.Config{
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
HistoryFile: "/tmp/.picoclaw_history",
|
HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"),
|
||||||
HistoryLimit: 100,
|
HistoryLimit: 100,
|
||||||
InterruptPrompt: "^C",
|
InterruptPrompt: "^C",
|
||||||
EOFPrompt: "exit",
|
EOFPrompt: "exit",
|
||||||
@@ -566,8 +567,8 @@ func gatewayCmd() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
bus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, bus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
cronStorePath := filepath.Join(filepath.Dir(getConfigPath()), "cron", "jobs.json")
|
cronStorePath := filepath.Join(filepath.Dir(getConfigPath()), "cron", "jobs.json")
|
||||||
cronService := cron.NewCronService(cronStorePath, nil)
|
cronService := cron.NewCronService(cronStorePath, nil)
|
||||||
@@ -579,7 +580,7 @@ func gatewayCmd() {
|
|||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
|
|
||||||
channelManager, err := channels.NewManager(cfg, bus)
|
channelManager, err := channels.NewManager(cfg, msgBus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error creating channel manager: %v\n", err)
|
fmt.Printf("Error creating channel manager: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -598,6 +599,12 @@ func gatewayCmd() {
|
|||||||
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
|
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if discordChannel, ok := channelManager.GetChannel("discord"); ok {
|
||||||
|
if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
|
||||||
|
dc.SetTranscriber(transcriber)
|
||||||
|
logger.InfoC("voice", "Groq transcription attached to Discord channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enabledChannels := channelManager.GetEnabledChannels()
|
enabledChannels := channelManager.GetEnabledChannels()
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -1,16 +1,18 @@
|
|||||||
module github.com/sipeed/picoclaw
|
module github.com/sipeed/picoclaw
|
||||||
|
|
||||||
go 1.18
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bwmarrin/discordgo v0.28.1
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
github.com/chzyer/readline v1.5.1
|
github.com/chzyer/readline v1.5.1
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/crypto v0.28.0 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
44
go.sum
44
go.sum
@@ -1,5 +1,5 @@
|
|||||||
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
|
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||||
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||||
@@ -10,17 +10,49 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
|||||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||||
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) BuildMessages(history []providers.Message, currentMessage string, media []string) []providers.Message {
|
func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string) []providers.Message {
|
||||||
messages := []providers.Message{}
|
messages := []providers.Message{}
|
||||||
|
|
||||||
systemPrompt := cb.BuildSystemPrompt()
|
systemPrompt := cb.BuildSystemPrompt()
|
||||||
@@ -103,6 +103,10 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, currentMess
|
|||||||
systemPrompt += "\n\n" + skillsContent
|
systemPrompt += "\n\n" + skillsContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if summary != "" {
|
||||||
|
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
|
||||||
|
}
|
||||||
|
|
||||||
messages = append(messages, providers.Message{
|
messages = append(messages, providers.Message{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: systemPrompt,
|
Content: systemPrompt,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
@@ -25,11 +27,13 @@ type AgentLoop struct {
|
|||||||
provider providers.LLMProvider
|
provider providers.LLMProvider
|
||||||
workspace string
|
workspace string
|
||||||
model string
|
model string
|
||||||
|
contextWindow int
|
||||||
maxIterations int
|
maxIterations int
|
||||||
sessions *session.SessionManager
|
sessions *session.SessionManager
|
||||||
contextBuilder *ContextBuilder
|
contextBuilder *ContextBuilder
|
||||||
tools *tools.ToolRegistry
|
tools *tools.ToolRegistry
|
||||||
running bool
|
running bool
|
||||||
|
summarizing sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAgentLoop(cfg *config.Config, bus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
|
func NewAgentLoop(cfg *config.Config, bus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
|
||||||
@@ -53,11 +57,13 @@ func NewAgentLoop(cfg *config.Config, bus *bus.MessageBus, provider providers.LL
|
|||||||
provider: provider,
|
provider: provider,
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
model: cfg.Agents.Defaults.Model,
|
model: cfg.Agents.Defaults.Model,
|
||||||
|
contextWindow: cfg.Agents.Defaults.MaxTokens,
|
||||||
maxIterations: cfg.Agents.Defaults.MaxToolIterations,
|
maxIterations: cfg.Agents.Defaults.MaxToolIterations,
|
||||||
sessions: sessionsManager,
|
sessions: sessionsManager,
|
||||||
contextBuilder: NewContextBuilder(workspace),
|
contextBuilder: NewContextBuilder(workspace),
|
||||||
tools: toolsRegistry,
|
tools: toolsRegistry,
|
||||||
running: false,
|
running: false,
|
||||||
|
summarizing: sync.Map{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,8 +115,12 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
||||||
|
history := al.sessions.GetHistory(msg.SessionKey)
|
||||||
|
summary := al.sessions.GetSummary(msg.SessionKey)
|
||||||
|
|
||||||
messages := al.contextBuilder.BuildMessages(
|
messages := al.contextBuilder.BuildMessages(
|
||||||
al.sessions.GetHistory(msg.SessionKey),
|
history,
|
||||||
|
summary,
|
||||||
msg.Content,
|
msg.Content,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
@@ -187,7 +197,128 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||||||
|
|
||||||
al.sessions.AddMessage(msg.SessionKey, "user", msg.Content)
|
al.sessions.AddMessage(msg.SessionKey, "user", msg.Content)
|
||||||
al.sessions.AddMessage(msg.SessionKey, "assistant", finalContent)
|
al.sessions.AddMessage(msg.SessionKey, "assistant", finalContent)
|
||||||
|
|
||||||
|
// Context compression logic
|
||||||
|
newHistory := al.sessions.GetHistory(msg.SessionKey)
|
||||||
|
|
||||||
|
// Token Awareness (Dynamic)
|
||||||
|
// Trigger if history > 20 messages OR estimated tokens > 75% of context window
|
||||||
|
tokenEstimate := al.estimateTokens(newHistory)
|
||||||
|
threshold := al.contextWindow * 75 / 100
|
||||||
|
|
||||||
|
if len(newHistory) > 20 || tokenEstimate > threshold {
|
||||||
|
if _, loading := al.summarizing.LoadOrStore(msg.SessionKey, true); !loading {
|
||||||
|
go func() {
|
||||||
|
defer al.summarizing.Delete(msg.SessionKey)
|
||||||
|
al.summarizeSession(msg.SessionKey)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
al.sessions.Save(al.sessions.GetOrCreate(msg.SessionKey))
|
al.sessions.Save(al.sessions.GetOrCreate(msg.SessionKey))
|
||||||
|
|
||||||
return finalContent, nil
|
return finalContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) summarizeSession(sessionKey string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
history := al.sessions.GetHistory(sessionKey)
|
||||||
|
summary := al.sessions.GetSummary(sessionKey)
|
||||||
|
|
||||||
|
// Keep last 4 messages for continuity
|
||||||
|
if len(history) <= 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toSummarize := history[:len(history)-4]
|
||||||
|
|
||||||
|
// Oversized Message Guard (Dynamic)
|
||||||
|
// Skip messages larger than 50% of context window to prevent summarizer overflow.
|
||||||
|
maxMessageTokens := al.contextWindow / 2
|
||||||
|
validMessages := make([]providers.Message, 0)
|
||||||
|
omitted := false
|
||||||
|
|
||||||
|
for _, m := range toSummarize {
|
||||||
|
if m.Role != "user" && m.Role != "assistant" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Estimate tokens for this message
|
||||||
|
msgTokens := len(m.Content) / 4
|
||||||
|
if msgTokens > maxMessageTokens {
|
||||||
|
omitted = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validMessages = append(validMessages, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(validMessages) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-Part Summarization
|
||||||
|
// Split into two parts if history is significant
|
||||||
|
var finalSummary string
|
||||||
|
if len(validMessages) > 10 {
|
||||||
|
mid := len(validMessages) / 2
|
||||||
|
part1 := validMessages[:mid]
|
||||||
|
part2 := validMessages[mid:]
|
||||||
|
|
||||||
|
s1, _ := al.summarizeBatch(ctx, part1, "")
|
||||||
|
s2, _ := al.summarizeBatch(ctx, part2, "")
|
||||||
|
|
||||||
|
// Merge them
|
||||||
|
mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
|
||||||
|
resp, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, al.model, map[string]interface{}{
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"temperature": 0.3,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
finalSummary = resp.Content
|
||||||
|
} else {
|
||||||
|
finalSummary = s1 + " " + s2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalSummary, _ = al.summarizeBatch(ctx, validMessages, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if omitted && finalSummary != "" {
|
||||||
|
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalSummary != "" {
|
||||||
|
al.sessions.SetSummary(sessionKey, finalSummary)
|
||||||
|
al.sessions.TruncateHistory(sessionKey, 4)
|
||||||
|
al.sessions.Save(al.sessions.GetOrCreate(sessionKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Message, existingSummary string) (string, error) {
|
||||||
|
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"
|
||||||
|
if existingSummary != "" {
|
||||||
|
prompt += "Existing context: " + existingSummary + "\n"
|
||||||
|
}
|
||||||
|
prompt += "\nCONVERSATION:\n"
|
||||||
|
for _, m := range batch {
|
||||||
|
prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"temperature": 0.3,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return response.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
|
||||||
|
total := 0
|
||||||
|
for _, m := range messages {
|
||||||
|
total += len(m.Content) / 4 // Simple heuristic: 4 chars per token
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,26 @@ package channels
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DiscordChannel struct {
|
type DiscordChannel struct {
|
||||||
*BaseChannel
|
*BaseChannel
|
||||||
session *discordgo.Session
|
session *discordgo.Session
|
||||||
config config.DiscordConfig
|
config config.DiscordConfig
|
||||||
|
transcriber *voice.GroqTranscriber
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
|
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
|
||||||
@@ -28,9 +37,14 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
|
|||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
session: session,
|
session: session,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
transcriber: nil,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
||||||
|
c.transcriber = transcriber
|
||||||
|
}
|
||||||
|
|
||||||
func (c *DiscordChannel) Start(ctx context.Context) error {
|
func (c *DiscordChannel) Start(ctx context.Context) error {
|
||||||
logger.InfoC("discord", "Starting Discord bot")
|
logger.InfoC("discord", "Starting Discord bot")
|
||||||
|
|
||||||
@@ -103,11 +117,48 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||||||
mediaPaths := []string{}
|
mediaPaths := []string{}
|
||||||
|
|
||||||
for _, attachment := range m.Attachments {
|
for _, attachment := range m.Attachments {
|
||||||
mediaPaths = append(mediaPaths, attachment.URL)
|
isAudio := isAudioFile(attachment.Filename, attachment.ContentType)
|
||||||
if content != "" {
|
|
||||||
content += "\n"
|
if isAudio {
|
||||||
|
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
|
||||||
|
if localPath != "" {
|
||||||
|
mediaPaths = append(mediaPaths, localPath)
|
||||||
|
|
||||||
|
transcribedText := ""
|
||||||
|
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := c.transcriber.Transcribe(ctx, localPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Voice transcription failed: %v", err)
|
||||||
|
transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", localPath)
|
||||||
|
} else {
|
||||||
|
transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text)
|
||||||
|
log.Printf("Audio transcribed successfully: %s", result.Text)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
transcribedText = fmt.Sprintf("[audio: %s]", localPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if content != "" {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
content += transcribedText
|
||||||
|
} else {
|
||||||
|
mediaPaths = append(mediaPaths, attachment.URL)
|
||||||
|
if content != "" {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
content += fmt.Sprintf("[attachment: %s]", attachment.URL)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mediaPaths = append(mediaPaths, attachment.URL)
|
||||||
|
if content != "" {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
content += fmt.Sprintf("[attachment: %s]", attachment.URL)
|
||||||
}
|
}
|
||||||
content += fmt.Sprintf("[attachment: %s]", attachment.URL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if content == "" && len(mediaPaths) == 0 {
|
if content == "" && len(mediaPaths) == 0 {
|
||||||
@@ -136,3 +187,60 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||||||
|
|
||||||
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
|
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAudioFile(filename, contentType string) bool {
|
||||||
|
audioExtensions := []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"}
|
||||||
|
audioTypes := []string{"audio/", "application/ogg", "application/x-ogg"}
|
||||||
|
|
||||||
|
for _, ext := range audioExtensions {
|
||||||
|
if strings.HasSuffix(strings.ToLower(filename), ext) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, audioType := range audioTypes {
|
||||||
|
if strings.HasPrefix(strings.ToLower(contentType), audioType) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
||||||
|
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
|
||||||
|
if err := os.MkdirAll(mediaDir, 0755); err != nil {
|
||||||
|
log.Printf("Failed to create media directory: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath := filepath.Join(mediaDir, filename)
|
||||||
|
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to download attachment: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
log.Printf("Failed to download attachment, status: %d", resp.StatusCode)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(localPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create file: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to write file: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Attachment downloaded successfully to: %s", localPath)
|
||||||
|
return localPath
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,16 +2,29 @@ package channels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
|
larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
||||||
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||||
|
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FeishuChannel struct {
|
type FeishuChannel struct {
|
||||||
*BaseChannel
|
*BaseChannel
|
||||||
config config.FeishuConfig
|
config config.FeishuConfig
|
||||||
|
client *lark.Client
|
||||||
|
wsClient *larkws.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
||||||
@@ -20,18 +33,55 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
|
|||||||
return &FeishuChannel{
|
return &FeishuChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
client: lark.NewClient(cfg.AppID, cfg.AppSecret),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *FeishuChannel) Start(ctx context.Context) error {
|
func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||||
log.Println("Feishu channel started")
|
if c.config.AppID == "" || c.config.AppSecret == "" {
|
||||||
|
return fmt.Errorf("feishu app_id or app_secret is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey).
|
||||||
|
OnP2MessageReceiveV1(c.handleMessageReceive)
|
||||||
|
|
||||||
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.cancel = cancel
|
||||||
|
c.wsClient = larkws.NewClient(
|
||||||
|
c.config.AppID,
|
||||||
|
c.config.AppSecret,
|
||||||
|
larkws.WithEventHandler(dispatcher),
|
||||||
|
)
|
||||||
|
wsClient := c.wsClient
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
c.setRunning(true)
|
c.setRunning(true)
|
||||||
|
logger.InfoC("feishu", "Feishu channel started (websocket mode)")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := wsClient.Start(runCtx); err != nil {
|
||||||
|
logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *FeishuChannel) Stop(ctx context.Context) error {
|
func (c *FeishuChannel) Stop(ctx context.Context) error {
|
||||||
log.Println("Feishu channel stopped")
|
c.mu.Lock()
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
c.cancel = nil
|
||||||
|
}
|
||||||
|
c.wsClient = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
c.setRunning(false)
|
c.setRunning(false)
|
||||||
|
logger.InfoC("feishu", "Feishu channel stopped")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,31 +90,126 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
|||||||
return fmt.Errorf("feishu channel not running")
|
return fmt.Errorf("feishu channel not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
htmlContent := markdownToFeishuCard(msg.Content)
|
if msg.ChatID == "" {
|
||||||
|
return fmt.Errorf("chat ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("Feishu send to %s: %s", msg.ChatID, truncateString(htmlContent, 100))
|
payload, err := json.Marshal(map[string]string{"text": msg.Content})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal feishu content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := larkim.NewCreateMessageReqBuilder().
|
||||||
|
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||||
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||||
|
ReceiveId(msg.ChatID).
|
||||||
|
MsgType(larkim.MsgTypeText).
|
||||||
|
Content(string(payload)).
|
||||||
|
Uuid(fmt.Sprintf("picoclaw-%d", time.Now().UnixNano())).
|
||||||
|
Build()).
|
||||||
|
Build()
|
||||||
|
|
||||||
|
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send feishu message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Success() {
|
||||||
|
return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("feishu", "Feishu message sent", map[string]interface{}{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *FeishuChannel) handleIncomingMessage(data map[string]interface{}) {
|
func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||||
senderID, _ := data["sender_id"].(string)
|
if event == nil || event.Event == nil || event.Event.Message == nil {
|
||||||
chatID, _ := data["chat_id"].(string)
|
return nil
|
||||||
content, _ := data["content"].(string)
|
}
|
||||||
|
|
||||||
log.Printf("Feishu message from %s: %s...", senderID, truncateString(content, 50))
|
message := event.Event.Message
|
||||||
|
sender := event.Event.Sender
|
||||||
|
|
||||||
metadata := make(map[string]string)
|
chatID := stringValue(message.ChatId)
|
||||||
if messageID, ok := data["message_id"].(string); ok {
|
if chatID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
senderID := extractFeishuSenderID(sender)
|
||||||
|
if senderID == "" {
|
||||||
|
senderID = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
content := extractFeishuMessageContent(message)
|
||||||
|
if content == "" {
|
||||||
|
content = "[empty message]"
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := map[string]string{}
|
||||||
|
if messageID := stringValue(message.MessageId); messageID != "" {
|
||||||
metadata["message_id"] = messageID
|
metadata["message_id"] = messageID
|
||||||
}
|
}
|
||||||
if userName, ok := data["sender_name"].(string); ok {
|
if messageType := stringValue(message.MessageType); messageType != "" {
|
||||||
metadata["sender_name"] = userName
|
metadata["message_type"] = messageType
|
||||||
|
}
|
||||||
|
if chatType := stringValue(message.ChatType); chatType != "" {
|
||||||
|
metadata["chat_type"] = chatType
|
||||||
|
}
|
||||||
|
if sender != nil && sender.TenantKey != nil {
|
||||||
|
metadata["tenant_key"] = *sender.TenantKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{
|
||||||
|
"sender_id": senderID,
|
||||||
|
"chat_id": chatID,
|
||||||
|
"preview": truncateString(content, 80),
|
||||||
|
})
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func markdownToFeishuCard(markdown string) string {
|
func extractFeishuSenderID(sender *larkim.EventSender) string {
|
||||||
return markdown
|
if sender == nil || sender.SenderId == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if sender.SenderId.UserId != nil && *sender.SenderId.UserId != "" {
|
||||||
|
return *sender.SenderId.UserId
|
||||||
|
}
|
||||||
|
if sender.SenderId.OpenId != nil && *sender.SenderId.OpenId != "" {
|
||||||
|
return *sender.SenderId.OpenId
|
||||||
|
}
|
||||||
|
if sender.SenderId.UnionId != nil && *sender.SenderId.UnionId != "" {
|
||||||
|
return *sender.SenderId.UnionId
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFeishuMessageContent(message *larkim.EventMessage) string {
|
||||||
|
if message == nil || message.Content == nil || *message.Content == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.MessageType != nil && *message.MessageType == larkim.MsgTypeText {
|
||||||
|
var textPayload struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(*message.Content), &textPayload); err == nil {
|
||||||
|
return textPayload.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return *message.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValue(v *string) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,19 @@ func (m *Manager) initChannels() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.Feishu.Enabled {
|
||||||
|
logger.DebugC("channels", "Attempting to initialize Feishu channel")
|
||||||
|
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
m.channels["feishu"] = feishu
|
||||||
|
logger.InfoC("channels", "Feishu channel enabled successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
|
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize Discord channel")
|
logger.DebugC("channels", "Attempting to initialize Discord channel")
|
||||||
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
|
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ package channels
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
@@ -17,11 +22,13 @@ import (
|
|||||||
|
|
||||||
type TelegramChannel struct {
|
type TelegramChannel struct {
|
||||||
*BaseChannel
|
*BaseChannel
|
||||||
bot *tgbotapi.BotAPI
|
bot *tgbotapi.BotAPI
|
||||||
config config.TelegramConfig
|
config config.TelegramConfig
|
||||||
chatIDs map[string]int64
|
chatIDs map[string]int64
|
||||||
updates tgbotapi.UpdatesChannel
|
updates tgbotapi.UpdatesChannel
|
||||||
transcriber *voice.GroqTranscriber
|
transcriber *voice.GroqTranscriber
|
||||||
|
placeholders sync.Map // chatID -> messageID
|
||||||
|
stopThinking sync.Map // chatID -> chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) {
|
func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) {
|
||||||
@@ -33,11 +40,13 @@ func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*Telegr
|
|||||||
base := NewBaseChannel("telegram", cfg, bus, cfg.AllowFrom)
|
base := NewBaseChannel("telegram", cfg, bus, cfg.AllowFrom)
|
||||||
|
|
||||||
return &TelegramChannel{
|
return &TelegramChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
bot: bot,
|
bot: bot,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
chatIDs: make(map[string]int64),
|
chatIDs: make(map[string]int64),
|
||||||
transcriber: nil,
|
transcriber: nil,
|
||||||
|
placeholders: sync.Map{},
|
||||||
|
stopThinking: sync.Map{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,8 +113,26 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||||||
return fmt.Errorf("invalid chat ID: %w", err)
|
return fmt.Errorf("invalid chat ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop thinking animation
|
||||||
|
if stop, ok := c.stopThinking.Load(msg.ChatID); ok {
|
||||||
|
close(stop.(chan struct{}))
|
||||||
|
c.stopThinking.Delete(msg.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
htmlContent := markdownToTelegramHTML(msg.Content)
|
htmlContent := markdownToTelegramHTML(msg.Content)
|
||||||
|
|
||||||
|
// Try to edit placeholder
|
||||||
|
if pID, ok := c.placeholders.Load(msg.ChatID); ok {
|
||||||
|
c.placeholders.Delete(msg.ChatID)
|
||||||
|
editMsg := tgbotapi.NewEditMessageText(chatID, pID.(int), htmlContent)
|
||||||
|
editMsg.ParseMode = tgbotapi.ModeHTML
|
||||||
|
|
||||||
|
if _, err := c.bot.Send(editMsg); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Fallback to new message if edit fails
|
||||||
|
}
|
||||||
|
|
||||||
tgMsg := tgbotapi.NewMessage(chatID, htmlContent)
|
tgMsg := tgbotapi.NewMessage(chatID, htmlContent)
|
||||||
tgMsg.ParseMode = tgbotapi.ModeHTML
|
tgMsg.ParseMode = tgbotapi.ModeHTML
|
||||||
|
|
||||||
@@ -222,6 +249,37 @@ func (c *TelegramChannel) handleMessage(update tgbotapi.Update) {
|
|||||||
|
|
||||||
log.Printf("Telegram message from %s: %s...", senderID, truncateString(content, 50))
|
log.Printf("Telegram message from %s: %s...", senderID, truncateString(content, 50))
|
||||||
|
|
||||||
|
// Thinking indicator
|
||||||
|
c.bot.Send(tgbotapi.NewChatAction(chatID, tgbotapi.ChatTyping))
|
||||||
|
|
||||||
|
stopChan := make(chan struct{})
|
||||||
|
c.stopThinking.Store(fmt.Sprintf("%d", chatID), stopChan)
|
||||||
|
|
||||||
|
pMsg, err := c.bot.Send(tgbotapi.NewMessage(chatID, "Thinking... 💭"))
|
||||||
|
if err == nil {
|
||||||
|
pID := pMsg.MessageID
|
||||||
|
c.placeholders.Store(fmt.Sprintf("%d", chatID), pID)
|
||||||
|
|
||||||
|
go func(cid int64, mid int, stop <-chan struct{}) {
|
||||||
|
dots := []string{".", "..", "..."}
|
||||||
|
emotes := []string{"💭", "🤔", "☁️"}
|
||||||
|
i := 0
|
||||||
|
ticker := time.NewTicker(2000 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
i++
|
||||||
|
text := fmt.Sprintf("Thinking%s %s", dots[i%len(dots)], emotes[i%len(emotes)])
|
||||||
|
edit := tgbotapi.NewEditMessageText(cid, mid, text)
|
||||||
|
c.bot.Send(edit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(chatID, pID, stopChan)
|
||||||
|
}
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_id": fmt.Sprintf("%d", message.MessageID),
|
"message_id": fmt.Sprintf("%d", message.MessageID),
|
||||||
"user_id": fmt.Sprintf("%d", user.ID),
|
"user_id": fmt.Sprintf("%d", user.ID),
|
||||||
@@ -251,9 +309,20 @@ func (c *TelegramChannel) downloadFileWithInfo(file *tgbotapi.File, ext string)
|
|||||||
url := file.Link(c.bot.Token)
|
url := file.Link(c.bot.Token)
|
||||||
log.Printf("File URL: %s", url)
|
log.Printf("File URL: %s", url)
|
||||||
|
|
||||||
mediaDir := "/tmp/picoclaw_media"
|
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
|
||||||
|
if err := os.MkdirAll(mediaDir, 0755); err != nil {
|
||||||
|
log.Printf("Failed to create media directory: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s/%s%s", mediaDir, file.FilePath[:min(16, len(file.FilePath))], ext)
|
localPath := filepath.Join(mediaDir, file.FilePath[:min(16, len(file.FilePath))]+ext)
|
||||||
|
|
||||||
|
if err := c.downloadFromURL(url, localPath); err != nil {
|
||||||
|
log.Printf("Failed to download file: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return localPath
|
||||||
}
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
func min(a, b int) int {
|
||||||
@@ -263,6 +332,32 @@ func min(a, b int) int {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) downloadFromURL(url, localPath string) error {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download failed with status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("File downloaded successfully to: %s", localPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *TelegramChannel) downloadFile(fileID, ext string) string {
|
func (c *TelegramChannel) downloadFile(fileID, ext string) string {
|
||||||
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
|
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -277,9 +372,20 @@ func (c *TelegramChannel) downloadFile(fileID, ext string) string {
|
|||||||
url := file.Link(c.bot.Token)
|
url := file.Link(c.bot.Token)
|
||||||
log.Printf("File URL: %s", url)
|
log.Printf("File URL: %s", url)
|
||||||
|
|
||||||
mediaDir := "/tmp/picoclaw_media"
|
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
|
||||||
|
if err := os.MkdirAll(mediaDir, 0755); err != nil {
|
||||||
|
log.Printf("Failed to create media directory: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s/%s%s", mediaDir, fileID[:16], ext)
|
localPath := filepath.Join(mediaDir, fileID[:16]+ext)
|
||||||
|
|
||||||
|
if err := c.downloadFromURL(url, localPath); err != nil {
|
||||||
|
log.Printf("Failed to download file: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return localPath
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseChatID(chatIDStr string) (int64, error) {
|
func parseChatID(chatIDStr string) (int64, error) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
type Session struct {
|
type Session struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Messages []providers.Message `json:"messages"`
|
Messages []providers.Message `json:"messages"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
Updated time.Time `json:"updated"`
|
Updated time.Time `json:"updated"`
|
||||||
}
|
}
|
||||||
@@ -92,6 +93,45 @@ func (sm *SessionManager) GetHistory(key string) []providers.Message {
|
|||||||
return history
|
return history
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sm *SessionManager) GetSummary(key string) string {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
|
||||||
|
session, ok := sm.sessions[key]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return session.Summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SessionManager) SetSummary(key string, summary string) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
session, ok := sm.sessions[key]
|
||||||
|
if ok {
|
||||||
|
session.Summary = summary
|
||||||
|
session.Updated = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
session, ok := sm.sessions[key]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(session.Messages) <= keepLast {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Messages = session.Messages[len(session.Messages)-keepLast:]
|
||||||
|
session.Updated = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
func (sm *SessionManager) Save(session *Session) error {
|
func (sm *SessionManager) Save(session *Session) error {
|
||||||
if sm.storage == "" {
|
if sm.storage == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GroqTranscriber struct {
|
type GroqTranscriber struct {
|
||||||
@@ -27,6 +28,8 @@ type TranscriptionResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewGroqTranscriber(apiKey string) *GroqTranscriber {
|
func NewGroqTranscriber(apiKey string) *GroqTranscriber {
|
||||||
|
logger.DebugCF("voice", "Creating Groq transcriber", map[string]interface{}{"has_api_key": apiKey != ""})
|
||||||
|
|
||||||
apiBase := "https://api.groq.com/openai/v1"
|
apiBase := "https://api.groq.com/openai/v1"
|
||||||
return &GroqTranscriber{
|
return &GroqTranscriber{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
@@ -38,79 +41,125 @@ func NewGroqTranscriber(apiKey string) *GroqTranscriber {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
|
func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
|
||||||
log.Printf("Starting transcription for audio file: %s", audioFilePath)
|
logger.InfoCF("voice", "Starting transcription", map[string]interface{}{"audio_file": audioFilePath})
|
||||||
|
|
||||||
audioFile, err := os.Open(audioFilePath)
|
audioFile, err := os.Open(audioFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to open audio file", map[string]interface{}{"path": audioFilePath, "error": err})
|
||||||
return nil, fmt.Errorf("failed to open audio file: %w", err)
|
return nil, fmt.Errorf("failed to open audio file: %w", err)
|
||||||
}
|
}
|
||||||
defer audioFile.Close()
|
defer audioFile.Close()
|
||||||
|
|
||||||
fileInfo, err := audioFile.Stat()
|
fileInfo, err := audioFile.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to get file info", map[string]interface{}{"path": audioFilePath, "error": err})
|
||||||
return nil, fmt.Errorf("failed to get file info: %w", err)
|
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("voice", "Audio file details", map[string]interface{}{
|
||||||
|
"size_bytes": fileInfo.Size(),
|
||||||
|
"file_name": filepath.Base(audioFilePath),
|
||||||
|
})
|
||||||
|
|
||||||
var requestBody bytes.Buffer
|
var requestBody bytes.Buffer
|
||||||
writer := multipart.NewWriter(&requestBody)
|
writer := multipart.NewWriter(&requestBody)
|
||||||
|
|
||||||
part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
|
part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to create form file", map[string]interface{}{"error": err})
|
||||||
return nil, fmt.Errorf("failed to create form file: %w", err)
|
return nil, fmt.Errorf("failed to create form file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := io.Copy(part, audioFile); err != nil {
|
copied, err := io.Copy(part, audioFile)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to copy file content", map[string]interface{}{"error": err})
|
||||||
return nil, fmt.Errorf("failed to copy file content: %w", err)
|
return nil, fmt.Errorf("failed to copy file content: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("voice", "File copied to request", map[string]interface{}{"bytes_copied": copied})
|
||||||
|
|
||||||
if err := writer.WriteField("model", "whisper-large-v3"); err != nil {
|
if err := writer.WriteField("model", "whisper-large-v3"); err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to write model field", map[string]interface{}{"error": err})
|
||||||
return nil, fmt.Errorf("failed to write model field: %w", err)
|
return nil, fmt.Errorf("failed to write model field: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := writer.WriteField("response_format", "json"); err != nil {
|
if err := writer.WriteField("response_format", "json"); err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to write response_format field", map[string]interface{}{"error": err})
|
||||||
return nil, fmt.Errorf("failed to write response_format field: %w", err)
|
return nil, fmt.Errorf("failed to write response_format field: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := writer.Close(); err != nil {
|
if err := writer.Close(); err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to close multipart writer", map[string]interface{}{"error": err})
|
||||||
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := t.apiBase + "/audio/transcriptions"
|
url := t.apiBase + "/audio/transcriptions"
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
|
req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to create request", map[string]interface{}{"error": err})
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
req.Header.Set("Authorization", "Bearer "+t.apiKey)
|
req.Header.Set("Authorization", "Bearer "+t.apiKey)
|
||||||
|
|
||||||
log.Printf("Sending transcription request to Groq API (file size: %d bytes)", fileInfo.Size())
|
logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]interface{}{
|
||||||
|
"url": url,
|
||||||
|
"request_size_bytes": requestBody.Len(),
|
||||||
|
"file_size_bytes": fileInfo.Size(),
|
||||||
|
})
|
||||||
|
|
||||||
resp, err := t.httpClient.Do(req)
|
resp, err := t.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to send request", map[string]interface{}{"error": err})
|
||||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to read response", map[string]interface{}{"error": err})
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
logger.ErrorCF("voice", "API error", map[string]interface{}{
|
||||||
|
"status_code": resp.StatusCode,
|
||||||
|
"response": string(body),
|
||||||
|
})
|
||||||
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("voice", "Received response from Groq API", map[string]interface{}{
|
||||||
|
"status_code": resp.StatusCode,
|
||||||
|
"response_size_bytes": len(body),
|
||||||
|
})
|
||||||
|
|
||||||
var result TranscriptionResponse
|
var result TranscriptionResponse
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
logger.ErrorCF("voice", "Failed to unmarshal response", map[string]interface{}{"error": err})
|
||||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Transcription completed successfully (text length: %d chars)", len(result.Text))
|
logger.InfoCF("voice", "Transcription completed successfully", map[string]interface{}{
|
||||||
|
"text_length": len(result.Text),
|
||||||
|
"language": result.Language,
|
||||||
|
"duration_seconds": result.Duration,
|
||||||
|
"transcription_preview": truncateText(result.Text, 50),
|
||||||
|
})
|
||||||
|
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *GroqTranscriber) IsAvailable() bool {
|
func (t *GroqTranscriber) IsAvailable() bool {
|
||||||
return t.apiKey != ""
|
available := t.apiKey != ""
|
||||||
|
logger.DebugCF("voice", "Checking transcriber availability", map[string]interface{}{"available": available})
|
||||||
|
return available
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateText(text string, maxLen int) string {
|
||||||
|
if len(text) <= maxLen {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return text[:maxLen] + "..."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user