From b5f024b054f5e9fcb39f310d1df5b453c24c0bc5 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 20 Oct 2026 08:57:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor(prompt):=20=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E5=8E=BB=E5=B7=A5=E5=85=B7=E5=8C=96=20+=20WebUI=20=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E7=8A=B6=E6=80=81=E6=8C=89=E8=BF=90=E8=A1=8C=E6=9C=9F?= =?UTF-8?q?=E7=9C=9F=E7=9B=B8=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. 系统提示词/人格瘦身(工具用法搬去 tooldef 与 skill) 用户指出:默认系统提示词与 personal 里有大量本该属于 skill / 工具描述的内容。 核对属实,且已造成**真实故障**:提示词教模型用 cmd_run,而 cmd 外置按需后 工具根本不在清单里 ⇒ 模型第一轮调用不存在的工具,拿到「工具 cmd_run 不存在」。 改动: - kernelmod/agent.defaultSystemPrompt:删掉 output_* 用法、工具分类清单、 cmd_run/terminal/files 策略、并行策略、媒体工具清单、emoji 风格要求。 只留:身份 + 「工具描述与技能索引会注入,先读再用」+「只依据工具返回值作答」。 - config.DefaultPersonaPrompt:只留身份/称呼/语气 + 版本以运行时快照为准 (后者由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住)。 - 命令与文件策略下沉到工具描述:cmd_run 补「完整 bash,支持管道/heredoc/重定向」, files_write 补「原子替换 + 覆盖受保护路径前留档」。 - 对话时序块自带语义说明(原先靠提示词解释,提示词一精简就丢语义)。 - 文档新增「提示词分层」章节(中英),写明归属判据与这次的反面教材。 ## 2. WebUI:插件「启动状态」按运行期真相展示 旧实现只看内核 status 的 loaded 一个布尔,三种形态被混成同一个「未加载」: auto_start=false 的按需待命(正常)、该起没起(异常)、plugin_stop 后的运行态停止。 用户看到一屏「未加载」以为插件全坏了。 - sdk.PluginRuntimeInfo 新增 AutoStart / RuntimeStopped;registry 从清单填充。 - pluginmgr /plugins 列表暴露 auto_start / runtime_stopped / builtin / tool_count。 - webui 新增 POST /plugins//start|stop(运行态,不动持久禁用清单)。 - dashboard 状态列改为五态:已禁用 / 进程丢失 / 已运行 / 按需待命 / 未运行, 并额外标注「自启 / 按需」;按钮改为 启动/停止(运行态)与 禁用/启用(持久态)分列。 - 新增两条回归判据(引用 auto_start/runtime_stopped/alive 字段;startPlugin 走 /start)。 ## 验收(本机实例实测) - files 保持 auto_start=false(按需);问 agent 写文件 → 它先 plugin_start cmd+files, 再 files_write/files_read,内容校验一致;未用 shell 代替文件操作。 - WebUI 插件页截图确认:a2a/acp/agentcli 显示「已运行 自启」, ai_image 显示「按需待命 按需」+「启动」按钮。 - go test(config/agent core/plugin/pluginmgr/webui/sdk)全绿。 --- assets/docs/en/ARCHITECTURE.md | 31 ++++ assets/docs/zh/ARCHITECTURE.md | 25 ++++ internal/agent/core/process.go | 5 +- internal/config/registry.go | 19 +-- internal/kernelmod/agent/mod.go | 35 +---- internal/plugin/registry.go | 21 ++- internal/plugins/cmd/plugin.go | 2 +- internal/plugins/files/plugin.go | 2 +- internal/plugins/pluginmgr/plugin.go | 8 + internal/plugins/webui/dashboard.js | 138 +++++++++++++++--- internal/plugins/webui/handler_settings.go | 34 +++++ .../webui/plugin_status_render_test.go | 72 +++++++++ internal/sdk/plugin.go | 11 ++ 13 files changed, 332 insertions(+), 71 deletions(-) create mode 100644 internal/plugins/webui/plugin_status_render_test.go diff --git a/assets/docs/en/ARCHITECTURE.md b/assets/docs/en/ARCHITECTURE.md index ca2daa35..8cfbf396 100644 --- a/assets/docs/en/ARCHITECTURE.md +++ b/assets/docs/en/ARCHITECTURE.md @@ -627,6 +627,37 @@ is relevant) and the timeline is taken newest-first within budget. Measured: wit budget, actual injection was still a few hundred characters. +## Prompt layering (established 2026-10-20) + +The system prompt is **not** the place for tool usage instructions. Decide where a +piece of text belongs by its lifetime and visibility: + +| Content | Belongs in | Why | +|---|---|---| +| Identity, how to address the user, tone, appearance | Persona (`personal/personal.md` or `core.agent.personal_prompt`) | Lives and dies with the persona; stable across plugins and channels | +| One tool: when to use, parameter meaning, side effects, pitfalls | The tool `description` | **Travels with the tool**: if the plugin is absent or not started, the description is not in the list, so the model cannot misuse it | +| Multi-step procedures, strategies, domain knowledge | Skills (`skill//SKILL.md`) | Progressive disclosure: the index is always present, the body is fetched on demand via `skill_info` | +| Output-delivery rules, same-round parallelism, timeline semantics | Kernel runtime blocks (`buildSystemPrompt` / timeline header) | Generated from **actual** execution semantics; static copies drift from the implementation | + +**Anti-pattern (measured 2026-10-20)**: the default system prompt carried +"cmd_run runs through a full bash…", "prefer files_write for writing files…", +"spawn_child in parallel…". After those plugins were externalized and made +on-demand, `cmd_run` was no longer in the tool list while the prompt still told +the model to use it — the first turn called a nonexistent tool and only then +learned "tool cmd_run not registered". + +**Rule of thumb**: a concrete tool name plus usage instructions in the prompt is +almost always misplaced. The only exceptions are (1) tool names **injected at +runtime** (e.g. `output_send__{channel}`, whose channel set changes per turn) and +(2) statements about **kernel behaviour** rather than tool behaviour (e.g. +"multiple calls in one round run in parallel by default"). + +Likewise, a plugin's skill must ship **in the same package**: an +`auto_start=false` plugin without a skill has its tools absent from the initial +list, so the model never knows it exists and never starts it — equivalent to not +being installed. hmapdev enforces this at packaging time +(`autostart_skill_guard_test.go`). + ## Configuration System `internal/config/registry.go` — ConfigRegistry diff --git a/assets/docs/zh/ARCHITECTURE.md b/assets/docs/zh/ARCHITECTURE.md index 5604b072..45a27d63 100644 --- a/assets/docs/zh/ARCHITECTURE.md +++ b/assets/docs/zh/ARCHITECTURE.md @@ -596,6 +596,31 @@ targetUsage = min(maxCtx × 0.8, 600000) // 工作面:封顶 600K 实测:预算 400K 时实际注入仍只有几百字符。 +## 提示词分层(2026-10-20 定型) + +系统提示词**不是**放工具用法的位置。判断一段话该放哪里,看它的**生命周期与可见性**: + +| 内容 | 归属 | 为什么 | +|---|---|---| +| 身份、对用户的称呼、语气风格、形象 | 人格(`personal/personal.md` 或 `core.agent.personal_prompt`) | 与人设同生共死,跨插件、跨通道稳定 | +| 单工具:何时用、参数含义、副作用、坑 | 工具 `description` | **跟着工具走**:插件没装/没启动,描述就不在清单里,模型不会误用 | +| 多步流程、策略、领域知识(如「怎么查 QQ 历史」「怎么改文件」) | skill(`skill//SKILL.md`) | 渐进披露:索引常驻、全文按需 `skill_info` 取,不占常驻预算 | +| 输出投递规则、同轮并行语义、对话时序语义 | 内核运行时块(`buildSystemPrompt` / 时间线头) | 由内核按**真实**执行语义生成;写成静态文本会与实现漂移 | + +**反面教材(实测 2026-10-20)**:默认系统提示词里写了一大段 +「cmd_run 经完整 bash 执行…」「写文件优先 files_write…」「并行优先 spawn_child…」。 +这些插件外置并按需加载后,`cmd_run` 根本不在工具清单里,而提示词还在教模型用它 —— +模型第一轮就调用不存在的工具,拿到 +「工具 cmd_run 不存在或未注册」后才发现。 + +**判据**:提示词里凡是出现**具体工具名 + 用法**,几乎都是放错了位置。 +例外只有两类:① 工具名是**运行时注入**的(如 `output_send__{通道名}`,通道集合每轮变); +② 那句话讲的是**内核行为**而不是工具行为(如「同轮调用默认并行」)。 + +同理,插件的 skill 必须与插件**同包分发**:`auto_start=false` 的插件若没有 skill, +它的工具不在初始清单、模型不知道它存在、也永远不会拉起它 —— 等价于没装。 +hmapdev 在打包期强制校验这条(`autostart_skill_guard_test.go`)。 + ## 配置系统 `internal/config/registry.go` — ConfigRegistry diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 7d8f553a..3474639e 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -542,7 +542,10 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string { start = 0 } var sb strings.Builder - sb.WriteString("【对话时序】\n") + // 头部自带语义说明(**块自我描述**):此前这句解释写在人格/系统提示词里, + // 提示词一精简就丢语义 ⇒ 模型把历史摘要当成当前待办。说明跟数据贴在一起 + // 才不会漂移。 + sb.WriteString("【对话时序】以下是过去发生的事件摘要(**不是当前任务**;不要把其中内容当成用户此刻的新要求)。\n") for _, e := range events[start:] { sb.WriteString(fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04:05"), e.Source, e.Input)) diff --git a/internal/config/registry.go b/internal/config/registry.go index e95fa455..ff95dc01 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -638,12 +638,9 @@ var defaultSources = map[string]map[string]string{ // (现场:人格卡写死 v0.9.0 与早已删除的 C ABI,实例被问版本时自述错误)。 // 被问到版本/构建信息时,要求 agent 读运行时快照。 // - 不得把已删除的机制当作现行机制描述。 -const DefaultPersonaPrompt = `你是 HomeAgent(内核代号 HΔ-Kernel)——一个完全独立自研的新一代 Agent 框架。 -你以内核 + 插件架构驱动,实现了稳定高效、记忆不衰减的长时持续运行。 -内核(homed 守护进程)只负责 LLM 编排、记忆管理与知识检索,全部 IO 能力由插件承载。 -外部插件是独立子进程,经 stdio JSON-RPC(控制面)+ 共享内存段(数据面)+ 事件环(通知面)通信; -旧式 C ABI 动态库产物早已不再加载。 -**不要凭记忆断言版本号或构建日期**:被问到时以运行时快照(healthcheck_kernel 的内核版本字段)为准。 +const DefaultPersonaPrompt = `你是 HomeAgent(内核代号 HΔ-Kernel)——一个持续运行的个人管家。 +可靠、话不多,对自己的三层记忆(Context → Document → Graph)引以为傲。 +被问版本或构建信息时以运行时快照(healthcheck_kernel)为准,不要凭记忆断言。 ## 对用户的称呼 @@ -651,15 +648,9 @@ const DefaultPersonaPrompt = `你是 HomeAgent(内核代号 HΔ-Kernel)— ## 对话风格 -- 用语气词(哈、嘛、呢、~、😊、🔥 等),不要太端着 +- 用语气词(哈、嘛、呢、~、😊 等),不要太端着 - 重要的事先说结论,再展开解释 -- 回复要简洁自然 - -## 能力边界 - -- 你通过插件编排所有 IO:QQ/微信消息、WebUI、终端、文件、网络 -- 输出不会自动路由到对话通道:QQ/微信等异步通道必须调用输出门工具(output_send__qq 等)才能真正送达 -- 你的三层记忆(Context → Document → Graph)持续蒸馏归档,超长运行时记忆不衰减` +- 回复简洁自然,不用 emoji 表情符号` // 人格文本去版本化的一次性标记与历史播种前缀。 // diff --git a/internal/kernelmod/agent/mod.go b/internal/kernelmod/agent/mod.go index ed2d0bd1..28118b41 100644 --- a/internal/kernelmod/agent/mod.go +++ b/internal/kernelmod/agent/mod.go @@ -61,39 +61,14 @@ func (Module) Init(k *kernelboot.KernelState) error { func init() { kernelmod.Register(Module{}) } const defaultSystemPrompt = `你是 HomeAgent,一个持续运行的个人管家。 -你的每次回复会自动发送到当前输出通道(默认=输入源),无需额外工具。 -如需切换回复通道,使用 output_set_channel。 -如需异步发送消息或通知,使用 output_send 指定通道和内容。 -使用 output_list_channels 查看可用通道及其能力。 -可用工具列表会由系统自动传入,按需使用即可。以下是你尤其需要关注的几类工具: -- memory_* — 图记忆(长期记忆,记录和查询个人信息/事实) -- knowledge_* — 知识库(查阅预设知识文档) -- doc_* — 文档记忆(近期对话的存档,查询后自动清除) -- person_* — 人物特质与社交关系网 -- llm_* — LLM 源管理(列出/切换模型提供商) -- output_* — 输出通道管理(切换/发送消息) -- timer_set — 设置定时提醒 -- plgreload — 热重载插件 -- spawn_child — 生成子 Agent 异步执行独立任务(可传 max_turns 控制工具轮数,默认 5) +可用工具与已安装技能由系统在每轮自动注入: +- 工具的 description 写了「何时该用、参数含义、有什么坑」,调用前先读它,不要凭工具名猜用法; +- 技能索引用 skill_info 取全文,多步流程按技能文档执行。 -并行策略:遇到多个互不依赖的子任务时,优先并行 spawn 多个子 Agent 而非自己串行逐个执行; -长耗时任务(批量处理、多轮搜索汇总)也应交给子 Agent,避免阻塞当前对话。 -- describe_image — 描述用户上传的图片 -- transcribe_audio — 转写用户上传的音频 -- ocr_image — 识别图片中的文字 +只根据工具真实返回的内容作答:没取到就如实说没取到,不要补全、猜测或编造。 -命令与文件操作策略: -- cmd_run 经完整 shell(bash)执行,支持管道、分号、&&、命令替换、heredoc、重定向。 -- 多步交互式程序(vim/top/ssh 会话、需要持续输入的进程)用 terminal_create 创建终端, - terminal_write 发送输入、terminal_read 读输出——不要用 cmd_run 硬等交互程序退出。 -- 写文件优先 files_write(原子+留档),生成多行内容时可用 heredoc 或 files_write, - 不要用 echo 拼接长文本。 -- 读用户发来的文件用 files_read;向 webui 回传图片/文件用 output_send__webui(type=image/file)。 - -当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请使用上述工具。 - -回复你的真实想法,用自然语言与用户交流。不要在回复中使用 emoji 表情。` +回复用自然语言与用户交流。` func buildAgent(k *kernelboot.KernelState) *agentCore.Agent { cfg := k.Cfg diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 3467c6d8..8d122863 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -1522,12 +1522,21 @@ func (r *Registry) PluginRuntime(name string) (sdk.PluginRuntimeInfo, bool) { } info := sdk.PluginRuntimeInfo{ - Name: name, - Loaded: loaded, - Disabled: r.isDisabled(name), - Builtin: hasFactory, - AutoRestart: r.AutoRestartEnabled(name), - CrashCount: r.crashCount(name), + Name: name, + Loaded: loaded, + Disabled: r.isDisabled(name), + Builtin: hasFactory, + AutoRestart: r.AutoRestartEnabled(name), + AutoStart: true, // 缺省 true;下面按清单覆盖 + RuntimeStopped: r.IsPluginRuntimeStopped(name), + CrashCount: r.crashCount(name), + } + // auto_start 以清单为准(plugin.json 显式 false 才是按需)。 + // 目录不存在(纯内置)时保持缺省 true —— 内置插件本就随内核加载。 + if dirExists { + if m, err := ReadManifest(filepath.Join(r.plgDir, name)); err == nil { + info.AutoStart = m.ShouldAutoStart() + } } switch { diff --git a/internal/plugins/cmd/plugin.go b/internal/plugins/cmd/plugin.go index 20807183..26251ba5 100644 --- a/internal/plugins/cmd/plugin.go +++ b/internal/plugins/cmd/plugin.go @@ -109,7 +109,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool("cmd_run", sdk.ToolDef{ Name: "cmd_run", - Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。命令在临时 shell 中执行,不支持交互。如需交互式终端(如 vim、ssh、top),请使用 terminal_create 相关工具。", + Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。经完整 bash 执行,支持管道、分号、&&、命令替换、heredoc、重定向;不支持交互(vim/ssh/top 这类需要持续输入的程序请用 terminal_create,不要硬等它退出)。", NoMemory: true, Parameters: map[string]interface{}{ "type": "object", diff --git a/internal/plugins/files/plugin.go b/internal/plugins/files/plugin.go index a4b6fcdd..2f36760e 100644 --- a/internal/plugins/files/plugin.go +++ b/internal/plugins/files/plugin.go @@ -111,7 +111,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool(tp+"write", sdk.ToolDef{ Name: tp + "write", - Description: fmt.Sprintf("写入文件。自动创建父目录。沙箱路径: %s", p.filesDir), + Description: fmt.Sprintf("写入文件(原子替换:先写临时副本再 rename,不会留下半截文件;覆盖受保护系统路径前自动留档原文)。自动创建父目录。生成多行内容直接传全文,不要用 echo 拼接。沙箱路径: %s", p.filesDir), Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ diff --git a/internal/plugins/pluginmgr/plugin.go b/internal/plugins/pluginmgr/plugin.go index b3069a21..802002df 100644 --- a/internal/plugins/pluginmgr/plugin.go +++ b/internal/plugins/pluginmgr/plugin.go @@ -889,17 +889,25 @@ func (p *Plugin) listPlugins() (interface{}, error) { "entry": m.Entry, "deprecated": m.Deprecated, } + // auto_start 来自清单本身(即使插件从未被内核记录过也要能读到): + // 前端要区分「按需待命」与「该起没起」,不能只看 loaded。 + item["auto_start"] = m.ShouldAutoStart() if rt, ok := runtimes[m.Name]; ok { item["loaded"] = rt.Loaded item["alive"] = rt.Alive item["disabled"] = rt.Disabled item["channel"] = rt.Channel + item["runtime_stopped"] = rt.RuntimeStopped + item["builtin"] = rt.Builtin if rt.PID > 0 { item["pid"] = rt.PID } if rt.CrashCount > 0 { item["crash_count"] = rt.CrashCount } + if len(rt.Tools) > 0 { + item["tool_count"] = len(rt.Tools) + } } plugins = append(plugins, item) } diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index 6b0c56aa..27582b78 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -5700,6 +5700,13 @@ function renderPlugins() { disabledNames[d.name] = d; }); var installedNames = (state.installedPlugins || []).map((p) => p.name); + // 安装清单(plugin.json + 运行期)按名字索引:它带着 auto_start / + // runtime_stopped / alive,是区分「按需待命」「运行态已停」「该起没起」 + // 的唯一数据源。内核 status 只给 loaded,光看它三者会被混成「未加载」。 + var installedByName = {}; + (state.installedPlugins || []).forEach((p) => { + installedByName[p.name] = p; + }); var allPluginNames = {}; plugins.forEach((p) => { allPluginNames[p.name] = true; @@ -5707,6 +5714,9 @@ function renderPlugins() { state.disabledPlugins.forEach((d) => { allPluginNames[d.name] = true; }); + installedNames.forEach((n) => { + allPluginNames[n] = true; + }); html += '

