diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index 66d566f..9ef1009 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -1124,6 +1124,33 @@ justify-content: flex-end; gap: 8px; } + + /* 首启人格向导(复用 confirm-* 弹窗)*/ + .persona-ta { + width: 100%; + display: none; + margin-bottom: 12px; + font: 12px/1.5 var(--font-mono, monospace); + padding: 8px; + border-radius: var(--radius-md, 8px); + border: 1px solid var(--glass-border); + background: var(--glass-bg); + color: var(--text-primary); + box-sizing: border-box; + } + .persona-warn { + display: none; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + margin-bottom: 10px; + } + .persona-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + } .empty-state { text-align: center; padding: 48px 24px; @@ -2521,8 +2548,87 @@ if (card) card.style.transform = ""; }); - // ===== API ===== - async function api(p, o) { + // ===== 首启人格向导 ===== + // 人格是配置项(core.agent.personal_prompt,默认模板不含任何版本号)。 + // 首次启动问一次「默认 / 自定义 / 稍后」,之后不再打扰; + // 不回答 = 稍后 = 保留默认人格,绝不阻塞启动。 + async function maybeShowPersonaWizard() { + var st; + try { + st = await api("/persona"); + } catch (e) { + return; // 拿不到状态就不打扰用户 + } + if (!st || st.initialized) return; + var ov = document.createElement("div"); + ov.className = "confirm-overlay"; + ov.style.display = "flex"; + ov.innerHTML = + '
' + + "

" + escHtml(__("人格设定", "Persona")) + "

" + + "

" + escHtml(__( + "首次启动:选一下助手的人格。选「使用默认」即可(之后可在设置里修改);自定义内容在下次重启后生效。", + "First run: pick your assistant's persona. \"Use default\" is fine (change it later in Settings); custom content takes effect after the next restart." + )) + "

