Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c807d76a0 |
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# cyrene-plugins
|
||||||
|
# Community plugin SDK and plugin-manager service for Cyrene AI
|
||||||
|
|
||||||
|
# Binaries
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
/plugin-manager
|
||||||
|
/plugin-manager.exe
|
||||||
|
|
||||||
|
# Test binary
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of go test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Data
|
||||||
|
data/
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright 2026 AskaEth
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
@@ -1,3 +1,83 @@
|
|||||||
# Cyrene-Plugins
|
# Cyrene Plugins
|
||||||
|
|
||||||
昔涟主项目的拓展插件管理器。
|
Cyrene AI 的插件系统:社区可扩展工具 SDK + Plugin Manager 服务。
|
||||||
|
|
||||||
|
## 结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── sdk/ # 插件 SDK (Plugin, Tool, HostAPI 接口 + 类型定义)
|
||||||
|
├── manager/ # ToolRegistry (调用日志环形缓冲区) + PluginManager (生命周期管理)
|
||||||
|
├── calculator/ # 内置插件 (13 个)
|
||||||
|
├── crypto/ # - 加密/哈希
|
||||||
|
├── datetime/ # - 日期时间
|
||||||
|
├── file/ # - 文件操作
|
||||||
|
├── http/ # - HTTP 请求
|
||||||
|
├── iot_control/ # - IoT 设备控制
|
||||||
|
├── iot_query/ # - IoT 设备查询
|
||||||
|
├── json/ # - JSON 处理
|
||||||
|
├── markdown/ # - Markdown 渲染
|
||||||
|
├── random/ # - 随机数生成
|
||||||
|
├── text/ # - 文本处理
|
||||||
|
├── web_fetch/ # - 网页抓取
|
||||||
|
├── web_search/ # - 网页搜索
|
||||||
|
├── cmd/
|
||||||
|
│ └── plugin-manager/ # Plugin Manager 服务 (REST API, 端口 8094)
|
||||||
|
└── go.mod
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@git.yeij.top:AskaEth/Cyrene-Plugins.git
|
||||||
|
cd Cyrene-Plugins
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行 Plugin Manager
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/plugin-manager/
|
||||||
|
# 监听 :8094,提供 REST API 管理插件
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 端点
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/v1/plugins` | 列出所有插件 |
|
||||||
|
| GET | `/api/v1/plugins/:id` | 插件详情 |
|
||||||
|
| POST | `/api/v1/plugins/:id/enable` | 启用插件 |
|
||||||
|
| POST | `/api/v1/plugins/:id/disable` | 禁用插件 |
|
||||||
|
| POST | `/api/v1/plugins/:id/reload` | 热重载 |
|
||||||
|
| DELETE | `/api/v1/plugins/:id` | 卸载 |
|
||||||
|
| GET | `/api/v1/plugins/:id/tools` | 列出插件工具 |
|
||||||
|
| GET | `/api/v1/tools` | 列出所有工具 |
|
||||||
|
| POST | `/api/v1/tools/:id/execute` | 执行工具 |
|
||||||
|
| GET | `/api/v1/health` | 健康检查 |
|
||||||
|
|
||||||
|
## 开发插件
|
||||||
|
|
||||||
|
实现 `sdk.Plugin` 接口(`Metadata`, `Init`, `Start`, `Stop`, `Health`, `Tools`),然后在 `cmd/plugin-manager/main.go` 中注册。
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
|
||||||
|
|
||||||
|
type MyPlugin struct{}
|
||||||
|
|
||||||
|
func (p *MyPlugin) Metadata() sdk.PluginMetadata {
|
||||||
|
return sdk.PluginMetadata{Name: "my-plugin", Version: "1.0.0"}
|
||||||
|
}
|
||||||
|
// ... 实现其余接口
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与 Cyrene 主项目的集成
|
||||||
|
|
||||||
|
主项目 `ai-core` 通过 `go.mod` replace 指令引用本仓库进行本地开发:
|
||||||
|
|
||||||
|
```
|
||||||
|
replace git.yeij.top/AskaEth/Cyrene-Plugins => ../../../cyrene-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package calculator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-Plugins/manager"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
|
||||||
|
iotquery "git.yeij.top/AskaEth/Cyrene-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
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-Plugins/calculator"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-Plugins/crypto"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-Plugins/datetime"
|
||||||
|
fileplugin "git.yeij.top/AskaEth/Cyrene-Plugins/file"
|
||||||
|
httpplugin "git.yeij.top/AskaEth/Cyrene-Plugins/http"
|
||||||
|
iotcontrol "git.yeij.top/AskaEth/Cyrene-Plugins/iot_control"
|
||||||
|
iotquery "git.yeij.top/AskaEth/Cyrene-Plugins/iot_query"
|
||||||
|
jsonplugin "git.yeij.top/AskaEth/Cyrene-Plugins/json"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-Plugins/manager"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-Plugins/markdown"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-Plugins/random"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-Plugins/text"
|
||||||
|
webfetch "git.yeij.top/AskaEth/Cyrene-Plugins/web_fetch"
|
||||||
|
websearch "git.yeij.top/AskaEth/Cyrene-Plugins/web_search"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-Plugins/cmd/plugin-manager/internal/config"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-Plugins/cmd/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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package datetime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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
|
||||||
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package iotcontrol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package iotquery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
package jsonplugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package markdown
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package random
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
mathrand "math/rand"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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{})
|
||||||
|
}
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
package text
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package webfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package websearch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene-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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user