From a899d777c3b6ba1c516051440dfc3f850acdb783 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 29 Jul 2026 14:48:14 +0800 Subject: [PATCH] sdk: embed non-toolchain SDK in third_party, add NoMemory/Cleaner support - Embed sdk/, example/, meta/, go.mod from homeagent-sdk (no .git) - Core .gitignore excludes SDK toolchain: bin/, tools/, package/ - RegisterInputChannel + ChannelDef(NoMemory, Cleaner) in SDK - IOManager input channel registry with GetInputChannelDef - eventloop: apply channel Cleaner/NoMemory to interrupt text - context engine: channelDefLookup applied in textForVector - document store: ChannelCleaner param for archive functions - All callers/adapters updated with ChannelDef{} default --- .gitignore | 8 + go.mod | 2 + internal/agent/core/agent.go | 3 + internal/agent/core/agent_tools_test.go | 1 + internal/agent/core/context.go | 70 +++- internal/agent/core/eventloop.go | 19 +- internal/agent/io/channel.go | 55 ++- internal/agent/io/channel_test.go | 1 + internal/memory/cut.go | 183 +++++++-- internal/memory/cut_test.go | 8 +- internal/memory/document/document.go | 42 ++- internal/memory/document/document_test.go | 14 +- internal/memory/static_embedder.go | 26 +- internal/memory/vector/store.go | 18 +- internal/memory/vector/store_test.go | 2 +- internal/plugin/cabi/loader.go | 2 +- internal/plugin/registry.go | 14 +- internal/plugins/clawhubadapter/registry.go | 2 +- internal/plugins/cli/plugin.go | 2 +- internal/plugins/webui/plugin.go | 2 +- internal/sdk/plugin.go | 6 + third_party/homeagent-sdk/go.mod | 3 + third_party/homeagent-sdk/meta/meta.go | 87 +++++ third_party/homeagent-sdk/sdk/knowledge.go | 14 + third_party/homeagent-sdk/sdk/llm.go | 8 + third_party/homeagent-sdk/sdk/memory.go | 87 +++++ third_party/homeagent-sdk/sdk/plugin.go | 369 +++++++++++++++++++ third_party/homeagent-sdk/sdk/plugin_test.go | 253 +++++++++++++ third_party/homeagent-sdk/sdk/settings.go | 58 +++ 29 files changed, 1265 insertions(+), 94 deletions(-) create mode 100644 third_party/homeagent-sdk/go.mod create mode 100644 third_party/homeagent-sdk/meta/meta.go create mode 100644 third_party/homeagent-sdk/sdk/knowledge.go create mode 100644 third_party/homeagent-sdk/sdk/llm.go create mode 100644 third_party/homeagent-sdk/sdk/memory.go create mode 100644 third_party/homeagent-sdk/sdk/plugin.go create mode 100644 third_party/homeagent-sdk/sdk/plugin_test.go create mode 100644 third_party/homeagent-sdk/sdk/settings.go diff --git a/.gitignore b/.gitignore index 423fffa..b2a1d1b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,11 @@ login.json cmd/gui/node_modules/ cmd/gui/dist/ +.gopath/ + +# SDK 工具链 — 核心仓不追踪 +third_party/homeagent-sdk/bin/ +third_party/homeagent-sdk/tools/ +third_party/homeagent-sdk/package/ +third_party/homeagent-sdk/.gitignore +third_party/homeagent-sdk/README* diff --git a/go.mod b/go.mod index 7d00523..f33a9af 100644 --- a/go.mod +++ b/go.mod @@ -13,3 +13,5 @@ require github.com/yanyiwu/gojieba v1.4.7 require github.com/yalue/onnxruntime_go v1.13.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.7.2 + +replace gitcode.com/JianFeeeee/homeagent-sdk => ./third_party/homeagent-sdk diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index ed59f27..ec669cc 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -178,6 +178,9 @@ func New(cfg AgentConfig) *Agent { if cfg.StageHost != nil { rc.SetToolDefLookup(cfg.StageHost.ToolDef) } + if cfg.IO != nil { + rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef) + } return &Agent{ id: cfg.ID, diff --git a/internal/agent/core/agent_tools_test.go b/internal/agent/core/agent_tools_test.go index e84d28d..80d5742 100644 --- a/internal/agent/core/agent_tools_test.go +++ b/internal/agent/core/agent_tools_test.go @@ -22,6 +22,7 @@ func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.too func (d *mockOutputDevice) Start() error { return nil } func (d *mockOutputDevice) Stop() error { return nil } func (d *mockOutputDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps } +func (d *mockOutputDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} } func (d *mockOutputDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) { if d.toolFn != nil { return d.toolFn(tool, args) diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index a6bbf87..848b8e2 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -34,13 +34,14 @@ type ContextEvent struct { const contextFlushInterval = 5 * time.Second type RelevanceContext struct { - mu sync.Mutex - events []*ContextEvent - embedder *memory.StaticEmbedder - savePath string - saveTimer *time.Timer - dirty bool - toolDefLookup func(name string) *sdk.ToolDef + mu sync.Mutex + events []*ContextEvent + embedder *memory.StaticEmbedder + savePath string + saveTimer *time.Timer + dirty bool + toolDefLookup func(name string) *sdk.ToolDef + channelDefLookup func(name string) (sdk.ChannelDef, bool) } func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext { @@ -60,6 +61,12 @@ func (c *RelevanceContext) SetToolDefLookup(fn func(name string) *sdk.ToolDef) { c.toolDefLookup = fn } +func (c *RelevanceContext) SetChannelDefLookup(fn func(name string) (sdk.ChannelDef, bool)) { + c.mu.Lock() + defer c.mu.Unlock() + c.channelDefLookup = fn +} + func (c *RelevanceContext) load() { data, err := os.ReadFile(c.savePath) if err != nil { @@ -75,7 +82,7 @@ func (c *RelevanceContext) load() { c.events = events } -func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef) string { +func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef, channelDefLookup func(name string) (sdk.ChannelDef, bool)) string { var text string switch { case evt.Source == "agent" && evt.Response != "": @@ -86,6 +93,16 @@ func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolD text = evt.Input } + // 计算层:应用输入通道的 Cleaner(不改原文,仅在计算层清洗) + if channelDefLookup != nil { + if chDef, ok := channelDefLookup(evt.Source); ok && chDef.Cleaner != nil { + text = chDef.Cleaner(text) + } + if chDef, ok := channelDefLookup(evt.Source); ok && chDef.NoMemory { + return "" + } + } + // 计算层:附加工具输出,NoMemory 跳过,其余经 Cleaner 过滤 if toolDefLookup != nil { noMemory := make(map[string]bool) @@ -130,8 +147,41 @@ func (c *RelevanceContext) toolOutputClean(name, output string) string { return output } +// inputChannelClean 根据输入通道的 Def 清洗输入文本,用于计算层。 +func (c *RelevanceContext) inputChannelClean(source, input string) string { + if c.channelDefLookup == nil { + return input + } + chDef, ok := c.channelDefLookup(source) + if !ok { + return input + } + if chDef.Cleaner != nil { + return chDef.Cleaner(input) + } + return input +} + +// channelCleanerForDoc 返回 ChannelCleaner,使 document 包在存档时能按来源查找 Cleaner。 +func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner { + if c.channelDefLookup == nil { + return nil + } + return func(source string) func(string) string { + chDef, ok := c.channelDefLookup(source) + if !ok { + return nil + } + return chDef.Cleaner + } +} + func (c *RelevanceContext) computeVector(evt *ContextEvent) vector.Vector { - return c.embedder.Vectorize(textForVector(evt, c.toolDefLookup)) + text := textForVector(evt, c.toolDefLookup, c.channelDefLookup) + if text == "" { + return nil + } + return c.embedder.Vectorize(text) } func (c *RelevanceContext) Save() error { @@ -274,7 +324,7 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume ToolResults: convertToolResults(s.event.ToolResults), } } - doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean) + doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc()) if err == nil && doc != nil { archived = len(entries) } diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index 80076bb..a8da4d5 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -281,6 +281,11 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { if v, ok := evt.Payload["no_memory"].(bool); ok { noMemory = v } + if !noMemory && a.io != nil { + if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.NoMemory { + noMemory = true + } + } stageCtx := a.stageCtxFromInput(input, evt.Source, "") stageCtx.Extra["input_source"] = evt.Source @@ -297,12 +302,20 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { input = stageCtx.RawMessage + // 计算层用的清洗文本(不改原文):通道 Cleaner 提取语义内容后用于向量化/提关键词 + cleanInput := input + if a.io != nil { + if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil { + cleanInput = chDef.Cleaner(input) + } + } + a.publishEvent(events.EventRawInput, map[string]interface{}{ "content": input, "source": evt.Source, }) - archived := a.context.Prune(input, a.maxContextSize-1, a.docStore) + archived := a.context.Prune(cleanInput, a.maxContextSize-1, a.docStore) if archived > 0 { log.Printf("[agent] pruned %d low-relevance events to document memory", archived) } @@ -328,7 +341,7 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { a.context.Append(ContextEvent{ Timestamp: time.Now(), Source: "agent", - Input: input, + Input: cleanInput, Response: response, ToolsUsed: toolsUsed, ToolResults: toolResults, @@ -337,7 +350,7 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { a.emitResponse(evt, response) if !stageCtx.NoMemory { - a.emitMemoryCandidate(evt.Source, input, response, toolResults, toolsUsed) + a.emitMemoryCandidate(evt.Source, cleanInput, response, toolResults, toolsUsed) } } diff --git a/internal/agent/io/channel.go b/internal/agent/io/channel.go index 7a66ecb..779388b 100644 --- a/internal/agent/io/channel.go +++ b/internal/agent/io/channel.go @@ -6,8 +6,13 @@ import ( "runtime/debug" "sync" "time" + + pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" ) +// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。 +type ChannelDef = pubsdk.ChannelDef + type DeviceType int const ( @@ -60,6 +65,7 @@ type Device interface { Start() error Stop() error OutputCapabilities() OutputCapability + ChannelDef() ChannelDef } type ToolHandler func(args map[string]interface{}) (interface{}, error) @@ -90,20 +96,22 @@ type OutputEvent struct { } type IOManager struct { - mu sync.RWMutex - devices map[string]Device - inputCh chan *InputEvent - interruptCh chan *InputEvent - outputCh chan *OutputEvent - nextReqID int64 + mu sync.RWMutex + devices map[string]Device + inputCh chan *InputEvent + interruptCh chan *InputEvent + outputCh chan *OutputEvent + nextReqID int64 + inputChannels map[string]ChannelDef } func NewIOManager() *IOManager { return &IOManager{ - devices: make(map[string]Device), - inputCh: make(chan *InputEvent, 256), - interruptCh: make(chan *InputEvent, 64), - outputCh: make(chan *OutputEvent, 256), + devices: make(map[string]Device), + inputCh: make(chan *InputEvent, 256), + interruptCh: make(chan *InputEvent, 64), + outputCh: make(chan *OutputEvent, 256), + inputChannels: make(map[string]ChannelDef), } } @@ -333,6 +341,28 @@ func (m *IOManager) EmitTextTo(target, outputChannel, text string) { func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh } func (m *IOManager) OutputChan() <-chan *OutputEvent { return m.outputCh } +// RegisterInputChannel 注册输入通道的记忆行为 +func (m *IOManager) RegisterInputChannel(name string, def ChannelDef) { + m.mu.Lock() + defer m.mu.Unlock() + m.inputChannels[name] = def +} + +// UnregisterInputChannel 注销输入通道 +func (m *IOManager) UnregisterInputChannel(name string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.inputChannels, name) +} + +// GetInputChannelDef 查询输入通道的记忆行为定义 +func (m *IOManager) GetInputChannelDef(name string) (ChannelDef, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + def, ok := m.inputChannels[name] + return def, ok +} + func (m *IOManager) GetAllTools() []ToolDef { m.mu.RLock() defer m.mu.RUnlock() @@ -434,6 +464,7 @@ func (d *Microphone) OutputCapabilities() OutputCapability { return 0 } // 纯 func (d *Microphone) Description() string { return fmt.Sprintf("麦克风 (%s, %dHz)", d.name, d.sampleRate) } func (d *Microphone) Start() error { return nil } func (d *Microphone) Stop() error { return nil } +func (d *Microphone) ChannelDef() ChannelDef { return ChannelDef{} } func (d *Microphone) Tools() []ToolDef { return []ToolDef{{ @@ -468,6 +499,7 @@ func (d *Speaker) OutputCapabilities() OutputCapability { return CapText | CapAu func (d *Speaker) Description() string { return fmt.Sprintf("扬声器 (%s)", d.name) } func (d *Speaker) Start() error { return nil } func (d *Speaker) Stop() error { return nil } +func (d *Speaker) ChannelDef() ChannelDef { return ChannelDef{} } func (d *Speaker) Tools() []ToolDef { return []ToolDef{{ @@ -504,6 +536,7 @@ func (d *Camera) OutputCapabilities() OutputCapability { return CapImage } // func (d *Camera) Description() string { return fmt.Sprintf("摄像头 (%s)", d.name) } func (d *Camera) Start() error { return nil } func (d *Camera) Stop() error { return nil } +func (d *Camera) ChannelDef() ChannelDef { return ChannelDef{} } func (d *Camera) Tools() []ToolDef { return []ToolDef{ @@ -551,6 +584,7 @@ func (d *RobotArm) OutputCapabilities() OutputCapability { return CapStructured func (d *RobotArm) Description() string { return fmt.Sprintf("机械臂 (%s)", d.name) } func (d *RobotArm) Start() error { return nil } func (d *RobotArm) Stop() error { return nil } +func (d *RobotArm) ChannelDef() ChannelDef { return ChannelDef{} } func (d *RobotArm) Tools() []ToolDef { return []ToolDef{ @@ -602,6 +636,7 @@ func (d *GPIODevice) OutputCapabilities() OutputCapability { return CapStructure func (d *GPIODevice) Description() string { return "GPIO 通用引脚" } func (d *GPIODevice) Start() error { return nil } func (d *GPIODevice) Stop() error { return nil } +func (d *GPIODevice) ChannelDef() ChannelDef { return ChannelDef{} } func (d *GPIODevice) Tools() []ToolDef { return []ToolDef{ diff --git a/internal/agent/io/channel_test.go b/internal/agent/io/channel_test.go index cd438b1..80d96c2 100644 --- a/internal/agent/io/channel_test.go +++ b/internal/agent/io/channel_test.go @@ -43,6 +43,7 @@ func (d *mockDevice) Stop() error { return nil } func (d *mockDevice) OutputCapabilities() OutputCapability { return d.caps } +func (d *mockDevice) ChannelDef() ChannelDef { return ChannelDef{} } func (d *mockDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) { if d.executeFn != nil { return d.executeFn(tool, args) diff --git a/internal/memory/cut.go b/internal/memory/cut.go index ccda389..2c6db9d 100644 --- a/internal/memory/cut.go +++ b/internal/memory/cut.go @@ -6,10 +6,39 @@ import ( "path/filepath" "strings" "sync" + "unicode/utf8" "github.com/yanyiwu/gojieba" ) +// contentPOS 有实义的词性标签:只保留名词/动词/形容词/专名等 +var contentPOS = map[string]bool{ + "n": true, // 普通名词 + "nr": true, // 人名 + "ns": true, // 地名 + "nt": true, // 机构名 + "nw": true, // 作品名/URL + "nz": true, // 其他专名 + "v": true, // 动词 + "vd": true, // 副动词 + "vn": true, // 名动词 + "a": true, // 形容词 + "ad": true, // 副形词 + "an": true, // 名形词 + "i": true, // 成语 + "l": true, // 习用语 + "j": true, // 简称 + "s": true, // 处所词 + "f": true, // 方位词 + "b": true, // 区别词 + "z": true, // 状态词 + "t": true, // 时间词 + "eng": true, // 英文 + "x": true, // 非语素字 + "zg": true, // 其他 + "un": true, // 未知词性——保守保留 +} + var ( jiebaOnce sync.Once jiebaInst *gojieba.Jieba @@ -69,32 +98,109 @@ func jiebaDictDir() string { } var stopWords = map[string]bool{ - "的": true, "了": true, "是": true, "在": true, "有": true, - "和": true, "就": true, "不": true, "人": true, "都": true, - "一": true, "一个": true, "上": true, "也": true, "很": true, - "到": true, "说": true, "要": true, "去": true, "你": true, - "会": true, "着": true, "没有": true, "看": true, "好": true, - "自己": true, "这": true, "他": true, "她": true, "它": true, - "我": true, "我们": true, "你们": true, "他们": true, - "吗": true, "吧": true, "啊": true, - "嗯": true, "哦": true, "哈": true, "呀": true, "嘛": true, - "然后": true, "因为": true, "所以": true, "如果": true, "但是": true, - "可能": true, "还是": true, "已经": true, - "就是": true, "不是": true, "是的": true, - "非常": true, "比较": true, "应该": true, "需要": true, - "能够": true, "目前": true, "现在": true, "今天": true, "昨天": true, - "明天": true, "知道": true, "觉得": true, "认为": true, - "能": true, "没": true, "对": true, + // ── 代词 ── + "我": true, "你": true, "他": true, "她": true, "它": true, + "我们": true, "你们": true, "他们": true, "她们": true, "它们": true, + "自己": true, "别人": true, "大家": true, + "这": true, "那": true, "哪": true, + "这个": true, "那个": true, "哪个": true, + "这里": true, "那里": true, "哪里": true, + "这些": true, "那些": true, "哪些": true, + "什么": true, "怎么": true, "怎样": true, "怎么样": true, + "谁": true, "为什么": true, + // ── 量词 ── + "一个": true, "每个": true, + "种": true, "个": true, "些": true, "点": true, + // ── 介词 ── + "在": true, "到": true, "对": true, "从": true, "把": true, + "被": true, "让": true, "给": true, "向": true, "往": true, + "跟": true, "和": true, "与": true, "同": true, "比": true, + "关于": true, "对于": true, "按照": true, "根据": true, "通过": true, + "因为": true, "由于": true, "为了": true, "为": true, + // ── 连词 ── + "或": true, "或者": true, + "但是": true, "但": true, "可是": true, "不过": true, + "然而": true, "虽然": true, "尽管": true, "即使": true, + "如果": true, "假如": true, "只要": true, "除非": true, + "而且": true, "并且": true, "同时": true, "此外": true, + "所以": true, "因此": true, "于是": true, + "然后": true, "接着": true, "从而": true, + "不是": true, "就是": true, "而是": true, + // ── 助词 ── + "的": true, "地": true, "得": true, + "了": true, "着": true, "过": true, + "所": true, + "吗": true, "吧": true, "啊": true, "呢": true, "啦": true, + "嗯": true, "哦": true, "哈": true, "呀": true, "嘛": true, "哟": true, + "噢": true, "喔": true, "呵": true, "嘿": true, "喂": true, + "呐": true, "呗": true, "咚": true, "噗": true, + // ── 副词 ── + "不": true, "没": true, "没有": true, "别": true, "不要": true, + "很": true, "太": true, "非常": true, "十分": true, "特别": true, + "比较": true, "相当": true, "更": true, "最": true, + "都": true, "也": true, "还": true, "又": true, "再": true, + "就": true, "才": true, "便": true, + "已经": true, "曾经": true, "刚": true, "刚刚": true, + "正在": true, "正": true, + "将要": true, "将": true, + "一直": true, "总是": true, "从来": true, + "经常": true, "通常": true, "往往": true, + "可能": true, "也许": true, "大概": true, "大约": true, + "一定": true, "肯定": true, "必须": true, + "当然": true, "其实": true, "确实": true, + "一起": true, "一块": true, + "仍然": true, "依然": true, "还是": true, + "互相": true, "分别": true, + "是否": true, "能否": true, "可否": true, + "越": true, "挺": true, + // ── 判断动词 ── + "是": true, "有": true, + "是的": true, "有的": true, + // ── 能愿动词 ── + "能": true, "能够": true, "可以": true, "会": true, + "要": true, "需要": true, "想要": true, + "应该": true, "应当": true, "该": true, + "愿意": true, "肯": true, "敢": true, + // ── 常用弱义动词 ── + "说": true, "看": true, "做": true, "来": true, "去": true, + "用": true, "想": true, "知道": true, + "觉得": true, "认为": true, "发现": true, "看到": true, + "表示": true, "告诉": true, "问": true, "回答": true, + "成为": true, "作为": true, "进行": true, "使用": true, + // ── 时间词(泛化) ── + "今天": true, "昨天": true, "明天": true, "前天": true, "后天": true, + "早上": true, "上午": true, "中午": true, "下午": true, "晚上": true, + "现在": true, "目前": true, "之前": true, "之后": true, "以后": true, + "最近": true, "刚才": true, "以前": true, "原来": true, "将来": true, + // ── 人称/泛指 ── + "人": true, "人们": true, "东西": true, "事情": true, + "问题": true, "情况": true, "时候": true, "地方": true, + "方式": true, "方法": true, "原因": true, "结果": true, + // ── 高频语气组合 ── + "好的": true, "好吧": true, "好": true, + "好了": true, "对了": true, "行了": true, + "没事": true, "没关系": true, "算了": true, + "看起来": true, "看上去": true, "听起来": true, + "可以说": true, "也就是说": true, "的话": true, + "来着": true, "罢了": true, "便是": true, + // ── 对话/消息类弱义词 ── + "消息": true, "回复": true, "发送": true, + "查看": true, "显示": true, "输出": true, "输入": true, + "文件": true, "内容": true, "信息": true, + "来自": true, "收到": true, + // ── 英文停用词 ── "the": true, "a": true, "an": true, "is": true, "are": true, "was": true, "were": true, "be": true, "been": true, "being": true, "have": true, "has": true, "had": true, "do": true, "does": true, "did": true, "will": true, "would": true, "could": true, "should": true, - "may": true, "might": true, "can": true, "shall": true, "this": true, - "that": true, "these": true, "those": true, "it": true, "its": true, + "may": true, "might": true, "can": true, "shall": true, + "this": true, "that": true, "these": true, "those": true, + "it": true, "its": true, "and": true, "or": true, "but": true, "in": true, "on": true, "at": true, "to": true, "for": true, "of": true, "with": true, "what": true, "how": true, "why": true, "which": true, "where": true, "when": true, "who": true, "whom": true, + "please": true, "yes": true, "no": true, "not": true, } // TokenizeWords 使用 jieba 精确模式分词,返回去重后的所有词 token(不过滤停用词) @@ -118,30 +224,45 @@ func TokenizeWords(text string) []string { return result } -func ExtractKeywords(text string) []string { +// TokenizeContentWords 使用 jieba 精确模式分词 + 词性过滤,只保留名词/动词/形容词/专名等有实义的词 +// 过滤停用词 + 短词(<2 字符),返回去重结果。适用于向量化、关键词提取等需要语义质量的任务。 +func TokenizeContentWords(text string) []string { text = CleanText(text) x := GetJieba() if x == nil { return nil } - words := x.Cut(text, false) - var keywords []string + tagged := x.Tag(text) + var result []string seen := make(map[string]bool) - for _, w := range words { - if stopWords[w] || seen[w] { + for _, t := range tagged { + idx := strings.LastIndex(t, "/") + if idx < 0 { continue } - r := []rune(w) - if len(r) < 2 { + word := t[:idx] + tag := t[idx+1:] + word = strings.TrimSpace(word) + if word == "" || seen[word] { continue } - seen[w] = true - keywords = append(keywords, w) + if stopWords[word] { + continue + } + if utf8.RuneCountInString(word) < 2 { + continue + } + if !contentPOS[tag] { + continue + } + seen[word] = true + result = append(result, word) } - if len(keywords) > 5 { - keywords = keywords[:5] - } - return keywords + return result +} + +func ExtractKeywords(text string) []string { + return TokenizeContentWords(text) } // CutExact 精确模式分词:返回去停用词后的所有有义项(不限数量),用于 doc→graph 蒸馏 diff --git a/internal/memory/cut_test.go b/internal/memory/cut_test.go index 49136c7..9eb33db 100644 --- a/internal/memory/cut_test.go +++ b/internal/memory/cut_test.go @@ -23,8 +23,8 @@ func TestCutExact(t *testing.T) { { name: "chinese_sentence", text: "今天天气怎么样", - min: 2, - not: nil, + min: 1, // "怎么样" 已加入停用词表 + not: []string{"怎么样"}, }, { name: "stop_words_removed", @@ -47,8 +47,8 @@ func TestCutExact(t *testing.T) { { name: "qq_conversation", text: "今天天气怎么样 → 今天天气很好", - min: 2, - not: nil, + min: 1, // "怎么样" "很" 已加入停用词表 + not: []string{"怎么样", "很好", "很"}, }, { name: "all_stop_words", diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index ce14428..f91d158 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -15,6 +15,10 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" ) +// ChannelCleaner 按事件来源查找输入通道的 Cleaner 函数。 +// 返回 nil 表示不使用额外清洗。 +type ChannelCleaner func(source string) func(string) string + // Doc — 记忆文档:由上下文提炼而来 type Doc struct { ID string `json:"id"` @@ -128,7 +132,7 @@ func (s *Store) Insert(doc *Doc) error { // toolCleanFn 可选,func(name, output string) string,按工具名对输出进行过滤/清洗: // - 返回 "" → 跳过该工具输出(NoMemory) // - 返回清洗后文本 → 用于计算层(Cleaner),原文不受影响 -func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer, cleanFn func(string) string, toolCleanFn func(name, output string) string) (*Doc, error) { +func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*Doc, error) { if len(entries) == 0 { return nil, nil } @@ -151,9 +155,9 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.V content := strings.Join(parts, "\n") contentHash := simpleHash(content) - summary := summarizeEntries(entries, cleanFn, toolCleanFn) - tags := extractTags(entries, cleanFn, toolCleanFn) - entities := extractEntities(entries, cleanFn, toolCleanFn) + summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner) + tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner) + entities := extractEntities(entries, cleanFn, toolCleanFn, channelCleaner) s.mu.Lock() @@ -441,7 +445,7 @@ type ContextEntry struct { ToolResults []ToolResultItem } -func summarizeEntries(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string) string { +func summarizeEntries(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) string { if len(entries) == 0 { return "" } @@ -449,7 +453,13 @@ func summarizeEntries(entries []ContextEntry, cleanText func(string) string, too var topics []string for _, e := range entries { sources[e.Source]++ - words := memory.ExtractKeywords(cleanText(e.Content)) + content := e.Content + if channelCleaner != nil { + if c := channelCleaner(e.Source); c != nil { + content = c(content) + } + } + words := memory.ExtractKeywords(cleanText(content)) topics = append(topics, words...) for _, tr := range e.ToolResults { out := tr.Output @@ -490,10 +500,16 @@ func summarizeEntries(entries []ContextEntry, cleanText func(string) string, too return summary } -func extractTags(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string) []string { +func extractTags(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) []string { tagSet := make(map[string]bool) for _, e := range entries { - for _, kw := range memory.ExtractKeywords(cleanText(e.Content)) { + content := e.Content + if channelCleaner != nil { + if c := channelCleaner(e.Source); c != nil { + content = c(content) + } + } + for _, kw := range memory.ExtractKeywords(cleanText(content)) { tagSet[kw] = true } for _, tr := range e.ToolResults { @@ -520,11 +536,17 @@ func extractTags(entries []ContextEntry, cleanText func(string) string, toolClea return tags } -func extractEntities(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string) []string { +func extractEntities(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) []string { var entities []string seen := make(map[string]bool) for _, e := range entries { - for _, kw := range memory.ExtractKeywords(cleanText(e.Content)) { + content := e.Content + if channelCleaner != nil { + if c := channelCleaner(e.Source); c != nil { + content = c(content) + } + } + for _, kw := range memory.ExtractKeywords(cleanText(content)) { if len(kw) >= 2 && !seen[kw] { seen[kw] = true entities = append(entities, kw) diff --git a/internal/memory/document/document_test.go b/internal/memory/document/document_test.go index b1523ed..e990c13 100644 --- a/internal/memory/document/document_test.go +++ b/internal/memory/document/document_test.go @@ -82,7 +82,7 @@ func TestContextToDoc(t *testing.T) { {Timestamp: time.Now(), Source: "user", Content: "特别是Go语言", Response: "Go很棒"}, } - doc, err := s.ContextToDoc("test", entries, nil, nil, nil) + doc, err := s.ContextToDoc("test", entries, nil, nil, nil, nil) if err != nil { t.Fatal(err) } @@ -178,7 +178,7 @@ func TestSummarizeEntries(t *testing.T) { {Source: "user", Content: "今天天气如何"}, {Source: "user", Content: "明天会下雨吗"}, } - summary := summarizeEntries(entries, func(s string) string { return s }, nil) + summary := summarizeEntries(entries, func(s string) string { return s }, nil, nil) if summary == "" { t.Error("summary should not be empty") } @@ -198,7 +198,7 @@ func TestExtractTags(t *testing.T) { entries := []ContextEntry{ {Content: "我喜欢喝咖啡和编程"}, } - tags := extractTags(entries, func(s string) string { return s }, nil) + tags := extractTags(entries, func(s string) string { return s }, nil, nil) if len(tags) == 0 { t.Error("should extract tags") } @@ -384,7 +384,7 @@ func TestSummarizeEntriesWithToolCleanFn(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - summary := summarizeEntries([]ContextEntry{entry}, func(s string) string { return s }, tc.toolCleanFn) + summary := summarizeEntries([]ContextEntry{entry}, func(s string) string { return s }, tc.toolCleanFn, nil) for _, w := range tc.wantTopics { if !contains(summary, w) { t.Errorf("summary should contain %q, got: %s", w, summary) @@ -410,7 +410,7 @@ func TestExtractTagsWithToolCleanFn(t *testing.T) { } // toolCleanFn 返回 "" → NoMemory,工具输出被跳过 - tagsSkip := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "" }) + tagsSkip := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "" }, nil) for _, tag := range tagsSkip { if tag == "编程" || tag == "咖啡" { t.Errorf("NoMemory tool should not contribute keywords, got tag: %s", tag) @@ -418,7 +418,7 @@ func TestExtractTagsWithToolCleanFn(t *testing.T) { } // toolCleanFn 返回清洗文本 → 用清洗后内容提取关键词 - tagsClean := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "咖啡 编程" }) + tagsClean := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "咖啡 编程" }, nil) found := false for _, tag := range tagsClean { if tag == "编程" { @@ -456,7 +456,7 @@ func TestContextToDocContentPreservesRawToolOutput(t *testing.T) { cleaner := func(name, output string) string { return "天气 温度" } - doc, err := s.ContextToDoc("test", entries, nil, nil, cleaner) + doc, err := s.ContextToDoc("test", entries, nil, nil, cleaner, nil) if err != nil { t.Fatal(err) } diff --git a/internal/memory/static_embedder.go b/internal/memory/static_embedder.go index ccfc18a..631ef1c 100644 --- a/internal/memory/static_embedder.go +++ b/internal/memory/static_embedder.go @@ -271,19 +271,31 @@ func (e *StaticEmbedder) tokenize(text string) []string { if e.jieba == nil { return nil } - words := e.jieba.Cut(text, false) + tagged := e.jieba.Tag(text) var result []string seen := make(map[string]bool) - for _, w := range words { - w = strings.TrimSpace(w) - if w == "" || e.stopWords[w] || seen[w] { + for _, t := range tagged { + idx := strings.LastIndex(t, "/") + if idx < 0 { continue } - if utf8.RuneCountInString(w) < 2 { + word := t[:idx] + tag := t[idx+1:] + word = strings.TrimSpace(word) + if word == "" || seen[word] { continue } - seen[w] = true - result = append(result, w) + if e.stopWords[word] { + continue + } + if utf8.RuneCountInString(word) < 2 { + continue + } + if !contentPOS[tag] { + continue + } + seen[word] = true + result = append(result, word) } return result } diff --git a/internal/memory/vector/store.go b/internal/memory/vector/store.go index 46e10b7..37c94f1 100644 --- a/internal/memory/vector/store.go +++ b/internal/memory/vector/store.go @@ -191,12 +191,18 @@ func (v *TFIDFVectorizer) Vectorize(text string) Vector { vec := make(Vector) for f, count := range tf { tfNorm := count / maxTF - idf := 1.0 - if v.totalDocs > 0 { - df := v.docFreq[f] - if df > 0 { - idf = math.Log(float64(v.totalDocs+1)/df+1) + 1 - } + if v.totalDocs < 3 { + vec[f] = tfNorm + continue + } + df := v.docFreq[f] + if df <= 0 { + continue + } + // 平滑 IDF,高频词趋近 0,低频词趋近 log(N) + idf := math.Log(float64(v.totalDocs+1) / (df + 1)) + if idf < 0.1 { + continue } vec[f] = tfNorm * idf } diff --git a/internal/memory/vector/store_test.go b/internal/memory/vector/store_test.go index 605cc85..28bb0e6 100644 --- a/internal/memory/vector/store_test.go +++ b/internal/memory/vector/store_test.go @@ -149,7 +149,7 @@ func TestStoreInsertAndSearch(t *testing.T) { func TestStoreRemove(t *testing.T) { s := NewStore() v := NewTFIDFVectorizer(NGramTokenizer(1)) - v.Train([]string{"a"}) + v.Train([]string{"hello world", "hello a", "foo bar", "baz qux", "test doc"}) s.Insert("1", "a", v.Vectorize("a"), nil) s.Insert("2", "a", v.Vectorize("a"), nil) diff --git a/internal/plugin/cabi/loader.go b/internal/plugin/cabi/loader.go index e2899ab..c08b943 100644 --- a/internal/plugin/cabi/loader.go +++ b/internal/plugin/cabi/loader.go @@ -320,7 +320,7 @@ func go_core_dispatch(methodID C.int, ctx unsafe.Pointer, s1, s2, s3 *C.char, i1 case 3: // CORE_REGISTER_OUTPUT_CH pid := pluginID chName := a1 - s.RegisterOutputChannel(chName, n1, a2, func(args map[string]interface{}) (interface{}, error) { + s.RegisterOutputChannel(chName, n1, a2, sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) { // Output is async: return immediately, send in background // to avoid nested cgo calls (cgo within cgo can crash) go func() { diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 25a1811..d127972 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -121,6 +121,7 @@ type channelDevice struct { desc string caps agentIO.OutputCapability handler sdk.ToolHandler + chDef agentIO.ChannelDef } func (d *channelDevice) Name() string { return d.name } @@ -133,6 +134,7 @@ func (d *channelDevice) Tools() []agentIO.ToolDef { return nil } func (d *channelDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) { return d.handler(args) } +func (d *channelDevice) ChannelDef() agentIO.ChannelDef { return d.chDef } func (r *Registry) buildSDK(name string) *sdk.PluginSDK { sett := sdk.NewSettings(name, r.cfgReg) @@ -152,7 +154,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { regAPI = func(name string) error { return nil } } - regOutput := func(chName string, caps int, desc string, handler sdk.ToolHandler) error { + regOutput := func(chName string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error { if r.iom == nil { return nil } @@ -161,9 +163,18 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { caps: agentIO.OutputCapability(caps), desc: desc, handler: handler, + chDef: agentIO.ChannelDef(def), }) } + regInput := func(name string, def sdk.ChannelDef) error { + if r.iom == nil { + return nil + } + r.iom.RegisterInputChannel(name, agentIO.ChannelDef(def)) + return nil + } + return sdk.New(name, sdk.SDKConfig{ IOManager: r.iom, EventBus: r.evBus, @@ -177,6 +188,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { RegStage: regStage, RegAPI: regAPI, RegOutput: regOutput, + RegInput: regInput, }) } diff --git a/internal/plugins/clawhubadapter/registry.go b/internal/plugins/clawhubadapter/registry.go index 0ba13cc..ee93654 100644 --- a/internal/plugins/clawhubadapter/registry.go +++ b/internal/plugins/clawhubadapter/registry.go @@ -190,7 +190,7 @@ func (r *ChannelRegistry) Dispatch(data json.RawMessage, pluginName string, sp * caps = 1 } desc := fmt.Sprintf("OC channel %s (from %s)", chName, pn) - s.RegisterOutputChannel(chName, caps, desc, func(args map[string]interface{}) (interface{}, error) { + s.RegisterOutputChannel(chName, caps, desc, sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) { return sp.CallTool(chName, args) }) diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go index 6e389d5..dc5c911 100644 --- a/internal/plugins/cli/plugin.go +++ b/internal/plugins/cli/plugin.go @@ -72,7 +72,7 @@ func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { s.SetAutoRestart(true) - s.RegisterOutputChannel("cli", 1, "CLI 终端", func(args map[string]interface{}) (interface{}, error) { + s.RegisterOutputChannel("cli", 1, "CLI 终端", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) { payload, _ := args["payload"].(string) if payload != "" { fmt.Println(payload) diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go index 3bdca64..bf82728 100644 --- a/internal/plugins/webui/plugin.go +++ b/internal/plugins/webui/plugin.go @@ -157,7 +157,7 @@ func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { s.SetAutoRestart(true) - s.RegisterOutputChannel("webui", 1, "Web 控制台", func(args map[string]interface{}) (interface{}, error) { + s.RegisterOutputChannel("webui", 1, "Web 控制台", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) { payload, _ := args["payload"].(string) if payload != "" { p.evBus.Publish(&events.Event{ diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index 6fd5908..985034b 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -48,6 +48,8 @@ const ( ) type APIRegistrar = pubsdk.APIRegistrar type OutputChannelRegistrar = pubsdk.OutputChannelRegistrar +type InputChannelRegistrar = pubsdk.InputChannelRegistrar +type ChannelDef = pubsdk.ChannelDef type PluginSDK struct { *pubsdk.PluginSDK @@ -92,6 +94,7 @@ type SDKConfig struct { RegStage StageRegistrar RegAPI APIRegistrar RegOutput OutputChannelRegistrar + RegInput InputChannelRegistrar } func New(name string, cfg SDKConfig) *PluginSDK { @@ -99,6 +102,9 @@ func New(name string, cfg SDKConfig) *PluginSDK { if cfg.IOManager != nil { base.SetIOInjector(ioAdapter{iom: cfg.IOManager}) } + if cfg.RegInput != nil { + base.SetInputChannelRegistrar(cfg.RegInput) + } base.SetMemoryAPI(cfg.Memory) base.SetTextMemoryAPI(cfg.TextMemory) base.SetDocMemoryAPI(cfg.DocMemory) diff --git a/third_party/homeagent-sdk/go.mod b/third_party/homeagent-sdk/go.mod new file mode 100644 index 0000000..22645b1 --- /dev/null +++ b/third_party/homeagent-sdk/go.mod @@ -0,0 +1,3 @@ +module gitcode.com/JianFeeeee/homeagent-sdk + +go 1.21.0 diff --git a/third_party/homeagent-sdk/meta/meta.go b/third_party/homeagent-sdk/meta/meta.go new file mode 100644 index 0000000..9543e22 --- /dev/null +++ b/third_party/homeagent-sdk/meta/meta.go @@ -0,0 +1,87 @@ +// Package meta 收集 HomeAgent SDK 的全部元数据。 +// 版本号应与核心 meta.Version 保持一致。 +// ABI 版本与 Dispatch Method ID 应与核心仓 internal/meta/meta.go 保持一致。 +package meta + +var ( + // Version 是 HomeAgent SDK 版本号。 + // 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。 + Version = "0.7.2" + + // Commit 是构建时的 Git commit hash。 + Commit = "unknown" + + // BuildTime 是构建时间。 + BuildTime = "unknown" + + // SDKName 是 SDK 名称。 + SDKName = "HomeAgent SDK" + + // CoreModule 是核心仓的 Go module path,供 plugindev 生成 go.mod 时使用。 + CoreModule = "gitcode.com/JianFeeeee/HomeAgent" + + // CoreVersion 是此 SDK 所兼容的最低核心版本。 + CoreVersion = "0.7.2" +) + +// FullVersion 返回完整的版本字符串。 +func FullVersion() string { + return SDKName + " v" + Version + " (" + Commit + ")" +} + +// ---- ABI 版本(与核心仓 internal/meta/meta.go 同步) ---- +// 修改时需确保核心仓与 SDK 仓的值一致。 + +const ( + ABIVersion = 1 + ABIVersionMin = 1 +) + +// ---- Dispatch Method IDs(与核心仓 internal/meta/meta.go 同步) ---- +const ( + CoreRegisterTool = 1 + CoreRegisterStage = 2 + CoreRegisterOutputCh = 3 + CoreRegisterPluginAPI = 4 + CoreInjectText = 5 + CoreInjectInterruptText = 6 + CoreInjectTextNoMemory = 7 + CoreSetAutoRestart = 8 + CoreMemoryRecall = 9 + CoreMemoryCommit = 10 + CoreMemoryIntrospect = 11 + CoreMemoryMerge = 12 + CoreMemoryPurge = 13 + CoreDocQuery = 14 + CoreKnowledgeSearch = 15 + CoreSettingsGet = 16 + CoreSettingsSet = 17 + CoreSettingsRegisterDef = 18 + CoreLLMListSources = 19 + CoreLLMSetSource = 20 + CoreSocialGetPerson = 21 + CoreSocialGetNetwork = 22 + CoreSubscribe = 23 + CoreUnsubscribe = 24 + CoreFreeString = 25 + CoreSettingsGetCore = 26 + CoreSettingsSetCore = 27 + CoreSettingsListCore = 28 + CoreSettingsGetPlugin = 29 + CoreSettingsSetPlugin = 30 + CoreSettingsListPlugin = 31 + CoreDocInsert = 32 + CoreDocRemove = 33 + CoreDocStats = 34 + CoreKnowledgeAdd = 35 + CoreKnowledgeList = 36 + CoreLLMCurrentSource = 37 + CoreSocialGetTrait = 38 + CoreSocialGetRelations = 39 + CoreSocialListPersons = 40 + CoreTextMemoryAppend = 41 + CoreSettingsList = 42 + CoreSettingsDefs = 43 + CoreSettingsDump = 44 + CoreSettingsPlugins = 45 +) diff --git a/third_party/homeagent-sdk/sdk/knowledge.go b/third_party/homeagent-sdk/sdk/knowledge.go new file mode 100644 index 0000000..4c9d5d7 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/knowledge.go @@ -0,0 +1,14 @@ +package sdk + +// KnowledgeAPI provides access to the knowledge store. +type KnowledgeAPI interface { + Search(query string, topK int) ([]*Knowledge, error) + Add(name, content string) error + List() ([]string, error) +} + +// Knowledge represents a knowledge entry. +type Knowledge struct { + Name string `json:"name"` + Content string `json:"content"` +} diff --git a/third_party/homeagent-sdk/sdk/llm.go b/third_party/homeagent-sdk/sdk/llm.go new file mode 100644 index 0000000..b9da86a --- /dev/null +++ b/third_party/homeagent-sdk/sdk/llm.go @@ -0,0 +1,8 @@ +package sdk + +// LLMAPI provides access to the LLM provider manager. +type LLMAPI interface { + ListSources() []string + SetSource(name string) error + CurrentSource() string +} diff --git a/third_party/homeagent-sdk/sdk/memory.go b/third_party/homeagent-sdk/sdk/memory.go new file mode 100644 index 0000000..215b313 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/memory.go @@ -0,0 +1,87 @@ +package sdk + +// MemoryAPI provides access to the graph memory (entity-relation store). +type MemoryAPI interface { + Recall(query []string, depth int) ([]Entity, []Relation, error) + Commit(triples []Triple) error + Introspect() (map[string]interface{}, error) + MergeEntities(source, target string) (int, error) + Purge(criteria map[string]string, mode string) (int, error) +} + +// Entity represents a named entity in the knowledge graph. +type Entity struct { + Name string `json:"name"` + Type string `json:"type"` + MentionCount int `json:"mention_count"` +} + +// Relation represents a relationship between two entities. +type Relation struct { + SourceName string `json:"source_name"` + TargetName string `json:"target_name"` + RelationType string `json:"relation_type"` + Confidence float64 `json:"confidence,omitempty"` +} + +// Triple represents a subject-relation-object triple for the knowledge graph. +type Triple struct { + Subject string `json:"subject"` + Relation string `json:"relation"` + Object string `json:"object"` + Confidence float64 `json:"confidence,omitempty"` + SubjectType string `json:"subject_type,omitempty"` + ObjectType string `json:"object_type,omitempty"` +} + +// TextMemoryAPI provides access to chronological text event storage. +type TextMemoryAPI interface { + Append(evt TextEvent) error +} + +// TextEvent represents a single text memory event. +type TextEvent struct { + Role string `json:"role"` + Content string `json:"content"` + Timestamp int64 `json:"timestamp"` + Channel string `json:"channel,omitempty"` +} + +// DocMemoryAPI provides access to the document vector store. +type DocMemoryAPI interface { + Query(text string, topK int) []*Doc + Insert(doc *Doc) error + Remove(id string) + Stats() map[string]interface{} +} + +// Doc represents a document in the document store. +type Doc struct { + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Score float64 `json:"score,omitempty"` +} + +// SocialAPI provides read-only access to the social graph (person profiles and relationships). +// External plugins can query person traits and social networks but cannot modify them. +type SocialAPI interface { + GetPerson(name string) (*PersonProfile, error) + GetTrait(name, trait string) (string, bool) + GetRelations(name string) ([]SocialRelation, error) + GetNetwork(name string, depth int) ([]*PersonProfile, error) + ListPersons() ([]string, error) +} + +// PersonProfile represents a person's complete profile (traits + social relations). +type PersonProfile struct { + Name string `json:"name"` + Traits map[string]string `json:"traits,omitempty"` + Relations []SocialRelation `json:"relations,omitempty"` +} + +// SocialRelation represents a social relationship between two persons. +type SocialRelation struct { + Person string `json:"person"` + Relation string `json:"relation"` +} diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go new file mode 100644 index 0000000..081285c --- /dev/null +++ b/third_party/homeagent-sdk/sdk/plugin.go @@ -0,0 +1,369 @@ +package sdk + +import ( + "sync" + + "gitcode.com/JianFeeeee/homeagent-sdk/meta" +) + +// SDKVersion 是对外暴露的 SDK 版本号。 +var SDKVersion = meta.Version + +// Plugin is the interface every plugin must implement. +type Plugin interface { + Name() string + Start(sdk *PluginSDK) error + Stop() error +} + +// ToolHandler is a function that handles a tool call. +type ToolHandler func(args map[string]interface{}) (interface{}, error) + +// StageHandler is a function that handles a pipeline stage event. +type StageHandler func(ctx *StageContext) error + +// Stage represents a point in the message processing pipeline. +type Stage string + +const ( + StageOnInput Stage = "on_input" + StagePreAction Stage = "pre_action" + StagePostAction Stage = "post_action" + StageBeforeToolcall Stage = "before_toolcall" + StageAfterToolcall Stage = "after_toolcall" + StageBeforeOutput Stage = "before_output" + StageAfterOutput Stage = "after_output" +) + +// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。 +// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中 +// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用 +type ChannelDef struct { + NoMemory bool + Cleaner func(string) string +} + +// StageContext provides context for stage handlers. +type StageContext struct { + mu sync.RWMutex + RawMessage string + UserID string + GroupID string + ContextMsgs []map[string]interface{} + LLMText string + ReasoningContent string + TokenUsage map[string]int + ToolCalls []ToolCall + ToolResults []ToolResult + FinalText string + Response *string + Phase Stage + Memory []MemItem + NoMemory bool + Extra map[string]interface{} + Errors []string // 阶段处理过程中的错误信息 +} + +func (c *StageContext) RLock() { c.mu.RLock() } +func (c *StageContext) RUnlock() { c.mu.RUnlock() } +func (c *StageContext) Lock() { c.mu.Lock() } +func (c *StageContext) Unlock() { c.mu.Unlock() } +func (c *StageContext) IsResponded() bool { c.mu.RLock(); defer c.mu.RUnlock(); return c.Response != nil } + +// MemItem represents a memory item in stage context. +type MemItem struct { + Role string `json:"role"` + Content string `json:"content"` + Score float64 `json:"score"` +} + +// ToolCall represents a model's request to call a tool. +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Plugin string `json:"plugin,omitempty"` + Arguments map[string]interface{} `json:"arguments"` +} + +// ToolResult represents the result of a tool call. +type ToolResult struct { + CallID string `json:"call_id"` + Name string `json:"name"` + Plugin string `json:"plugin,omitempty"` + Success bool `json:"success"` + Result interface{} `json:"result"` +} + +// ToolDef describes a tool that the plugin exposes. +type ToolDef struct { + Name string `json:"name"` + Plugin string `json:"plugin,omitempty"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` + NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留 + Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用 +} + +// IOInjector provides methods for injecting input and interrupts into the agent pipeline. +// All methods accept (source, channel) where channel is the target output channel +// for routing the agent's response. +type IOInjector interface { + InjectInterruptText(source, channel, text string) + InjectText(source, channel, text string) + InjectTextNoMemory(source, channel, text string) +} + +// EventType identifies the kind of system event. +type EventType string + +const ( + EventRawInput EventType = "raw_input" + EventAgentOutput EventType = "agent_output" + EventAgentLLMChain EventType = "agent_llm_chain" + EventToolCall EventType = "tool_call" + EventReasoning EventType = "reasoning" + EventStage EventType = "stage" + EventSystem EventType = "system" +) + +// Event represents a system event published by the kernel. +type Event struct { + Type EventType `json:"type"` + Source string `json:"source"` + Payload map[string]interface{} `json:"payload"` + Timestamp int64 `json:"timestamp"` +} + +// EventHandler processes a system event. +type EventHandler func(evt *Event) + +// EventSubscriber allows plugins to subscribe to kernel events. +// This is a restricted interface: plugins can subscribe but the kernel +// controls which events are delivered. +type EventSubscriber interface { + Subscribe(eventType EventType, handler EventHandler) func() +} + +// StageScope controls which events a stage handler receives. +type StageScope int + +const ( + // StageScopeGlobal receives all stage events (default). + StageScopeGlobal StageScope = 0 + // StageScopeOwnTools only receives events for this plugin's own tool calls + // (before_toolcall / after_toolcall only). Other stages degrade to global. + StageScopeOwnTools StageScope = 1 +) + +// ToolRegistrar registers a tool dynamically. +type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error + +// StageRegistrar registers a stage handler. +type StageRegistrar func(stage Stage, handler StageHandler) + +// APIRegistrar registers a plugin API for external access. +type APIRegistrar func(name string) error + +// InputChannelRegistrar registers an input channel with its memory behavior. +type InputChannelRegistrar func(name string, def ChannelDef) error + +// OutputChannelRegistrar registers an output channel that the output_send tool can use. +type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error + +// Output capability flags +const ( + CapText = 1 + CapFile = 2 + CapImage = 4 + CapAudio = 8 + CapStructured = 16 +) + +// PluginSDK is the main API surface provided to plugins at runtime. +// It wraps tool registration, settings, memory, knowledge, LLM, and IO injection. +type PluginSDK struct { + name string + regTool ToolRegistrar + regStage StageRegistrar + regAPI APIRegistrar + regOutput OutputChannelRegistrar + regInput InputChannelRegistrar + io IOInjector + mem MemoryAPI + textMem TextMemoryAPI + docMem DocMemoryAPI + know KnowledgeAPI + llm LLMAPI + sett SettingsAPI + social SocialAPI + events EventSubscriber + + autoRestart bool +} + +// New creates a PluginSDK with the given dependencies. +func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK { + return &PluginSDK{ + name: name, + sett: sett, + regTool: regTool, + regStage: regStage, + regAPI: regAPI, + regOutput: regOutput, + autoRestart: true, + } +} + +// PluginName returns the name of the plugin. +func (s *PluginSDK) PluginName() string { return s.name } + +// Settings returns the settings API for reading/writing plugin configuration. +func (s *PluginSDK) Settings() SettingsAPI { return s.sett } + +// Memory returns the graph memory API (may be nil if not available). +func (s *PluginSDK) Memory() MemoryAPI { return s.mem } + +// TextMemory returns the text memory API (may be nil if not available). +func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem } + +// DocMemory returns the document memory API (may be nil if not available). +func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem } + +// Knowledge returns the knowledge store API (may be nil if not available). +func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know } + +// LLM returns the LLM provider API (may be nil if not available). +func (s *PluginSDK) LLM() LLMAPI { return s.llm } + +// Social returns the social graph API (may be nil if not available). +func (s *PluginSDK) Social() SocialAPI { return s.social } + +// Events returns the event subscriber for listening to kernel events (may be nil if not available). +func (s *PluginSDK) Events() EventSubscriber { return s.events } + +// RegisterTool registers a tool that the LLM can call. +func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error { + if def.Plugin == "" { + def.Plugin = s.name + } + if s.regTool != nil { + return s.regTool(name, def, handler) + } + return nil +} + +// RegisterStage registers a handler for a pipeline stage. +// scope: StageScopeGlobal (default) — receives all stage events. +// StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools. +func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope) { + if s.regStage == nil { + return + } + sc := StageScopeGlobal + if len(scope) > 0 { + sc = scope[0] + } + if sc == StageScopeGlobal { + s.regStage(stage, handler) + return + } + // OwnTools scope — only for before_toolcall / after_toolcall + if stage != StageBeforeToolcall && stage != StageAfterToolcall { + s.regStage(stage, handler) + return + } + s.regStage(stage, func(ctx *StageContext) error { + ctx.RLock() + match := false + switch stage { + case StageBeforeToolcall: + match = len(ctx.ToolCalls) > 0 && ctx.ToolCalls[0].Plugin == s.name + case StageAfterToolcall: + match = len(ctx.ToolResults) > 0 && ctx.ToolResults[0].Plugin == s.name + } + ctx.RUnlock() + if !match { + return nil + } + return handler(ctx) + }) +} + +// RegisterPluginAPI registers this plugin's API for access by other plugins. +func (s *PluginSDK) RegisterPluginAPI(name string) error { + if s.regAPI != nil { + return s.regAPI(name) + } + return nil +} + +// RegisterOutputChannel registers an output channel that the output_send tool can route to. +// name: channel name (e.g. "qq", "webui") +// caps: bitmask of supported output capabilities (CapText, CapFile, etc.) +// desc: description of the channel, expected meta format, and type enum +// def: 通道在记忆计算层的行为(NoMemory/Cleaner) +// handler: receives args map with keys: payload (string), type (string), meta (string|optional) +func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error { + if s.regOutput != nil { + return s.regOutput(name, caps, desc, def, handler) + } + return nil +} + +// RegisterInputChannel registers an input channel with its memory behavior. +// def.NoMemory: 此通道输入不参与记忆计算 +// def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词 +func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error { + if s.regInput != nil { + return s.regInput(name, def) + } + return nil +} + +// SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup). +func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) { s.regOutput = r } + +// SetInputChannelRegistrar sets the input channel registrar (called by the core at startup). +func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar) { s.regInput = r } + +// SetIOInjector sets the IO injector (called by the core at startup). +func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io } + +// SetMemoryAPI sets the memory API (called by the core at startup). +func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) { s.mem = mem } +func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) { s.textMem = tm } +func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) { s.docMem = dm } +func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) { s.know = kn } +func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm } +func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social } +func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es } + +// ---- IO Convenience Methods ---- + +// InjectInterruptText injects a text interrupt that can preempt current LLM processing. +func (s *PluginSDK) InjectInterruptText(source, channel, text string) { + if s.io != nil { + s.io.InjectInterruptText(source, channel, text) + } +} + +// InjectText injects a text message into the agent pipeline. +func (s *PluginSDK) InjectText(source, channel, text string) { + if s.io != nil { + s.io.InjectText(source, channel, text) + } +} + +// InjectTextNoMemory injects a text message without generating memory. +func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) { + if s.io != nil { + s.io.InjectTextNoMemory(source, channel, text) + } +} + +// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。 +// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。 +func (s *PluginSDK) SetAutoRestart(enabled bool) { s.autoRestart = enabled } + +// AutoRestart 返回插件是否允许自动重启。 +func (s *PluginSDK) AutoRestart() bool { return s.autoRestart } diff --git a/third_party/homeagent-sdk/sdk/plugin_test.go b/third_party/homeagent-sdk/sdk/plugin_test.go new file mode 100644 index 0000000..0ad4263 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/plugin_test.go @@ -0,0 +1,253 @@ +package sdk + +import ( + "testing" +) + +func TestRegisterStageGlobalDefault(t *testing.T) { + called := false + regStage := func(stage Stage, handler StageHandler) { + called = true + } + s := &PluginSDK{regStage: regStage, name: "test"} + s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }) + + if !called { + t.Error("global scope: handler not registered") + } +} + +func TestRegisterStageGlobalExplicit(t *testing.T) { + called := false + regStage := func(stage Stage, handler StageHandler) { + called = true + } + s := &PluginSDK{regStage: regStage, name: "test"} + s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeGlobal) + + if !called { + t.Error("global scope: handler not registered") + } +} + +func TestRegisterStageOwnToolsMatch(t *testing.T) { + var registered StageHandler + regStage := func(stage Stage, handler StageHandler) { + registered = handler + } + s := &PluginSDK{regStage: regStage, name: "myplugin"} + s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools) + + if registered == nil { + t.Fatal("handler not registered") + } + + ctx := &StageContext{} + ctx.ToolCalls = []ToolCall{{Plugin: "myplugin", Name: "my_tool"}} + ctx.ToolResults = nil + + err := registered(ctx) + if err != nil { + t.Errorf("expected nil, got %v", err) + } +} + +func TestRegisterStageOwnToolsSkipOtherPlugin(t *testing.T) { + var registered StageHandler + regStage := func(stage Stage, handler StageHandler) { + registered = handler + } + s := &PluginSDK{regStage: regStage, name: "myplugin"} + + callCount := 0 + s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { + callCount++ + return nil + }, StageScopeOwnTools) + + if registered == nil { + t.Fatal("handler not registered") + } + + ctx := &StageContext{} + ctx.ToolCalls = []ToolCall{{Plugin: "other", Name: "other_tool"}} + + err := registered(ctx) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + if callCount != 0 { + t.Error("handler should not be called for other plugin's tool") + } +} + +func TestRegisterStageOwnToolsNonToolcallDegrades(t *testing.T) { + regStage := func(stage Stage, handler StageHandler) { + if stage != StagePreAction { + t.Errorf("expected StagePreAction, got %s", stage) + } + } + s := &PluginSDK{regStage: regStage, name: "test"} + s.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil }, StageScopeOwnTools) +} + +func TestRegisterStageOwnToolsStageBeforeToolcallNoToolCalls(t *testing.T) { + var registered StageHandler + regStage := func(stage Stage, handler StageHandler) { + registered = handler + } + s := &PluginSDK{regStage: regStage, name: "myplugin"} + + callCount := 0 + s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { + callCount++ + return nil + }, StageScopeOwnTools) + + if registered == nil { + t.Fatal("handler not registered") + } + + ctx := &StageContext{} + + err := registered(ctx) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + if callCount != 0 { + t.Error("handler should not be called when ToolCalls is empty") + } +} + +func TestRegisterStageOwnToolsStageAfterToolcallMatch(t *testing.T) { + var registered StageHandler + regStage := func(stage Stage, handler StageHandler) { + registered = handler + } + s := &PluginSDK{regStage: regStage, name: "myplugin"} + + callCount := 0 + s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error { + callCount++ + return nil + }, StageScopeOwnTools) + + if registered == nil { + t.Fatal("handler not registered") + } + + ctx := &StageContext{} + ctx.ToolResults = []ToolResult{{Plugin: "myplugin", Name: "my_tool"}} + + err := registered(ctx) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + if callCount != 1 { + t.Error("handler should be called for own plugin's tool result") + } +} + +func TestRegisterStageOwnToolsStageAfterToolcallSkip(t *testing.T) { + var registered StageHandler + regStage := func(stage Stage, handler StageHandler) { + registered = handler + } + s := &PluginSDK{regStage: regStage, name: "myplugin"} + + callCount := 0 + s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error { + callCount++ + return nil + }, StageScopeOwnTools) + + ctx := &StageContext{} + ctx.ToolResults = []ToolResult{{Plugin: "other", Name: "other_tool"}} + + err := registered(ctx) + if err != nil { + t.Errorf("expected nil, got %v", err) + } + if callCount != 0 { + t.Error("handler should not be called for other plugin's tool result") + } +} + +func TestRegisterStageOwnToolsNilRegStage(t *testing.T) { + s := &PluginSDK{name: "test"} + s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools) +} + +func TestToolDefCleaner(t *testing.T) { + called := false + def := ToolDef{ + Name: "test_clean", + Description: "A test tool with cleaner", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + Cleaner: func(output string) string { + called = true + return "cleaned:" + output + }, + } + if def.Cleaner == nil { + t.Fatal("Cleaner should not be nil") + } + result := def.Cleaner("raw output") + if !called { + t.Error("Cleaner was not called") + } + if result != "cleaned:raw output" { + t.Errorf("expected 'cleaned:raw output', got '%s'", result) + } +} + +func TestToolDefNoMemory(t *testing.T) { + def := ToolDef{ + Name: "test_nomem", + Description: "A test tool with NoMemory", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + NoMemory: true, + } + if !def.NoMemory { + t.Error("NoMemory should be true") + } + if def.Cleaner != nil { + t.Error("Cleaner should be nil when not set") + } +} + +func TestToolDefNoMemoryDefaultFalse(t *testing.T) { + def := ToolDef{ + Name: "test_default", + Description: "A test tool with defaults", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + } + if def.NoMemory { + t.Error("NoMemory should default to false") + } +} + +func TestToolDefRegisterPreservesNoMemory(t *testing.T) { + var capturedDef ToolDef + regTool := func(name string, def ToolDef, handler ToolHandler) error { + capturedDef = def + return nil + } + s := &PluginSDK{regTool: regTool, name: "test"} + def := ToolDef{ + Name: "test_tool", + Description: "test desc", + Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}, + NoMemory: true, + Cleaner: func(s string) string { return s }, + } + s.RegisterTool("test_tool", def, func(args map[string]interface{}) (interface{}, error) { + return nil, nil + }) + if !capturedDef.NoMemory { + t.Error("NoMemory should be preserved through RegisterTool") + } + if capturedDef.Cleaner == nil { + t.Error("Cleaner should be preserved through RegisterTool") + } +} diff --git a/third_party/homeagent-sdk/sdk/settings.go b/third_party/homeagent-sdk/sdk/settings.go new file mode 100644 index 0000000..61bfdc6 --- /dev/null +++ b/third_party/homeagent-sdk/sdk/settings.go @@ -0,0 +1,58 @@ +package sdk + +type SettingsAPI interface { + // Get reads the plugin's own config value (config_ table). + Get(key string) (interface{}, error) + + // Set writes a config value to the plugin's own config table. + Set(key string, value interface{}) error + + // List returns all keys matching the given prefix. + List(prefix string) ([]string, error) + + // GetCore reads the core config table. + GetCore(key string) (interface{}, error) + + // SetCore writes to the core config table. + SetCore(key string, value interface{}) error + + // ListCore lists core config keys matching the prefix. + ListCore(prefix string) ([]string, error) + + // GetPlugin reads another plugin's config table. + GetPlugin(plugin, key string) (interface{}, error) + + // SetPlugin writes to another plugin's config table. + SetPlugin(plugin, key string, value interface{}) error + + // ListPlugin lists another plugin's config keys matching the prefix. + ListPlugin(plugin, prefix string) ([]string, error) + + // RegisterDef registers a config definition for UI display. + RegisterDef(def ConfigDef) + + // Defs returns config definitions matching the prefix. + Defs(prefix string) []*ConfigDef + + // Dump returns all config values. + Dump() map[string]interface{} + + // Plugins returns a list of all plugin config namespaces. + Plugins() []string +} + +// ConfigDef describes a configuration field for the WebUI. +type ConfigDef struct { + Key string `json:"key"` + Default interface{} `json:"default,omitempty"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` + Options []string `json:"options,omitempty"` + Min float64 `json:"min,omitempty"` + Max float64 `json:"max,omitempty"` + Step float64 `json:"step,omitempty"` + Required bool `json:"required,omitempty"` + Secret bool `json:"secret,omitempty"` +}