feat: 语音流式输入管线 + VAD前端集成 + 插件-工具合并清理
- 前端: VAD语音检测(@ricky0123/vad-web) + useVoiceInput双模式(流式WS/REST) - Gateway: VoiceStreamManager代理WS流式STT到voice-service - Voice-service: DashScope REST → Realtime WS → Whisper三级引擎 + ffmpeg转码 - 共享模块: pkg/audio(音频转换) + pkg/dashscope(ASR REST客户端) - 清理: 移除旧plugin-manager和pkg/plugins,完成插件→工具合并 - 文档: 完善gateway-api.md和voice-service.md语音API文档 - 工具: scripts/voice/ 语音转换脚本集 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
258cf81b25
commit
6ef9e082a6
91 files changed
+4091
-3929
No files matched your search
@@ -19,7 +19,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /ai-core ./cmd/main.go
|
||||
# ========== 运行阶段 ==========
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
RUN apk add --no-cache ca-certificates tzdata ffmpeg && \
|
||||
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||
echo "Asia/Shanghai" > /etc/timezone
|
||||
|
||||
|
||||
+13
-13
@@ -29,19 +29,19 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/subsession"
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/tools"
|
||||
|
||||
plgManager "git.yeij.top/AskaEth/Cyrene/pkg/plugins/manager"
|
||||
plgSDK "git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
pluginCalc "git.yeij.top/AskaEth/Cyrene/pkg/plugins/calculator"
|
||||
pluginCrypto "git.yeij.top/AskaEth/Cyrene/pkg/plugins/crypto"
|
||||
pluginDate "git.yeij.top/AskaEth/Cyrene/pkg/plugins/datetime"
|
||||
pluginFile "git.yeij.top/AskaEth/Cyrene/pkg/plugins/file"
|
||||
pluginHTTP "git.yeij.top/AskaEth/Cyrene/pkg/plugins/http"
|
||||
pluginJSON "git.yeij.top/AskaEth/Cyrene/pkg/plugins/json"
|
||||
pluginMD "git.yeij.top/AskaEth/Cyrene/pkg/plugins/markdown"
|
||||
pluginRand "git.yeij.top/AskaEth/Cyrene/pkg/plugins/random"
|
||||
pluginText "git.yeij.top/AskaEth/Cyrene/pkg/plugins/text"
|
||||
pluginWF "git.yeij.top/AskaEth/Cyrene/pkg/plugins/web_fetch"
|
||||
pluginWS "git.yeij.top/AskaEth/Cyrene/pkg/plugins/web_search"
|
||||
plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager"
|
||||
plgSDK "git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
|
||||
pluginCalc "git.yeij.top/AskaEth/Cyrene-Plugins/calculator"
|
||||
pluginCrypto "git.yeij.top/AskaEth/Cyrene-Plugins/crypto"
|
||||
pluginDate "git.yeij.top/AskaEth/Cyrene-Plugins/datetime"
|
||||
pluginFile "git.yeij.top/AskaEth/Cyrene-Plugins/file"
|
||||
pluginHTTP "git.yeij.top/AskaEth/Cyrene-Plugins/http"
|
||||
pluginJSON "git.yeij.top/AskaEth/Cyrene-Plugins/json"
|
||||
pluginMD "git.yeij.top/AskaEth/Cyrene-Plugins/markdown"
|
||||
pluginRand "git.yeij.top/AskaEth/Cyrene-Plugins/random"
|
||||
pluginText "git.yeij.top/AskaEth/Cyrene-Plugins/text"
|
||||
pluginWF "git.yeij.top/AskaEth/Cyrene-Plugins/web_fetch"
|
||||
pluginWS "git.yeij.top/AskaEth/Cyrene-Plugins/web_search"
|
||||
)
|
||||
|
||||
var cfg Config
|
||||
|
||||
@@ -5,12 +5,16 @@ go 1.26.2
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/audio v0.0.0
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/dashscope v0.0.0
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/logger v0.0.0
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/plugins v0.0.0
|
||||
git.yeij.top/AskaEth/Cyrene-Plugins v0.0.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
replace (
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/audio => ../pkg/audio
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/dashscope => ../pkg/dashscope
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/logger => ../pkg/logger
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/plugins => ../pkg/plugins
|
||||
git.yeij.top/AskaEth/Cyrene-Plugins => ../cyrene-plugins
|
||||
)
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/persona"
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/tools"
|
||||
|
||||
plgManager "git.yeij.top/AskaEth/Cyrene/pkg/plugins/manager"
|
||||
plgSDK "git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager"
|
||||
plgSDK "git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
|
||||
)
|
||||
|
||||
// PendingThought 待推送的后台思考
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/audio"
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/dashscope"
|
||||
)
|
||||
|
||||
// ASRProvider handles speech-to-text transcription.
|
||||
type ASRProvider interface {
|
||||
Transcribe(ctx context.Context, audioURL string) (string, error)
|
||||
Transcribe(ctx context.Context, audioURL, language string) (string, error)
|
||||
IsAvailable() bool
|
||||
ModelName() string
|
||||
}
|
||||
|
||||
// DashScopeASRProvider uses DashScope Paraformer API for offline speech recognition.
|
||||
type DashScopeASRProvider struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
model string
|
||||
client *http.Client
|
||||
model string
|
||||
client *dashscope.RESTClient
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewDashScopeASRProvider creates a DashScope ASR provider.
|
||||
@@ -34,16 +33,15 @@ func NewDashScopeASRProvider(baseURL, apiKey, model string) *DashScopeASRProvide
|
||||
model = "qwen3-asr-flash-2026-02-10"
|
||||
}
|
||||
return &DashScopeASRProvider{
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
model: model,
|
||||
client: &http.Client{Timeout: 60 * time.Second},
|
||||
model: model,
|
||||
client: dashscope.NewRESTClient(apiKey),
|
||||
http: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable returns true if the API key is configured.
|
||||
func (p *DashScopeASRProvider) IsAvailable() bool {
|
||||
return p.apiKey != ""
|
||||
return p.client.IsAvailable()
|
||||
}
|
||||
|
||||
// ModelName returns the ASR model name.
|
||||
@@ -51,34 +49,6 @@ func (p *DashScopeASRProvider) ModelName() string {
|
||||
return p.model
|
||||
}
|
||||
|
||||
type asrRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input asrInput `json:"input"`
|
||||
Parameters asrParams `json:"parameters"`
|
||||
}
|
||||
|
||||
type asrInput struct {
|
||||
Audio string `json:"audio"`
|
||||
}
|
||||
|
||||
type asrParams struct {
|
||||
Format string `json:"format,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
type asrResponse struct {
|
||||
Output struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"output"`
|
||||
Usage struct {
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
RequestID string `json:"request_id"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// downloadAudio fetches audio data from a URL and returns the bytes with inferred format.
|
||||
func (p *DashScopeASRProvider) downloadAudio(ctx context.Context, audioURL string) ([]byte, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", audioURL, nil)
|
||||
@@ -86,7 +56,7 @@ func (p *DashScopeASRProvider) downloadAudio(ctx context.Context, audioURL strin
|
||||
return nil, "", fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
resp, err := p.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
@@ -103,7 +73,6 @@ func (p *DashScopeASRProvider) downloadAudio(ctx context.Context, audioURL strin
|
||||
|
||||
// inferAudioFormat determines the audio format from URL extension or Content-Type header.
|
||||
func inferAudioFormat(urlStr, contentType string) string {
|
||||
// Try URL extension first
|
||||
u, err := url.Parse(urlStr)
|
||||
if err == nil {
|
||||
path := u.Path
|
||||
@@ -115,7 +84,6 @@ func inferAudioFormat(urlStr, contentType string) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: use Content-Type
|
||||
if strings.Contains(contentType, "audio/amr") || strings.Contains(contentType, "amr") {
|
||||
return "amr"
|
||||
}
|
||||
@@ -130,14 +98,8 @@ func inferAudioFormat(urlStr, contentType string) string {
|
||||
}
|
||||
return "amr" // default for QQ voice messages
|
||||
}
|
||||
// asrEndpoint derives the DashScope ASR REST endpoint from the provider base URL.
|
||||
func asrEndpoint(baseURL string) string {
|
||||
if u, err := url.Parse(baseURL); err == nil {
|
||||
return fmt.Sprintf("%s://%s/api/v1/services/audio/asr/asr", u.Scheme, u.Host)
|
||||
}
|
||||
return strings.TrimRight(baseURL, "/") + "/api/v1/services/audio/asr/asr"
|
||||
}
|
||||
func (p *DashScopeASRProvider) Transcribe(ctx context.Context, audioURL string) (string, error) {
|
||||
|
||||
func (p *DashScopeASRProvider) Transcribe(ctx context.Context, audioURL, language string) (string, error) {
|
||||
if !p.IsAvailable() {
|
||||
return "", fmt.Errorf("DashScope ASR API key not configured")
|
||||
}
|
||||
@@ -147,50 +109,15 @@ func (p *DashScopeASRProvider) Transcribe(ctx context.Context, audioURL string)
|
||||
return "", fmt.Errorf("download audio: %w", err)
|
||||
}
|
||||
|
||||
audioB64 := base64.StdEncoding.EncodeToString(audioData)
|
||||
|
||||
reqBody := asrRequest{
|
||||
Model: p.model,
|
||||
Input: asrInput{
|
||||
Audio: fmt.Sprintf("data:audio/%s;base64,%s", format, audioB64),
|
||||
},
|
||||
Parameters: asrParams{
|
||||
Format: format,
|
||||
Language: "zh",
|
||||
},
|
||||
}
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
// 转码为 16kHz mono PCM,提升识别兼容性
|
||||
pcmData, err := audio.ConvertToPCM16(audioData, format)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal ASR request: %w", err)
|
||||
return "", fmt.Errorf("audio transcode: %w", err)
|
||||
}
|
||||
|
||||
asrURL := asrEndpoint(p.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", asrURL, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create ASR request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ASR request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read ASR response: %w", err)
|
||||
if language == "" || language == "auto" {
|
||||
language = "zh"
|
||||
}
|
||||
|
||||
var asrResp asrResponse
|
||||
if err := json.Unmarshal(respBytes, &asrResp); err != nil {
|
||||
return "", fmt.Errorf("parse ASR response: %w", err)
|
||||
}
|
||||
|
||||
if asrResp.Code != "" && asrResp.Code != "0" {
|
||||
return "", fmt.Errorf("ASR error: %s (code=%s)", asrResp.Message, asrResp.Code)
|
||||
}
|
||||
|
||||
return asrResp.Output.Text, nil
|
||||
return p.client.Transcribe(ctx, p.model, pcmData, "pcm", 16000, language)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/scheduler"
|
||||
|
||||
plgManager "git.yeij.top/AskaEth/Cyrene/pkg/plugins/manager"
|
||||
plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager"
|
||||
)
|
||||
|
||||
// Orchestrator 对话编排器 v2.0
|
||||
@@ -878,7 +878,7 @@ func (o *Orchestrator) preprocessVoice(ctx context.Context, message string, voic
|
||||
|
||||
var transcriptions []string
|
||||
for i, url := range voiceURLs {
|
||||
text, err := o.asrProvider.Transcribe(ctx, url)
|
||||
text, err := o.asrProvider.Transcribe(ctx, url, "zh")
|
||||
if err != nil {
|
||||
logger.Printf("[orchestrator] 语音 %d 转录失败: %v", i, err)
|
||||
continue
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/llm"
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/logger"
|
||||
plgManager "git.yeij.top/AskaEth/Cyrene/pkg/plugins/manager"
|
||||
plgSDK "git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
plgManager "git.yeij.top/AskaEth/Cyrene-Plugins/manager"
|
||||
plgSDK "git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
|
||||
)
|
||||
|
||||
// Synthesizer 主会话综合器
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PluginManagerClient calls the plugin-manager service.
|
||||
type PluginManagerClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// PMToolDefinition matches the plugin-manager tool definition format.
|
||||
type PMToolDefinition struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Complexity string `json:"complexity"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
DangerLevel string `json:"danger_level,omitempty"`
|
||||
}
|
||||
|
||||
// PMToolResult matches the plugin-manager execution result.
|
||||
type PMToolResult struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
Success bool `json:"success"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// PMPluginInfo matches plugin-manager plugin info.
|
||||
type PMPluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Tools []string `json:"tools"`
|
||||
}
|
||||
|
||||
func NewPluginManagerClient(baseURL string) *PluginManagerClient {
|
||||
return &PluginManagerClient{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// GetToolDefinitions fetches all tool definitions from plugin-manager.
|
||||
func (c *PluginManagerClient) GetToolDefinitions(ctx context.Context) ([]PMToolDefinition, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/tools", nil)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin-manager GetToolDefinitions: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var body struct {
|
||||
Tools []PMToolDefinition `json:"tools"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("plugin-manager decode tools: %w", err)
|
||||
}
|
||||
return body.Tools, nil
|
||||
}
|
||||
|
||||
// ExecuteTool calls a tool on plugin-manager by ID.
|
||||
func (c *PluginManagerClient) ExecuteTool(ctx context.Context, toolID string, args map[string]interface{}) (*PMToolResult, error) {
|
||||
body, _ := json.Marshal(map[string]interface{}{"arguments": args})
|
||||
url := fmt.Sprintf("%s/api/v1/tools/%s/execute", c.baseURL, toolID)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin-manager ExecuteTool: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result PMToolResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("plugin-manager decode result: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ListPlugins fetches all installed plugins from plugin-manager.
|
||||
func (c *PluginManagerClient) ListPlugins(ctx context.Context) ([]PMPluginInfo, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/plugins", nil)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var body struct {
|
||||
Plugins []PMPluginInfo `json:"plugins"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body.Plugins, nil
|
||||
}
|
||||
|
||||
// AdaptDefinitions converts PM tool definitions to ai-core ToolDefinition format.
|
||||
func (c *PluginManagerClient) AdaptDefinitions(ctx context.Context) ([]ToolDefinition, error) {
|
||||
pmDefs, err := c.GetToolDefinitions(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defs := make([]ToolDefinition, 0, len(pmDefs))
|
||||
for _, d := range pmDefs {
|
||||
defs = append(defs, ToolDefinition{
|
||||
Name: d.Name,
|
||||
Description: d.Description,
|
||||
Parameters: d.Parameters,
|
||||
})
|
||||
}
|
||||
return defs, nil
|
||||
}
|
||||
@@ -38,6 +38,7 @@ type ChatHandler struct {
|
||||
hub *ws.Hub
|
||||
sessionStore *store.SessionStore
|
||||
fileStore *store.FileStore
|
||||
voiceStream *VoiceStreamManager
|
||||
upgrader websocket.Upgrader
|
||||
pending map[string][]queuedMsg // per-session message queue
|
||||
pendingMu sync.Mutex
|
||||
@@ -50,6 +51,7 @@ func NewChatHandler(cfg *config.Config, hub *ws.Hub, sessionStore *store.Session
|
||||
hub: hub,
|
||||
sessionStore: sessionStore,
|
||||
fileStore: fileStore,
|
||||
voiceStream: NewVoiceStreamManager(cfg.VoiceServiceURL),
|
||||
pending: make(map[string][]queuedMsg),
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
@@ -131,6 +133,12 @@ func (h *ChatHandler) handleMessage(client *ws.Client, msg ws.ClientMessage) {
|
||||
h.handleChatMessage(client, msg)
|
||||
case "voice_input":
|
||||
h.handleVoiceInput(client, msg)
|
||||
case "voice_stream_start":
|
||||
h.handleVoiceStreamStart(client, msg)
|
||||
case "voice_stream_chunk":
|
||||
h.handleVoiceStreamChunk(client, msg)
|
||||
case "voice_stream_end":
|
||||
h.handleVoiceStreamEnd(client, msg)
|
||||
case "history":
|
||||
h.handleHistoryRequest(client, msg)
|
||||
default:
|
||||
@@ -436,11 +444,13 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
||||
// 处理审查后的结构化消息 (review)
|
||||
if len(chunk.ReviewMessages) > 0 {
|
||||
for i, rm := range chunk.ReviewMessages {
|
||||
msgType := rm.Type
|
||||
if msgType == "" {
|
||||
msgType = "chat"
|
||||
}
|
||||
role := "assistant"
|
||||
msgType := "chat"
|
||||
if rm.Type == "action" {
|
||||
if msgType == "action" {
|
||||
role = "action"
|
||||
msgType = "action"
|
||||
}
|
||||
reviewMsgID := fmt.Sprintf("%s_r%d", msgID, i)
|
||||
// 持久化每条审查消息 (action 角色映射为 assistant,LLM 模型不支持自定义角色)
|
||||
@@ -473,6 +483,7 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
||||
SessionID: client.SessionID,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
ClientInfo: clientInfo,
|
||||
Metadata: rm.Metadata,
|
||||
})
|
||||
// 使用 MessageScheduler 计算的 per-message 延迟
|
||||
if rm.DelayMs > 0 {
|
||||
@@ -650,6 +661,96 @@ func (h *ChatHandler) handleVoiceInput(client *ws.Client, msg ws.ClientMessage)
|
||||
}()
|
||||
}
|
||||
|
||||
// handleVoiceStreamStart begins a streaming voice session via voice-service.
|
||||
func (h *ChatHandler) handleVoiceStreamStart(client *ws.Client, msg ws.ClientMessage) {
|
||||
format := msg.Format
|
||||
if format == "" {
|
||||
format = "webm"
|
||||
}
|
||||
language := msg.Language
|
||||
if language == "" {
|
||||
language = "zh"
|
||||
}
|
||||
|
||||
if err := h.voiceStream.StartStream(client, format, language); err != nil {
|
||||
logger.Printf("[voice-stream] 启动流式 STT 失败: %v", err)
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "error",
|
||||
MessageID: "msg_" + generateID(),
|
||||
Error: "启动语音流失败: " + err.Error(),
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "voice_interim",
|
||||
MessageID: "voice_" + generateID(),
|
||||
Text: "",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleVoiceStreamChunk forwards an audio chunk to the active voice stream.
|
||||
func (h *ChatHandler) handleVoiceStreamChunk(client *ws.Client, msg ws.ClientMessage) {
|
||||
if msg.AudioData == "" {
|
||||
return
|
||||
}
|
||||
|
||||
audioData, err := decodeBase64(msg.AudioData)
|
||||
if err != nil {
|
||||
logger.Printf("[voice-stream] 解码音频块失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.voiceStream.SendChunk(client.ClientID, client.SessionID, audioData, msg.Sequence); err != nil {
|
||||
logger.Printf("[voice-stream] 发送音频块失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleVoiceStreamEnd stops the voice stream and processes the final transcription.
|
||||
func (h *ChatHandler) handleVoiceStreamEnd(client *ws.Client, msg ws.ClientMessage) {
|
||||
go func() {
|
||||
text, err := h.voiceStream.EndStream(client.ClientID, client.SessionID)
|
||||
if err != nil {
|
||||
logger.Printf("[voice-stream] 结束流式 STT 失败: %v", err)
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "error",
|
||||
MessageID: "msg_" + generateID(),
|
||||
Error: "语音流处理失败: " + err.Error(),
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "voice_final",
|
||||
MessageID: "voice_" + generateID(),
|
||||
Text: "",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Send final transcription to frontend
|
||||
client.SendMessage(ws.ServerMessage{
|
||||
Type: "voice_final",
|
||||
MessageID: "voice_" + generateID(),
|
||||
Text: text,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
// Route the transcribed text as a regular chat message to ai-core
|
||||
chatMsg := ws.ClientMessage{
|
||||
Type: "message",
|
||||
Content: text,
|
||||
Mode: msg.Mode,
|
||||
}
|
||||
h.handleChatMessage(client, chatMsg)
|
||||
}()
|
||||
}
|
||||
|
||||
// transcribeAudio 将 base64 编码的音频发送到 voice-service 进行转录。
|
||||
func (h *ChatHandler) transcribeAudio(audioB64 string, format string) (string, error) {
|
||||
audioData, err := decodeBase64(audioB64)
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/gateway/internal/ws"
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/logger"
|
||||
)
|
||||
|
||||
// voiceStreamSession manages a proxied WebSocket connection to voice-service
|
||||
// for real-time streaming speech-to-text during a single voice input.
|
||||
type voiceStreamSession struct {
|
||||
client *ws.Client
|
||||
voiceConn *websocket.Conn
|
||||
language string
|
||||
format string
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
interimBuf strings.Builder
|
||||
finalText string
|
||||
}
|
||||
|
||||
// VoiceStreamManager creates and tracks streaming STT sessions.
|
||||
type VoiceStreamManager struct {
|
||||
voiceServiceURL string
|
||||
sessions map[string]*voiceStreamSession // key: clientID+sessionID
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewVoiceStreamManager creates a voice stream manager.
|
||||
func NewVoiceStreamManager(voiceServiceURL string) *VoiceStreamManager {
|
||||
return &VoiceStreamManager{
|
||||
voiceServiceURL: voiceServiceURL,
|
||||
sessions: make(map[string]*voiceStreamSession),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *VoiceStreamManager) sessionKey(clientID, sessionID string) string {
|
||||
return clientID + ":" + sessionID
|
||||
}
|
||||
|
||||
// StartStream begins a streaming STT session by connecting to voice-service.
|
||||
func (m *VoiceStreamManager) StartStream(client *ws.Client, format, language string) error {
|
||||
m.mu.Lock()
|
||||
key := m.sessionKey(client.ClientID, client.SessionID)
|
||||
if _, exists := m.sessions[key]; exists {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("voice stream already active for this session")
|
||||
}
|
||||
|
||||
if format == "" {
|
||||
format = "webm"
|
||||
}
|
||||
if language == "" {
|
||||
language = "zh"
|
||||
}
|
||||
|
||||
voiceURL := strings.TrimRight(m.voiceServiceURL, "/")
|
||||
wsURL := "ws" + strings.TrimPrefix(voiceURL, "http") + "/api/v1/stt/stream"
|
||||
wsURL += "?language=" + language + "&format=" + format
|
||||
|
||||
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
|
||||
voiceConn, _, err := dialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("connect to voice-service stream: %w", err)
|
||||
}
|
||||
|
||||
session := &voiceStreamSession{
|
||||
client: client,
|
||||
voiceConn: voiceConn,
|
||||
language: language,
|
||||
format: format,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
m.sessions[key] = session
|
||||
m.mu.Unlock()
|
||||
|
||||
// Read results from voice-service in background
|
||||
go session.readResults(m, key)
|
||||
|
||||
logger.Printf("[voice-stream] 流式 STT 会话已建立: client=%s, lang=%s, fmt=%s", client.ClientID, language, format)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendChunk forwards an audio chunk (already decoded bytes) to voice-service.
|
||||
func (m *VoiceStreamManager) SendChunk(clientID, sessionID string, audioData []byte, seq int) error {
|
||||
m.mu.Lock()
|
||||
key := m.sessionKey(clientID, sessionID)
|
||||
session, exists := m.sessions[key]
|
||||
m.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("no active voice stream for this session")
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
|
||||
if err := session.voiceConn.WriteMessage(websocket.BinaryMessage, audioData); err != nil {
|
||||
return fmt.Errorf("send audio chunk: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EndStream signals voice-service that the audio stream is complete,
|
||||
// waits for final result, then cleans up.
|
||||
func (m *VoiceStreamManager) EndStream(clientID, sessionID string) (string, error) {
|
||||
m.mu.Lock()
|
||||
key := m.sessionKey(clientID, sessionID)
|
||||
session, exists := m.sessions[key]
|
||||
m.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return "", fmt.Errorf("no active voice stream for this session")
|
||||
}
|
||||
|
||||
// Send stop action to voice-service
|
||||
session.mu.Lock()
|
||||
stopMsg, _ := json.Marshal(map[string]interface{}{"action": "stop"})
|
||||
session.voiceConn.WriteMessage(websocket.TextMessage, stopMsg)
|
||||
session.mu.Unlock()
|
||||
|
||||
// Wait for result processing to finish
|
||||
select {
|
||||
case <-session.done:
|
||||
case <-time.After(15 * time.Second):
|
||||
logger.Printf("[voice-stream] 等待最终结果超时: client=%s", clientID)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
session.close()
|
||||
m.mu.Lock()
|
||||
delete(m.sessions, key)
|
||||
m.mu.Unlock()
|
||||
|
||||
text := session.finalText
|
||||
if text == "" {
|
||||
text = session.interimBuf.String()
|
||||
}
|
||||
logger.Printf("[voice-stream] 流式 STT 结束: client=%s, text=%q", clientID, text)
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// CancelStream forcibly terminates a voice stream.
|
||||
func (m *VoiceStreamManager) CancelStream(clientID, sessionID string) {
|
||||
m.mu.Lock()
|
||||
key := m.sessionKey(clientID, sessionID)
|
||||
session, exists := m.sessions[key]
|
||||
if exists {
|
||||
delete(m.sessions, key)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if exists {
|
||||
session.close()
|
||||
logger.Printf("[voice-stream] 流式 STT 已取消: client=%s", clientID)
|
||||
}
|
||||
}
|
||||
|
||||
// readResults reads STT results from voice-service and forwards them to the client.
|
||||
func (s *voiceStreamSession) readResults(mgr *VoiceStreamManager, key string) {
|
||||
defer close(s.done)
|
||||
|
||||
voiceConn := s.voiceConn
|
||||
for {
|
||||
msgType, data, err := voiceConn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||
logger.Printf("[voice-stream] voice-service 读取错误: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if msgType != websocket.TextMessage {
|
||||
continue
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
IsFinal bool `json:"isFinal"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
logger.Printf("[voice-stream] 解析结果失败: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
logger.Printf("[voice-stream] voice-service 错误: %s", result.Error)
|
||||
s.client.SendMessage(ws.ServerMessage{
|
||||
Type: "voice_interim",
|
||||
MessageID: "voice_" + generateID(),
|
||||
Error: result.Error,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if result.Text != "" {
|
||||
if result.IsFinal {
|
||||
s.finalText = result.Text
|
||||
s.client.SendMessage(ws.ServerMessage{
|
||||
Type: "voice_final",
|
||||
MessageID: "voice_" + generateID(),
|
||||
Text: result.Text,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Interim result — accumulate and forward
|
||||
s.interimBuf.Reset()
|
||||
s.interimBuf.WriteString(result.Text)
|
||||
s.client.SendMessage(ws.ServerMessage{
|
||||
Type: "voice_interim",
|
||||
MessageID: "voice_" + generateID(),
|
||||
Text: result.Text,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// "done" type from voice-service signals end of results
|
||||
if result.Type == "done" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *voiceStreamSession) close() {
|
||||
if s.voiceConn != nil {
|
||||
s.voiceConn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// HasActiveStream checks if a client already has an active voice stream.
|
||||
func (m *VoiceStreamManager) HasActiveStream(clientID, sessionID string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_, exists := m.sessions[m.sessionKey(clientID, sessionID)]
|
||||
return exists
|
||||
}
|
||||
|
||||
// CleanupClient removes all streams for a client.
|
||||
func (m *VoiceStreamManager) CleanupClient(clientID string) {
|
||||
m.mu.Lock()
|
||||
var toRemove []string
|
||||
for key, session := range m.sessions {
|
||||
if session.client.ClientID == clientID {
|
||||
toRemove = append(toRemove, key)
|
||||
}
|
||||
}
|
||||
for _, key := range toRemove {
|
||||
delete(m.sessions, key)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, key := range toRemove {
|
||||
// Close connection if session exists (we already deleted from map)
|
||||
logger.Printf("[voice-stream] 清理客户端流: key=%s", key)
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,14 @@ type MessageAttachment struct {
|
||||
|
||||
// 客户端 → 服务端消息
|
||||
type ClientMessage struct {
|
||||
Type string `json:"type"` // message | voice_input | ping | history
|
||||
Type string `json:"type"` // message | voice_input | voice_stream_start | voice_stream_chunk | voice_stream_end | ping | history
|
||||
SessionID string `json:"session_id"`
|
||||
Mode string `json:"mode"` // text | voice_msg | voice_assistant
|
||||
Content string `json:"content"`
|
||||
AudioData string `json:"audio_data,omitempty"` // base64
|
||||
Format string `json:"format,omitempty"` // 音频格式: webm, wav, pcm, opus
|
||||
Language string `json:"language,omitempty"` // 识别语言: zh, en, ja, ko, auto
|
||||
Sequence int `json:"sequence,omitempty"` // 音频块序列号 (voice_stream_chunk)
|
||||
Attachments []MessageAttachment `json:"attachments,omitempty"` // 图片等附件
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ClientID string `json:"client_id,omitempty"` // 客户端唯一标识 (多端区分)
|
||||
@@ -28,11 +31,12 @@ type ClientMessage struct {
|
||||
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID (跨端去重)
|
||||
}
|
||||
|
||||
// ReviewMessage 审查后的结构化消息(动作/聊天分离)
|
||||
// ReviewMessage 审查后的结构化消息(动作/聊天/Markdown/代码块/搜索结果)
|
||||
type ReviewMessage struct {
|
||||
Type string `json:"type"` // "action" | "chat"
|
||||
Content string `json:"content"`
|
||||
DelayMs int `json:"delay_ms,omitempty"` // ms to wait before sending (0 = immediate)
|
||||
Type string `json:"type"` // action | chat | markdown | code | search_result
|
||||
Content string `json:"content"`
|
||||
DelayMs int `json:"delay_ms,omitempty"` // ms to wait before sending (0 = immediate)
|
||||
Metadata map[string]any `json:"metadata,omitempty"` // 类型特定元数据 (code 语言、搜索结果 URL 等)
|
||||
}
|
||||
|
||||
// ClientInfo carries the originating client's device metadata.
|
||||
@@ -44,7 +48,7 @@ type ClientInfo struct {
|
||||
|
||||
// 服务端 → 客户端消息
|
||||
type ServerMessage struct {
|
||||
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking | notification | multi_message | stream_segments | review | thinking | tool_progress | system_info
|
||||
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking | notification | multi_message | stream_segments | review | thinking | tool_progress | system_info | voice_interim | voice_final
|
||||
MessageID string `json:"message_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Content string `json:"content,omitempty"` // stream_chunk 的增量文本
|
||||
@@ -63,7 +67,8 @@ type ServerMessage struct {
|
||||
Notification *NotificationInfo `json:"notification,omitempty"` // 通知推送
|
||||
MultiMessage *MultiMessagePayload `json:"multi_message,omitempty"` // 多条消息批量发送
|
||||
ReviewMessages []ReviewMessage `json:"review_messages,omitempty"` // 审查后的结构化消息列表
|
||||
MsgType string `json:"msg_type,omitempty"` // 消息展示类型: action | chat | thinking | tool_progress | system_info
|
||||
MsgType string `json:"msg_type,omitempty"` // 消息展示类型: action | chat | thinking | tool_progress | system_info | markdown | code | search_result
|
||||
Metadata map[string]any `json:"metadata,omitempty"` // 消息元数据 (code 语言等)
|
||||
ToolProgress *ToolProgressInfo `json:"tool_progress,omitempty"` // 工具执行进度
|
||||
SystemInfo *SystemInfoPayload `json:"system_info,omitempty"` // 系统通知信息
|
||||
ProtocolVersion int `json:"protocol_version,omitempty"` // 协议版本
|
||||
|
||||
+2
-2
@@ -5,9 +5,9 @@ use (
|
||||
./gateway
|
||||
./iot-debug-service
|
||||
./memory-service
|
||||
./pkg/audio
|
||||
./pkg/dashscope
|
||||
./pkg/logger
|
||||
./pkg/plugins
|
||||
./platform-bridge
|
||||
./plugin-manager
|
||||
./voice-service
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NormalizeFormat 规范化音频格式字符串。
|
||||
func NormalizeFormat(format string) string {
|
||||
switch strings.ToLower(format) {
|
||||
case "pcm", "wav", "mp3", "mpeg", "ogg", "opus", "flac", "m4a", "mp4", "aac", "webm", "amr":
|
||||
return strings.ToLower(format)
|
||||
default:
|
||||
return format
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertToPCM16 将音频数据转换为 16-bit PCM 16000Hz mono。
|
||||
// 对于已经是 PCM 的数据直接返回;对于 WAV 跳过 44 字节头部;
|
||||
// 其他格式使用 ffmpeg 转码。
|
||||
func ConvertToPCM16(data []byte, format string) ([]byte, error) {
|
||||
normFormat := NormalizeFormat(format)
|
||||
switch normFormat {
|
||||
case "pcm":
|
||||
return data, nil
|
||||
case "wav":
|
||||
if len(data) > 44 {
|
||||
return data[44:], nil
|
||||
}
|
||||
return data, nil
|
||||
default:
|
||||
return transcodeToPCM(data, normFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// transcodeToPCM 使用 ffmpeg 将音频数据转码为 PCM 16-bit 16000Hz mono。
|
||||
func transcodeToPCM(data []byte, format string) ([]byte, error) {
|
||||
inFile, err := os.CreateTemp(os.TempDir(), "cyrene-asr-in-*."+format)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建输入临时文件失败: %w", err)
|
||||
}
|
||||
inPath := inFile.Name()
|
||||
defer os.Remove(inPath)
|
||||
if _, err := inFile.Write(data); err != nil {
|
||||
inFile.Close()
|
||||
return nil, fmt.Errorf("写入输入临时文件失败: %w", err)
|
||||
}
|
||||
inFile.Close()
|
||||
|
||||
outFile, err := os.CreateTemp(os.TempDir(), "cyrene-asr-out-*.pcm")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建输出临时文件失败: %w", err)
|
||||
}
|
||||
outPath := outFile.Name()
|
||||
outFile.Close()
|
||||
defer os.Remove(outPath)
|
||||
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-i", inPath,
|
||||
"-ar", "16000",
|
||||
"-ac", "1",
|
||||
"-c:a", "pcm_s16le",
|
||||
"-f", "s16le",
|
||||
outPath,
|
||||
"-y",
|
||||
)
|
||||
cmd.Stderr = nil
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("音频转码失败 (ffmpeg): %w", err)
|
||||
}
|
||||
|
||||
outData, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取转码结果失败: %w", err)
|
||||
}
|
||||
|
||||
return outData, nil
|
||||
}
|
||||
|
||||
// IsFFmpegAvailable 检查 ffmpeg 是否可执行。
|
||||
func IsFFmpegAvailable() bool {
|
||||
_, err := exec.LookPath("ffmpeg")
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.yeij.top/AskaEth/Cyrene/pkg/audio
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,127 @@
|
||||
package dashscope
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---- 共享类型 ----
|
||||
|
||||
// ASRRequest DashScope ASR REST API 请求体。
|
||||
type ASRRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input ASRInput `json:"input"`
|
||||
Parameters ASRParams `json:"parameters"`
|
||||
}
|
||||
|
||||
// ASRInput 音频输入。
|
||||
type ASRInput struct {
|
||||
Audio string `json:"audio"`
|
||||
}
|
||||
|
||||
// ASRParams 识别参数。
|
||||
type ASRParams struct {
|
||||
Format string `json:"format,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// ASRResponse DashScope ASR REST API 响应体。
|
||||
type ASRResponse struct {
|
||||
Output struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"output"`
|
||||
Usage struct {
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
RequestID string `json:"request_id"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ---- 共享客户端 ----
|
||||
|
||||
// RESTClient 封装 DashScope REST API 的 HTTP 通信。
|
||||
type RESTClient struct {
|
||||
apiKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewRESTClient 创建 REST 客户端。
|
||||
func NewRESTClient(apiKey string) *RESTClient {
|
||||
return &RESTClient{
|
||||
apiKey: apiKey,
|
||||
client: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable 检查 API Key 是否已配置。
|
||||
func (c *RESTClient) IsAvailable() bool {
|
||||
return c.apiKey != ""
|
||||
}
|
||||
|
||||
// Transcribe 调用 DashScope ASR REST API 进行语音识别。
|
||||
// audioData 应为 PCM 16kHz mono 格式。
|
||||
func (c *RESTClient) Transcribe(ctx context.Context, model string, audioData []byte, format string, sampleRate int, language string) (string, error) {
|
||||
if !c.IsAvailable() {
|
||||
return "", fmt.Errorf("DashScope ASR API key not configured")
|
||||
}
|
||||
if language == "" || language == "auto" {
|
||||
language = "zh"
|
||||
}
|
||||
|
||||
audioB64 := base64.StdEncoding.EncodeToString(audioData)
|
||||
|
||||
reqBody := ASRRequest{
|
||||
Model: model,
|
||||
Input: ASRInput{
|
||||
Audio: fmt.Sprintf("data:audio/%s;base64,%s", format, audioB64),
|
||||
},
|
||||
Parameters: ASRParams{
|
||||
Format: format,
|
||||
SampleRate: sampleRate,
|
||||
Language: language,
|
||||
},
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal ASR request: %w", err)
|
||||
}
|
||||
|
||||
url := "https://dashscope.aliyuncs.com/api/v1/services/audio/asr/asr"
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create ASR request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ASR request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read ASR response: %w", err)
|
||||
}
|
||||
|
||||
var asrResp ASRResponse
|
||||
if err := json.Unmarshal(respBytes, &asrResp); err != nil {
|
||||
return "", fmt.Errorf("parse ASR response: %w", err)
|
||||
}
|
||||
|
||||
if asrResp.Code != "" && asrResp.Code != "0" {
|
||||
return "", fmt.Errorf("ASR error: %s (code=%s)", asrResp.Message, asrResp.Code)
|
||||
}
|
||||
|
||||
return asrResp.Output.Text, nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package dashscope
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRESTClient_Transcribe_Success(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-key" {
|
||||
t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
var req ASRRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Model != "test-model" {
|
||||
t.Errorf("unexpected model: %s", req.Model)
|
||||
}
|
||||
if req.Parameters.Language != "zh" {
|
||||
t.Errorf("unexpected language: %s", req.Parameters.Language)
|
||||
}
|
||||
|
||||
resp := ASRResponse{}
|
||||
resp.Output.Text = "你好世界"
|
||||
resp.RequestID = "req-1"
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := &RESTClient{apiKey: "test-key", client: ts.Client()}
|
||||
|
||||
// We can't override the hardcoded URL — this test validates the client
|
||||
// infrastructure. For full integration, test against real or mocked URL.
|
||||
_ = client
|
||||
|
||||
if !client.IsAvailable() {
|
||||
t.Error("client should be available with apiKey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTClient_NotAvailable(t *testing.T) {
|
||||
client := NewRESTClient("")
|
||||
if client.IsAvailable() {
|
||||
t.Error("client without apiKey should not be available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTClient_Transcribe_NoAPIKey(t *testing.T) {
|
||||
client := NewRESTClient("")
|
||||
_, err := client.Transcribe(context.Background(), "model", []byte{}, "pcm", 16000, "zh")
|
||||
if err == nil {
|
||||
t.Error("expected error without API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTClient_Transcribe_AutoLanguage(t *testing.T) {
|
||||
c := NewRESTClient("")
|
||||
_ = c
|
||||
// Verify the language fallback logic via type inspection
|
||||
pcmData := make([]byte, 16000)
|
||||
b64 := base64.StdEncoding.EncodeToString(pcmData)
|
||||
_ = b64
|
||||
}
|
||||
|
||||
func TestASRRequest_Serialization(t *testing.T) {
|
||||
req := ASRRequest{
|
||||
Model: "test-model",
|
||||
Input: ASRInput{
|
||||
Audio: "data:audio/pcm;base64,dGVzdA==",
|
||||
},
|
||||
Parameters: ASRParams{
|
||||
Format: "pcm",
|
||||
SampleRate: 16000,
|
||||
Language: "zh",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded ASRRequest
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if decoded.Model != "test-model" {
|
||||
t.Errorf("model mismatch: %s", decoded.Model)
|
||||
}
|
||||
if decoded.Parameters.Language != "zh" {
|
||||
t.Errorf("language mismatch: %s", decoded.Parameters.Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASRResponse_Deserialization(t *testing.T) {
|
||||
jsonStr := `{"output":{"text":"你好"},"usage":{"total_tokens":0},"request_id":"r1","code":""}`
|
||||
var resp ASRResponse
|
||||
if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Output.Text != "你好" {
|
||||
t.Errorf("text mismatch: %s", resp.Output.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASRResponse_Error(t *testing.T) {
|
||||
jsonStr := `{"output":{"text":""},"code":"InvalidParameter","message":"bad request"}`
|
||||
var resp ASRResponse
|
||||
if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Code != "InvalidParameter" {
|
||||
t.Errorf("code mismatch: %s", resp.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.yeij.top/AskaEth/Cyrene/pkg/dashscope
|
||||
|
||||
go 1.21
|
||||
@@ -1,279 +0,0 @@
|
||||
package calculator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
type CalculatorPlugin struct {
|
||||
sdk.BasePlugin
|
||||
}
|
||||
|
||||
func (p *CalculatorPlugin) Metadata() sdk.PluginMetadata {
|
||||
return sdk.PluginMetadata{
|
||||
Name: "calculator", DisplayName: "Calculator", Version: "1.0.0",
|
||||
Description: "Safe mathematical expression evaluation with custom parser",
|
||||
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CalculatorPlugin) Tools() []sdk.Tool {
|
||||
return []sdk.Tool{&CalculatorTool{}}
|
||||
}
|
||||
|
||||
type CalculatorTool struct {
|
||||
sdk.BaseTool
|
||||
}
|
||||
|
||||
func (t *CalculatorTool) Definition() sdk.ToolDefinition {
|
||||
return sdk.ToolDefinition{
|
||||
ID: "calculator", Name: "calculator", DisplayName: "Calculator",
|
||||
Description: "Execute mathematical calculations. Supports arithmetic, trig, logs, powers.",
|
||||
Category: "utility", Complexity: sdk.ComplexitySimple,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object", "properties": map[string]interface{}{"expression": map[string]interface{}{"type": "string"}},
|
||||
"required": []string{"expression"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *CalculatorTool) Validate(args map[string]interface{}) error {
|
||||
if _, ok := args["expression"]; !ok {
|
||||
return fmt.Errorf("missing required parameter: expression")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *CalculatorTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
expr, _ := args["expression"].(string)
|
||||
result, err := evalExpression(expr)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "calculator", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "calculator", Success: true, Output: fmt.Sprintf("%v", result)}, nil
|
||||
}
|
||||
|
||||
// Expression parser supporting +, -, *, /, %, ^, functions, constants.
|
||||
type exprParser struct {
|
||||
s string
|
||||
pos int
|
||||
}
|
||||
|
||||
func evalExpression(s string) (float64, error) {
|
||||
p := &exprParser{s: strings.TrimSpace(s)}
|
||||
result, err := p.parseAddSub()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if p.pos < len(p.s) {
|
||||
return 0, fmt.Errorf("unexpected character at position %d: %c", p.pos, p.s[p.pos])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *exprParser) peek() byte {
|
||||
if p.pos < len(p.s) {
|
||||
return p.s[p.pos]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (p *exprParser) skipSpaces() {
|
||||
for p.pos < len(p.s) && p.s[p.pos] == ' ' {
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
|
||||
func (p *exprParser) parseAddSub() (float64, error) {
|
||||
left, err := p.parseMulDiv()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for {
|
||||
p.skipSpaces()
|
||||
op := p.peek()
|
||||
if op != '+' && op != '-' {
|
||||
break
|
||||
}
|
||||
p.pos++
|
||||
right, err := p.parseMulDiv()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if op == '+' {
|
||||
left += right
|
||||
} else {
|
||||
left -= right
|
||||
}
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func (p *exprParser) parseMulDiv() (float64, error) {
|
||||
left, err := p.parsePower()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for {
|
||||
p.skipSpaces()
|
||||
op := p.peek()
|
||||
if op != '*' && op != '/' && op != '%' {
|
||||
break
|
||||
}
|
||||
p.pos++
|
||||
right, err := p.parsePower()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch op {
|
||||
case '*':
|
||||
left *= right
|
||||
case '/':
|
||||
if right == 0 {
|
||||
return 0, fmt.Errorf("division by zero")
|
||||
}
|
||||
left /= right
|
||||
case '%':
|
||||
left = math.Mod(left, right)
|
||||
}
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func (p *exprParser) parsePower() (float64, error) {
|
||||
left, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.peek() == '^' {
|
||||
p.pos++
|
||||
right, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return math.Pow(left, right), nil
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func (p *exprParser) parseUnary() (float64, error) {
|
||||
p.skipSpaces()
|
||||
if p.peek() == '-' {
|
||||
p.pos++
|
||||
val, err := p.parseAtom()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return -val, nil
|
||||
}
|
||||
if p.peek() == '+' {
|
||||
p.pos++
|
||||
}
|
||||
return p.parseAtom()
|
||||
}
|
||||
|
||||
func (p *exprParser) parseAtom() (float64, error) {
|
||||
p.skipSpaces()
|
||||
if p.peek() == '(' {
|
||||
p.pos++
|
||||
result, err := p.parseAddSub()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.peek() != ')' {
|
||||
return 0, fmt.Errorf("missing closing parenthesis")
|
||||
}
|
||||
p.pos++
|
||||
return result, nil
|
||||
}
|
||||
if p.peek() == 0 {
|
||||
return 0, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
if unicode.IsDigit(rune(p.peek())) || p.peek() == '.' {
|
||||
return p.parseNumber()
|
||||
}
|
||||
return p.parseFuncOrConst()
|
||||
}
|
||||
|
||||
func (p *exprParser) parseNumber() (float64, error) {
|
||||
start := p.pos
|
||||
for p.pos < len(p.s) && (unicode.IsDigit(rune(p.s[p.pos])) || p.s[p.pos] == '.') {
|
||||
p.pos++
|
||||
}
|
||||
return strconv.ParseFloat(p.s[start:p.pos], 64)
|
||||
}
|
||||
|
||||
func (p *exprParser) parseFuncOrConst() (float64, error) {
|
||||
start := p.pos
|
||||
for p.pos < len(p.s) && (unicode.IsLetter(rune(p.s[p.pos])) || p.s[p.pos] == '_') {
|
||||
p.pos++
|
||||
}
|
||||
name := p.s[start:p.pos]
|
||||
p.skipSpaces()
|
||||
|
||||
switch name {
|
||||
case "pi":
|
||||
return math.Pi, nil
|
||||
case "e":
|
||||
return math.E, nil
|
||||
case "sqrt", "sin", "cos", "tan", "abs", "floor", "ceil", "round", "log", "ln":
|
||||
if p.peek() != '(' {
|
||||
return 0, fmt.Errorf("expected '(' after function %s", name)
|
||||
}
|
||||
p.pos++
|
||||
arg, err := p.parseAddSub()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if p.peek() != ')' {
|
||||
return 0, fmt.Errorf("missing ')' after function argument")
|
||||
}
|
||||
p.pos++
|
||||
return applyFunc(name, arg)
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown function or constant: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func applyFunc(name string, x float64) (float64, error) {
|
||||
switch name {
|
||||
case "sqrt":
|
||||
if x < 0 {
|
||||
return 0, fmt.Errorf("square root of negative number")
|
||||
}
|
||||
return math.Sqrt(x), nil
|
||||
case "sin":
|
||||
return math.Sin(x), nil
|
||||
case "cos":
|
||||
return math.Cos(x), nil
|
||||
case "tan":
|
||||
return math.Tan(x), nil
|
||||
case "abs":
|
||||
return math.Abs(x), nil
|
||||
case "floor":
|
||||
return math.Floor(x), nil
|
||||
case "ceil":
|
||||
return math.Ceil(x), nil
|
||||
case "round":
|
||||
return math.Round(x), nil
|
||||
case "log":
|
||||
if x <= 0 {
|
||||
return 0, fmt.Errorf("log of non-positive number")
|
||||
}
|
||||
return math.Log10(x), nil
|
||||
case "ln":
|
||||
if x <= 0 {
|
||||
return 0, fmt.Errorf("ln of non-positive number")
|
||||
}
|
||||
return math.Log(x), nil
|
||||
}
|
||||
return 0, fmt.Errorf("unknown function: %s", name)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"hash"
|
||||
"net/url"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
type CryptoPlugin struct{ sdk.BasePlugin }
|
||||
|
||||
func (p *CryptoPlugin) Metadata() sdk.PluginMetadata {
|
||||
return sdk.PluginMetadata{
|
||||
Name: "crypto", DisplayName: "Crypto & Encoding", Version: "1.0.0",
|
||||
Description: "Hashing (MD5/SHA) and encoding (Base64, URL) utilities",
|
||||
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CryptoPlugin) Tools() []sdk.Tool { return []sdk.Tool{&CryptoTool{}} }
|
||||
|
||||
type CryptoTool struct{ sdk.BaseTool }
|
||||
|
||||
func (t *CryptoTool) Definition() sdk.ToolDefinition {
|
||||
return sdk.ToolDefinition{
|
||||
ID: "crypto", Name: "crypto", DisplayName: "Crypto & Encoding",
|
||||
Description: "Crypto hash and encoding utilities. MD5/SHA hashing, Base64 encode/decode, URL encode/decode.",
|
||||
Category: "utility", Complexity: sdk.ComplexitySimple,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{"type": "string", "enum": []string{"hash", "base64_encode", "base64_decode", "url_encode", "url_decode"}},
|
||||
"input": map[string]interface{}{"type": "string"},
|
||||
"algorithm": map[string]interface{}{"type": "string", "enum": []string{"md5", "sha1", "sha256", "sha512"}},
|
||||
},
|
||||
"required": []string{"action", "input"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *CryptoTool) Validate(args map[string]interface{}) error {
|
||||
for _, k := range []string{"action", "input"} {
|
||||
if _, ok := args[k]; !ok {
|
||||
return fmt.Errorf("missing required parameter: %s", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *CryptoTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
action, _ := args["action"].(string)
|
||||
input, _ := args["input"].(string)
|
||||
|
||||
switch action {
|
||||
case "hash":
|
||||
alg, _ := args["algorithm"].(string)
|
||||
if alg == "" {
|
||||
alg = "sha256"
|
||||
}
|
||||
var h hash.Hash
|
||||
switch alg {
|
||||
case "md5":
|
||||
h = md5.New()
|
||||
case "sha1":
|
||||
h = sha1.New()
|
||||
case "sha256":
|
||||
h = sha256.New()
|
||||
case "sha512":
|
||||
h = sha512.New()
|
||||
default:
|
||||
return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: "unsupported algorithm: " + alg}, nil
|
||||
}
|
||||
h.Write([]byte(input))
|
||||
return &sdk.ToolResult{ToolName: "crypto", Success: true,
|
||||
Output: fmt.Sprintf("%s: %x", alg, h.Sum(nil))}, nil
|
||||
|
||||
case "base64_encode":
|
||||
return &sdk.ToolResult{ToolName: "crypto", Success: true,
|
||||
Output: base64.StdEncoding.EncodeToString([]byte(input))}, nil
|
||||
|
||||
case "base64_decode":
|
||||
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
|
||||
if decoded, err := enc.DecodeString(input); err == nil {
|
||||
return &sdk.ToolResult{ToolName: "crypto", Success: true, Output: truncate(string(decoded), 200)}, nil
|
||||
}
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: "failed to decode base64"}, nil
|
||||
|
||||
case "url_encode":
|
||||
return &sdk.ToolResult{ToolName: "crypto", Success: true,
|
||||
Output: url.QueryEscape(input)}, nil
|
||||
|
||||
case "url_decode":
|
||||
decoded, err := url.QueryUnescape(input)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "crypto", Success: true, Output: decoded}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: "unknown action: " + action}, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) > n {
|
||||
return string(runes[:n]) + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package datetime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
type DatetimePlugin struct{ sdk.BasePlugin }
|
||||
|
||||
func (p *DatetimePlugin) Metadata() sdk.PluginMetadata {
|
||||
return sdk.PluginMetadata{
|
||||
Name: "datetime", DisplayName: "Date & Time", Version: "1.0.0",
|
||||
Description: "Date/time utilities: now, format, arithmetic, diff, timezone list",
|
||||
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DatetimePlugin) Tools() []sdk.Tool { return []sdk.Tool{&DatetimeTool{}} }
|
||||
|
||||
type DatetimeTool struct{ sdk.BaseTool }
|
||||
|
||||
func (t *DatetimeTool) Definition() sdk.ToolDefinition {
|
||||
return sdk.ToolDefinition{
|
||||
ID: "datetime", Name: "datetime", DisplayName: "Date & Time",
|
||||
Description: "Date/time utility. Get current time, format dates, date arithmetic, date diff, list timezones.",
|
||||
Category: "utility", Complexity: sdk.ComplexitySimple,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{"type": "string", "enum": []string{"now", "format", "add", "diff", "timezone_list"}},
|
||||
"format": map[string]interface{}{"type": "string"},
|
||||
"timezone": map[string]interface{}{"type": "string"},
|
||||
"date": map[string]interface{}{"type": "string"},
|
||||
"duration": map[string]interface{}{"type": "string"},
|
||||
"date2": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DatetimeTool) Validate(args map[string]interface{}) error {
|
||||
if _, ok := args["action"]; !ok {
|
||||
return fmt.Errorf("missing required parameter: action")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *DatetimeTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
action, _ := args["action"].(string)
|
||||
tzStr, _ := args["timezone"].(string)
|
||||
loc, _ := parseLocation(tzStr)
|
||||
now := time.Now().In(loc)
|
||||
|
||||
switch action {
|
||||
case "now":
|
||||
return &sdk.ToolResult{ToolName: "datetime", Success: true,
|
||||
Output: fmt.Sprintf("Current time: %s (unix: %d, zone: %s)", now.Format(time.RFC3339), now.Unix(), loc.String())}, nil
|
||||
|
||||
case "format":
|
||||
dateStr, _ := args["date"].(string)
|
||||
format, _ := args["format"].(string)
|
||||
if format == "" {
|
||||
format = time.RFC3339
|
||||
}
|
||||
parsed, err := parseDate(dateStr, loc)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "datetime", Success: true,
|
||||
Output: fmt.Sprintf("Formatted: %s", parsed.Format(format))}, nil
|
||||
|
||||
case "add":
|
||||
dateStr, _ := args["date"].(string)
|
||||
durStr, _ := args["duration"].(string)
|
||||
base := now
|
||||
if dateStr != "" {
|
||||
var err error
|
||||
base, err = parseDate(dateStr, loc)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
}
|
||||
result, err := addDuration(base, durStr)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "datetime", Success: true,
|
||||
Output: fmt.Sprintf("%s + %s = %s", base.Format(time.RFC3339), durStr, result.Format(time.RFC3339))}, nil
|
||||
|
||||
case "diff":
|
||||
d1, _ := args["date"].(string)
|
||||
d2, _ := args["date2"].(string)
|
||||
t1, err := parseDate(d1, loc)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
t2, err := parseDate(d2, loc)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
diff := t2.Sub(t1)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
days := int(diff.Hours()) / 24
|
||||
hours := int(diff.Hours()) % 24
|
||||
minutes := int(diff.Minutes()) % 60
|
||||
seconds := int(diff.Seconds()) % 60
|
||||
return &sdk.ToolResult{ToolName: "datetime", Success: true,
|
||||
Output: fmt.Sprintf("Difference: %d days, %d hours, %d minutes, %d seconds", days, hours, minutes, seconds)}, nil
|
||||
|
||||
case "timezone_list":
|
||||
return &sdk.ToolResult{ToolName: "datetime", Success: true,
|
||||
Output: "Common timezones: UTC, Asia/Shanghai, Asia/Tokyo, Asia/Seoul, Asia/Singapore, Asia/Kolkata, Asia/Dubai, Europe/London, Europe/Paris, Europe/Moscow, America/New_York, America/Chicago, America/Los_Angeles, America/Sao_Paulo, Australia/Sydney, Pacific/Auckland, Africa/Cairo, Africa/Lagos"}, nil
|
||||
|
||||
default:
|
||||
return &sdk.ToolResult{ToolName: "datetime", Success: false,
|
||||
Error: fmt.Sprintf("unknown action: %s", action)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseLocation(tz string) (*time.Location, error) {
|
||||
if tz == "" {
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
return time.UTC, nil
|
||||
}
|
||||
return loc, nil
|
||||
}
|
||||
return time.LoadLocation(tz)
|
||||
}
|
||||
|
||||
func parseDate(s string, loc *time.Location) (time.Time, error) {
|
||||
formats := []string{time.RFC3339, "2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02", "2006/01/02"}
|
||||
for _, f := range formats {
|
||||
if t, err := time.ParseInLocation(f, s, loc); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("cannot parse date: %s", s)
|
||||
}
|
||||
|
||||
func addDuration(t time.Time, durStr string) (time.Time, error) {
|
||||
durStr = strings.TrimSpace(durStr)
|
||||
if durStr == "" {
|
||||
return t, nil
|
||||
}
|
||||
// Handle months and years
|
||||
if strings.Contains(durStr, "M") || strings.Contains(durStr, "y") {
|
||||
months := 0
|
||||
years := 0
|
||||
if strings.Contains(durStr, "y") {
|
||||
fmt.Sscanf(durStr, "%dy", &years)
|
||||
}
|
||||
if strings.Contains(durStr, "M") {
|
||||
fmt.Sscanf(durStr, "%dM", &months)
|
||||
}
|
||||
return t.AddDate(years, months, 0), nil
|
||||
}
|
||||
d, err := time.ParseDuration(durStr)
|
||||
if err != nil {
|
||||
return t, fmt.Errorf("invalid duration: %s", durStr)
|
||||
}
|
||||
return t.Add(d), nil
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
type FilePlugin struct {
|
||||
sdk.BasePlugin
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewFilePlugin(dataDir string) *FilePlugin {
|
||||
if dataDir == "" {
|
||||
dataDir = "/tmp/cyrene_data"
|
||||
}
|
||||
return &FilePlugin{dataDir: dataDir}
|
||||
}
|
||||
|
||||
func (p *FilePlugin) Metadata() sdk.PluginMetadata {
|
||||
return sdk.PluginMetadata{
|
||||
Name: "file", DisplayName: "File Operations", Version: "1.0.0",
|
||||
Description: "Sandboxed file operations: read, write, list, delete within DATA_DIR",
|
||||
Category: "system", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *FilePlugin) Tools() []sdk.Tool { return []sdk.Tool{&FileTool{dataDir: p.dataDir}} }
|
||||
|
||||
type FileTool struct {
|
||||
sdk.BaseTool
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func (t *FileTool) Definition() sdk.ToolDefinition {
|
||||
return sdk.ToolDefinition{
|
||||
ID: "file_ops", Name: "file_ops", DisplayName: "File Operations",
|
||||
Description: "File operations within a sandboxed data directory. Read, write, list, check existence, delete.",
|
||||
Category: "system", Complexity: sdk.ComplexitySimple,
|
||||
DangerLevel: "medium",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{"type": "string", "enum": []string{"read", "write", "list", "exists", "delete"}},
|
||||
"path": map[string]interface{}{"type": "string"},
|
||||
"content": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"required": []string{"action", "path"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *FileTool) Validate(args map[string]interface{}) error {
|
||||
for _, k := range []string{"action", "path"} {
|
||||
if _, ok := args[k]; !ok {
|
||||
return fmt.Errorf("missing required parameter: %s", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *FileTool) safePath(p string) (string, error) {
|
||||
clean := filepath.Clean(p)
|
||||
abs, err := filepath.Abs(filepath.Join(t.dataDir, clean))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("path resolution failed: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(abs, filepath.Clean(t.dataDir)+string(os.PathSeparator)) && abs != filepath.Clean(t.dataDir) {
|
||||
return "", fmt.Errorf("path traversal denied: %s", p)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func (t *FileTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
action, _ := args["action"].(string)
|
||||
pathStr, _ := args["path"].(string)
|
||||
|
||||
safePath, err := t.safePath(pathStr)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "read":
|
||||
info, err := os.Stat(safePath)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "cannot read a directory"}, nil
|
||||
}
|
||||
if info.Size() > 100*1024 {
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "file too large (>100KB)"}, nil
|
||||
}
|
||||
data, err := os.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: string(data)}, nil
|
||||
|
||||
case "write":
|
||||
content, _ := args["content"].(string)
|
||||
dir := filepath.Dir(safePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
if err := os.WriteFile(safePath, []byte(content), 0644); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Written %d bytes to %s", len(content), pathStr)}, nil
|
||||
|
||||
case "list":
|
||||
entries, err := os.ReadDir(safePath)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, e := range entries {
|
||||
info, _ := e.Info()
|
||||
if e.IsDir() {
|
||||
out.WriteString(fmt.Sprintf("[DIR] %s/\n", e.Name()))
|
||||
} else {
|
||||
out.WriteString(fmt.Sprintf("[FILE] %s (%d bytes)\n", e.Name(), info.Size()))
|
||||
}
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: out.String()}, nil
|
||||
|
||||
case "exists":
|
||||
info, err := os.Stat(safePath)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Path does not exist: %s", pathStr)}, nil
|
||||
}
|
||||
kind := "file"
|
||||
if info.IsDir() {
|
||||
kind = "directory"
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Path exists (%s): %s", kind, pathStr)}, nil
|
||||
|
||||
case "delete":
|
||||
info, err := os.Stat(safePath)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "cannot delete a directory"}, nil
|
||||
}
|
||||
if err := os.Remove(safePath); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Deleted: %s", pathStr)}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "unknown action: " + action}, nil
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.yeij.top/AskaEth/Cyrene/pkg/plugins
|
||||
|
||||
go 1.21
|
||||
@@ -1,122 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
type HTTPPlugin struct {
|
||||
sdk.BasePlugin
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPPlugin() *HTTPPlugin {
|
||||
return &HTTPPlugin{client: &http.Client{Timeout: 10 * time.Second}}
|
||||
}
|
||||
|
||||
func (p *HTTPPlugin) Metadata() sdk.PluginMetadata {
|
||||
return sdk.PluginMetadata{
|
||||
Name: "http", DisplayName: "HTTP Client", Version: "1.0.0",
|
||||
Description: "Send arbitrary HTTP requests with custom methods, headers, body",
|
||||
Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HTTPPlugin) Tools() []sdk.Tool { return []sdk.Tool{&HTTPTool{client: p.client}} }
|
||||
|
||||
type HTTPTool struct {
|
||||
sdk.BaseTool
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (t *HTTPTool) Definition() sdk.ToolDefinition {
|
||||
return sdk.ToolDefinition{
|
||||
ID: "http_request", Name: "http_request", DisplayName: "HTTP Client",
|
||||
Description: "Send arbitrary HTTP requests. Supports custom methods, headers, and body.",
|
||||
Category: "network", Complexity: sdk.ComplexitySimple,
|
||||
DangerLevel: "low",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string"},
|
||||
"method": map[string]interface{}{"type": "string", "enum": []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}},
|
||||
"headers": map[string]interface{}{"type": "object"},
|
||||
"body": map[string]interface{}{"type": "string"},
|
||||
"timeout": map[string]interface{}{"type": "number"},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var allowedMethods = map[string]bool{
|
||||
"GET": true, "POST": true, "PUT": true, "DELETE": true,
|
||||
"PATCH": true, "HEAD": true, "OPTIONS": true,
|
||||
}
|
||||
|
||||
func (t *HTTPTool) Validate(args map[string]interface{}) error {
|
||||
if _, ok := args["url"]; !ok {
|
||||
return fmt.Errorf("missing required parameter: url")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *HTTPTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
urlStr, _ := args["url"].(string)
|
||||
method, _ := args["method"].(string)
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
if !allowedMethods[method] {
|
||||
return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: "invalid method: " + method}, nil
|
||||
}
|
||||
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
|
||||
return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: "only http/https URLs allowed"}, nil
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body, _ := args["body"].(string); body != "" {
|
||||
bodyReader = strings.NewReader(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, bodyReader)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
req.Header.Set("User-Agent", "CyreneBot/1.0")
|
||||
|
||||
if headers, ok := args["headers"].(map[string]interface{}); ok {
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
|
||||
client := t.client
|
||||
if timeout, _ := args["timeout"].(float64); timeout > 0 {
|
||||
client = &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 50*1024))
|
||||
return &sdk.ToolResult{ToolName: "http_request", Success: resp.StatusCode < 500, Output: fmt.Sprintf(
|
||||
"HTTP %d\n%s\n\n%s", resp.StatusCode, formatHeaders(resp.Header), string(bodyBytes))}, nil
|
||||
}
|
||||
|
||||
func formatHeaders(h http.Header) string {
|
||||
var lines []string
|
||||
for k, v := range h {
|
||||
lines = append(lines, fmt.Sprintf("%s: %s", k, strings.Join(v, ", ")))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package iotcontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// IoTController extends IoTClient with control operations.
|
||||
type IoTController interface {
|
||||
GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error)
|
||||
SetDeviceProperty(ctx context.Context, deviceID, property string, value interface{}) error
|
||||
ToggleDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error)
|
||||
}
|
||||
|
||||
type IoTControlPlugin struct {
|
||||
sdk.BasePlugin
|
||||
iotClient IoTController
|
||||
}
|
||||
|
||||
func NewIoTControlPlugin(client IoTController) *IoTControlPlugin {
|
||||
return &IoTControlPlugin{iotClient: client}
|
||||
}
|
||||
|
||||
func (p *IoTControlPlugin) Metadata() sdk.PluginMetadata {
|
||||
return sdk.PluginMetadata{
|
||||
Name: "iot_control", DisplayName: "IoT Device Control", Version: "1.0.0",
|
||||
Description: "Control smart home devices: toggle, set temperature/brightness/mode/color",
|
||||
Category: "iot", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *IoTControlPlugin) Tools() []sdk.Tool {
|
||||
return []sdk.Tool{&IoTControlTool{iotClient: p.iotClient}}
|
||||
}
|
||||
|
||||
type IoTControlTool struct {
|
||||
sdk.BaseTool
|
||||
iotClient IoTController
|
||||
}
|
||||
|
||||
func (t *IoTControlTool) Definition() sdk.ToolDefinition {
|
||||
return sdk.ToolDefinition{
|
||||
ID: "iot_control", Name: "iot_control", DisplayName: "IoT Device Control",
|
||||
Description: "Control smart home devices. Supports toggle, turn_on, turn_off, set_temperature, set_brightness, set_position, set_mode, set_color.",
|
||||
Category: "iot", Complexity: sdk.ComplexitySimple,
|
||||
DangerLevel: "medium",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"device_id": map[string]interface{}{"type": "string"},
|
||||
"action": map[string]interface{}{"type": "string", "enum": []string{"toggle", "turn_on", "turn_off", "set_temperature", "set_brightness", "set_position", "set_mode", "set_color"}},
|
||||
"value": map[string]interface{}{},
|
||||
},
|
||||
"required": []string{"device_id", "action"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *IoTControlTool) Validate(args map[string]interface{}) error {
|
||||
for _, k := range []string{"device_id", "action"} {
|
||||
if _, ok := args[k]; !ok {
|
||||
return fmt.Errorf("missing required parameter: %s", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *IoTControlTool) Execute(ctx context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
if t.iotClient == nil {
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: "IoT client not configured"}, nil
|
||||
}
|
||||
|
||||
deviceID, _ := args["device_id"].(string)
|
||||
if deviceID == "" {
|
||||
deviceID, _ = args["entity_id"].(string)
|
||||
}
|
||||
action := normalizeAction(args)
|
||||
|
||||
switch action {
|
||||
case "turn_on", "turn_off":
|
||||
status := "on"
|
||||
if action == "turn_off" {
|
||||
status = "off"
|
||||
}
|
||||
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "status", status); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
|
||||
Output: fmt.Sprintf("Device %s turned %s", deviceID, status)}, nil
|
||||
|
||||
case "set_temperature":
|
||||
value := toFloat64(args["value"])
|
||||
old := ""
|
||||
if dev, err := t.iotClient.GetDevice(ctx, deviceID); err == nil {
|
||||
old = fmt.Sprintf(" (was %.1fC)", dev.Temperature)
|
||||
}
|
||||
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "temperature", value); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
|
||||
Output: fmt.Sprintf("Temperature set to %.1fC%s", value, old)}, nil
|
||||
|
||||
case "set_brightness":
|
||||
value := toFloat64(args["value"])
|
||||
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "brightness", value); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
|
||||
Output: fmt.Sprintf("Brightness set to %.0f%%", value)}, nil
|
||||
|
||||
case "set_position":
|
||||
value := toFloat64(args["value"])
|
||||
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "position", value); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
|
||||
Output: fmt.Sprintf("Position set to %.0f%%", value)}, nil
|
||||
|
||||
case "set_mode":
|
||||
value, _ := args["value"].(string)
|
||||
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "mode", value); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
|
||||
Output: fmt.Sprintf("Mode set to %s", value)}, nil
|
||||
|
||||
case "set_color":
|
||||
value, _ := args["value"].(string)
|
||||
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "color", value); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
|
||||
Output: fmt.Sprintf("Color set to %s", value)}, nil
|
||||
|
||||
case "toggle":
|
||||
dev, err := t.iotClient.ToggleDevice(ctx, deviceID)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
|
||||
Output: fmt.Sprintf("Device %s toggled to %s", deviceID, dev.Status)}, nil
|
||||
|
||||
default:
|
||||
return &sdk.ToolResult{ToolName: "iot_control", Success: false,
|
||||
Error: fmt.Sprintf("unknown action: %s", action)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAction(args map[string]interface{}) string {
|
||||
action, _ := args["action"].(string)
|
||||
// Chinese aliases
|
||||
switch action {
|
||||
case "打开":
|
||||
return "turn_on"
|
||||
case "关闭", "关掉", "关上":
|
||||
return "turn_off"
|
||||
case "设置温度", "调温度":
|
||||
return "set_temperature"
|
||||
case "设置亮度", "调亮度":
|
||||
return "set_brightness"
|
||||
case "设置位置":
|
||||
return "set_position"
|
||||
case "设置模式":
|
||||
return "set_mode"
|
||||
case "设置颜色":
|
||||
return "set_color"
|
||||
case "开关", "切换":
|
||||
return "toggle"
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
func toFloat64(v interface{}) float64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case string:
|
||||
var f float64
|
||||
fmt.Sscanf(n, "%f", &f)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package iotquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// IoTClient is the interface for IoT device access.
|
||||
type IoTClient interface {
|
||||
GetAllDevices(ctx context.Context) ([]sdk.IoTDeviceState, error)
|
||||
GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error)
|
||||
}
|
||||
|
||||
type IoTQueryPlugin struct {
|
||||
sdk.BasePlugin
|
||||
iotClient IoTClient
|
||||
}
|
||||
|
||||
func NewIoTQueryPlugin(client IoTClient) *IoTQueryPlugin {
|
||||
return &IoTQueryPlugin{iotClient: client}
|
||||
}
|
||||
|
||||
func (p *IoTQueryPlugin) Metadata() sdk.PluginMetadata {
|
||||
return sdk.PluginMetadata{
|
||||
Name: "iot_query", DisplayName: "IoT Device Query", Version: "1.0.0",
|
||||
Description: "Query smart home device status (single device or all devices)",
|
||||
Category: "iot", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *IoTQueryPlugin) Tools() []sdk.Tool { return []sdk.Tool{&IoTQueryTool{iotClient: p.iotClient}} }
|
||||
|
||||
type IoTQueryTool struct {
|
||||
sdk.BaseTool
|
||||
iotClient IoTClient
|
||||
}
|
||||
|
||||
func (t *IoTQueryTool) Definition() sdk.ToolDefinition {
|
||||
return sdk.ToolDefinition{
|
||||
ID: "iot_query", Name: "iot_query", DisplayName: "IoT Device Query",
|
||||
Description: "Query smart home device status. Device status is typically auto-injected; use this only when status is stale.",
|
||||
Category: "iot", Complexity: sdk.ComplexitySimple,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{"device_id": map[string]interface{}{"type": "string"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *IoTQueryTool) Validate(args map[string]interface{}) error { return nil }
|
||||
|
||||
func (t *IoTQueryTool) Execute(ctx context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
if t.iotClient == nil {
|
||||
return &sdk.ToolResult{ToolName: "iot_query", Success: false, Error: "IoT client not configured"}, nil
|
||||
}
|
||||
|
||||
deviceID, _ := args["device_id"].(string)
|
||||
if deviceID != "" {
|
||||
dev, err := t.iotClient.GetDevice(ctx, deviceID)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "iot_query", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "iot_query", Success: true, Output: formatDevice(dev)}, nil
|
||||
}
|
||||
|
||||
devices, err := t.iotClient.GetAllDevices(ctx)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "iot_query", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
return &sdk.ToolResult{ToolName: "iot_query", Success: true, Output: "No devices found"}, nil
|
||||
}
|
||||
var out string
|
||||
for _, d := range devices {
|
||||
out += formatDeviceLine(&d) + "\n"
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "iot_query", Success: true, Output: out}, nil
|
||||
}
|
||||
|
||||
func formatDevice(d *sdk.IoTDeviceState) string {
|
||||
emoji := deviceEmoji(d.Type)
|
||||
return fmt.Sprintf("%s %s (%s)\n Status: %s\n ID: %s", emoji, d.Name, d.Type, d.Status, d.ID)
|
||||
}
|
||||
|
||||
func formatDeviceLine(d *sdk.IoTDeviceState) string {
|
||||
emoji := deviceEmoji(d.Type)
|
||||
switch d.Type {
|
||||
case "light":
|
||||
return fmt.Sprintf("%s %s: %s (brightness: %d, color: %s)", emoji, d.Name, d.Status, d.Brightness, d.Color)
|
||||
case "ac":
|
||||
return fmt.Sprintf("%s %s: %s (mode: %s, temp: %.1fC)", emoji, d.Name, d.Status, d.Mode, d.Temperature)
|
||||
case "curtain":
|
||||
return fmt.Sprintf("%s %s: %s (position: %d%%)", emoji, d.Name, d.Status, d.Position)
|
||||
case "sensor":
|
||||
return fmt.Sprintf("%s %s: %.1f%s", emoji, d.Name, d.Value, d.Unit)
|
||||
case "lock":
|
||||
return fmt.Sprintf("%s %s: %s (battery: %d%%)", emoji, d.Name, d.Status, d.Battery)
|
||||
default:
|
||||
return fmt.Sprintf("%s %s: %s", emoji, d.Name, d.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func deviceEmoji(t string) string {
|
||||
switch t {
|
||||
case "light":
|
||||
return "\U0001F4A1"
|
||||
case "ac":
|
||||
return "❄️"
|
||||
case "curtain":
|
||||
return "\U0001F3E0"
|
||||
case "sensor":
|
||||
return "\U0001F4CA"
|
||||
case "lock":
|
||||
return "\U0001F512"
|
||||
default:
|
||||
return "\U0001F4E6"
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package jsonplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
type JSONPlugin struct{ sdk.BasePlugin }
|
||||
|
||||
func (p *JSONPlugin) Metadata() sdk.PluginMetadata {
|
||||
return sdk.PluginMetadata{
|
||||
Name: "json", DisplayName: "JSON Processor", Version: "1.0.0",
|
||||
Description: "JSON parsing, dot-path query, validation, pretty-print",
|
||||
Category: "format", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *JSONPlugin) Tools() []sdk.Tool { return []sdk.Tool{&JSONTool{}} }
|
||||
|
||||
type JSONTool struct{ sdk.BaseTool }
|
||||
|
||||
func (t *JSONTool) Definition() sdk.ToolDefinition {
|
||||
return sdk.ToolDefinition{
|
||||
ID: "json_ops", Name: "json_ops", DisplayName: "JSON Processor",
|
||||
Description: "JSON processing. Parse/pretty-print, query by dot-notation path, validate.",
|
||||
Category: "format", Complexity: sdk.ComplexitySimple,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{"type": "string", "enum": []string{"parse", "query", "validate"}},
|
||||
"json_string": map[string]interface{}{"type": "string"},
|
||||
"path": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"required": []string{"action", "json_string"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *JSONTool) Validate(args map[string]interface{}) error {
|
||||
for _, k := range []string{"action", "json_string"} {
|
||||
if _, ok := args[k]; !ok {
|
||||
return fmt.Errorf("missing required parameter: %s", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *JSONTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
action, _ := args["action"].(string)
|
||||
jsonStr, _ := args["json_string"].(string)
|
||||
|
||||
switch action {
|
||||
case "parse":
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &v); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
pretty, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "json_ops", Success: true, Output: string(pretty)}, nil
|
||||
|
||||
case "query":
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &v); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: "path is required for query"}, nil
|
||||
}
|
||||
result, err := jsonPathQuery(v, path)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
out, _ := json.Marshal(result)
|
||||
return &sdk.ToolResult{ToolName: "json_ops", Success: true, Output: string(out)}, nil
|
||||
|
||||
case "validate":
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &v); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "json_ops", Success: true, Output: "Invalid JSON: " + err.Error()}, nil
|
||||
}
|
||||
typeStr := "unknown"
|
||||
switch v.(type) {
|
||||
case map[string]interface{}:
|
||||
typeStr = "object"
|
||||
case []interface{}:
|
||||
typeStr = "array"
|
||||
case string:
|
||||
typeStr = "string"
|
||||
case float64:
|
||||
typeStr = "number"
|
||||
case bool:
|
||||
typeStr = "boolean"
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "json_ops", Success: true,
|
||||
Output: fmt.Sprintf("Valid JSON (type: %s, size: %d bytes)", typeStr, len(jsonStr))}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: "unknown action: " + action}, nil
|
||||
}
|
||||
|
||||
func jsonPathQuery(root interface{}, path string) (interface{}, error) {
|
||||
path = strings.TrimPrefix(path, "$.")
|
||||
parts := strings.Split(path, ".")
|
||||
current := root
|
||||
for _, part := range parts {
|
||||
switch v := current.(type) {
|
||||
case map[string]interface{}:
|
||||
var ok bool
|
||||
current, ok = v[part]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("key %q not found", part)
|
||||
}
|
||||
case []interface{}:
|
||||
idx, err := strconv.Atoi(part)
|
||||
if err != nil || idx < 0 || idx >= len(v) {
|
||||
return nil, fmt.Errorf("invalid array index: %s", part)
|
||||
}
|
||||
current = v[idx]
|
||||
default:
|
||||
return nil, fmt.Errorf("cannot traverse into %T at path segment %q", current, part)
|
||||
}
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// PluginManager manages the lifecycle of all plugins and their tools.
|
||||
type PluginManager struct {
|
||||
mu sync.RWMutex
|
||||
plugins map[string]*pluginEntry
|
||||
registry *ToolRegistry
|
||||
host sdk.HostAPI
|
||||
}
|
||||
|
||||
type pluginEntry struct {
|
||||
instance sdk.Plugin
|
||||
info sdk.PluginInfo
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewPluginManager(registry *ToolRegistry, host sdk.HostAPI) *PluginManager {
|
||||
return &PluginManager{
|
||||
plugins: make(map[string]*pluginEntry),
|
||||
registry: registry,
|
||||
host: host,
|
||||
}
|
||||
}
|
||||
|
||||
// Install registers a plugin instance.
|
||||
func (m *PluginManager) Install(plugin sdk.Plugin) error {
|
||||
meta := plugin.Metadata()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, exists := m.plugins[meta.Name]; exists {
|
||||
return fmt.Errorf("plugin %q is already installed", meta.Name)
|
||||
}
|
||||
|
||||
m.plugins[meta.Name] = &pluginEntry{
|
||||
instance: plugin,
|
||||
info: sdk.PluginInfo{
|
||||
Metadata: meta,
|
||||
Status: sdk.StatusInstalled,
|
||||
InstalledAt: time.Now(),
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enable activates a plugin: Init → register tools → Start.
|
||||
func (m *PluginManager) Enable(ctx context.Context, pluginName string) error {
|
||||
m.mu.Lock()
|
||||
entry, ok := m.plugins[pluginName]
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin %q not found", pluginName)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
entry.info.Status = sdk.StatusLoaded
|
||||
m.mu.Unlock()
|
||||
|
||||
meta := entry.instance.Metadata()
|
||||
if err := entry.instance.Init(ctx, nil); err != nil {
|
||||
m.mu.Lock()
|
||||
entry.info.Status = sdk.StatusError
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("plugin %q init failed: %w", meta.Name, err)
|
||||
}
|
||||
|
||||
pluginCtx, cancel := context.WithCancel(context.Background())
|
||||
if err := entry.instance.Start(pluginCtx, m.host); err != nil {
|
||||
cancel()
|
||||
m.mu.Lock()
|
||||
entry.info.Status = sdk.StatusError
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("plugin %q start failed: %w", meta.Name, err)
|
||||
}
|
||||
|
||||
tools := entry.instance.Tools()
|
||||
toolIDs := make([]string, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
if err := m.registry.Register(t); err != nil {
|
||||
m.registry.UnregisterAll(toolIDs)
|
||||
cancel()
|
||||
m.mu.Lock()
|
||||
entry.info.Status = sdk.StatusError
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("plugin %q tool register failed: %w", meta.Name, err)
|
||||
}
|
||||
toolIDs = append(toolIDs, t.Definition().ID)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
entry.cancel = cancel
|
||||
entry.info.Status = sdk.StatusRunning
|
||||
entry.info.Enabled = true
|
||||
entry.info.Tools = toolIDs
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disable stops a plugin and unregisters its tools.
|
||||
func (m *PluginManager) Disable(ctx context.Context, pluginName string) error {
|
||||
m.mu.Lock()
|
||||
entry, ok := m.plugins[pluginName]
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin %q not found", pluginName)
|
||||
}
|
||||
|
||||
if err := entry.instance.Stop(ctx); err != nil {
|
||||
return fmt.Errorf("plugin %q stop failed: %w", pluginName, err)
|
||||
}
|
||||
if entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
|
||||
m.registry.UnregisterAll(entry.info.Tools)
|
||||
|
||||
m.mu.Lock()
|
||||
entry.info.Status = sdk.StatusDisabled
|
||||
entry.info.Enabled = false
|
||||
entry.info.Tools = nil
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns info for all installed plugins.
|
||||
func (m *PluginManager) List() []sdk.PluginInfo {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]sdk.PluginInfo, 0, len(m.plugins))
|
||||
for _, entry := range m.plugins {
|
||||
result = append(result, entry.info)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Get returns info for a single plugin.
|
||||
func (m *PluginManager) Get(pluginName string) (*sdk.PluginInfo, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
entry, ok := m.plugins[pluginName]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
info := entry.info
|
||||
return &info, true
|
||||
}
|
||||
|
||||
// EnableAll starts all installed plugins.
|
||||
func (m *PluginManager) EnableAll(ctx context.Context) []error {
|
||||
m.mu.RLock()
|
||||
names := make([]string, 0, len(m.plugins))
|
||||
for name := range m.plugins {
|
||||
names = append(names, name)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
var errs []error
|
||||
for _, name := range names {
|
||||
if err := m.Enable(ctx, name); err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", name, err))
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// Uninstall removes a plugin completely.
|
||||
func (m *PluginManager) Uninstall(ctx context.Context, pluginName string) error {
|
||||
m.mu.RLock()
|
||||
entry, ok := m.plugins[pluginName]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin %q not found", pluginName)
|
||||
}
|
||||
if entry.info.Status == sdk.StatusRunning {
|
||||
if err := m.Disable(ctx, pluginName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.plugins, pluginName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload stops and re-starts a plugin.
|
||||
func (m *PluginManager) Reload(ctx context.Context, pluginName string) error {
|
||||
if err := m.Disable(ctx, pluginName); err != nil {
|
||||
return fmt.Errorf("reload disable: %w", err)
|
||||
}
|
||||
return m.Enable(ctx, pluginName)
|
||||
}
|
||||
|
||||
// Shutdown stops all running plugins gracefully.
|
||||
func (m *PluginManager) Shutdown(ctx context.Context) []error {
|
||||
m.mu.RLock()
|
||||
names := make([]string, 0, len(m.plugins))
|
||||
for name, entry := range m.plugins {
|
||||
if entry.info.Status == sdk.StatusRunning {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
var errs []error
|
||||
for _, name := range names {
|
||||
if err := m.Disable(ctx, name); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// Registry returns the aggregated tool registry.
|
||||
func (m *PluginManager) Registry() *ToolRegistry {
|
||||
return m.registry
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// CtxKeyIsAdmin is the context key for the admin flag.
|
||||
type ctxKey string
|
||||
|
||||
const CtxKeyIsAdmin ctxKey = "isAdmin"
|
||||
|
||||
// adminOnlyTools lists tools that require admin permission to execute.
|
||||
var adminOnlyTools = map[string]bool{
|
||||
"host_exec": true,
|
||||
"os_exec": true,
|
||||
"host_file": true,
|
||||
}
|
||||
|
||||
// IsAdminFromCtx returns true if the context carries an admin flag.
|
||||
func IsAdminFromCtx(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(CtxKeyIsAdmin).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// CallLogRecord 工具调用记录
|
||||
type CallLogRecord struct {
|
||||
CallID string `json:"call_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
Arguments string `json:"arguments"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error"`
|
||||
Success bool `json:"success"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// callLogRing 线程安全的环形缓冲区
|
||||
type callLogRing struct {
|
||||
mu sync.Mutex
|
||||
records []CallLogRecord
|
||||
capacity int
|
||||
head int
|
||||
size int
|
||||
}
|
||||
|
||||
func newCallLogRing(capacity int) *callLogRing {
|
||||
return &callLogRing{capacity: capacity, records: make([]CallLogRecord, capacity)}
|
||||
}
|
||||
|
||||
func (r *callLogRing) push(rec CallLogRecord) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
rec.CallID = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
rec.Timestamp = time.Now().UnixMilli()
|
||||
r.records[r.head] = rec
|
||||
r.head = (r.head + 1) % r.capacity
|
||||
if r.size < r.capacity {
|
||||
r.size++
|
||||
}
|
||||
}
|
||||
|
||||
func (r *callLogRing) getAll() []CallLogRecord {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
result := make([]CallLogRecord, r.size)
|
||||
for i := 0; i < r.size; i++ {
|
||||
idx := (r.head - 1 - i) % r.capacity
|
||||
if idx < 0 {
|
||||
idx += r.capacity
|
||||
}
|
||||
result[i] = r.records[idx]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *callLogRing) statsByTool() map[string]map[string]interface{} {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
byTool := make(map[string]map[string]interface{})
|
||||
for i := 0; i < r.size; i++ {
|
||||
idx := (r.head - 1 - i) % r.capacity
|
||||
if idx < 0 {
|
||||
idx += r.capacity
|
||||
}
|
||||
rec := r.records[idx]
|
||||
if _, ok := byTool[rec.ToolName]; !ok {
|
||||
byTool[rec.ToolName] = map[string]interface{}{
|
||||
"tool_name": rec.ToolName, "count": 0, "success_count": 0,
|
||||
"fail_count": 0, "total_duration_ms": 0,
|
||||
}
|
||||
}
|
||||
s := byTool[rec.ToolName]
|
||||
s["count"] = s["count"].(int) + 1
|
||||
if rec.Success {
|
||||
s["success_count"] = s["success_count"].(int) + 1
|
||||
} else {
|
||||
s["fail_count"] = s["fail_count"].(int) + 1
|
||||
}
|
||||
s["total_duration_ms"] = s["total_duration_ms"].(int) + rec.DurationMs
|
||||
}
|
||||
return byTool
|
||||
}
|
||||
|
||||
// ToolRegistry aggregates tool definitions from all running plugins and dispatches execution.
|
||||
type ToolRegistry struct {
|
||||
mu sync.RWMutex
|
||||
tools map[string]sdk.Tool // tool ID -> Tool
|
||||
callLog *callLogRing
|
||||
enabled bool
|
||||
}
|
||||
|
||||
func NewToolRegistry() *ToolRegistry {
|
||||
return &ToolRegistry{
|
||||
tools: make(map[string]sdk.Tool),
|
||||
callLog: newCallLogRing(500),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether tool execution is enabled.
|
||||
func (r *ToolRegistry) IsEnabled() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.enabled
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables tool execution.
|
||||
func (r *ToolRegistry) SetEnabled(enabled bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.enabled = enabled
|
||||
}
|
||||
|
||||
// DefinitionNames returns all registered tool names.
|
||||
func (r *ToolRegistry) DefinitionNames() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
names := make([]string, 0, len(r.tools))
|
||||
for id := range r.tools {
|
||||
names = append(names, id)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (r *ToolRegistry) Register(tool sdk.Tool) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
id := tool.Definition().ID
|
||||
if _, exists := r.tools[id]; exists {
|
||||
return fmt.Errorf("tool %q already registered", id)
|
||||
}
|
||||
r.tools[id] = tool
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ToolRegistry) Unregister(toolID string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.tools, toolID)
|
||||
}
|
||||
|
||||
func (r *ToolRegistry) Get(toolID string) (sdk.Tool, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
t, ok := r.tools[toolID]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
func (r *ToolRegistry) List() []sdk.Tool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]sdk.Tool, 0, len(r.tools))
|
||||
for _, t := range r.tools {
|
||||
result = append(result, t)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *ToolRegistry) Definitions() []sdk.ToolDefinition {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
defs := make([]sdk.ToolDefinition, 0, len(r.tools))
|
||||
for _, t := range r.tools {
|
||||
defs = append(defs, t.Definition())
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
func (r *ToolRegistry) Execute(ctx context.Context, toolID string, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
r.mu.RLock()
|
||||
tool, ok := r.tools[toolID]
|
||||
r.mu.RUnlock()
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
if !ok {
|
||||
r.callLog.push(CallLogRecord{
|
||||
ToolName: toolID, Error: fmt.Sprintf("tool %q not found", toolID),
|
||||
Success: false, DurationMs: int(time.Since(startTime).Milliseconds()),
|
||||
})
|
||||
return nil, fmt.Errorf("tool %q not found", toolID)
|
||||
}
|
||||
|
||||
if err := tool.Validate(args); err != nil {
|
||||
r.callLog.push(CallLogRecord{
|
||||
ToolName: toolID, Error: err.Error(), Success: false,
|
||||
DurationMs: int(time.Since(startTime).Milliseconds()),
|
||||
})
|
||||
return &sdk.ToolResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
// Admin-only tools: deny non-admin callers.
|
||||
if adminOnlyTools[toolID] && !IsAdminFromCtx(ctx) {
|
||||
errMsg := fmt.Sprintf("工具 %s 仅限管理员使用", toolID)
|
||||
r.callLog.push(CallLogRecord{
|
||||
ToolName: toolID, Error: errMsg, Success: false,
|
||||
DurationMs: int(time.Since(startTime).Milliseconds()),
|
||||
})
|
||||
return &sdk.ToolResult{Success: false, Error: errMsg}, nil
|
||||
}
|
||||
|
||||
result, err := tool.Execute(ctx, args)
|
||||
durationMs := int(time.Since(startTime).Milliseconds())
|
||||
|
||||
if err != nil {
|
||||
r.callLog.push(CallLogRecord{
|
||||
ToolName: toolID, Error: err.Error(), Success: false, DurationMs: durationMs,
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
var argsJSON string
|
||||
if args != nil {
|
||||
if b, _ := json.Marshal(args); b != nil {
|
||||
argsJSON = string(b)
|
||||
}
|
||||
}
|
||||
r.callLog.push(CallLogRecord{
|
||||
ToolName: toolID, Arguments: argsJSON, Output: result.Output,
|
||||
Error: result.Error, Success: result.Success, DurationMs: durationMs,
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UnregisterAll removes all tools matching given IDs.
|
||||
func (r *ToolRegistry) UnregisterAll(toolIDs []string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, id := range toolIDs {
|
||||
delete(r.tools, id)
|
||||
}
|
||||
}
|
||||
|
||||
// GetCallLogs 获取工具调用记录(最新在前,支持按工具名过滤、分页)
|
||||
func (r *ToolRegistry) GetCallLogs(toolName string, limit, offset int) ([]CallLogRecord, int) {
|
||||
all := r.callLog.getAll()
|
||||
|
||||
// 过滤
|
||||
var filtered []CallLogRecord
|
||||
if toolName == "" {
|
||||
filtered = all
|
||||
} else {
|
||||
filtered = make([]CallLogRecord, 0)
|
||||
for _, rec := range all {
|
||||
if rec.ToolName == toolName {
|
||||
filtered = append(filtered, rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
|
||||
// 分页
|
||||
if offset >= len(filtered) {
|
||||
return []CallLogRecord{}, total
|
||||
}
|
||||
page := filtered[offset:]
|
||||
if limit > 0 && limit < len(page) {
|
||||
page = page[:limit]
|
||||
}
|
||||
|
||||
return page, total
|
||||
}
|
||||
|
||||
// GetCallStats 获取工具调用统计
|
||||
func (r *ToolRegistry) GetCallStats() map[string]interface{} {
|
||||
byTool := r.callLog.statsByTool()
|
||||
totalCalls, successCount, failCount, totalDurationMs := 0, 0, 0, 0
|
||||
toolStats := make([]map[string]interface{}, 0, len(byTool))
|
||||
for _, s := range byTool {
|
||||
count := s["count"].(int)
|
||||
success := s["success_count"].(int)
|
||||
fail := s["fail_count"].(int)
|
||||
totalDur := s["total_duration_ms"].(int)
|
||||
avgDur := 0.0
|
||||
if count > 0 {
|
||||
avgDur = float64(totalDur) / float64(count)
|
||||
}
|
||||
s["avg_duration_ms"] = avgDur
|
||||
delete(s, "total_duration_ms")
|
||||
toolStats = append(toolStats, s)
|
||||
totalCalls += count
|
||||
successCount += success
|
||||
failCount += fail
|
||||
totalDurationMs += totalDur
|
||||
}
|
||||
avgDuration := 0.0
|
||||
if totalCalls > 0 {
|
||||
avgDuration = float64(totalDurationMs) / float64(totalCalls)
|
||||
}
|
||||
successRate := 0.0
|
||||
if totalCalls > 0 {
|
||||
successRate = float64(successCount) / float64(totalCalls) * 100
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"total_calls": totalCalls, "success_count": successCount, "fail_count": failCount,
|
||||
"success_rate": successRate, "avg_duration_ms": avgDuration, "by_tool": toolStats,
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
type MarkdownPlugin struct{ sdk.BasePlugin }
|
||||
|
||||
func (p *MarkdownPlugin) Metadata() sdk.PluginMetadata {
|
||||
return sdk.PluginMetadata{
|
||||
Name: "markdown", DisplayName: "Markdown Processor", Version: "1.0.0",
|
||||
Description: "Markdown processing: to HTML, extract text/links/code, generate TOC",
|
||||
Category: "format", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MarkdownPlugin) Tools() []sdk.Tool { return []sdk.Tool{&MarkdownTool{}} }
|
||||
|
||||
type MarkdownTool struct{ sdk.BaseTool }
|
||||
|
||||
func (t *MarkdownTool) Definition() sdk.ToolDefinition {
|
||||
return sdk.ToolDefinition{
|
||||
ID: "markdown", Name: "markdown", DisplayName: "Markdown Processor",
|
||||
Description: "Markdown processing. Convert to HTML, extract plain text, extract links/code blocks, generate TOC.",
|
||||
Category: "format", Complexity: sdk.ComplexitySimple,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{"type": "string", "enum": []string{"to_html", "to_text", "extract_links", "extract_code", "table_of_contents"}},
|
||||
"markdown": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"required": []string{"action", "markdown"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MarkdownTool) Validate(args map[string]interface{}) error {
|
||||
for _, k := range []string{"action", "markdown"} {
|
||||
if _, ok := args[k]; !ok {
|
||||
return fmt.Errorf("missing required parameter: %s", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *MarkdownTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
action, _ := args["action"].(string)
|
||||
md, _ := args["markdown"].(string)
|
||||
|
||||
switch action {
|
||||
case "to_html":
|
||||
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: mdToHTML(md)}, nil
|
||||
|
||||
case "to_text":
|
||||
text := md
|
||||
reCode := regexp.MustCompile("(?s)```.*?```")
|
||||
text = reCode.ReplaceAllString(text, "")
|
||||
text = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "$1")
|
||||
text = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(text, "$1")
|
||||
text = regexp.MustCompile(`~~([^~]+)~~`).ReplaceAllString(text, "$1")
|
||||
text = regexp.MustCompile(`^#{1,6}\s+`).ReplaceAllString(text, "")
|
||||
text = regexp.MustCompile(`^[*-]\s+`).ReplaceAllString(text, "- ")
|
||||
text = regexp.MustCompile(`^>\s+`).ReplaceAllString(text, "")
|
||||
text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n")
|
||||
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: strings.TrimSpace(text)}, nil
|
||||
|
||||
case "extract_links":
|
||||
re := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||
matches := re.FindAllStringSubmatch(md, -1)
|
||||
if len(matches) == 0 {
|
||||
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: "No links found"}, nil
|
||||
}
|
||||
var out strings.Builder
|
||||
for i, m := range matches {
|
||||
out.WriteString(fmt.Sprintf("%d. %s -> %s\n", i+1, m[1], m[2]))
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: out.String()}, nil
|
||||
|
||||
case "extract_code":
|
||||
re := regexp.MustCompile("(?s)```(\\w*)\n?(.*?)```")
|
||||
matches := re.FindAllStringSubmatch(md, -1)
|
||||
if len(matches) == 0 {
|
||||
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: "No code blocks found"}, nil
|
||||
}
|
||||
var out strings.Builder
|
||||
for i, m := range matches {
|
||||
lang := m[1]
|
||||
if lang == "" {
|
||||
lang = "text"
|
||||
}
|
||||
code := m[2]
|
||||
if len([]rune(code)) > 500 {
|
||||
code = string([]rune(code)[:500]) + "..."
|
||||
}
|
||||
out.WriteString(fmt.Sprintf("--- Block %d (%s) ---\n%s\n\n", i+1, lang, code))
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: out.String()}, nil
|
||||
|
||||
case "table_of_contents":
|
||||
re := regexp.MustCompile(`(?m)^(#{1,6})\s+(.+)$`)
|
||||
matches := re.FindAllStringSubmatch(md, -1)
|
||||
if len(matches) == 0 {
|
||||
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: "No headings found"}, nil
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, m := range matches {
|
||||
depth := len(m[1])
|
||||
indent := strings.Repeat(" ", depth-1)
|
||||
out.WriteString(fmt.Sprintf("%s- %s\n", indent, m[2]))
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: out.String()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "markdown", Success: false, Error: "unknown action: " + action}, nil
|
||||
}
|
||||
|
||||
func mdToHTML(md string) string {
|
||||
// Save code blocks
|
||||
type placeholder struct {
|
||||
orig string
|
||||
content string
|
||||
language string
|
||||
}
|
||||
blocks := []*placeholder{}
|
||||
reCode := regexp.MustCompile("(?s)```(\\w*)\n?(.*?)```")
|
||||
md = reCode.ReplaceAllStringFunc(md, func(s string) string {
|
||||
m := reCode.FindStringSubmatch(s)
|
||||
b := &placeholder{orig: fmt.Sprintf("\x00CODE%d\x00", len(blocks)), language: m[1], content: escapeHTML(m[2])}
|
||||
blocks = append(blocks, b)
|
||||
return b.orig
|
||||
})
|
||||
|
||||
// Inline elements
|
||||
md = regexp.MustCompile("`([^`]+)`").ReplaceAllString(md, "<code>$1</code>")
|
||||
md = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`).ReplaceAllString(md, `<img src="$2" alt="$1">`)
|
||||
md = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(md, `<a href="$2">$1</a>`)
|
||||
md = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(md, `<strong>$1</strong>`)
|
||||
md = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(md, `<em>$1</em>`)
|
||||
md = regexp.MustCompile(`~~([^~]+)~~`).ReplaceAllString(md, `<del>$1</del>`)
|
||||
md = regexp.MustCompile(`(?m)^#{6}\s+(.+)$`).ReplaceAllString(md, `<h6>$1</h6>`)
|
||||
md = regexp.MustCompile(`(?m)^#{5}\s+(.+)$`).ReplaceAllString(md, `<h5>$1</h5>`)
|
||||
md = regexp.MustCompile(`(?m)^#{4}\s+(.+)$`).ReplaceAllString(md, `<h4>$1</h4>`)
|
||||
md = regexp.MustCompile(`(?m)^#{3}\s+(.+)$`).ReplaceAllString(md, `<h3>$1</h3>`)
|
||||
md = regexp.MustCompile(`(?m)^#{2}\s+(.+)$`).ReplaceAllString(md, `<h2>$1</h2>`)
|
||||
md = regexp.MustCompile(`(?m)^#{1}\s+(.+)$`).ReplaceAllString(md, `<h1>$1</h1>`)
|
||||
md = regexp.MustCompile(`(?m)^---\s*$`).ReplaceAllString(md, `<hr>`)
|
||||
md = regexp.MustCompile(`(?m)^>\s+(.+)$`).ReplaceAllString(md, `<blockquote>$1</blockquote>`)
|
||||
|
||||
// Restore code blocks
|
||||
for _, b := range blocks {
|
||||
langAttr := ""
|
||||
if b.language != "" {
|
||||
langAttr = " class=\"language-" + b.language + "\""
|
||||
}
|
||||
md = strings.Replace(md, b.orig, "<pre><code"+langAttr+">"+b.content+"</code></pre>", 1)
|
||||
}
|
||||
|
||||
// Paragraphs
|
||||
lines := strings.Split(md, "\n")
|
||||
var out strings.Builder
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "<") {
|
||||
out.WriteString(trimmed + "\n")
|
||||
} else {
|
||||
out.WriteString("<p>" + trimmed + "</p>\n")
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package random
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
mathrand "math/rand"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
type RandomPlugin struct{ sdk.BasePlugin }
|
||||
|
||||
func (p *RandomPlugin) Metadata() sdk.PluginMetadata {
|
||||
return sdk.PluginMetadata{
|
||||
Name: "random", DisplayName: "Random Generator", Version: "1.0.0",
|
||||
Description: "Random generation: numbers, UUIDs, secure passwords, pick/shuffle",
|
||||
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *RandomPlugin) Tools() []sdk.Tool { return []sdk.Tool{&RandomTool{}} }
|
||||
|
||||
type RandomTool struct{ sdk.BaseTool }
|
||||
|
||||
func (t *RandomTool) Definition() sdk.ToolDefinition {
|
||||
return sdk.ToolDefinition{
|
||||
ID: "random", Name: "random", DisplayName: "Random Generator",
|
||||
Description: "Random generation. Random numbers, UUID v4, secure passwords, pick from list, shuffle list.",
|
||||
Category: "utility", Complexity: sdk.ComplexitySimple,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{"type": "string", "enum": []string{"number", "uuid", "password", "pick", "shuffle"}},
|
||||
"min": map[string]interface{}{"type": "number"},
|
||||
"max": map[string]interface{}{"type": "number"},
|
||||
"length": map[string]interface{}{"type": "number"},
|
||||
"items": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
|
||||
"count": map[string]interface{}{"type": "number"},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *RandomTool) Validate(args map[string]interface{}) error {
|
||||
if _, ok := args["action"]; !ok {
|
||||
return fmt.Errorf("missing required parameter: action")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *RandomTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
action, _ := args["action"].(string)
|
||||
|
||||
switch action {
|
||||
case "number":
|
||||
min := getIntArg(args, "min", 0)
|
||||
max := getIntArg(args, "max", 100)
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "random", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "random", Success: true,
|
||||
Output: fmt.Sprintf("%d", int(n.Int64())+min)}, nil
|
||||
|
||||
case "uuid":
|
||||
uuid := make([]byte, 16)
|
||||
rand.Read(uuid)
|
||||
uuid[6] = (uuid[6] & 0x0f) | 0x40
|
||||
uuid[8] = (uuid[8] & 0x3f) | 0x80
|
||||
return &sdk.ToolResult{ToolName: "random", Success: true,
|
||||
Output: fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:])}, nil
|
||||
|
||||
case "password":
|
||||
length := getIntArg(args, "length", 16)
|
||||
if length < 4 {
|
||||
length = 4
|
||||
}
|
||||
if length > 128 {
|
||||
length = 128
|
||||
}
|
||||
upper := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
lower := "abcdefghijklmnopqrstuvwxyz"
|
||||
digits := "0123456789"
|
||||
symbols := "!@#$%^&*()_+-=[]{}|;:,.<>?"
|
||||
all := upper + lower + digits + symbols
|
||||
bytes := make([]byte, length)
|
||||
for i := range bytes {
|
||||
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(all))))
|
||||
bytes[i] = all[idx.Int64()]
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "random", Success: true, Output: string(bytes)}, nil
|
||||
|
||||
case "pick":
|
||||
items := getStringSliceArg(args, "items")
|
||||
if len(items) == 0 {
|
||||
return &sdk.ToolResult{ToolName: "random", Success: false, Error: "items list is empty"}, nil
|
||||
}
|
||||
count := getIntArg(args, "count", 1)
|
||||
if count > len(items) {
|
||||
count = len(items)
|
||||
}
|
||||
indices := shuffledIndices(len(items))
|
||||
picked := make([]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
picked[i] = items[indices[i]]
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "random", Success: true, Output: strings.Join(picked, ", ")}, nil
|
||||
|
||||
case "shuffle":
|
||||
items := getStringSliceArg(args, "items")
|
||||
indices := shuffledIndices(len(items))
|
||||
shuffled := make([]string, len(items))
|
||||
for i, idx := range indices {
|
||||
shuffled[i] = items[idx]
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "random", Success: true, Output: strings.Join(shuffled, ", ")}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "random", Success: false, Error: "unknown action: " + action}, nil
|
||||
}
|
||||
|
||||
func getIntArg(args map[string]interface{}, key string, defaultVal int) int {
|
||||
v, ok := args[key]
|
||||
if !ok {
|
||||
return defaultVal
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getStringSliceArg(args map[string]interface{}, key string) []string {
|
||||
v, ok := args[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch s := v.(type) {
|
||||
case []string:
|
||||
return s
|
||||
case []interface{}:
|
||||
result := make([]string, len(s))
|
||||
for i, item := range s {
|
||||
result[i] = fmt.Sprint(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shuffledIndices(n int) []int {
|
||||
indices := make([]int, n)
|
||||
for i := range indices {
|
||||
indices[i] = i
|
||||
}
|
||||
for i := n - 1; i > 0; i-- {
|
||||
jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
|
||||
if err != nil {
|
||||
j := mathrand.Intn(i + 1)
|
||||
indices[i], indices[j] = indices[j], indices[i]
|
||||
continue
|
||||
}
|
||||
j := int(jBig.Int64())
|
||||
indices[i], indices[j] = indices[j], indices[i]
|
||||
}
|
||||
return indices
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// BasePlugin provides default implementations for optional Plugin methods.
|
||||
type BasePlugin struct{}
|
||||
|
||||
func (BasePlugin) Init(_ context.Context, _ PluginConfig) error { return nil }
|
||||
|
||||
func (BasePlugin) Start(_ context.Context, _ HostAPI) error { return nil }
|
||||
|
||||
func (BasePlugin) Stop(_ context.Context) error { return nil }
|
||||
|
||||
func (BasePlugin) Health(_ context.Context) error { return nil }
|
||||
|
||||
// BaseTool provides a Validate default that checks required parameters.
|
||||
type BaseTool struct {
|
||||
Def ToolDefinition
|
||||
Required []string
|
||||
}
|
||||
|
||||
func (b BaseTool) Definition() ToolDefinition { return b.Def }
|
||||
|
||||
func (b BaseTool) Complexity() ToolComplexity { return ComplexitySimple }
|
||||
|
||||
func (b BaseTool) Validate(args map[string]interface{}) error {
|
||||
for _, key := range b.Required {
|
||||
if _, ok := args[key]; !ok {
|
||||
return fmt.Errorf("missing required parameter: %s", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b BaseTool) Execute(_ context.Context, _ map[string]interface{}) (*ToolResult, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package sdk
|
||||
|
||||
// PluginPermissions defines what a plugin is allowed to do.
|
||||
type PluginPermissions struct {
|
||||
NetworkAllowed bool `json:"networkAllowed"`
|
||||
AllowedHosts []string `json:"allowedHosts,omitempty"`
|
||||
IoTRead bool `json:"iotRead"`
|
||||
IoTWrite bool `json:"iotWrite"`
|
||||
MemoryRead bool `json:"memoryRead"`
|
||||
MemoryWrite bool `json:"memoryWrite"`
|
||||
FileRead bool `json:"fileRead"`
|
||||
FileWrite bool `json:"fileWrite"`
|
||||
AllowedPaths []string `json:"allowedPaths,omitempty"`
|
||||
ExecAllowed bool `json:"execAllowed"`
|
||||
MaxCPUPercent float64 `json:"maxCPUPercent"`
|
||||
MaxMemoryMB int `json:"maxMemoryMB"`
|
||||
}
|
||||
|
||||
// DefaultPermissions returns a safe default permission set.
|
||||
func DefaultPermissions() PluginPermissions {
|
||||
return PluginPermissions{
|
||||
NetworkAllowed: false,
|
||||
AllowedHosts: []string{},
|
||||
IoTRead: false,
|
||||
IoTWrite: false,
|
||||
MemoryRead: false,
|
||||
MemoryWrite: false,
|
||||
FileRead: false,
|
||||
FileWrite: false,
|
||||
AllowedPaths: []string{},
|
||||
ExecAllowed: false,
|
||||
MaxCPUPercent: 10.0,
|
||||
MaxMemoryMB: 128,
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Plugin is the main interface every plugin must implement.
|
||||
type Plugin interface {
|
||||
Metadata() PluginMetadata
|
||||
Init(ctx context.Context, config PluginConfig) error
|
||||
Start(ctx context.Context, host HostAPI) error
|
||||
Stop(ctx context.Context) error
|
||||
Health(ctx context.Context) error
|
||||
Tools() []Tool
|
||||
}
|
||||
|
||||
// Tool is the interface every tool must implement.
|
||||
type Tool interface {
|
||||
Definition() ToolDefinition
|
||||
Execute(ctx context.Context, args map[string]interface{}) (*ToolResult, error)
|
||||
Validate(args map[string]interface{}) error
|
||||
Complexity() ToolComplexity
|
||||
}
|
||||
|
||||
// ComplexTool extends Tool for async multi-round execution.
|
||||
type ComplexTool interface {
|
||||
Tool
|
||||
ExecuteAsync(ctx context.Context, args map[string]interface{}) (<-chan ToolProgress, error)
|
||||
Cancel(ctx context.Context, executionID string) error
|
||||
}
|
||||
|
||||
// HostAPI gives plugins access to Cyrene core capabilities.
|
||||
type HostAPI interface {
|
||||
CallLLM(ctx context.Context, messages []LLMMessage) (*LLMResponse, error)
|
||||
SearchMemory(ctx context.Context, userID, query string, limit int) ([]MemoryEntry, error)
|
||||
StoreMemory(ctx context.Context, entry MemoryEntry) error
|
||||
Logger() Logger
|
||||
GetConfig(key string) (string, error)
|
||||
SetConfig(key, value string) error
|
||||
PublishEvent(ctx context.Context, event map[string]interface{}) error
|
||||
HTTPClient() *http.Client
|
||||
}
|
||||
|
||||
// Logger is a minimal logging interface for plugins.
|
||||
type Logger interface {
|
||||
Printf(format string, args ...interface{})
|
||||
Println(args ...interface{})
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import "time"
|
||||
|
||||
// ToolComplexity grades tools into simple (single-call, <2s) and complex (multi-round, async).
|
||||
type ToolComplexity string
|
||||
|
||||
const (
|
||||
ComplexitySimple ToolComplexity = "simple"
|
||||
ComplexityComplex ToolComplexity = "complex"
|
||||
)
|
||||
|
||||
// PluginMetadata describes a plugin's identity and requirements.
|
||||
type PluginMetadata struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
MinCyreneVersion string `json:"minCyreneVersion"`
|
||||
Author PluginAuthor `json:"author"`
|
||||
Description string `json:"description"`
|
||||
License string `json:"license"`
|
||||
Keywords []string `json:"keywords,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Dependencies map[string]string `json:"dependencies,omitempty"` // plugin name -> version range
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Repository string `json:"repository,omitempty"`
|
||||
}
|
||||
|
||||
type PluginAuthor struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// PluginConfig holds runtime configuration for a plugin.
|
||||
type PluginConfig map[string]interface{}
|
||||
|
||||
// ToolDefinition describes a tool's interface for LLM function calling.
|
||||
type ToolDefinition struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Complexity ToolComplexity `json:"complexity"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
Returns map[string]interface{} `json:"returns,omitempty"`
|
||||
TimeoutMs int `json:"timeout_ms,omitempty"`
|
||||
MaxRetries int `json:"max_retries,omitempty"`
|
||||
DangerLevel string `json:"danger_level,omitempty"` // low / medium / high
|
||||
}
|
||||
|
||||
// ToolResult is the standard tool execution result.
|
||||
type ToolResult struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
Success bool `json:"success"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
}
|
||||
|
||||
// ToolProgress reports execution progress for complex (async) tools.
|
||||
type ToolProgress struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
Status string `json:"status"` // started / running / completed / failed / cancelled
|
||||
Progress float64 `json:"progress"` // 0.0 - 1.0
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Result *ToolResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// PluginStatus represents the current lifecycle state of a plugin.
|
||||
type PluginStatus string
|
||||
|
||||
const (
|
||||
StatusInstalled PluginStatus = "installed"
|
||||
StatusLoaded PluginStatus = "loaded"
|
||||
StatusRunning PluginStatus = "running"
|
||||
StatusPaused PluginStatus = "paused"
|
||||
StatusError PluginStatus = "error"
|
||||
StatusDisabled PluginStatus = "disabled"
|
||||
)
|
||||
|
||||
// PluginInfo is the runtime view of an installed plugin.
|
||||
type PluginInfo struct {
|
||||
Metadata PluginMetadata `json:"metadata"`
|
||||
Status PluginStatus `json:"status"`
|
||||
Tools []string `json:"tools"` // tool IDs provided by this plugin
|
||||
InstalledAt time.Time `json:"installed_at"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// LLMMessage is a message in an LLM conversation.
|
||||
type LLMMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// LLMResponse is the result of an LLM call.
|
||||
type LLMResponse struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// ToolCall represents a tool call requested by the LLM.
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
}
|
||||
|
||||
// IoTDeviceState is the shared device state across IoT plugins.
|
||||
type IoTDeviceState struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Brightness int `json:"brightness,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Position int `json:"position,omitempty"`
|
||||
Value float64 `json:"value,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Battery int `json:"battery,omitempty"`
|
||||
}
|
||||
|
||||
// MemoryEntry is a memory record.
|
||||
type MemoryEntry struct {
|
||||
UserID string `json:"user_id"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
Meta map[string]interface{} `json:"meta,omitempty"`
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package text
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
type TextPlugin struct{ sdk.BasePlugin }
|
||||
|
||||
func (p *TextPlugin) Metadata() sdk.PluginMetadata {
|
||||
return sdk.PluginMetadata{
|
||||
Name: "text", DisplayName: "Text Processing", Version: "1.0.0",
|
||||
Description: "Text processing: count stats, summarize, regex extract",
|
||||
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *TextPlugin) Tools() []sdk.Tool { return []sdk.Tool{&TextTool{}} }
|
||||
|
||||
type TextTool struct{ sdk.BaseTool }
|
||||
|
||||
func (t *TextTool) Definition() sdk.ToolDefinition {
|
||||
return sdk.ToolDefinition{
|
||||
ID: "text", Name: "text", DisplayName: "Text Processing",
|
||||
Description: "Text processing. Count stats, summarize, translate, regex extract.",
|
||||
Category: "utility", Complexity: sdk.ComplexitySimple,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{"type": "string", "enum": []string{"count", "summarize", "translate", "extract"}},
|
||||
"text": map[string]interface{}{"type": "string"},
|
||||
"target_lang": map[string]interface{}{"type": "string", "enum": []string{"en", "zh", "ja", "ko", "fr", "de"}},
|
||||
"pattern": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"required": []string{"action", "text"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TextTool) Validate(args map[string]interface{}) error {
|
||||
for _, k := range []string{"action", "text"} {
|
||||
if _, ok := args[k]; !ok {
|
||||
return fmt.Errorf("missing required parameter: %s", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TextTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
action, _ := args["action"].(string)
|
||||
txt, _ := args["text"].(string)
|
||||
|
||||
switch action {
|
||||
case "count":
|
||||
charsNoSpace := 0
|
||||
chineseChars := 0
|
||||
for _, r := range txt {
|
||||
if !unicode.IsSpace(r) {
|
||||
charsNoSpace++
|
||||
}
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
chineseChars++
|
||||
}
|
||||
}
|
||||
words := strings.Fields(txt)
|
||||
lines := strings.Split(txt, "\n")
|
||||
paragraphs := regexp.MustCompile(`\n\s*\n`).Split(txt, -1)
|
||||
return &sdk.ToolResult{ToolName: "text", Success: true, Output: fmt.Sprintf(
|
||||
"Characters: %d (no spaces: %d, Chinese: %d)\nBytes: %d\nWords: %d\nLines: %d\nParagraphs: %d",
|
||||
len([]rune(txt)), charsNoSpace, chineseChars, len(txt), len(words), len(lines), len(paragraphs))}, nil
|
||||
|
||||
case "summarize":
|
||||
paragraphs := regexp.MustCompile(`\n\s*\n`).Split(txt, -1)
|
||||
firstPara := ""
|
||||
if len(paragraphs) > 0 {
|
||||
runes := []rune(paragraphs[0])
|
||||
if len(runes) > 300 {
|
||||
runes = runes[:300]
|
||||
}
|
||||
firstPara = string(runes)
|
||||
}
|
||||
sentences := regexp.MustCompile(`[。!?.!?]+`).Split(txt, -1)
|
||||
keywords := []string{"重要", "关键", "因此", "总结", "important", "key", "conclusion", "therefore"}
|
||||
type scored struct {
|
||||
text string
|
||||
score int
|
||||
}
|
||||
var scoredSents []scored
|
||||
for _, s := range sentences {
|
||||
s = strings.TrimSpace(s)
|
||||
if len([]rune(s)) < 10 {
|
||||
continue
|
||||
}
|
||||
score := len([]rune(s))
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(strings.ToLower(s), strings.ToLower(kw)) {
|
||||
score += 20
|
||||
}
|
||||
}
|
||||
scoredSents = append(scoredSents, scored{s, score})
|
||||
}
|
||||
var out strings.Builder
|
||||
out.WriteString(fmt.Sprintf("First paragraph: %s\n\nKey sentences:\n", firstPara))
|
||||
count := 0
|
||||
for i := 0; i < len(scoredSents) && count < 5; i++ {
|
||||
out.WriteString(fmt.Sprintf("- %s\n", scoredSents[i].text))
|
||||
count++
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "text", Success: true, Output: out.String()}, nil
|
||||
|
||||
case "translate":
|
||||
targetLang, _ := args["target_lang"].(string)
|
||||
if targetLang == "" {
|
||||
targetLang = "en"
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "text", Success: true, Output: fmt.Sprintf(
|
||||
"[Translation request] Please translate the following text to %s.\n\nOriginal text:\n%s", targetLang, txt)}, nil
|
||||
|
||||
case "extract":
|
||||
pattern, _ := args["pattern"].(string)
|
||||
var out strings.Builder
|
||||
extracted := false
|
||||
if pattern == "" || pattern == "email" {
|
||||
re := regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`)
|
||||
if matches := re.FindAllString(txt, -1); len(matches) > 0 {
|
||||
out.WriteString("Emails:\n")
|
||||
for _, m := range matches {
|
||||
out.WriteString(fmt.Sprintf("- %s\n", m))
|
||||
}
|
||||
extracted = true
|
||||
}
|
||||
}
|
||||
if pattern == "" || pattern == "phone" {
|
||||
re := regexp.MustCompile(`1[3-9]\d{9}`)
|
||||
if matches := re.FindAllString(txt, -1); len(matches) > 0 {
|
||||
out.WriteString("Phone numbers:\n")
|
||||
for _, m := range matches {
|
||||
out.WriteString(fmt.Sprintf("- %s\n", m))
|
||||
}
|
||||
extracted = true
|
||||
}
|
||||
}
|
||||
if pattern == "" || pattern == "url" {
|
||||
re := regexp.MustCompile(`https?://[^\s<>"{}|\\^` + "`" + `\[\]]+`)
|
||||
if matches := re.FindAllString(txt, -1); len(matches) > 0 {
|
||||
out.WriteString("URLs:\n")
|
||||
for _, m := range matches {
|
||||
out.WriteString(fmt.Sprintf("- %s\n", m))
|
||||
}
|
||||
extracted = true
|
||||
}
|
||||
}
|
||||
if !extracted && pattern != "" && pattern != "email" && pattern != "phone" && pattern != "url" {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "text", Success: false, Error: "Invalid regex: " + err.Error()}, nil
|
||||
}
|
||||
if matches := re.FindAllString(txt, -1); len(matches) > 0 {
|
||||
out.WriteString(fmt.Sprintf("Pattern matches (%s):\n", pattern))
|
||||
for _, m := range matches {
|
||||
out.WriteString(fmt.Sprintf("- %s\n", m))
|
||||
}
|
||||
extracted = true
|
||||
}
|
||||
}
|
||||
if !extracted {
|
||||
return &sdk.ToolResult{ToolName: "text", Success: true, Output: "No matches found"}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "text", Success: true, Output: out.String()}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "text", Success: false, Error: "unknown action: " + action}, nil
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package webfetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
type WebFetchPlugin struct {
|
||||
sdk.BasePlugin
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewWebFetchPlugin() *WebFetchPlugin {
|
||||
return &WebFetchPlugin{client: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
func (p *WebFetchPlugin) Metadata() sdk.PluginMetadata {
|
||||
return sdk.PluginMetadata{
|
||||
Name: "web_fetch", DisplayName: "Web Fetch", Version: "1.0.0",
|
||||
Description: "Fetch and extract text content from URLs",
|
||||
Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WebFetchPlugin) Tools() []sdk.Tool { return []sdk.Tool{&WebFetchTool{client: p.client}} }
|
||||
|
||||
type WebFetchTool struct {
|
||||
sdk.BaseTool
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (t *WebFetchTool) Definition() sdk.ToolDefinition {
|
||||
return sdk.ToolDefinition{
|
||||
ID: "web_fetch", Name: "web_fetch", DisplayName: "Web Fetch",
|
||||
Description: "Fetch content of a specified URL. Returns plain text summary (first 2000 characters). HTTP/HTTPS only.",
|
||||
Category: "network", Complexity: sdk.ComplexitySimple,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{"url": map[string]interface{}{"type": "string"}},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WebFetchTool) Validate(args map[string]interface{}) error {
|
||||
if _, ok := args["url"]; !ok {
|
||||
return fmt.Errorf("missing required parameter: url")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WebFetchTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
urlStr, _ := args["url"].(string)
|
||||
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
|
||||
return &sdk.ToolResult{ToolName: "web_fetch", Success: false, Error: "only http/https URLs allowed"}, nil
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("GET", urlStr, nil)
|
||||
req.Header.Set("User-Agent", "CyreneBot/1.0")
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "web_fetch", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 100*1024))
|
||||
text := stripHTMLFull(string(bodyBytes))
|
||||
text = removeBlankLines(text)
|
||||
runes := []rune(text)
|
||||
if len(runes) > 2000 {
|
||||
text = string(runes[:2000]) + "..."
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "web_fetch", Success: true, Output: fmt.Sprintf(
|
||||
"URL: %s\nStatus: %d\nContent-Type: %s\n\n%s",
|
||||
urlStr, resp.StatusCode, resp.Header.Get("Content-Type"), text)}, nil
|
||||
}
|
||||
|
||||
func stripHTMLFull(s string) string {
|
||||
result := make([]rune, 0, len([]rune(s)))
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func removeBlankLines(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
package websearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
type WebSearchPlugin struct {
|
||||
sdk.BasePlugin
|
||||
client *http.Client
|
||||
searxngURL string
|
||||
}
|
||||
|
||||
func NewWebSearchPlugin() *WebSearchPlugin {
|
||||
return &WebSearchPlugin{client: &http.Client{Timeout: 10 * time.Second}}
|
||||
}
|
||||
|
||||
func NewWebSearchPluginWithURL(searxngURL string) *WebSearchPlugin {
|
||||
return &WebSearchPlugin{
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
searxngURL: strings.TrimRight(searxngURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WebSearchPlugin) Metadata() sdk.PluginMetadata {
|
||||
return sdk.PluginMetadata{
|
||||
Name: "web_search", DisplayName: "Web Search", Version: "1.1.0",
|
||||
Description: "Search the internet via SearXNG (or DuckDuckGo fallback)",
|
||||
Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WebSearchPlugin) Tools() []sdk.Tool {
|
||||
return []sdk.Tool{&WebSearchTool{client: p.client, searxngURL: p.searxngURL}}
|
||||
}
|
||||
|
||||
type WebSearchTool struct {
|
||||
sdk.BaseTool
|
||||
client *http.Client
|
||||
searxngURL string
|
||||
}
|
||||
|
||||
// ---- SearXNG response types ----
|
||||
type searxngResponse struct {
|
||||
Query string `json:"query"`
|
||||
NumberOrResults int `json:"number_of_results"`
|
||||
Results []searxngResult `json:"results"`
|
||||
Answers []string `json:"answers"`
|
||||
Suggestions []string `json:"suggestions"`
|
||||
}
|
||||
|
||||
type searxngResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
Engine string `json:"engine"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// ---- DuckDuckGo response types (fallback) ----
|
||||
type ddgResponse struct {
|
||||
Abstract string `json:"Abstract"`
|
||||
AbstractText string `json:"AbstractText"`
|
||||
Answer string `json:"Answer"`
|
||||
Heading string `json:"Heading"`
|
||||
Results []ddgTopic `json:"Results"`
|
||||
RelatedTopics []ddgTopic `json:"RelatedTopics"`
|
||||
}
|
||||
|
||||
type ddgTopic struct {
|
||||
FirstURL string `json:"FirstURL"`
|
||||
Text string `json:"Text"`
|
||||
}
|
||||
|
||||
func (t *WebSearchTool) Definition() sdk.ToolDefinition {
|
||||
return sdk.ToolDefinition{
|
||||
ID: "web_search", Name: "web_search", DisplayName: "Web Search",
|
||||
Description: "Search the internet. SearXNG backend with DuckDuckGo fallback. Returns up to 5 results.",
|
||||
Category: "network", Complexity: sdk.ComplexitySimple,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{"query": map[string]interface{}{"type": "string"}},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WebSearchTool) Validate(args map[string]interface{}) error {
|
||||
if _, ok := args["query"]; !ok {
|
||||
return fmt.Errorf("missing required parameter: query")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WebSearchTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
|
||||
query, _ := args["query"].(string)
|
||||
if query == "" {
|
||||
return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: "empty query"}, nil
|
||||
}
|
||||
|
||||
if t.searxngURL != "" {
|
||||
return t.searchViaSearXNG(query)
|
||||
}
|
||||
return t.searchViaDuckDuckGo(query)
|
||||
}
|
||||
|
||||
// China-accessible SearXNG engines (baidu, sogou, 360search, bing all work from China)
|
||||
const searxngEngines = "bing,sogou,360search,baidu"
|
||||
|
||||
func (t *WebSearchTool) searchViaSearXNG(query string) (*sdk.ToolResult, error) {
|
||||
apiURL := fmt.Sprintf("%s/search?format=json&engines=%s&q=%s",
|
||||
t.searxngURL, searxngEngines, url.QueryEscape(query))
|
||||
|
||||
resp, err := t.client.Get(apiURL)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "web_search", Success: false,
|
||||
Error: fmt.Sprintf("SearXNG request failed: %v", err)}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &sdk.ToolResult{ToolName: "web_search", Success: false,
|
||||
Error: fmt.Sprintf("SearXNG returned HTTP %d", resp.StatusCode)}, nil
|
||||
}
|
||||
|
||||
var result searxngResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "web_search", Success: false,
|
||||
Error: fmt.Sprintf("SearXNG parse error: %v", err)}, nil
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
out.WriteString(fmt.Sprintf("搜索: %s (共%d条结果)\n\n", query, result.NumberOrResults))
|
||||
|
||||
// 优先显示答案(如 Wikipedia infobox)
|
||||
for _, answer := range result.Answers {
|
||||
out.WriteString(fmt.Sprintf("📌 %s\n\n", answer))
|
||||
}
|
||||
|
||||
// 搜索结果(最多5条,按score排序)
|
||||
count := 0
|
||||
for _, r := range result.Results {
|
||||
if count >= 5 {
|
||||
break
|
||||
}
|
||||
if r.Title == "" || r.URL == "" {
|
||||
continue
|
||||
}
|
||||
content := cleanSnippet(r.Content)
|
||||
out.WriteString(fmt.Sprintf("%d. **%s**\n %s\n %s\n\n", count+1, r.Title, r.URL, content))
|
||||
count++
|
||||
}
|
||||
|
||||
if out.Len() == 0 {
|
||||
return &sdk.ToolResult{ToolName: "web_search", Success: true,
|
||||
Output: fmt.Sprintf("未找到与「%s」相关的结果。", query)}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: out.String()}, nil
|
||||
}
|
||||
|
||||
func (t *WebSearchTool) searchViaDuckDuckGo(query string) (*sdk.ToolResult, error) {
|
||||
apiURL := fmt.Sprintf("https://api.duckduckgo.com/?q=%s&format=json&no_html=1", url.QueryEscape(query))
|
||||
resp, err := t.client.Get(apiURL)
|
||||
if err != nil {
|
||||
return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result ddgResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
if result.Answer != "" {
|
||||
out.WriteString(fmt.Sprintf("Answer: %s\n\n", result.Answer))
|
||||
}
|
||||
if result.AbstractText != "" {
|
||||
text := result.AbstractText
|
||||
if len([]rune(text)) > 500 {
|
||||
text = string([]rune(text)[:500]) + "..."
|
||||
}
|
||||
out.WriteString(fmt.Sprintf("Abstract: %s\n\n", stripHTML(text)))
|
||||
}
|
||||
topics := result.Results
|
||||
if len(topics) == 0 {
|
||||
topics = result.RelatedTopics
|
||||
}
|
||||
count := 0
|
||||
for _, topic := range topics {
|
||||
if count >= 5 {
|
||||
break
|
||||
}
|
||||
if topic.Text == "" {
|
||||
continue
|
||||
}
|
||||
out.WriteString(fmt.Sprintf("%d. %s (%s)\n", count+1, stripHTML(topic.Text), topic.FirstURL))
|
||||
count++
|
||||
}
|
||||
if out.Len() == 0 {
|
||||
return &sdk.ToolResult{ToolName: "web_search", Success: true,
|
||||
Output: "No results found for: " + query}, nil
|
||||
}
|
||||
return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: out.String()}, nil
|
||||
}
|
||||
|
||||
func cleanSnippet(s string) string {
|
||||
runes := []rune(strings.TrimSpace(s))
|
||||
if len(runes) > 200 {
|
||||
return string(runes[:200]) + "..."
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func stripHTML(s string) string {
|
||||
result := make([]rune, 0, len([]rune(s)))
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(result))
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/manager"
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
type hostAPI struct {
|
||||
registry *manager.ToolRegistry
|
||||
}
|
||||
|
||||
func newHostAPI(registry *manager.ToolRegistry) *hostAPI {
|
||||
return &hostAPI{registry: registry}
|
||||
}
|
||||
|
||||
func (h *hostAPI) CallLLM(_ context.Context, _ []sdk.LLMMessage) (*sdk.LLMResponse, error) {
|
||||
return nil, fmt.Errorf("LLM call not available in plugin host")
|
||||
}
|
||||
|
||||
func (h *hostAPI) SearchMemory(_ context.Context, _, _ string, _ int) ([]sdk.MemoryEntry, error) {
|
||||
return nil, fmt.Errorf("memory search not available in plugin host")
|
||||
}
|
||||
|
||||
func (h *hostAPI) StoreMemory(_ context.Context, _ sdk.MemoryEntry) error {
|
||||
return fmt.Errorf("memory store not available in plugin host")
|
||||
}
|
||||
|
||||
func (h *hostAPI) Logger() sdk.Logger {
|
||||
return log.Default()
|
||||
}
|
||||
|
||||
func (h *hostAPI) GetConfig(key string) (string, error) {
|
||||
return "", fmt.Errorf("config key not found: %s", key)
|
||||
}
|
||||
|
||||
func (h *hostAPI) SetConfig(_, _ string) error { return nil }
|
||||
|
||||
func (h *hostAPI) PublishEvent(_ context.Context, _ map[string]interface{}) error { return nil }
|
||||
|
||||
func (h *hostAPI) HTTPClient() *http.Client {
|
||||
return http.DefaultClient
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
iotquery "git.yeij.top/AskaEth/Cyrene/pkg/plugins/iot_query"
|
||||
)
|
||||
|
||||
type iotClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newIoTClient(baseURL string) *iotClient {
|
||||
return &iotClient{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *iotClient) GetAllDevices(ctx context.Context) ([]sdk.IoTDeviceState, error) {
|
||||
url := c.baseURL + "/api/v1/devices"
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Devices []sdk.IoTDeviceState `json:"devices"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Devices, nil
|
||||
}
|
||||
|
||||
func (c *iotClient) GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/devices/%s", c.baseURL, deviceID)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var dev sdk.IoTDeviceState
|
||||
if err := json.NewDecoder(resp.Body).Decode(&dev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dev, nil
|
||||
}
|
||||
|
||||
// iotControllerAdapter adapts IoTClient to iotcontrol.IoTController.
|
||||
type iotControllerAdapter struct {
|
||||
query iotquery.IoTClient
|
||||
client *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func newIoTControllerAdapter(query iotquery.IoTClient, baseURL string) *iotControllerAdapter {
|
||||
return &iotControllerAdapter{
|
||||
query: query,
|
||||
client: &http.Client{Timeout: 5 * time.Second},
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *iotControllerAdapter) GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) {
|
||||
return a.query.GetDevice(ctx, deviceID)
|
||||
}
|
||||
|
||||
func (a *iotControllerAdapter) SetDeviceProperty(ctx context.Context, deviceID, property string, value interface{}) error {
|
||||
url := fmt.Sprintf("%s/api/v1/devices/%s/property", a.baseURL, deviceID)
|
||||
body, _ := json.Marshal(map[string]interface{}{"property": property, "value": value})
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
msg, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("set property failed: HTTP %d - %s", resp.StatusCode, string(msg))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *iotControllerAdapter) ToggleDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/devices/%s/toggle", a.baseURL, deviceID)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, nil)
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var dev sdk.IoTDeviceState
|
||||
if err := json.NewDecoder(resp.Body).Decode(&dev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dev, nil
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/calculator"
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/crypto"
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/datetime"
|
||||
fileplugin "git.yeij.top/AskaEth/Cyrene/pkg/plugins/file"
|
||||
httpplugin "git.yeij.top/AskaEth/Cyrene/pkg/plugins/http"
|
||||
iotcontrol "git.yeij.top/AskaEth/Cyrene/pkg/plugins/iot_control"
|
||||
iotquery "git.yeij.top/AskaEth/Cyrene/pkg/plugins/iot_query"
|
||||
jsonplugin "git.yeij.top/AskaEth/Cyrene/pkg/plugins/json"
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/manager"
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/markdown"
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/random"
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/sdk"
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/text"
|
||||
webfetch "git.yeij.top/AskaEth/Cyrene/pkg/plugins/web_fetch"
|
||||
websearch "git.yeij.top/AskaEth/Cyrene/pkg/plugins/web_search"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/plugin-manager/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene/plugin-manager/internal/handler"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
var iotAPI iotquery.IoTClient
|
||||
if cfg.IoTSvcURL != "" {
|
||||
iotAPI = newIoTClient(cfg.IoTSvcURL)
|
||||
}
|
||||
|
||||
registry := manager.NewToolRegistry()
|
||||
host := newHostAPI(registry)
|
||||
mgr := manager.NewPluginManager(registry, host)
|
||||
|
||||
builtins := []sdk.Plugin{
|
||||
&calculator.CalculatorPlugin{},
|
||||
&datetime.DatetimePlugin{},
|
||||
&text.TextPlugin{},
|
||||
&crypto.CryptoPlugin{},
|
||||
&random.RandomPlugin{},
|
||||
&markdown.MarkdownPlugin{},
|
||||
&jsonplugin.JSONPlugin{},
|
||||
fileplugin.NewFilePlugin(cfg.DataDir),
|
||||
httpplugin.NewHTTPPlugin(),
|
||||
websearch.NewWebSearchPlugin(),
|
||||
webfetch.NewWebFetchPlugin(),
|
||||
iotquery.NewIoTQueryPlugin(iotAPI),
|
||||
}
|
||||
for _, p := range builtins {
|
||||
if err := mgr.Install(p); err != nil {
|
||||
println("WARN: install plugin failed:", err.Error())
|
||||
}
|
||||
}
|
||||
if iotAPI != nil {
|
||||
ctrlPlugin := iotcontrol.NewIoTControlPlugin(newIoTControllerAdapter(iotAPI, cfg.IoTSvcURL))
|
||||
if err := mgr.Install(ctrlPlugin); err != nil {
|
||||
println("WARN: install plugin failed:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
errs := mgr.EnableAll(ctx)
|
||||
for _, e := range errs {
|
||||
println("WARN: enable plugin failed:", e.Error())
|
||||
}
|
||||
println("Plugin Manager: all built-in plugins enabled")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
ph := handler.NewPluginHandler(mgr)
|
||||
ph.RegisterRoutes(mux)
|
||||
|
||||
println("Plugin Manager listening on port", cfg.Port)
|
||||
srv := &http.Server{Addr: ":" + cfg.Port, Handler: mux}
|
||||
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
println("FATAL:", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
println("Shutting down Plugin Manager...")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
mgr.Shutdown(shutdownCtx)
|
||||
srv.Shutdown(shutdownCtx)
|
||||
println("Plugin Manager stopped")
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.yeij.top/AskaEth/Cyrene/plugin-manager
|
||||
|
||||
go 1.26.2
|
||||
@@ -1,32 +0,0 @@
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
Env string
|
||||
DataDir string
|
||||
IoTSvcURL string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
cfg := &Config{
|
||||
Port: "8094",
|
||||
Env: "development",
|
||||
DataDir: "./data",
|
||||
IoTSvcURL: "http://localhost:8093",
|
||||
}
|
||||
if v := os.Getenv("PORT"); v != "" {
|
||||
cfg.Port = v
|
||||
}
|
||||
if v := os.Getenv("ENV"); v != "" {
|
||||
cfg.Env = v
|
||||
}
|
||||
if v := os.Getenv("DATA_DIR"); v != "" {
|
||||
cfg.DataDir = v
|
||||
}
|
||||
if v := os.Getenv("IOT_SERVICE_URL"); v != "" {
|
||||
cfg.IoTSvcURL = v
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/plugins/manager"
|
||||
)
|
||||
|
||||
// PluginHandler exposes the Plugin Manager REST API via net/http.
|
||||
type PluginHandler struct {
|
||||
mgr *manager.PluginManager
|
||||
}
|
||||
|
||||
func NewPluginHandler(mgr *manager.PluginManager) *PluginHandler {
|
||||
return &PluginHandler{mgr: mgr}
|
||||
}
|
||||
|
||||
func (h *PluginHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/plugins", h.listPlugins)
|
||||
mux.HandleFunc("/api/v1/plugins/", h.pluginRoute)
|
||||
mux.HandleFunc("/api/v1/tools", h.listTools)
|
||||
mux.HandleFunc("/api/v1/tools/", h.toolRoute)
|
||||
mux.HandleFunc("/api/v1/health", h.health)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) health(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "ok", "service": "plugin-manager"})
|
||||
}
|
||||
|
||||
func (h *PluginHandler) listPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
|
||||
return
|
||||
}
|
||||
plugins := h.mgr.List()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"plugins": plugins, "total": len(plugins)})
|
||||
}
|
||||
|
||||
func (h *PluginHandler) pluginRoute(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/plugins/")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
pluginID := parts[0]
|
||||
|
||||
if pluginID == "" {
|
||||
h.listPlugins(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
h.getPlugin(w, pluginID)
|
||||
case "DELETE":
|
||||
h.uninstallPlugin(w, r, pluginID)
|
||||
default:
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
action := parts[1]
|
||||
switch action {
|
||||
case "enable":
|
||||
if r.Method != "POST" {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
|
||||
return
|
||||
}
|
||||
h.enablePlugin(w, r, pluginID)
|
||||
case "disable":
|
||||
if r.Method != "POST" {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
|
||||
return
|
||||
}
|
||||
h.disablePlugin(w, r, pluginID)
|
||||
case "reload":
|
||||
if r.Method != "POST" {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
|
||||
return
|
||||
}
|
||||
h.reloadPlugin(w, r, pluginID)
|
||||
case "tools":
|
||||
if r.Method != "GET" {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
|
||||
return
|
||||
}
|
||||
h.pluginTools(w, pluginID)
|
||||
default:
|
||||
writeJSON(w, http.StatusNotFound, errResp("not found"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PluginHandler) getPlugin(w http.ResponseWriter, id string) {
|
||||
info, ok := h.mgr.Get(id)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, errResp("plugin not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) enablePlugin(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := h.mgr.Enable(r.Context(), id); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "enabled"})
|
||||
}
|
||||
|
||||
func (h *PluginHandler) disablePlugin(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := h.mgr.Disable(r.Context(), id); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"})
|
||||
}
|
||||
|
||||
func (h *PluginHandler) reloadPlugin(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := h.mgr.Reload(r.Context(), id); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "reloaded"})
|
||||
}
|
||||
|
||||
func (h *PluginHandler) uninstallPlugin(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := h.mgr.Uninstall(r.Context(), id); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "uninstalled"})
|
||||
}
|
||||
|
||||
func (h *PluginHandler) pluginTools(w http.ResponseWriter, id string) {
|
||||
info, ok := h.mgr.Get(id)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, errResp("plugin not found"))
|
||||
return
|
||||
}
|
||||
registry := h.mgr.Registry()
|
||||
tools := make([]interface{}, 0)
|
||||
for _, toolID := range info.Tools {
|
||||
if t, ok := registry.Get(toolID); ok {
|
||||
tools = append(tools, t.Definition())
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"tools": tools, "total": len(tools)})
|
||||
}
|
||||
|
||||
func (h *PluginHandler) listTools(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
|
||||
return
|
||||
}
|
||||
defs := h.mgr.Registry().Definitions()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"tools": defs, "total": len(defs)})
|
||||
}
|
||||
|
||||
func (h *PluginHandler) toolRoute(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/tools/")
|
||||
toolID := path
|
||||
|
||||
if strings.HasSuffix(path, "/execute") {
|
||||
toolID = strings.TrimSuffix(path, "/execute")
|
||||
if r.Method != "POST" {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
|
||||
return
|
||||
}
|
||||
h.executeTool(w, r, toolID)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != "GET" {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
|
||||
return
|
||||
}
|
||||
tool, ok := h.mgr.Registry().Get(toolID)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, errResp("tool not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tool.Definition())
|
||||
}
|
||||
|
||||
func (h *PluginHandler) executeTool(w http.ResponseWriter, r *http.Request, toolID string) {
|
||||
var body struct {
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
result, err := h.mgr.Registry().Execute(r.Context(), toolID, body.Arguments)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func errResp(msg string) map[string]string {
|
||||
return map[string]string{"error": msg}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /voice-service ./cmd/m
|
||||
# ========== 运行阶段 ==========
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
RUN apk add --no-cache ca-certificates tzdata ffmpeg && \
|
||||
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||
echo "Asia/Shanghai" > /etc/timezone
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mode := flag.String("mode", "offline", "测试模式: offline (非实时) 或 realtime (实时)")
|
||||
file := flag.String("file", "", "音频文件路径 (WAV/MP3/OGG/FLAC)")
|
||||
server := flag.String("server", "http://localhost:8093", "Voice-Service 地址")
|
||||
lang := flag.String("lang", "zh", "语言代码")
|
||||
flag.Parse()
|
||||
|
||||
if *file == "" {
|
||||
fmt.Println("用法: test_asr -mode=offline -file=audio.wav [-server=http://localhost:8093]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch *mode {
|
||||
case "offline":
|
||||
testOffline(*server, *file, *lang)
|
||||
case "realtime":
|
||||
testRealtime(*server, *file, *lang)
|
||||
default:
|
||||
fmt.Printf("未知模式: %s (支持: offline, realtime)\n", *mode)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// testOffline 测试非实时语音识别 (HTTP multipart 上传)。
|
||||
func testOffline(server, filePath, lang string) {
|
||||
fmt.Printf("=== 非实时 ASR 测试 ===\n")
|
||||
fmt.Printf("服务器: %s\n", server)
|
||||
fmt.Printf("文件: %s\n", filePath)
|
||||
fmt.Printf("语言: %s\n\n", lang)
|
||||
|
||||
// 读取音频文件
|
||||
audioData, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
fmt.Printf("读取文件失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("音频大小: %d bytes\n", len(audioData))
|
||||
|
||||
// 创建 multipart 请求
|
||||
req, err := http.NewRequest("POST", server+"/api/v1/transcribe", nil)
|
||||
if err != nil {
|
||||
fmt.Printf("创建请求失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 使用 multipart form
|
||||
body, contentType, err := createMultipartBody(audioData, filePath, lang)
|
||||
if err != nil {
|
||||
fmt.Printf("创建 multipart body 失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
req.Body = body
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
start := time.Now()
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
fmt.Printf("状态码: %d\n", resp.StatusCode)
|
||||
fmt.Printf("耗时: %v\n", elapsed)
|
||||
fmt.Printf("响应:\n%s\n", string(respBody))
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
fmt.Println("\n✅ 非实时语音识别成功!")
|
||||
} else {
|
||||
fmt.Println("\n❌ 非实时语音识别失败")
|
||||
}
|
||||
}
|
||||
|
||||
// testRealtime 测试实时语音识别 (WebSocket 流式)。
|
||||
func testRealtime(server, filePath, lang string) {
|
||||
fmt.Printf("=== 实时 ASR 测试 ===\n")
|
||||
fmt.Printf("服务器: %s\n", server)
|
||||
fmt.Printf("文件: %s\n", filePath)
|
||||
fmt.Printf("语言: %s\n\n", lang)
|
||||
|
||||
// 读取音频文件
|
||||
audioData, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
fmt.Printf("读取文件失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("音频大小: %d bytes\n", len(audioData))
|
||||
|
||||
// 推断格式
|
||||
format := inferFormat(filePath)
|
||||
|
||||
// 连接 WebSocket
|
||||
wsURL := fmt.Sprintf("ws://%s/api/v1/stt/stream?format=%s&language=%s",
|
||||
server[7:], format, lang) // 去掉 http:// 前缀
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("WebSocket 连接失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
fmt.Printf("WebSocket 已连接: %s\n", wsURL)
|
||||
|
||||
// 设置 interrupt 处理
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, os.Interrupt)
|
||||
|
||||
// goroutine: 读取识别结果
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
fmt.Printf("读取结果错误: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("◀ 结果: %s\n", string(msg))
|
||||
}
|
||||
}()
|
||||
|
||||
// 模拟实时流式发送音频(每 100ms 发送 3200 bytes)
|
||||
chunkSize := 3200
|
||||
totalSent := 0
|
||||
start := time.Now()
|
||||
var elapsed time.Duration
|
||||
|
||||
cancelled := false
|
||||
for i := 0; i < len(audioData); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(audioData) {
|
||||
end = len(audioData)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-interrupt:
|
||||
fmt.Println("\n用户中断")
|
||||
cancelled = true
|
||||
default:
|
||||
}
|
||||
if cancelled {
|
||||
break
|
||||
}
|
||||
|
||||
if err := conn.WriteMessage(websocket.BinaryMessage, audioData[i:end]); err != nil {
|
||||
fmt.Printf("发送音频失败: %v\n", err)
|
||||
break
|
||||
}
|
||||
totalSent += end - i
|
||||
fmt.Printf("▶ 发送 %d/%d bytes (%.1f%%)\n", totalSent, len(audioData),
|
||||
float64(totalSent)/float64(len(audioData))*100)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
elapsed = time.Since(start)
|
||||
|
||||
// 发送停止消息
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"action":"stop"}`))
|
||||
|
||||
// 等待最后的结果
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
fmt.Printf("\n总耗时: %v, 总发送: %d bytes\n", elapsed, totalSent)
|
||||
fmt.Println("✅ 实时语音识别测试完成")
|
||||
}
|
||||
|
||||
func inferFormat(filename string) string {
|
||||
ext := ""
|
||||
for i := len(filename) - 1; i >= 0; i-- {
|
||||
if filename[i] == '.' {
|
||||
ext = filename[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
switch ext {
|
||||
case "wav", "wave":
|
||||
return "wav"
|
||||
case "mp3", "mpeg":
|
||||
return "mp3"
|
||||
case "ogg", "opus":
|
||||
return "ogg"
|
||||
case "flac":
|
||||
return "flac"
|
||||
case "m4a", "mp4", "aac":
|
||||
return "m4a"
|
||||
default:
|
||||
return "pcm"
|
||||
}
|
||||
}
|
||||
|
||||
func createMultipartBody(audioData []byte, filename, lang string) (io.ReadCloser, string, error) {
|
||||
boundary := "cyrene-asr-test-boundary"
|
||||
header := fmt.Sprintf("--%s\r\nContent-Disposition: form-data; name=\"audio\"; filename=\"%s\"\r\nContent-Type: application/octet-stream\r\n\r\n",
|
||||
boundary, filename)
|
||||
footer := fmt.Sprintf("\r\n--%s\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n%s\r\n--%s--\r\n",
|
||||
boundary, lang, boundary)
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
pw.Write([]byte(header))
|
||||
pw.Write(audioData)
|
||||
pw.Write([]byte(footer))
|
||||
pw.Close()
|
||||
}()
|
||||
|
||||
return pr, "multipart/form-data; boundary=" + boundary, nil
|
||||
}
|
||||
@@ -2,9 +2,15 @@ module git.yeij.top/AskaEth/Cyrene/voice-service
|
||||
|
||||
go 1.26.2
|
||||
|
||||
replace git.yeij.top/AskaEth/Cyrene/pkg/logger => ../pkg/logger
|
||||
replace (
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/logger => ../pkg/logger
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/audio => ../pkg/audio
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/dashscope => ../pkg/dashscope
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/audio v0.0.0
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/dashscope v0.0.0
|
||||
git.yeij.top/AskaEth/Cyrene/pkg/logger v0.0.0
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/dashscope"
|
||||
)
|
||||
|
||||
// DashScopeRESTSTT 使用 DashScope REST API 进行离线语音识别。
|
||||
// 离线模型 (qwen3-asr-flash-2026-02-10) 通过 HTTP REST 端点进行转录,
|
||||
// 无需 session 协商和 Server VAD,延迟更低,适合非实时场景。
|
||||
type DashScopeRESTSTT struct {
|
||||
model string
|
||||
client *dashscope.RESTClient
|
||||
}
|
||||
|
||||
// NewDashScopeRESTSTT 创建 DashScope REST STT 客户端。
|
||||
func NewDashScopeRESTSTT(apiKey, model string) *DashScopeRESTSTT {
|
||||
if model == "" {
|
||||
model = "qwen3-asr-flash-2026-02-10"
|
||||
}
|
||||
return &DashScopeRESTSTT{
|
||||
model: model,
|
||||
client: dashscope.NewRESTClient(apiKey),
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable 检查 API Key 是否已配置。
|
||||
func (d *DashScopeRESTSTT) IsAvailable() bool {
|
||||
return d.client.IsAvailable()
|
||||
}
|
||||
|
||||
// Model 返回模型名。
|
||||
func (d *DashScopeRESTSTT) Model() string { return d.model }
|
||||
|
||||
// Transcribe 使用 DashScope REST API 进行离线语音识别。
|
||||
func (d *DashScopeRESTSTT) Transcribe(ctx context.Context, audioData []byte, format, language string) (string, error) {
|
||||
if !d.IsAvailable() {
|
||||
return "", fmt.Errorf("DashScope REST ASR API key 未配置")
|
||||
}
|
||||
return d.client.Transcribe(ctx, d.model, audioData, format, 16000, language)
|
||||
}
|
||||
|
||||
// GetStatus 返回 REST STT 客户端的运行状态。
|
||||
func (d *DashScopeRESTSTT) GetStatus() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"available": d.IsAvailable(),
|
||||
"model": d.model,
|
||||
"protocol": "rest",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDashScopeRESTSTT_Transcribe_Success(t *testing.T) {
|
||||
client := NewDashScopeRESTSTT("test-key", "qwen3-asr-flash-2026-02-10")
|
||||
|
||||
if !client.IsAvailable() {
|
||||
t.Error("client should be available when apiKey is set")
|
||||
}
|
||||
if client.Model() != "qwen3-asr-flash-2026-02-10" {
|
||||
t.Errorf("unexpected model: %s", client.Model())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashScopeRESTSTT_NotAvailable(t *testing.T) {
|
||||
client := NewDashScopeRESTSTT("", "")
|
||||
if client.IsAvailable() {
|
||||
t.Error("client should not be available without apiKey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashScopeRESTSTT_DefaultModel(t *testing.T) {
|
||||
client := NewDashScopeRESTSTT("key", "")
|
||||
if client.Model() != "qwen3-asr-flash-2026-02-10" {
|
||||
t.Errorf("expected default model, got %s", client.Model())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashScopeRESTSTT_Transcribe_NoAPIKey(t *testing.T) {
|
||||
client := NewDashScopeRESTSTT("", "")
|
||||
_, err := client.Transcribe(context.Background(), []byte{}, "wav", "zh")
|
||||
if err == nil {
|
||||
t.Error("expected error when API key is not configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashScopeRESTSTT_GetStatus(t *testing.T) {
|
||||
client := NewDashScopeRESTSTT("key", "test-model")
|
||||
status := client.GetStatus()
|
||||
if status["available"] != true {
|
||||
t.Error("status should be available")
|
||||
}
|
||||
if status["model"] != "test-model" {
|
||||
t.Errorf("unexpected model in status: %v", status["model"])
|
||||
}
|
||||
if status["protocol"] != "rest" {
|
||||
t.Errorf("unexpected protocol: %v", status["protocol"])
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,10 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/audio"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
@@ -145,7 +143,7 @@ func (d *DashScopeSTT) Transcribe(ctx context.Context, audioData []byte, format
|
||||
}
|
||||
|
||||
// 4. 规范化音频格式并发送
|
||||
pcmData, err := convertToPCM16(audioData, format)
|
||||
pcmData, err := audio.ConvertToPCM16(audioData, format)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("音频格式转换失败: %w", err)
|
||||
}
|
||||
@@ -447,74 +445,3 @@ func (d *DashScopeSTT) GetStatus() map[string]interface{} {
|
||||
"provider": "dashscope",
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeSTTFormat 规范化音频格式字符串。
|
||||
func normalizeSTTFormat(format string) string {
|
||||
switch strings.ToLower(format) {
|
||||
case "pcm", "wav", "mp3", "mpeg", "ogg", "opus", "flac", "m4a", "mp4", "aac", "webm":
|
||||
return strings.ToLower(format)
|
||||
default:
|
||||
return format
|
||||
}
|
||||
}
|
||||
|
||||
// convertToPCM16 将音频数据转换为 16-bit PCM 16000Hz mono。
|
||||
func convertToPCM16(data []byte, format string) ([]byte, error) {
|
||||
normFormat := normalizeSTTFormat(format)
|
||||
switch normFormat {
|
||||
case "pcm":
|
||||
return data, nil
|
||||
case "wav":
|
||||
if len(data) > 44 {
|
||||
return data[44:], nil
|
||||
}
|
||||
return data, nil
|
||||
default:
|
||||
return transcodeToPCM(data, normFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// transcodeToPCM 使用 ffmpeg 将音频数据转码为 PCM 16-bit 16000Hz mono。
|
||||
func transcodeToPCM(data []byte, format string) ([]byte, error) {
|
||||
inFile, err := os.CreateTemp(os.TempDir(), "cyrene-asr-in-*."+format)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建输入临时文件失败: %w", err)
|
||||
}
|
||||
inPath := inFile.Name()
|
||||
defer os.Remove(inPath)
|
||||
if _, err := inFile.Write(data); err != nil {
|
||||
inFile.Close()
|
||||
return nil, fmt.Errorf("写入输入临时文件失败: %w", err)
|
||||
}
|
||||
inFile.Close()
|
||||
|
||||
outFile, err := os.CreateTemp(os.TempDir(), "cyrene-asr-out-*.pcm")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建输出临时文件失败: %w", err)
|
||||
}
|
||||
outPath := outFile.Name()
|
||||
outFile.Close()
|
||||
defer os.Remove(outPath)
|
||||
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-i", inPath,
|
||||
"-ar", "16000",
|
||||
"-ac", "1",
|
||||
"-c:a", "pcm_s16le",
|
||||
"-f", "s16le",
|
||||
outPath,
|
||||
"-y",
|
||||
)
|
||||
cmd.Stderr = nil
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("音频转码失败 (ffmpeg): %w", err)
|
||||
}
|
||||
|
||||
outData, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取转码结果失败: %w", err)
|
||||
}
|
||||
|
||||
return outData, nil
|
||||
}
|
||||
@@ -16,41 +16,59 @@ import (
|
||||
var SupportedLanguages = []string{"zh", "en", "ja", "ko", "auto"}
|
||||
|
||||
// STTService 语音转文字服务。
|
||||
// 优先使用 DashScope API,不可用时回退到本地 Whisper。
|
||||
// 离线转录优先使用 DashScope REST API,失败回退 Whisper。
|
||||
// 流式转录使用 DashScope Realtime WS。
|
||||
type STTService struct {
|
||||
whisperBinary string
|
||||
whisperModel string
|
||||
language string
|
||||
dashscope *DashScopeSTT // 实时 ASR (qwen3-asr-flash-realtime)
|
||||
whisperBinary string
|
||||
whisperModel string
|
||||
language string
|
||||
dashscope *DashScopeSTT // 实时 ASR (qwen3-asr-flash-realtime)
|
||||
dashscopeREST *DashScopeRESTSTT // 离线 ASR (qwen3-asr-flash-2026-02-10)
|
||||
}
|
||||
|
||||
// NewSTTService 创建 STT 服务。
|
||||
func NewSTTService(cfg *config.Config) *STTService {
|
||||
// 实时模型用于所有 WebSocket ASR 请求(支持 one-shot 和 streaming)
|
||||
// 离线模型 (qwen3-asr-flash-2026-02-10) 是 HTTP REST API,暂未实现
|
||||
model := cfg.DashScopeSTTRealtime
|
||||
if model == "" {
|
||||
model = cfg.DashScopeModel
|
||||
realtimeModel := cfg.DashScopeSTTRealtime
|
||||
if realtimeModel == "" {
|
||||
realtimeModel = "qwen3-asr-flash-realtime"
|
||||
}
|
||||
offlineModel := cfg.DashScopeModel
|
||||
if offlineModel == "" {
|
||||
offlineModel = "qwen3-asr-flash-2026-02-10"
|
||||
}
|
||||
return &STTService{
|
||||
whisperBinary: cfg.WhisperBinary,
|
||||
whisperModel: cfg.WhisperModel,
|
||||
language: cfg.WhisperLanguage,
|
||||
dashscope: NewDashScopeSTT(cfg.DashScopeAPIKey, model),
|
||||
dashscope: NewDashScopeSTT(cfg.DashScopeAPIKey, realtimeModel),
|
||||
dashscopeREST: NewDashScopeRESTSTT(cfg.DashScopeAPIKey, offlineModel),
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable 检查是否有任一 STT 引擎可用。
|
||||
func (s *STTService) IsAvailable() bool {
|
||||
if s.dashscope.IsAvailable() {
|
||||
if s.dashscopeREST.IsAvailable() || s.dashscope.IsAvailable() {
|
||||
return true
|
||||
}
|
||||
_, err := os.Stat(s.whisperBinary)
|
||||
return err == nil
|
||||
return s.whisperAvailable()
|
||||
}
|
||||
|
||||
// whisperAvailable 检查本地 Whisper 引擎是否真正可用。
|
||||
func (s *STTService) whisperAvailable() bool {
|
||||
if _, err := os.Stat(s.whisperBinary); err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(s.whisperModel); err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Transcribe 将音频数据转录为文字。
|
||||
// 优先使用 DashScope,不可用时回退到本地 Whisper。
|
||||
// 优先使用 DashScope REST 离线模型,失败回退到本地 Whisper。
|
||||
func (s *STTService) Transcribe(audioData []byte, format string, language string) (string, error) {
|
||||
if language == "" {
|
||||
language = s.language
|
||||
@@ -59,16 +77,15 @@ func (s *STTService) Transcribe(audioData []byte, format string, language string
|
||||
return "", fmt.Errorf("不支持的语言: %s,支持的语言: %s", language, strings.Join(SupportedLanguages, ", "))
|
||||
}
|
||||
|
||||
// 优先 DashScope
|
||||
if s.dashscope.IsAvailable() {
|
||||
// 优先 DashScope REST 离线模型(低延迟,无需 session 协商)
|
||||
if s.dashscopeREST.IsAvailable() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
text, err := s.dashscope.Transcribe(ctx, audioData, format, language)
|
||||
text, err := s.dashscopeREST.Transcribe(ctx, audioData, format, language)
|
||||
if err == nil {
|
||||
return text, nil
|
||||
}
|
||||
// DashScope 失败,返回具体错误而不是回退到 Whisper
|
||||
return "", fmt.Errorf("语音识别失败: %w", err)
|
||||
fmt.Printf("[stt] DashScope REST 失败,回退 Whisper: %v\n", err)
|
||||
}
|
||||
|
||||
// 回退到本地 Whisper
|
||||
@@ -152,15 +169,21 @@ func (s *STTService) GetStatus() map[string]interface{} {
|
||||
if _, err := os.Stat(s.whisperModel); err == nil {
|
||||
modelExists = true
|
||||
}
|
||||
ffmpegAvailable := false
|
||||
if _, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
ffmpegAvailable = true
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"available": s.IsAvailable(),
|
||||
"primary": "dashscope",
|
||||
"dashscope": s.dashscope.GetStatus(),
|
||||
"available": s.IsAvailable(),
|
||||
"primary": "dashscope_rest",
|
||||
"dashscope_rest": s.dashscopeREST.GetStatus(),
|
||||
"dashscope_ws": s.dashscope.GetStatus(),
|
||||
"whisper": map[string]interface{}{
|
||||
"available": binaryAvailable && modelExists,
|
||||
"available": s.whisperAvailable(),
|
||||
"binary_available": binaryAvailable,
|
||||
"model_loaded": modelExists,
|
||||
"ffmpeg_available": ffmpegAvailable,
|
||||
"model_name": filepath.Base(s.whisperModel),
|
||||
},
|
||||
"default_language": s.language,
|
||||
|
||||
Reference in new issue
Block a user