Files
sundynix-agentix/sundynix-gateway/internal/handler/platform_tools.go
T

254 lines
12 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 handler
import (
"context"
"encoding/json"
"log"
"sort"
"strings"
"time"
"github.com/sundynix/sundynix-shared/contract"
)
// 平台工具族(JARVIS 大脑中枢,见 JARVIS_BRAIN_DESIGN.md §2.1):gateway 自己作为第三个工具
// 提供方(与 mcp-go/mcp-py 同协议)。平台操作的权威——提交关卡 preflightCore/launchCore、
// 归属校验、计费——都在 gateway,工具就长在权威所在地,而不是让 mcp-go 绕一圈回来调。
// dispatcher 经 list_tools 动态发现,加工具只改这里的注册表。
//
// 安全铁律:①一律 inject user_id + 服务端归属校验,模型不可指定别人的资源;
// ②会烧钱的提交(gen_report)必须走 preflightCore(预算/暂停/积分硬拦截)同一关卡。
// platParam 工具参数声明(与 mcp-go paramSpec 同构,list_tools JSON 契约一致)。
type platParam struct {
Name string `json:"name"`
Type string `json:"type"`
Desc string `json:"desc"`
Required bool `json:"required"`
}
// platTool 一个平台工具:元信息 + 处理函数(注册表 = 唯一事实源,dispatch 与 list_tools 共用)。
type platTool struct {
cn string
desc string
params []platParam
inject []string
handler func(context.Context, *contract.ToolCall) *contract.ToolResult
}
// platformRegistry 平台工具注册表。想加新工具(run_orchestration/search_kb/usage_today…)只改这里。
func (h *Handler) platformRegistry() map[string]platTool {
return map[string]platTool{
"platform_recent_tasks": {
cn: "最近任务", desc: "查询当前用户最近的任务运行列表(状态/主题/时间)。用户问“我最近的任务怎么样了/都有什么任务”时调用。",
inject: []string{"user_id"}, handler: h.platRecentTasks,
},
"platform_task_status": {
cn: "任务状态", desc: "查询某个任务的当前状态与输出摘要。用户问“那个任务/报告跑完了吗、结果是什么”时调用。",
params: []platParam{{Name: "task_id", Type: "string", Desc: "任务 ID", Required: true}},
inject: []string{"user_id"}, handler: h.platTaskStatus,
},
"platform_gen_report": {
cn: "生成报告", desc: "提交一个报告生成任务(异步跑,规划→分章→成稿)。用户要“写/生成一份 XX 报告”时调用。返回 task_id 后告诉用户已开跑、可稍后询问进度,不要原地等待结果。",
params: []platParam{
{Name: "topic", Type: "string", Desc: "报告主题", Required: true},
{Name: "kb", Type: "string", Desc: "参考知识库名(可选,用户提到才填)"},
},
inject: []string{"user_id", "tenant_id", "session_id"}, handler: h.platGenReport,
},
"platform_open_view": {
cn: "打开界面", desc: "把用户的客户端界面切到某个页面。用户说“打开/带我去/看看 运行页、报告页、知识库”等时调用。view 取值:home(工作台)/studio(编排)/kb(知识库)/runs(运行)/report(报告)/memory(记忆)/usage(用量)。",
params: []platParam{
{Name: "view", Type: "string", Desc: "目标页面:home/studio/kb/runs/report/memory/usage", Required: true},
{Name: "task_id", Type: "string", Desc: "可选:聚焦的任务 ID(配合 runs 页)"},
},
inject: []string{"user_id"}, handler: h.platOpenView,
},
// —— 本地的手(P4,只读起步):执行发生在用户自己的桌面(沙箱工作目录内),服务端只路由。
// 桌面端不在线/没开本地访问时工具会明确报不可用——如实转告用户即可。
"local_list_dir": {
cn: "看本地目录", desc: "列出用户本地工作目录(或其子目录)里的文件。用户问“我这个目录/文件夹里有什么”时调用。仅用户桌面端在线且开启了本地访问才可用。",
params: []platParam{{Name: "path", Type: "string", Desc: "相对工作目录的子路径,空=根目录"}},
inject: []string{"user_id"}, handler: h.platLocalExec,
},
"local_read_file": {
cn: "读本地文件", desc: "读取用户本地工作目录内某个文件的文本内容(大文件截断)。用户让“看看/读一下 某个本地文件”时调用。仅用户桌面端在线且开启了本地访问才可用。",
params: []platParam{{Name: "path", Type: "string", Desc: "相对工作目录的文件路径", Required: true}},
inject: []string{"user_id"}, handler: h.platLocalExec,
},
}
}
// platLocalExec 把 local_* 调用经 NATS 路由到该用户的桌面 runner(见 local_runner.go)。
// 无 runner 在线时 NATS 无应答 → 明确报"不在线",绝不挂起任务。
func (h *Handler) platLocalExec(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
if uid == "" {
return &contract.ToolResult{OK: false, Error: "缺少用户身份"}
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
res, err := h.bus.CallTool(cctx, contract.LocalExecSubject(uid), call)
if err != nil {
return &contract.ToolResult{OK: true, Content: "本地执行器不在线:用户桌面端未运行或未开启本地文件访问。请如实告知用户。"}
}
return res
}
// ServePlatformTools 以队列组订阅 sundynix.tools.platform.>,返回 drain 供优雅停机。
func (h *Handler) ServePlatformTools() (func(context.Context), error) {
reg := h.platformRegistry()
names := make([]string, 0, len(reg))
for n := range reg {
names = append(names, n)
}
sort.Strings(names)
drain, err := h.bus.ServeTool(contract.SubjectToolsPlatformAll, contract.QueueToolsPlatform,
func(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
return h.platformDispatch(reg, ctx, call)
})
if err != nil {
return nil, err
}
log.Printf("[platform] 平台工具就绪 %s (queue=%s): %s",
contract.SubjectToolsPlatformAll, contract.QueueToolsPlatform, strings.Join(names, ", "))
return drain, nil
}
func (h *Handler) platformDispatch(reg map[string]platTool, ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
log.Printf("[platform] tool=%s task=%s", call.Tool, call.TaskID)
if call.Tool == "list_tools" {
return platListTools(reg)
}
td, ok := reg[call.Tool]
if !ok {
return &contract.ToolResult{OK: false, Error: "unknown platform tool: " + call.Tool}
}
return td.handler(ctx, call)
}
// platListTools 自省:JSON 契约与 mcp-go listTools 一致(dispatcher toolCatalogEntry 同一解析)。
func platListTools(reg map[string]platTool) *contract.ToolResult {
type info struct {
Name string `json:"name"`
CN string `json:"cn"`
Desc string `json:"desc"`
Agent bool `json:"agent_exposed"`
Params []platParam `json:"params,omitempty"`
Inject []string `json:"inject,omitempty"`
}
out := make([]info, 0, len(reg))
for name, td := range reg {
out = append(out, info{Name: name, CN: td.cn, Desc: td.desc, Agent: true, Params: td.params, Inject: td.inject})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
data, _ := json.Marshal(map[string]any{"service": "gateway-platform", "tools": out})
return &contract.ToolResult{OK: true, Content: string(data)}
}
// ---- 工具实现 ----
// platRecentTasks 最近任务列表(owner 隔离:只看自己的)。
func (h *Handler) platRecentTasks(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
if uid == "" {
return &contract.ToolResult{OK: false, Error: "缺少用户身份"}
}
rows := h.db.RecentRuns(ctx, uid, 10)
type item struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
Topic string `json:"topic,omitempty"`
At string `json:"at"`
}
out := make([]item, 0, len(rows))
for _, r := range rows {
out = append(out, item{TaskID: r.TaskID, Status: r.Status, Topic: r.Topic, At: r.At.Format("01-02 15:04")})
}
if len(out) == 0 {
return &contract.ToolResult{OK: true, Content: "没有任何任务运行记录。"}
}
data, _ := json.Marshal(out)
return &contract.ToolResult{OK: true, Content: string(data)}
}
// platTaskStatus 单任务状态 + 输出摘要。归属校验:不是本人的任务一律说不存在(不泄露存在性)。
func (h *Handler) platTaskStatus(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
taskID, _ := call.Args["task_id"].(string)
if uid == "" || strings.TrimSpace(taskID) == "" {
return &contract.ToolResult{OK: false, Error: "缺少 task_id"}
}
owner := h.db.TaskOwner(ctx, taskID)
if owner == "" || owner != uid {
return &contract.ToolResult{OK: true, Content: "没有找到这个任务(ID 不存在或不属于当前用户)。"}
}
status, detail := h.db.GetTaskStatus(ctx, taskID)
output, _ := h.db.GetRunDetail(ctx, taskID)
if rs := []rune(output); len(rs) > 600 {
output = string(rs[:600]) + "…(已截断)"
}
data, _ := json.Marshal(map[string]string{
"task_id": taskID, "status": status, "detail": detail, "output_preview": output,
})
return &contract.ToolResult{OK: true, Content: string(data)}
}
// platOpenView 界面动作(P2 动作通道):经语音事件通道把 navigate 发到该用户的语音会话,
// 由客户端按白名单执行。视图合法性双重校验(此处 + 会话 onVoiceEvent + 客户端),宁可不动。
func (h *Handler) platOpenView(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
view, _ := call.Args["view"].(string)
taskID, _ := call.Args["task_id"].(string)
if uid == "" || view == "" {
return &contract.ToolResult{OK: false, Error: "缺少 view"}
}
if !navigateViews[view] {
return &contract.ToolResult{OK: false, Error: "未知页面: " + view + "(可用 home/studio/kb/runs/report/memory/usage"}
}
if err := h.bus.PublishVoiceEvent(uid, &contract.VoiceEvent{Action: "navigate", View: view, TaskID: taskID}); err != nil {
return &contract.ToolResult{OK: false, Error: "下发界面动作失败: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: "已让客户端切到 " + view + " 页。告诉用户已打开即可。"}
}
// platGenReport 提交报告任务——与 HTTP GenerateReport 同一条关卡与发射流程
// preflightCore:预算/暂停/计费租户/积分硬拦截;launchCore:落库+录像+Publish)。
func (h *Handler) platGenReport(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
topic, _ := call.Args["topic"].(string)
kb, _ := call.Args["kb"].(string)
tid, _ := call.Args["tenant_id"].(string) // 发起任务的活跃租户(计费口径跟 HTTP 提交一致)
sid, _ := call.Args["session_id"].(string) // 挂回发起会话(语音会话可续聊“报告好了吗”)
topic = strings.TrimSpace(topic)
if uid == "" || topic == "" {
return &contract.ToolResult{OK: false, Error: "缺少报告主题"}
}
billingTenant, block := h.preflightCore(ctx, uid, tid)
if block != nil {
return &contract.ToolResult{OK: true, Content: "无法提交:" + block.message()}
}
id := newReportID()
graph, _ := json.Marshal(map[string]any{"topic": topic})
task := &contract.Task{
ID: id,
Graph: graph,
Meta: map[string]any{
contract.MetaIntent: contract.IntentReport,
contract.MetaTopic: topic,
contract.MetaKB: kb,
contract.MetaUserID: uid,
contract.MetaTenantID: billingTenant,
contract.MetaSessionID: sid,
},
}
if err := h.launchCore(ctx, uid, task); err != nil {
return &contract.ToolResult{OK: false, Error: "报告任务提交失败: " + err.Error()}
}
data, _ := json.Marshal(map[string]string{
"task_id": id, "msg": "报告任务已提交开跑,请告知用户任务号并说明完成后可询问进度。",
})
return &contract.ToolResult{OK: true, Content: string(data)}
}