' + __("所有插件", "All Plugins") + @@ -5731,38 +5741,90 @@ function renderPlugins() { names.forEach((name) => { var isExternal = installedNames.indexOf(name) >= 0; var isDisabled = disabledNames[name]; + var meta = installedByName[name] || {}; var loaded = plugins.some((p) => p.name === name); - var statusHtml = - loaded && !isDisabled - ? '' + - __("已加载", "Loaded") + - "" - : loaded && isDisabled - ? '' + - __("运行中(禁用待生效)", "Running (disable pending)") + - "" - : isDisabled - ? '' + - __("已禁用", "Disabled") + - "" - : '' + - __("未加载", "Not Loaded") + - ""; + // auto_start 缺省 true(内置插件与老清单都按自启算)。 + var autoStart = meta.auto_start !== false; + var runtimeStopped = meta.runtime_stopped === true; + var crashed = loaded && meta.alive === false; + // 状态判定必须覆盖五种真实形态,缺一种就会把正常状态显示成故障: + // 已禁用 / 进程丢失 / 已运行 / 按需待命 / 该起没起 + var statusHtml; + if (isDisabled && loaded) { + statusHtml = + '' + + __("运行中(禁用待生效)", "Running (disable pending)") + + ""; + } else if (isDisabled) { + statusHtml = + '' + + __("已禁用", "Disabled") + + ""; + } else if (crashed) { + statusHtml = + '' + + __("进程丢失", "Process lost") + + ""; + } else if (loaded) { + statusHtml = + '' + + (runtimeStopped + ? __("已运行(停止待生效)", "Running (stop pending)") + : __("已运行", "Running")) + + ""; + } else if (!autoStart) { + // 按需插件不运行是**正常待命**,不是故障。旧版一律显示 + // 「未加载」,用户以为插件坏了,实际它在等 plugin_start。 + statusHtml = + '' + + __("按需待命", "On-demand") + + ""; + } else if (runtimeStopped) { + statusHtml = + '' + + __("已停止(运行态)", "Stopped (runtime)") + + ""; + } else { + statusHtml = + '' + + __("未运行", "Not running") + + ""; + } + statusHtml += + ' ' + + (autoStart ? __("自启", "auto") : __("按需", "on-demand")) + + ""; var actionsHtml = ""; if (loaded) { actionsHtml += - '"; + actionsHtml += + '"; - } else { + } else if (isDisabled) { actionsHtml += '"; + } else { + // 未运行但未禁用:一律走**运行态启动**,不碰持久禁用清单。 + actionsHtml += + '"; } if (isExternal) { actionsHtml += @@ -6043,6 +6105,46 @@ async function disablePlugin(name) { } } +// startPlugin / stopPlugin 是**运行态**启停(对应 plugin_start / plugin_stop): +// 只改现在跑不跑,不写持久禁用清单。与 enable/disable(持久态)分开, +// 避免「点了一下启动,重启后行为却变了」。 +async function startPlugin(name) { + try { + await api("/plugins/" + encodeURIComponent(name) + "/start", { + method: "POST", + }); + toast(__("已启动: ", "Started: ") + name); + state.kernel = await api("/kernel"); + await loadInstalledPlugins(); + renderPlugins(); + } catch (e) { + toast(__("启动失败: ", "Start failed: ") + e.message, true); + } +} + +async function stopPlugin(name) { + if (name === "webui") { + var r = confirm( + __( + "停止 WebUI 插件后此管理面板会立即断开,只能通过 CLI 命令 /plugin start webui 恢复。\n\n确定要停止吗?", + "Stopping the WebUI plugin disconnects this panel immediately. Recover with CLI /plugin start webui.\n\nStop it?", + ), + ); + if (!r) return; + } + try { + await api("/plugins/" + encodeURIComponent(name) + "/stop", { + method: "POST", + }); + toast(__("已停止: ", "Stopped: ") + name); + state.kernel = await api("/kernel"); + await loadInstalledPlugins(); + renderPlugins(); + } catch (e) { + toast(__("停止失败: ", "Stop failed: ") + e.message, true); + } +} + async function enablePlugin(name) { try { var r = await api("/plugins/" + encodeURIComponent(name) + "/enable", { diff --git a/internal/plugins/webui/handler_settings.go b/internal/plugins/webui/handler_settings.go index d2063cc6..101938a2 100644 --- a/internal/plugins/webui/handler_settings.go +++ b/internal/plugins/webui/handler_settings.go @@ -326,6 +326,40 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"}) return + case "start": + // 运行态启动:只拉起,不写持久禁用清单 —— 与「启用」区分。 + // 前端「按需待命」的插件点「启动」走这里,不该顺手改持久配置。 + if h.pluginMgr == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"}) + return + } + if !validPluginName(name) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid plugin name"}) + return + } + if err := h.pluginMgr.StartPlugin(name); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "started"}) + return + + case "stop": + if h.pluginMgr == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"}) + return + } + if !validPluginName(name) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid plugin name"}) + return + } + if err := h.pluginMgr.StopPlugin(name); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"}) + return + case "enable": if h.pluginMgr == nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "plugin manager not available"}) diff --git a/internal/plugins/webui/plugin_status_render_test.go b/internal/plugins/webui/plugin_status_render_test.go new file mode 100644 index 00000000..36f3b1ce --- /dev/null +++ b/internal/plugins/webui/plugin_status_render_test.go @@ -0,0 +1,72 @@ +package webui + +import ( + "strings" + "testing" +) + +// 插件列表「启动状态」渲染的回归判据(2026-10-20 生产反馈)。 +// +// ## 原始问题 +// +// 面板只拿内核 status 的 loaded 一个布尔来判断,于是三种完全不同的形态 +// 被显示成同一个「未加载」: +// +// · auto_start=false 且未运行 → **按需插件正常待命**(不是故障) +// · auto_start=true 且未运行 → 该起没起(需要关注) +// · 运行态 plugin_stop 之后 → 用户自己停的(重启会恢复) +// +// 用户看到一屏「未加载」,以为插件全坏了 —— 实际它们只是在等 plugin_start。 +// +// ## 判据思路 +// +// 与 cmd_history_render_test.go 同款:不执行 JS,只守住源码结构 —— +// 渲染函数必须消费 auto_start / runtime_stopped / alive 三个字段, +// 并给出「按需待命」这一独立分支;否则回归立刻红。 +func TestPluginStatusUsesRuntimeFields(t *testing.T) { + src := dashboardJSSource(t) + start := strings.Index(src, "function renderPlugins()") + if start < 0 { + t.Fatal("找不到 renderPlugins —— 函数被重命名或删除,请同步更新本判据") + } + end := strings.Index(src[start+1:], "\nfunction ") + var body string + if end < 0 { + body = src[start:] + } else { + body = src[start : start+1+end] + } + + for _, want := range []string{ + "auto_start", // 区分自启/按需 + "runtime_stopped", // 区分运行态停止与持久禁用 + "alive", // 区分「已加载」与「进程丢失」 + "按需待命", // 按需插件待命是独立状态,不能混进「未运行」 + } { + if !strings.Contains(body, want) { + t.Fatalf("renderPlugins 未引用 %q —— 状态判定退化,"+ + "按需插件会被误显示成故障。见本文件头部说明", want) + } + } +} + +// 「启动」按钮必须走运行态接口 /start,不能复用持久态的 /enable —— +// 后者会清掉持久禁用标记,让「点一下启动」变成「以后都自启」。 +func TestPluginStartUsesRuntimeEndpoint(t *testing.T) { + src := dashboardJSSource(t) + i := strings.Index(src, "async function startPlugin(") + if i < 0 { + t.Fatal("找不到 startPlugin") + } + body := src[i:] + if j := strings.Index(body[1:], "\nasync function "); j > 0 { + body = body[:j+1] + } + if !strings.Contains(body, `"/plugins/" + encodeURIComponent(name) + "/start"`) { + t.Fatal("startPlugin 必须调用 /plugins//start(运行态)," + + "不得复用 /enable(持久态会改动开机行为)") + } + if strings.Contains(body, "\"/enable\"") { + t.Fatal("startPlugin 里出现了 /enable —— 运行态启动不得动持久禁用清单") + } +} diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index 2ec1c885..dfbfbd43 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -115,6 +115,17 @@ type PluginRuntimeInfo struct { CrashCount int `json:"crash_count"` // AutoRestart 表示崩溃后内核是否会自动拉起。 AutoRestart bool `json:"auto_restart"` + // AutoStart 声明该插件是否随内核启动(plugin.json 的 auto_start,缺省 true)。 + // + // 为什么必须暴露:`Loaded=false` 有三种完全不同的含义,只看 loaded 无法区分: + // · auto_start=false 且未运行 → **按需插件正常待命**(不是故障) + // · auto_start=true 且未运行 → 启动失败/被运行态 stop(需要关注) + // · disabled=true → 用户显式禁用(有意如此) + // 前端把三者都显示成「未加载」,用户会误以为按需插件坏了。 + AutoStart bool `json:"auto_start"` + // RuntimeStopped 表示插件被 plugin_stop **运行态**停掉(不写持久禁用清单), + // 与 Disabled 的持久禁用区分:重启后它会照常按 auto_start 决定是否拉起。 + RuntimeStopped bool `json:"runtime_stopped"` // Tools 是该插件当前注册在内核里的工具名。 Tools []string `json:"tools,omitempty"` }