Files
homeagent-sdk/example/mcp/sse.go
root e849f39663 feat(examples): 内置插件外置化 —— cmd/localuse/mcp 迁入 example + files 新增按行号精确编辑
## 背景

主仓按「只有必须使用内部 SDK 能力的插件才内置」的判定标准(见主仓
ARCHITECTURE「内置插件的判定标准」)把纯公开 SDK 插件移出编译期内置清单。
本仓为它们提供外部插件工程(example/ 下,hmapdev 可直接构建)。

★ 终审翻案:kbtree 与 multimodal **不**在本次外置之列 —— 逐方法对照
公开 SDK 后发现它们依赖内部专有能力(kbtree 要 KnowledgeAPI 的
Subtree/CategoryCounts/SearchIn/Categories 与树视图类型;multimodal 要
LLMAPI.Chat 与多模态消息类型),公开面没有,已加回主仓内置清单。

## 新增工程(全部对齐当前模板 + 随用随起契约)

| 工程 | 工具 | auto_start | 说明 |
|---|---|---|---|
| cmd | cmd_run | false(按需) | 沙箱执行 shell 命令 |
| localuse | screensee/screensue/camerasue/speakeruse/clipboardsee/clipboardsue/computeruse | false(按需) | 本机外设 |
| mcp | mcp_add/list/remove/restart_server | false(按需) | MCP 服务器接入(stdio/SSE) |

每工程:plg.json(含 sdk 字段声明)+ skill/<name>-tools/SKILL.md +
README。按 auto_start 契约(sdk/autostart.go 的 OnDemandNeedsSkill),
**auto_start=false 必须配 skill**,否则工具不出现在初始清单 ⇒ 模型不知道
它存在 ⇒ 永远不会被拉起,等于没装还占地方。

## 移植要点

- 注册:内置版 init()+RegisterFactory → 外部版导出 NewPluginFactory;
  RegisterPluginMeta 的显示名进 plg.json/plugin.json。
- mcp:本地 rpcRequest/rpcResponse/rpcError 与 hmapdev 生成的
  z_proc_gen.go 同名冲突(构建期生成物与本目录源码同包编译),
  本地类型改名 mcpRPC*;internal/safeclose 内联为 safeCloseChan
  (外部插件不能引 internal 包,close-of-closed-channel 纪律随之带过来)。
- module 名:cmd 的 go.mod 用 homeagent-cmd(避免与标准库 cmd 混淆);
  name_en 对齐插件名,hmapdev 以 toSnake(name_en) 命名产物
  (此前误写 Command/Local,产物成了 command_/local_)。

## files:新增 files_edit_line(按行号精确编辑)

files_edit 要求 old 文本全文件唯一,改「第三个 case 的 break」这类
非唯一文本时只能退化为整文件重写(有损)。files_edit_line 按行号
定位,op=replace/insert/delete,支持:
- 多条 edit 从后往前应用(前面行号不漂移)
- expect 安全闸:行内容与预期不符则整体拒绝(防行号过期改错位置)
- CRLF/末尾换行保真

skill 同步补 edit_line 行。测试 5 条(含 expect 拒绝路径)。

## 验证

hmapdev build 四工程全过:cmd/localuse/mcp/files 产物齐;
go test files 全绿。
2026-10-19 22:01:07 +08:00

116 lines
3.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
// SSETransport 通过 HTTP POST 进行 JSON-RPC 通信。
// 兼容两种服务端响应:application/json 直连响应,以及 Streamable HTTP
// 的异步响应(HTTP 202 + text/event-stream 的 SSE data 帧)。
type SSETransport struct {
url string
client *http.Client
}
func NewSSETransport(url string) *SSETransport {
// 必须带超时:远程 MCP server 网络抖动/无响应时,
// 无超时的 client 会让插件加载永久阻塞(webui 等后续插件全部起不来)
return &SSETransport{
url: url,
client: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
},
},
}
}
// parseResponseBody 根据 Content-Type 解析 JSON-RPC 响应
func parseResponseBody(contentType string, body []byte) (*mcpRPCResponse, error) {
if strings.Contains(contentType, "text/event-stream") {
// SSE 流:逐行提取 data: 帧
var lastJSON []byte
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(line, "data:") {
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" || data == "[DONE]" {
continue
}
var frame map[string]json.RawMessage
if err := json.Unmarshal([]byte(data), &frame); err == nil {
if _, isResp := frame["id"]; isResp || frame["result"] != nil || frame["error"] != nil {
lastJSON = []byte(data)
}
}
}
}
if len(lastJSON) == 0 {
return nil, fmt.Errorf("SSE 流中未找到 JSON-RPC 响应帧: %s", truncate(string(body), 300))
}
body = lastJSON
}
var rpcResp mcpRPCResponse
if err := json.Unmarshal(body, &rpcResp); err != nil {
return nil, fmt.Errorf("unmarshal response: %w, body=%s", err, truncate(string(body), 300))
}
return &rpcResp, nil
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n] + "..."
}
return s
}
func (t *SSETransport) Send(req *mcpRPCRequest) (*mcpRPCResponse, error) {
data, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
httpReq, err := http.NewRequest("POST", t.url, bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json, text/event-stream")
resp, err := t.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("http post: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
ct := resp.Header.Get("Content-Type")
// 非 2xx:尝试提取错误信息
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("http status %d: %s", resp.StatusCode, truncate(string(body), 300))
}
return parseResponseBody(ct, body)
}
func (t *SSETransport) Close() error {
t.client.CloseIdleConnections()
return nil
}