" + + '
' + + '' + + '
' + + '" + + '" + + '" + + "
"; + document.body.appendChild(ov); + var ta = ov.querySelector(".persona-ta"); + var warn = ov.querySelector(".persona-warn"); + var customOpen = false; + if (st.file_override) { + warn.style.display = "block"; + warn.textContent = __( + "注意:检测到 personal/personal.md,它优先于这里的设置。", + "Note: personal/personal.md exists and takes precedence over this choice." + ); + } + function close() { + ov.remove(); + } + async function submit(mode, content) { + try { + var r = await api("/persona", { + method: "POST", + body: JSON.stringify({ mode: mode, content: content || "" }), + }); + if (r && r.restart_required) toast(__("已保存,重启后生效", "Saved; takes effect after restart")); + else toast(__("已保存", "Saved")); + } catch (e) { + toast(__("保存失败:", "Save failed: ") + e, true); + } + close(); + } + ov.querySelectorAll("button[data-mode]").forEach(function (b) { + b.onclick = function () { + var mode = b.getAttribute("data-mode"); + if (mode !== "custom") { + submit(mode); + return; + } + if (!customOpen) { // 第一次点:展开文本域并预填当前人格 + customOpen = true; + ta.style.display = "block"; + ta.value = st.current_prompt || ""; + ta.focus(); + return; + } + if (!ta.value.trim()) { + toast(__("内容不能为空", "Content cannot be empty"), true); + return; + } + submit("custom", ta.value); + }; + }); + } + + // ===== API ===== + async function api(p, o) { var opts = { credentials: "include", headers: { "Content-Type": "application/json", ...o?.headers }, @@ -6516,6 +6622,7 @@ renderAll(); connectSSE(); startUptimeTicker(); + maybeShowPersonaWizard(); })(); setInterval(renderAll, 15000); // 消息同步轮询兜底:每30秒增量同步 chatHistory,补偿 SSE 断连窗口期 diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index afb3e09..d0d37ea 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -23,6 +23,7 @@ import ( "time" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" "gitcode.com/JianFeeeee/HomeAgent/internal/meta" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" "gitcode.com/JianFeeeee/HomeAgent/pkg/types" @@ -865,6 +866,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/terminals", h.requireAPI(h.handleTerminals)) mux.HandleFunc("/api/v1/cmd/history", h.requireAPI(h.handleCmdHistory)) mux.HandleFunc("/api/v1/kernel", h.requireAPI(h.handleKernel)) + mux.HandleFunc("/api/v1/persona", h.requireAPI(h.handlePersona)) mux.HandleFunc("/api/v1/plugins", h.requireAPI(h.handlePlugins)) mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID)) // 设备网关(可配置反代到 remotedevice;默认禁用,未启用时返回 404) @@ -974,6 +976,111 @@ func (h *Handler) handleKernel(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, h.status.GetKernelStatus()) } +// 人格设定:配置项键,以及「首启向导已经问过」的一次性标记。 +// +// 为什么需要向导:人格曾经只有 /personal/personal.md 一个来源且无人维护, +// 里面写死的旧版本号反过来让实例自述旧版本(v1.2.0 压测发现)。 +// 现在人格是配置项(默认模板不含任何版本号),首启问一次,之后不再打扰。 +const ( + personaPromptKey = "core.agent.personal_prompt" + personaInitMarker = "core.internal.persona_initialized" +) + +// handlePersona 是首启人格向导的后端。 +// +// GET → {initialized, current_prompt, file_override} +// POST → {"mode":"default"|"custom"|"later","content":"..."} +// 写入 core.agent.personal_prompt 并打一次性标记,返回 restart_required +// +// 生效时机:人格在 homed 启动时载入(以【人格设定】块拼进系统提示词), +// 所以**自定义内容需重启生效**;选「默认」或「稍后」(保持当前默认)无需重启。 +// 不回答就是「稍后」:保留默认并打标记,不阻塞任何流程。 +func (h *Handler) handlePersona(w http.ResponseWriter, r *http.Request) { + if h.settings == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "settings not available"}) + return + } + switch r.Method { + case http.MethodGet: + initialized := false + if v, err := h.settings.GetCore(personaInitMarker); err == nil { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + initialized = true + } + } + cur := "" + if v, err := h.settings.GetCore(personaPromptKey); err == nil { + if s, ok := v.(string); ok { + cur = s + } + } + if cur == "" { + cur = internalConfig.DefaultPersonaPrompt + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "initialized": initialized, + "current_prompt": cur, + "file_override": h.personaFileExists(), + }) + case http.MethodPost: + var req struct { + Mode string `json:"mode"` + Content string `json:"content"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + restart := false + switch req.Mode { + case "default": + if err := h.settings.SetCore(personaPromptKey, internalConfig.DefaultPersonaPrompt); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + case "custom": + if strings.TrimSpace(req.Content) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "content required for custom mode"}) + return + } + if err := h.settings.SetCore(personaPromptKey, req.Content); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + restart = true // 人格在启动时载入 + case "later": + // 保持当前(默认)人格,只打标记,不再问 + default: + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown mode"}) + return + } + if err := h.settings.SetCore(personaInitMarker, "1"); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "ok", "mode": req.Mode, "restart_required": restart, + }) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// personaFileExists 报告是否存在会覆盖配置项的人格文件(存在时它优先)。 +// 数据目录取自 core.daemon.data_dir(由播种写入)。 +func (h *Handler) personaFileExists() bool { + v, err := h.settings.GetCore("core.daemon.data_dir") + if err != nil { + return false + } + dir, _ := v.(string) + if dir == "" { + return false + } + _, err = os.Stat(filepath.Join(dir, "personal", "personal.md")) + return err == nil +} + func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: diff --git a/internal/plugins/webui/handler_persona_test.go b/internal/plugins/webui/handler_persona_test.go new file mode 100644 index 0000000..e28873a --- /dev/null +++ b/internal/plugins/webui/handler_persona_test.go @@ -0,0 +1,145 @@ +package webui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" + "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +func newPersonaHandler(t *testing.T) (*Handler, *internalConfig.ConfigRegistry) { + t.Helper() + dir := t.TempDir() + cfgReg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db")) + cfgReg.SeedDefaults(dir) + t.Cleanup(func() { cfgReg.Close() }) + h := NewHandler(testSDK(sdk.SDKConfig{Settings: sdk.NewSettings("webui", cfgReg)})) + return h, cfgReg +} + +func doPersona(t *testing.T, h *Handler, method, body string) *httptest.ResponseRecorder { + t.Helper() + var rd *strings.Reader + if body == "" { + rd = strings.NewReader("") + } else { + rd = strings.NewReader(body) + } + req := httptest.NewRequest(method, "/api/v1/persona", rd) + w := httptest.NewRecorder() + h.handlePersona(w, req) + return w +} + +// 首启向导的后端契约:GET 报告状态、POST 三选一、并且**只问一次**。 +func TestPersonaWizardFlow(t *testing.T) { + h, cfgReg := newPersonaHandler(t) + + // 1. 全新安装:未初始化,current_prompt 回落到内置默认模板 + w := doPersona(t, h, http.MethodGet, "") + if w.Code != http.StatusOK { + t.Fatalf("GET 状态码 %d", w.Code) + } + var got struct { + Initialized bool `json:"initialized"` + CurrentPrompt string `json:"current_prompt"` + FileOverride bool `json:"file_override"` + } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Initialized { + t.Fatal("全新安装不应已初始化") + } + if got.CurrentPrompt != internalConfig.DefaultPersonaPrompt { + t.Fatal("未设置时应回落到内置默认模板") + } + if got.FileOverride { + t.Fatal("没有人格文件时不应报告 file_override") + } + + // 2. 「稍后再说」= 保留默认、打标记、不再问 + w = doPersona(t, h, http.MethodPost, `{"mode":"later"}`) + if w.Code != http.StatusOK { + t.Fatalf("later 状态码 %d: %s", w.Code, w.Body.String()) + } + if v := cfgReg.GetString(personaInitMarker, ""); v == "" { + t.Fatal("later 也必须打一次性标记(否则每次启动都问)") + } + if v := cfgReg.GetString(personaPromptKey, ""); v != internalConfig.DefaultPersonaPrompt { + t.Fatalf("later 不应改动人格,实际 %q", v) + } + + // 3. 已初始化后 GET 应报 true + w = doPersona(t, h, http.MethodGet, "") + got.Initialized = false + _ = json.Unmarshal(w.Body.Bytes(), &got) + if !got.Initialized { + t.Fatal("打过标记后应报告已初始化") + } + + // 4. 自定义:写入内容 + 需要重启(人格在启动时载入) + h2, cfgReg2 := newPersonaHandler(t) + w = doPersona(t, h2, http.MethodPost, `{"mode":"custom","content":"你是测试人格"}`) + if w.Code != http.StatusOK { + t.Fatalf("custom 状态码 %d: %s", w.Code, w.Body.String()) + } + var pr struct { + RestartRequired bool `json:"restart_required"` + } + _ = json.Unmarshal(w.Body.Bytes(), &pr) + if !pr.RestartRequired { + t.Fatal("自定义人格应提示需要重启才生效") + } + if v := cfgReg2.GetString(personaPromptKey, ""); v != "你是测试人格" { + t.Fatalf("自定义内容未写库: %q", v) + } + + // 5. 空内容的 custom 必须被拒(否则等于静默清空人格) + h3, _ := newPersonaHandler(t) + if w = doPersona(t, h3, http.MethodPost, `{"mode":"custom","content":" "}`); w.Code != http.StatusBadRequest { + t.Fatalf("空内容应 400,实际 %d", w.Code) + } + // 6. 未知 mode 必须被拒 + if w = doPersona(t, h3, http.MethodPost, `{"mode":"nope"}`); w.Code != http.StatusBadRequest { + t.Fatalf("未知 mode 应 400,实际 %d", w.Code) + } + // 7. 被拒的请求不得打标记(否则向导会被跳过) + if v := cfgReg2.GetString(personaInitMarker, ""); v == "" { + t.Fatal("前置条件:第 4 步已打标记") + } + h4, cfgReg4 := newPersonaHandler(t) + _ = doPersona(t, h4, http.MethodPost, `{"mode":"nope"}`) + if v := cfgReg4.GetString(personaInitMarker, ""); v != "" { + t.Fatal("被拒的请求不应打标记") + } +} + +// 存在人格文件时 GET 要报告 file_override(它会覆盖配置项,向导应提示用户)。 +func TestPersonaWizardReportsFileOverride(t *testing.T) { + h, cfgReg := newPersonaHandler(t) + dir := cfgReg.GetString("core.daemon.data_dir", "") + if dir == "" { + t.Fatal("播种应写入 core.daemon.data_dir") + } + if err := os.MkdirAll(filepath.Join(dir, "personal"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "personal", "personal.md"), []byte("旧人格"), 0o644); err != nil { + t.Fatal(err) + } + w := doPersona(t, h, http.MethodGet, "") + var got struct { + FileOverride bool `json:"file_override"` + } + _ = json.Unmarshal(w.Body.Bytes(), &got) + if !got.FileOverride { + t.Fatal("存在 personal.md 时必须报告 file_override") + } +}