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."
+ )) + "
" +
+ '
' +
+ '
' +
+ '
' +
+ '' + escHtml(__("稍后再说", "Later")) + " " +
+ '' + escHtml(__("自定义…", "Custom…")) + " " +
+ '' + escHtml(__("使用默认", "Use default")) + " " +
+ "
";
+ 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")
+ }
+}