diff --git a/README.md b/README.md index 95b7c7c..644bc3e 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Android APK: - `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` - 当前最新 release 构建版本:`2.5.8`(`versionCode=21`) - 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` -- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于 +- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、主 Agent 提示词 / 记忆、技能、运维中心、关于 - 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口 - 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角仍是 `+添加` - 当前会话首页已升级成“项目聚合 + 线程下钻”的结构:如果某个 Codex 文件夹只导入了 1 个线程,会话列表直接显示这个线程;如果同一文件夹导入了多个线程,会话首页只显示该文件夹归档项,点进去再看这个项目下的全部线程 @@ -113,6 +113,7 @@ Android APK: - 当前 `我的 > AI 账号` 已补 `登录 OpenAI 平台账号` 与 `绑定 Master Codex Node` 两条显式入口;OpenAI API 登录成功后会立即设为当前主控 - 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:会先进入原生引导页,再自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入 - 当前 `AI 账号` 页顶部会显式展示“当前主控身份”,并提供 `校验主控 / 测试主 Agent 对话` 两个动作,切换主控后可直接验证聊天通路 +- 当前 `我的 > 主 Agent 提示词 / 记忆` 页面已补:管理员全局主提示词、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 项目记忆的新增、编辑、删除接口 - 当前 `OpenAiOnboardingActivity` 在登录成功后会直接给出 `测试主 Agent 对话` 入口,可一键跳到 `master-agent` 聊天页 - 当前主控若还是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,避免聊天直接掉成失败日志 - 当前原生 Android 的聊天发送已改成更短的客户端等待窗口;`master-agent` 单聊依赖服务端快速入队和消息流里的“思考中 / 超时 / 重试等待”状态,不再要求客户端长时间同步阻塞 @@ -301,6 +302,7 @@ npm run aab:release - 登录成功后的进入首页链路已做稳态处理:会先确认 `/api/auth/session` 可读,再执行 `replace(/conversations)`,并附带一次原生级兜底跳转,避免真机 WebView 偶发停留在“正在进入会话首页” - `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新 - 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill,并支持一键复制调用语句 +- 我的页新增 `主 Agent 提示词 / 记忆` 入口,`/me/master-agent` 会展示管理员全局主提示词、用户主提示词、当前对话附加提示词和用户记忆 - 我的页新增 `AI 账号` 入口,`/me/ai-accounts` 会展示主 GPT / 备用 GPT / API 容灾,并明确主链路优先走已登录 `ChatGPT Plus / Codex` 的 `Master Codex Node` - `AI 账号` 页面当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth;主 GPT 的登录动作必须在绑定电脑上的 Codex / ChatGPT Plus 会话里完成,再回手机端点“测试连接 / 校验连接” - `AI 账号` 页面当前已升级成双入口:首页会显式展示 `登录 OpenAI 平台账号` 和 `绑定电脑上的 Codex 节点` diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3dee57c..284ee30 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -48,6 +48,8 @@ + + diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index dd041a6..0d42eb4 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -113,6 +113,43 @@ public class BossApiClient { return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload); } + public ApiResponse updateProjectAgentControls( + String projectId, + @Nullable String modelOverride, + @Nullable String reasoningEffortOverride, + @Nullable String promptOverride + ) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride); + payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride); + payload.put("promptOverride", promptOverride == null ? JSONObject.NULL : promptOverride); + return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload); + } + + public ApiResponse getMasterAgentPromptProfile(String projectId) throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/prompt-profile", null); + } + + public ApiResponse updateMasterAgentPromptProfile(String projectId, JSONObject payload) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/prompt-profile", payload); + } + + public ApiResponse getMasterAgentMemories(String projectId) throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/memories", null); + } + + public ApiResponse createMasterAgentMemory(String projectId, JSONObject payload) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/memories", payload); + } + + public ApiResponse updateMasterAgentMemory(String projectId, String memoryId, JSONObject payload) throws IOException, JSONException { + return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/memories/" + encode(memoryId), payload); + } + + public ApiResponse deleteMasterAgentMemory(String projectId, String memoryId) throws IOException, JSONException { + return requestWithRestore("DELETE", "/api/v1/projects/" + encode(projectId) + "/memories/" + encode(memoryId), null); + } + public ApiResponse confirmDispatchPlan(String projectId, String planId, JSONArray approvedTargetProjectIds) throws IOException, JSONException { JSONObject payload = new JSONObject(); payload.put( diff --git a/android/app/src/main/java/com/hyzq/boss/MasterAgentMemoryActivity.java b/android/app/src/main/java/com/hyzq/boss/MasterAgentMemoryActivity.java new file mode 100644 index 0000000..076b7d5 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/MasterAgentMemoryActivity.java @@ -0,0 +1,398 @@ +package com.hyzq.boss; + +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.text.TextUtils; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.Spinner; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class MasterAgentMemoryActivity extends BossScreenActivity { + public static final String EXTRA_PROJECT_ID = "project_id"; + public static final String EXTRA_PROJECT_NAME = "project_name"; + + private static final String[] MEMORY_SCOPE_VALUES = {"global", "project"}; + private static final String[] MEMORY_SCOPE_LABELS = {"我的通用记忆", "当前项目记忆"}; + private static final String[] MEMORY_TYPE_VALUES = { + "user_preference", + "project_progress", + "decision", + "risk", + "blocking_issue", + "research_note", + "workflow_rule" + }; + private static final String[] MEMORY_TYPE_LABELS = { + "用户偏好", + "项目进度", + "决策", + "风险", + "阻塞", + "调研结论", + "工作规则" + }; + + private String projectId; + private String projectName; + private boolean contentLoaded; + private @Nullable JSONObject globalMemoriesPayload; + private @Nullable JSONObject projectMemoriesPayload; + private @Nullable JSONArray globalMemoryItems; + private @Nullable JSONArray projectMemoryItems; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); + projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME); + configureScreen("记忆", projectName == null ? "主 Agent 记忆" : projectName); + setHeaderAction("新增", v -> openMemoryEditor(null)); + updateSaveAvailability(); + reload(); + } + + @Override + protected void reload() { + if (projectId == null || projectId.isEmpty()) { + replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。")); + setRefreshing(false); + contentLoaded = false; + updateSaveAvailability(); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.getMasterAgentMemories(projectId); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + runOnUiThread(() -> renderMemories(response.json)); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + contentLoaded = false; + updateSaveAvailability(); + replaceContent(BossUi.buildEmptyCard(this, "记忆加载失败:" + error.getMessage())); + }); + } + }); + } + + private void renderMemories(JSONObject payload) { + JSONObject memories = payload.optJSONObject("memories"); + globalMemoriesPayload = memories == null ? null : memories.optJSONObject("global"); + projectMemoriesPayload = memories == null ? null : memories.optJSONObject("project"); + globalMemoryItems = extractMemoryItems(memories, "global"); + projectMemoryItems = extractMemoryItems(memories, "project"); + + replaceContent(); + appendContent(BossUi.buildSimpleProfileHeader( + this, + projectName == null ? "主 Agent" : projectName, + "自动沉淀 / 手动维护", + "项目记忆默认挂到当前项目,通用记忆属于当前用户。" + )); + appendContent(BossUi.buildSoftPanel( + this, + "记忆说明", + "主 Agent 会自动沉淀长期有用的信息。你也可以在这里手动新增、编辑或删除。", + "底层是结构化存储,前台展示为轻量卡片。" + )); + + renderSection( + "我的通用记忆", + globalMemoryItems, + "当前没有通用记忆。" + ); + renderSection( + "当前项目记忆", + projectMemoryItems, + "当前项目还没有沉淀记忆。" + ); + + contentLoaded = true; + updateSaveAvailability(); + setRefreshing(false); + } + + private void renderSection(String title, @Nullable JSONArray items, String emptyText) { + int count = items == null ? 0 : items.length(); + appendContent(BossUi.buildWechatMenuRow( + this, + title, + count <= 0 ? emptyText : "共 " + count + " 条", + null, + null, + null + )); + if (items == null || items.length() == 0) { + appendContent(BossUi.buildEmptyCard(this, emptyText)); + return; + } + for (int i = 0; i < items.length(); i++) { + JSONObject memory = items.optJSONObject(i); + if (memory == null) continue; + appendContent(buildMemoryRow(memory)); + } + } + + private @Nullable JSONArray extractMemoryItems(@Nullable JSONObject memories, String key) { + if (memories == null || !memories.has(key)) { + return null; + } + Object value = memories.opt(key); + if (value instanceof JSONArray) { + return (JSONArray) value; + } + if (value instanceof JSONObject) { + return ((JSONObject) value).optJSONArray("items"); + } + return null; + } + + private LinearLayout buildMemoryRow(JSONObject memory) { + String scope = memory.optString("scope", "global"); + String type = memory.optString("memoryType", "user_preference"); + String title = memory.optString("title", "未命名记忆"); + String content = memory.optString("content", ""); + String tags = joinTags(memory.optJSONArray("tags")); + String meta = memory.optString("updatedAt", memory.optString("createdAt", "")); + if (!TextUtils.isEmpty(tags)) { + meta = TextUtils.isEmpty(meta) ? tags : meta + " · " + tags; + } + String badge = "project".equals(scope) ? "当前项目" : "全局"; + String subtitle = memoryTypeLabel(type) + (TextUtils.isEmpty(content) ? "" : " · " + content); + return BossUi.buildWechatMenuRow( + this, + title, + subtitle, + meta, + badge, + v -> openMemoryEditor(memory) + ); + } + + private void openMemoryEditor(@Nullable JSONObject memory) { + LinearLayout form = new LinearLayout(this); + form.setOrientation(LinearLayout.VERTICAL); + + final Spinner scopeSpinner = new Spinner(this); + ArrayAdapter scopeAdapter = new ArrayAdapter<>( + this, + android.R.layout.simple_spinner_dropdown_item, + MEMORY_SCOPE_LABELS + ); + scopeSpinner.setAdapter(scopeAdapter); + + final Spinner typeSpinner = new Spinner(this); + ArrayAdapter typeAdapter = new ArrayAdapter<>( + this, + android.R.layout.simple_spinner_dropdown_item, + MEMORY_TYPE_LABELS + ); + typeSpinner.setAdapter(typeAdapter); + + final EditText titleInput = BossUi.buildInput(this, "记忆标题", false); + final EditText contentInput = BossUi.buildInput(this, "记忆内容", true); + final EditText tagsInput = BossUi.buildInput(this, "标签,逗号分隔", false); + contentInput.setMinLines(6); + + if (memory != null) { + titleInput.setText(memory.optString("title", "")); + contentInput.setText(memory.optString("content", "")); + tagsInput.setText(joinTags(memory.optJSONArray("tags"))); + scopeSpinner.setSelection("project".equals(memory.optString("scope", "global")) ? 1 : 0); + typeSpinner.setSelection(memoryTypeIndex(memory.optString("memoryType", "user_preference"))); + } else { + scopeSpinner.setSelection(0); + typeSpinner.setSelection(0); + } + + form.addView(BossUi.buildFormCell(this, "作用域", "决定是用户通用记忆还是当前项目记忆。", scopeSpinner)); + form.addView(BossUi.buildFormCell(this, "标题", "一句话说明这条记忆。", titleInput)); + form.addView(BossUi.buildFormCell(this, "内容", "主 Agent 读取时会使用这段内容。", contentInput)); + form.addView(BossUi.buildFormCell(this, "类型", "帮助主 Agent 决定优先级与使用场景。", typeSpinner)); + form.addView(BossUi.buildFormCell(this, "标签", "以逗号分隔,便于后续检索和归档。", tagsInput)); + + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setTitle(memory == null ? "新增记忆" : "编辑记忆") + .setView(form) + .setNegativeButton("取消", null) + .setPositiveButton("保存", (dialog, which) -> saveMemory( + memory, + MEMORY_SCOPE_VALUES[scopeSpinner.getSelectedItemPosition()], + titleInput.getText() == null ? "" : titleInput.getText().toString(), + contentInput.getText() == null ? "" : contentInput.getText().toString(), + MEMORY_TYPE_VALUES[typeSpinner.getSelectedItemPosition()], + tagsInput.getText() == null ? "" : tagsInput.getText().toString() + )); + + if (memory != null) { + builder.setNeutralButton("删除", (dialog, which) -> confirmDeleteMemory(memory)); + } + + builder.show(); + } + + private void confirmDeleteMemory(JSONObject memory) { + final String memoryId = memory.optString("memoryId", ""); + if (memoryId.isEmpty()) { + showMessage("缺少 memoryId"); + return; + } + new AlertDialog.Builder(this) + .setTitle("删除记忆") + .setMessage("确定删除这条记忆吗?") + .setNegativeButton("取消", null) + .setPositiveButton("删除", (dialog, which) -> deleteMemory(memoryId)) + .show(); + } + + private void saveMemory( + @Nullable JSONObject existingMemory, + String scope, + String title, + String content, + String memoryType, + String tagsText + ) { + if (!contentLoaded && existingMemory == null) { + showMessage("记忆尚未加载完成,请先刷新成功后再保存。"); + return; + } + final String normalizedTitle = title == null ? "" : title.trim(); + final String normalizedContent = content == null ? "" : content.trim(); + if (normalizedTitle.isEmpty()) { + showMessage("记忆标题不能为空"); + return; + } + if (normalizedContent.isEmpty()) { + showMessage("记忆内容不能为空"); + return; + } + final JSONArray tags = parseTags(tagsText); + final boolean projectScope = "project".equals(scope); + final String memoryId = existingMemory == null ? "" : existingMemory.optString("memoryId", ""); + setRefreshing(true); + executor.execute(() -> { + try { + JSONObject payload = new JSONObject(); + payload.put("scope", scope); + if (projectScope) { + payload.put("projectId", projectId); + } + payload.put("title", normalizedTitle); + payload.put("content", normalizedContent); + payload.put("memoryType", memoryType); + payload.put("tags", tags); + if (existingMemory != null && existingMemory.has("sourceMessageId")) { + payload.put("sourceMessageId", existingMemory.optString("sourceMessageId", "")); + } + + BossApiClient.ApiResponse response = memoryId.isEmpty() + ? apiClient.createMasterAgentMemory(projectId, payload) + : apiClient.updateMasterAgentMemory(projectId, memoryId, payload); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + runOnUiThread(() -> { + showMessage("记忆已保存"); + setResult(RESULT_OK); + finish(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("记忆保存失败:" + error.getMessage()); + }); + } + }); + } + + private void deleteMemory(String memoryId) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.deleteMasterAgentMemory(projectId, memoryId); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + runOnUiThread(() -> { + showMessage("记忆已删除"); + setResult(RESULT_OK); + finish(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("记忆删除失败:" + error.getMessage()); + }); + } + }); + } + + private int memoryTypeIndex(String memoryType) { + for (int i = 0; i < MEMORY_TYPE_VALUES.length; i++) { + if (MEMORY_TYPE_VALUES[i].equals(memoryType)) { + return i; + } + } + return 0; + } + + private JSONArray parseTags(String rawTags) { + JSONArray tags = new JSONArray(); + if (rawTags == null) { + return tags; + } + String[] parts = rawTags.split("[,,]"); + for (String part : parts) { + String tag = part == null ? "" : part.trim(); + if (!tag.isEmpty()) { + tags.put(tag); + } + } + return tags; + } + + private String joinTags(@Nullable JSONArray tags) { + if (tags == null || tags.length() == 0) { + return ""; + } + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < tags.length(); i++) { + String tag = tags.optString(i, "").trim(); + if (tag.isEmpty()) continue; + if (builder.length() > 0) { + builder.append(" · "); + } + builder.append(tag); + } + return builder.toString(); + } + + private String memoryTypeLabel(String memoryType) { + for (int i = 0; i < MEMORY_TYPE_VALUES.length; i++) { + if (MEMORY_TYPE_VALUES[i].equals(memoryType)) { + return MEMORY_TYPE_LABELS[i]; + } + } + return memoryType; + } + + private void updateSaveAvailability() { + if (headerActionButton != null) { + headerActionButton.setEnabled(contentLoaded); + headerActionButton.setAlpha(contentLoaded ? 1f : 0.45f); + } + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/MasterAgentPromptActivity.java b/android/app/src/main/java/com/hyzq/boss/MasterAgentPromptActivity.java new file mode 100644 index 0000000..ae17ec4 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/MasterAgentPromptActivity.java @@ -0,0 +1,182 @@ +package com.hyzq.boss; + +import android.os.Bundle; +import android.text.TextUtils; +import android.widget.EditText; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; + +import org.json.JSONObject; + +public class MasterAgentPromptActivity extends BossScreenActivity { + public static final String EXTRA_PROJECT_ID = "project_id"; + public static final String EXTRA_PROJECT_NAME = "project_name"; + + private String projectId; + private String projectName; + private boolean contentLoaded; + private @Nullable JSONObject promptPolicy; + private @Nullable JSONObject userPrompt; + private @Nullable JSONObject projectControls; + private @Nullable String adminPromptText; + private @Nullable String userPromptText; + private @Nullable String projectPromptOverrideText; + private EditText userPromptInput; + private EditText projectPromptInput; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); + projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME); + configureScreen("提示词", projectName == null ? "主 Agent 提示词分层" : projectName); + setHeaderAction("保存", v -> savePromptProfile()); + updateSaveAvailability(); + reload(); + } + + @Override + protected void reload() { + if (projectId == null || projectId.isEmpty()) { + replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。")); + setRefreshing(false); + contentLoaded = false; + updateSaveAvailability(); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.getMasterAgentPromptProfile(projectId); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + runOnUiThread(() -> renderPromptProfile(response.json)); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + contentLoaded = false; + updateSaveAvailability(); + replaceContent(BossUi.buildEmptyCard(this, "提示词加载失败:" + error.getMessage())); + }); + } + }); + } + + private void renderPromptProfile(JSONObject payload) { + promptPolicy = payload.optJSONObject("promptPolicy"); + userPrompt = payload.optJSONObject("userPrompt"); + projectControls = payload.optJSONObject("projectControls"); + adminPromptText = promptPolicy == null ? null : promptPolicy.optString("globalPrompt", ""); + userPromptText = userPrompt == null ? "" : userPrompt.optString("content", ""); + projectPromptOverrideText = payload.optString( + "projectPromptOverride", + projectControls == null ? "" : projectControls.optString("promptOverride", "") + ); + + replaceContent(); + appendContent(BossUi.buildSimpleProfileHeader( + this, + projectName == null ? "主 Agent" : projectName, + "管理员全局主提示词 + 用户私有主提示词 + 当前对话提示词", + "管理员提示词不可覆盖,用户可编辑自己的主提示词和当前对话覆盖。" + )); + + appendContent(BossUi.buildSoftPanel( + this, + "管理员全局主提示词", + TextUtils.isEmpty(adminPromptText) ? "暂无全局主提示词。" : adminPromptText, + "只读 · 由管理员 Web 后台配置 · 不可覆盖" + )); + + userPromptInput = BossUi.buildInput(this, "编辑当前用户的主 Agent 提示词", true); + userPromptInput.setText(TextUtils.isEmpty(userPromptText) ? "" : userPromptText); + userPromptInput.setMinLines(8); + userPromptInput.setText(userPromptText == null ? "" : userPromptText); + appendContent(BossUi.buildFormCell( + this, + "用户私有主提示词", + "仅影响当前登录用户的主 Agent 对话。", + userPromptInput + )); + + projectPromptInput = BossUi.buildInput(this, "编辑当前对话附加提示词", true); + projectPromptInput.setMinLines(8); + projectPromptInput.setText(projectPromptOverrideText == null ? "" : projectPromptOverrideText); + appendContent(BossUi.buildFormCell( + this, + "当前对话提示词", + "只对当前 master-agent 会话生效。", + projectPromptInput + )); + + appendContent(BossUi.buildSoftPanel( + this, + "合成预览", + buildPreviewText(), + "保存后会立即影响主 Agent 回复。" + )); + + contentLoaded = true; + updateSaveAvailability(); + setRefreshing(false); + } + + private String buildPreviewText() { + StringBuilder builder = new StringBuilder(); + if (!TextUtils.isEmpty(adminPromptText)) { + builder.append("【管理员全局主提示词】\n").append(adminPromptText).append("\n\n"); + } + String userText = userPromptInput == null ? userPromptText : userPromptInput.getText().toString(); + if (!TextUtils.isEmpty(userText)) { + builder.append("【用户私有主提示词】\n").append(userText).append("\n\n"); + } + String projectText = projectPromptInput == null ? projectPromptOverrideText : projectPromptInput.getText().toString(); + if (!TextUtils.isEmpty(projectText)) { + builder.append("【当前对话提示词】\n").append(projectText).append("\n\n"); + } + if (builder.length() == 0) { + return "当前没有任何提示词内容。"; + } + return builder.toString().trim(); + } + + private void savePromptProfile() { + if (!contentLoaded) { + showMessage("提示词尚未加载完成,请先刷新成功后再保存。"); + return; + } + final String userContent = userPromptInput == null ? "" : userPromptInput.getText().toString(); + final String promptOverride = projectPromptInput == null ? "" : projectPromptInput.getText().toString(); + setRefreshing(true); + executor.execute(() -> { + try { + JSONObject payload = new JSONObject(); + payload.put("userPromptContent", userContent); + payload.put("promptOverride", promptOverride); + BossApiClient.ApiResponse response = apiClient.updateMasterAgentPromptProfile(projectId, payload); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + runOnUiThread(() -> { + showMessage("提示词已保存"); + setResult(RESULT_OK); + finish(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("提示词保存失败:" + error.getMessage()); + }); + } + }); + } + + private void updateSaveAvailability() { + if (headerActionButton != null) { + headerActionButton.setEnabled(contentLoaded); + headerActionButton.setAlpha(contentLoaded ? 1f : 0.45f); + } + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index f6a3e50..1161b1a 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -74,6 +74,8 @@ public class ProjectDetailActivity extends BossScreenActivity { private @Nullable JSONObject currentRejectedDispatchPlan; private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection(); private ActivityResultLauncher conversationInfoLauncher; + private ActivityResultLauncher masterAgentPromptLauncher; + private ActivityResultLauncher masterAgentMemoryLauncher; private ActivityResultLauncher forwardTargetLauncher; private ActivityResultLauncher imagePickerLauncher; private ActivityResultLauncher videoPickerLauncher; @@ -164,6 +166,22 @@ public class ProjectDetailActivity extends BossScreenActivity { reload(); } ); + masterAgentPromptLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == RESULT_OK) { + reload(true); + } + } + ); + masterAgentMemoryLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == RESULT_OK) { + reload(true); + } + } + ); forwardTargetLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { @@ -627,12 +645,34 @@ public class ProjectDetailActivity extends BossScreenActivity { conversationInfoLauncher.launch(intent); } + private void openMasterAgentPromptProfile() { + if (projectId == null || projectId.isEmpty()) { + showMessage("缺少 projectId"); + return; + } + Intent intent = new Intent(this, MasterAgentPromptActivity.class); + intent.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_ID, projectId); + intent.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_NAME, initialProjectName); + masterAgentPromptLauncher.launch(intent); + } + + private void openMasterAgentMemories() { + if (projectId == null || projectId.isEmpty()) { + showMessage("缺少 projectId"); + return; + } + Intent intent = new Intent(this, MasterAgentMemoryActivity.class); + intent.putExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_ID, projectId); + intent.putExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_NAME, initialProjectName); + masterAgentMemoryLauncher.launch(intent); + } + private void showMasterAgentMoreMenu() { if (!isMasterAgentConversation()) { return; } new AlertDialog.Builder(this) - .setItems(new CharSequence[]{"模型", "推理强度", "会话信息", "刷新"}, (dialog, which) -> { + .setItems(new CharSequence[]{"模型", "推理强度", "提示词", "记忆", "会话信息", "刷新"}, (dialog, which) -> { switch (which) { case 0: showMasterAgentModelPicker(); @@ -641,9 +681,15 @@ public class ProjectDetailActivity extends BossScreenActivity { showMasterAgentReasoningPicker(); break; case 2: - openConversationInfo(); + openMasterAgentPromptProfile(); break; case 3: + openMasterAgentMemories(); + break; + case 4: + openConversationInfo(); + break; + case 5: reload(true); break; default: diff --git a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java index c19b3cf..e2c10f9 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java @@ -109,6 +109,85 @@ public class BossApiClientDispatchPlansTest { assertEquals("{\"modelOverride\":\"gpt-5.4\",\"reasoningEffortOverride\":\"high\"}", connection.requestBody()); } + @Test + public void updateProjectAgentControlsWritesPromptOverrideWhenProvided() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + BossApiClient.ApiResponse response = apiClient.updateProjectAgentControls("master-agent", "gpt-5.4", "high", "当前对话提示词"); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath); + assertEquals("POST", connection.requestMethodValue); + assertEquals( + "{\"modelOverride\":\"gpt-5.4\",\"reasoningEffortOverride\":\"high\",\"promptOverride\":\"当前对话提示词\"}", + connection.requestBody() + ); + } + + @Test + public void getMasterAgentPromptProfileUsesScopedEndpoint() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + BossApiClient.ApiResponse response = apiClient.getMasterAgentPromptProfile("master-agent"); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/master-agent/prompt-profile", apiClient.lastPath); + assertEquals("GET", connection.requestMethodValue); + } + + @Test + public void updateMasterAgentPromptProfileWritesUserPromptAndOverride() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + JSONObject payload = new JSONObject() + .put("userPromptContent", "用户私有主提示词") + .put("promptOverride", "当前对话提示词"); + BossApiClient.ApiResponse response = apiClient.updateMasterAgentPromptProfile("master-agent", payload); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/master-agent/prompt-profile", apiClient.lastPath); + assertEquals("POST", connection.requestMethodValue); + assertEquals("{\"userPromptContent\":\"用户私有主提示词\",\"promptOverride\":\"当前对话提示词\"}", connection.requestBody()); + } + + @Test + public void getMasterAgentMemoriesUsesScopedEndpoint() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/memories")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + BossApiClient.ApiResponse response = apiClient.getMasterAgentMemories("master-agent"); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/master-agent/memories", apiClient.lastPath); + assertEquals("GET", connection.requestMethodValue); + } + + @Test + public void createMasterAgentMemoryWritesStructuredPayload() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/memories")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + JSONObject payload = new JSONObject() + .put("scope", "project") + .put("projectId", "boss-console") + .put("title", "项目目标") + .put("content", "把会话页收成微信式列表") + .put("memoryType", "project_progress") + .put("tags", new JSONArray().put("ui").put("progress")); + BossApiClient.ApiResponse response = apiClient.createMasterAgentMemory("master-agent", payload); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/master-agent/memories", apiClient.lastPath); + assertEquals("POST", connection.requestMethodValue); + assertEquals( + "{\"scope\":\"project\",\"projectId\":\"boss-console\",\"title\":\"项目目标\",\"content\":\"把会话页收成微信式列表\",\"memoryType\":\"project_progress\",\"tags\":[\"ui\",\"progress\"]}", + connection.requestBody() + ); + } + @Test public void sendProjectMessageUsesQueueFriendlyReadTimeoutForMasterAgent() throws Exception { RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/messages")); diff --git a/android/app/src/test/java/com/hyzq/boss/MasterAgentMemoryActivityTest.java b/android/app/src/test/java/com/hyzq/boss/MasterAgentMemoryActivityTest.java new file mode 100644 index 0000000..fa15c5b --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/MasterAgentMemoryActivityTest.java @@ -0,0 +1,419 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Intent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.util.ReflectionHelpers; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.ProtocolException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.TimeUnit; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class MasterAgentMemoryActivityTest { + @Test + public void renderMemoriesShowsGlobalAndProjectSections() throws Exception { + TestMasterAgentMemoryActivity activity = Robolectric + .buildActivity( + TestMasterAgentMemoryActivity.class, + new Intent() + .putExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_ID, "master-agent") + .putExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_NAME, "主 Agent") + ) + .setup() + .get(); + + JSONObject globalMemory = new JSONObject() + .put("memoryId", "mem-global") + .put("scope", "global") + .put("title", "偏好") + .put("content", "优先中文回复") + .put("memoryType", "user_preference") + .put("tags", new JSONArray().put("ui")); + JSONObject projectMemory = new JSONObject() + .put("memoryId", "mem-project") + .put("scope", "project") + .put("projectId", "master-agent") + .put("title", "项目进度") + .put("content", "主 Agent 对话链已接通") + .put("memoryType", "project_progress") + .put("tags", new JSONArray().put("progress")); + JSONObject payload = new JSONObject() + .put("memories", new JSONObject() + .put("global", new JSONObject().put("items", new JSONArray().put(globalMemory))) + .put("project", new JSONObject().put("items", new JSONArray().put(projectMemory)))); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderMemories", + ReflectionHelpers.ClassParameter.from(JSONObject.class, payload) + ); + + View content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "我的通用记忆")); + assertTrue(viewTreeContainsText(content, "当前项目记忆")); + assertTrue(viewTreeContainsText(content, "优先中文回复")); + JSONObject memories = payload.getJSONObject("memories"); + JSONArray globalMemoryItems = (JSONArray) ReflectionHelpers.callInstanceMethod( + activity, + "extractMemoryItems", + ReflectionHelpers.ClassParameter.from(JSONObject.class, memories), + ReflectionHelpers.ClassParameter.from(String.class, "global") + ); + JSONArray projectMemoryItems = (JSONArray) ReflectionHelpers.callInstanceMethod( + activity, + "extractMemoryItems", + ReflectionHelpers.ClassParameter.from(JSONObject.class, memories), + ReflectionHelpers.ClassParameter.from(String.class, "project") + ); + assertEquals(1, globalMemoryItems.length()); + assertEquals(1, projectMemoryItems.length()); + assertEquals("偏好", globalMemoryItems.getJSONObject(0).getString("title")); + assertEquals("项目进度", projectMemoryItems.getJSONObject(0).getString("title")); + } + + @Test + public void saveMemoryWritesStructuredCreatePayload() throws Exception { + TestMasterAgentMemoryActivity activity = Robolectric + .buildActivity( + TestMasterAgentMemoryActivity.class, + new Intent() + .putExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_ID, "master-agent") + .putExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_NAME, "主 Agent") + ) + .setup() + .get(); + ReflectionHelpers.setField(activity, "contentLoaded", true); + ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient( + new RecordingConnection( + new URL("https://boss.hyzq.net/api/v1/projects/master-agent/memories"), + 200, + "{\"ok\":true}", + "{\"ok\":false,\"message\":\"MEMORY_SAVE_FAILED\"}" + ) + )); + ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); + ReflectionHelpers.setField(activity, "projectId", "master-agent"); + + ReflectionHelpers.callInstanceMethod( + activity, + "saveMemory", + ReflectionHelpers.ClassParameter.from(JSONObject.class, null), + ReflectionHelpers.ClassParameter.from(String.class, "project"), + ReflectionHelpers.ClassParameter.from(String.class, "项目目标"), + ReflectionHelpers.ClassParameter.from(String.class, "把会话页收成微信式列表"), + ReflectionHelpers.ClassParameter.from(String.class, "project_progress"), + ReflectionHelpers.ClassParameter.from(String.class, "ui,progress") + ); + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle(); + + assertEquals( + "{\"scope\":\"project\",\"projectId\":\"master-agent\",\"title\":\"项目目标\",\"content\":\"把会话页收成微信式列表\",\"memoryType\":\"project_progress\",\"tags\":[\"ui\",\"progress\"]}", + ((ScriptedBossApiClient) ReflectionHelpers.getField(activity, "apiClient")).connection.requestBody() + ); + } + + private static boolean viewTreeContainsText(View root, String expectedText) { + if (root instanceof TextView) { + CharSequence text = ((TextView) root).getText(); + if (text != null && text.toString().contains(expectedText)) { + return true; + } + } + if (!(root instanceof ViewGroup)) { + return false; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + if (viewTreeContainsText(group.getChildAt(index), expectedText)) { + return true; + } + } + return false; + } + + private static final class TestMasterAgentMemoryActivity extends MasterAgentMemoryActivity { + @Override + protected void reload() { + // Tests render synthetic payloads directly. + } + } + + private static final class DirectExecutorService extends AbstractExecutorService { + private boolean shutdown; + + @Override + public void shutdown() { + shutdown = true; + } + + @Override + public List shutdownNow() { + shutdown = true; + return Collections.emptyList(); + } + + @Override + public boolean isShutdown() { + return shutdown; + } + + @Override + public boolean isTerminated() { + return shutdown; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + + @Override + public void execute(Runnable command) { + command.run(); + } + } + + private static final class ScriptedBossApiClient extends BossApiClient { + private final Map connections; + private final RecordingConnection connection; + + private static final class InMemorySharedPreferences implements android.content.SharedPreferences { + private final Map values = new HashMap<>(); + + @Override + public Map getAll() { + return new HashMap<>(values); + } + + @Override + public String getString(String key, String defValue) { + Object value = values.get(key); + return value instanceof String ? (String) value : defValue; + } + + @Override + public java.util.Set getStringSet(String key, java.util.Set defValues) { + Object value = values.get(key); + if (!(value instanceof java.util.Set)) { + return defValues; + } + // noinspection unchecked + return (java.util.Set) value; + } + + @Override + public int getInt(String key, int defValue) { + Object value = values.get(key); + return value instanceof Integer ? (Integer) value : defValue; + } + + @Override + public long getLong(String key, long defValue) { + Object value = values.get(key); + return value instanceof Long ? (Long) value : defValue; + } + + @Override + public float getFloat(String key, float defValue) { + Object value = values.get(key); + return value instanceof Float ? (Float) value : defValue; + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + Object value = values.get(key); + return value instanceof Boolean ? (Boolean) value : defValue; + } + + @Override + public boolean contains(String key) { + return values.containsKey(key); + } + + @Override + public Editor edit() { + return new Editor() { + @Override public Editor putString(String key, String value) { values.put(key, value); return this; } + @Override public Editor putStringSet(String key, java.util.Set value) { values.put(key, value); return this; } + @Override public Editor putInt(String key, int value) { values.put(key, value); return this; } + @Override public Editor putLong(String key, long value) { values.put(key, value); return this; } + @Override public Editor putFloat(String key, float value) { values.put(key, value); return this; } + @Override public Editor putBoolean(String key, boolean value) { values.put(key, value); return this; } + @Override public Editor remove(String key) { values.remove(key); return this; } + @Override public Editor clear() { values.clear(); return this; } + @Override public boolean commit() { return true; } + @Override public void apply() {} + }; + } + + @Override public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {} + @Override public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {} + } + + ScriptedBossApiClient(RecordingConnection connection) { + super(new InMemorySharedPreferences(), "https://boss.hyzq.net"); + this.connection = connection; + this.connections = new HashMap<>(); + this.connections.put(connection.getURL().getPath(), connection); + } + + @Override + HttpURLConnection openConnection(String path) { + RecordingConnection scripted = connections.get(path); + if (scripted == null) { + throw new IllegalStateException("Missing scripted connection for " + path); + } + return scripted; + } + + @Override + String encode(String value) { + return value; + } + + @Override + void rememberIdentity(JSONObject json) { + // JVM 单测不需要落 Android 侧身份缓存。 + } + } + + private static final class InMemorySharedPreferences implements android.content.SharedPreferences { + private final Map values = new HashMap<>(); + + @Override + public Map getAll() { + return new HashMap<>(values); + } + + @Override + public String getString(String key, String defValue) { + Object value = values.get(key); + return value instanceof String ? (String) value : defValue; + } + + @Override + public java.util.Set getStringSet(String key, java.util.Set defValues) { + Object value = values.get(key); + return value instanceof java.util.Set ? (java.util.Set) value : defValues; + } + + @Override + public int getInt(String key, int defValue) { + Object value = values.get(key); + return value instanceof Integer ? (Integer) value : defValue; + } + + @Override + public long getLong(String key, long defValue) { + Object value = values.get(key); + return value instanceof Long ? (Long) value : defValue; + } + + @Override + public float getFloat(String key, float defValue) { + Object value = values.get(key); + return value instanceof Float ? (Float) value : defValue; + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + Object value = values.get(key); + return value instanceof Boolean ? (Boolean) value : defValue; + } + + @Override + public boolean contains(String key) { + return values.containsKey(key); + } + + @Override + public Editor edit() { + return new Editor() { + @Override + public Editor putString(String key, String value) { values.put(key, value); return this; } + @Override + public Editor putStringSet(String key, java.util.Set values) { InMemorySharedPreferences.this.values.put(key, values); return this; } + @Override + public Editor putInt(String key, int value) { InMemorySharedPreferences.this.values.put(key, value); return this; } + @Override + public Editor putLong(String key, long value) { InMemorySharedPreferences.this.values.put(key, value); return this; } + @Override + public Editor putFloat(String key, float value) { InMemorySharedPreferences.this.values.put(key, value); return this; } + @Override + public Editor putBoolean(String key, boolean value) { InMemorySharedPreferences.this.values.put(key, value); return this; } + @Override + public Editor remove(String key) { InMemorySharedPreferences.this.values.remove(key); return this; } + @Override + public Editor clear() { InMemorySharedPreferences.this.values.clear(); return this; } + @Override + public boolean commit() { return true; } + @Override + public void apply() {} + }; + } + + @Override + public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {} + + @Override + public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {} + } + + private static final class RecordingConnection extends HttpURLConnection { + private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream(); + private final Map requestHeaders = new HashMap<>(); + private final int responseCodeValue; + private final String responseBody; + private final String errorBody; + private String requestMethodValue = "GET"; + + RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) { + super(url); + this.responseCodeValue = responseCodeValue; + this.responseBody = responseBody; + this.errorBody = errorBody; + } + + @Override public void disconnect() {} + @Override public boolean usingProxy() { return false; } + @Override public void connect() {} + @Override public void setRequestMethod(String method) throws ProtocolException { requestMethodValue = method; } + @Override public void setRequestProperty(String key, String value) { requestHeaders.put(key, value); } + @Override public OutputStream getOutputStream() { return requestBody; } + @Override public int getResponseCode() { return responseCodeValue; } + @Override public InputStream getInputStream() { return new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8)); } + @Override public InputStream getErrorStream() { + if (responseCodeValue < 400) { + return null; + } + return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8)); + } + String requestBody() { return requestBody.toString(StandardCharsets.UTF_8); } + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/MasterAgentPromptActivityTest.java b/android/app/src/test/java/com/hyzq/boss/MasterAgentPromptActivityTest.java new file mode 100644 index 0000000..ff14cd9 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/MasterAgentPromptActivityTest.java @@ -0,0 +1,325 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Intent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.TextView; + +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.util.ReflectionHelpers; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.ProtocolException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.TimeUnit; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class MasterAgentPromptActivityTest { + @Test + public void renderPromptProfileShowsAdminUserAndConversationLayers() throws Exception { + TestMasterAgentPromptActivity activity = Robolectric + .buildActivity( + TestMasterAgentPromptActivity.class, + new Intent() + .putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_ID, "master-agent") + .putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_NAME, "主 Agent") + ) + .setup() + .get(); + + JSONObject payload = new JSONObject() + .put("promptPolicy", new JSONObject().put("globalPrompt", "全局主提示词")) + .put("userPrompt", new JSONObject().put("content", "用户私有主提示词")) + .put("projectControls", new JSONObject().put("promptOverride", "当前对话提示词")); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderPromptProfile", + ReflectionHelpers.ClassParameter.from(JSONObject.class, payload) + ); + + View content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "管理员全局主提示词")); + assertTrue(viewTreeContainsText(content, "全局主提示词")); + assertTrue(viewTreeContainsText(content, "用户私有主提示词")); + assertTrue(viewTreeContainsText(content, "当前对话提示词")); + assertTrue(viewTreeContainsText(content, "合成预览")); + } + + @Test + public void savePromptProfileWritesBothEditableLayers() throws Exception { + TestMasterAgentPromptActivity activity = Robolectric + .buildActivity( + TestMasterAgentPromptActivity.class, + new Intent() + .putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_ID, "master-agent") + .putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_NAME, "主 Agent") + ) + .setup() + .get(); + ReflectionHelpers.setField(activity, "contentLoaded", true); + ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient( + new RecordingConnection( + new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile"), + 200, + "{\"ok\":true,\"promptPolicy\":{\"globalPrompt\":\"全局主提示词\"},\"userPrompt\":{\"content\":\"用户私有主提示词\"},\"projectControls\":{\"promptOverride\":\"当前对话提示词\"}}", + "{\"ok\":false,\"message\":\"PROMPT_SAVE_FAILED\"}" + ) + )); + ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); + JSONObject payload = new JSONObject() + .put("promptPolicy", new JSONObject().put("globalPrompt", "全局主提示词")) + .put("userPrompt", new JSONObject().put("content", "用户私有主提示词")) + .put("projectControls", new JSONObject().put("promptOverride", "当前对话提示词")); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderPromptProfile", + ReflectionHelpers.ClassParameter.from(JSONObject.class, payload) + ); + + EditText userInput = ReflectionHelpers.getField(activity, "userPromptInput"); + EditText conversationInput = ReflectionHelpers.getField(activity, "projectPromptInput"); + userInput.setText("更新后的用户提示词"); + conversationInput.setText("更新后的对话提示词"); + + ReflectionHelpers.callInstanceMethod(activity, "savePromptProfile"); + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle(); + + assertEquals( + "{\"userPromptContent\":\"更新后的用户提示词\",\"promptOverride\":\"更新后的对话提示词\"}", + ((ScriptedBossApiClient) ReflectionHelpers.getField(activity, "apiClient")).connection.requestBody() + ); + } + + private static boolean viewTreeContainsText(View root, String expectedText) { + if (root instanceof TextView) { + CharSequence text = ((TextView) root).getText(); + if (text != null && text.toString().contains(expectedText)) { + return true; + } + } + if (!(root instanceof ViewGroup)) { + return false; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + if (viewTreeContainsText(group.getChildAt(index), expectedText)) { + return true; + } + } + return false; + } + + private static final class TestMasterAgentPromptActivity extends MasterAgentPromptActivity { + @Override + protected void reload() { + // Tests render synthetic payloads directly. + } + } + + private static final class DirectExecutorService extends AbstractExecutorService { + private boolean shutdown; + + @Override + public void shutdown() { + shutdown = true; + } + + @Override + public List shutdownNow() { + shutdown = true; + return Collections.emptyList(); + } + + @Override + public boolean isShutdown() { + return shutdown; + } + + @Override + public boolean isTerminated() { + return shutdown; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + + @Override + public void execute(Runnable command) { + command.run(); + } + } + + private static final class ScriptedBossApiClient extends BossApiClient { + private final Map connections; + private final RecordingConnection connection; + + ScriptedBossApiClient(RecordingConnection connection) { + super(new InMemorySharedPreferences(), "https://boss.hyzq.net"); + this.connection = connection; + this.connections = new HashMap<>(); + this.connections.put(connection.getURL().getPath(), connection); + } + + @Override + HttpURLConnection openConnection(String path) { + RecordingConnection scripted = connections.get(path); + if (scripted == null) { + throw new IllegalStateException("Missing scripted connection for " + path); + } + return scripted; + } + + @Override + String encode(String value) { + return value; + } + + @Override + void rememberIdentity(JSONObject json) { + // JVM 单测不需要落 Android 侧身份缓存。 + } + } + + private static final class InMemorySharedPreferences implements android.content.SharedPreferences { + private final Map values = new HashMap<>(); + + @Override + public Map getAll() { + return new HashMap<>(values); + } + + @Override + public String getString(String key, String defValue) { + Object value = values.get(key); + return value instanceof String ? (String) value : defValue; + } + + @Override + public Set getStringSet(String key, Set defValues) { + Object value = values.get(key); + if (!(value instanceof Set)) { + return defValues; + } + // noinspection unchecked + return (Set) value; + } + + @Override + public int getInt(String key, int defValue) { + Object value = values.get(key); + return value instanceof Integer ? (Integer) value : defValue; + } + + @Override + public long getLong(String key, long defValue) { + Object value = values.get(key); + return value instanceof Long ? (Long) value : defValue; + } + + @Override + public float getFloat(String key, float defValue) { + Object value = values.get(key); + return value instanceof Float ? (Float) value : defValue; + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + Object value = values.get(key); + return value instanceof Boolean ? (Boolean) value : defValue; + } + + @Override + public boolean contains(String key) { + return values.containsKey(key); + } + + @Override + public Editor edit() { + return new Editor() { + @Override + public Editor putString(String key, String value) { values.put(key, value); return this; } + @Override + public Editor putStringSet(String key, java.util.Set values) { InMemorySharedPreferences.this.values.put(key, values); return this; } + @Override + public Editor putInt(String key, int value) { InMemorySharedPreferences.this.values.put(key, value); return this; } + @Override + public Editor putLong(String key, long value) { InMemorySharedPreferences.this.values.put(key, value); return this; } + @Override + public Editor putFloat(String key, float value) { InMemorySharedPreferences.this.values.put(key, value); return this; } + @Override + public Editor putBoolean(String key, boolean value) { InMemorySharedPreferences.this.values.put(key, value); return this; } + @Override + public Editor remove(String key) { InMemorySharedPreferences.this.values.remove(key); return this; } + @Override + public Editor clear() { InMemorySharedPreferences.this.values.clear(); return this; } + @Override + public boolean commit() { return true; } + @Override + public void apply() {} + }; + } + + @Override + public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {} + + @Override + public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {} + } + + private static final class RecordingConnection extends HttpURLConnection { + private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream(); + private final Map requestHeaders = new HashMap<>(); + private final int responseCodeValue; + private final String responseBody; + private final String errorBody; + private String requestMethodValue = "GET"; + + RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) { + super(url); + this.responseCodeValue = responseCodeValue; + this.responseBody = responseBody; + this.errorBody = errorBody; + } + + @Override public void disconnect() {} + @Override public boolean usingProxy() { return false; } + @Override public void connect() {} + @Override public void setRequestMethod(String method) throws ProtocolException { requestMethodValue = method; } + @Override public void setRequestProperty(String key, String value) { requestHeaders.put(key, value); } + @Override public OutputStream getOutputStream() { return requestBody; } + @Override public int getResponseCode() { return responseCodeValue; } + @Override public InputStream getInputStream() { return new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8)); } + @Override public InputStream getErrorStream() { + if (responseCodeValue < 400) { + return null; + } + return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8)); + } + String requestBody() { return requestBody.toString(StandardCharsets.UTF_8); } + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityMasterAgentMenuTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityMasterAgentMenuTest.java index aa75359..8dfecf5 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityMasterAgentMenuTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityMasterAgentMenuTest.java @@ -44,8 +44,10 @@ public class ProjectDetailActivityMasterAgentMenuTest { assertMenuItem(listView, 0, "模型"); assertMenuItem(listView, 1, "推理强度"); - assertMenuItem(listView, 2, "会话信息"); - assertMenuItem(listView, 3, "刷新"); + assertMenuItem(listView, 2, "提示词"); + assertMenuItem(listView, 3, "记忆"); + assertMenuItem(listView, 4, "会话信息"); + assertMenuItem(listView, 5, "刷新"); } @Test diff --git a/docs/architecture/ai_handoff_index_cn.md b/docs/architecture/ai_handoff_index_cn.md index 742fa5d..aeabc6a 100644 --- a/docs/architecture/ai_handoff_index_cn.md +++ b/docs/architecture/ai_handoff_index_cn.md @@ -151,6 +151,7 @@ - `项目目标` 支持用户编辑、主 Agent 复核、完成项自动划线 - `版本迭代记录` 只读,由主 Agent 汇总 - `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` +- `我的 > 主 Agent 提示词 / 记忆` 当前可编辑管理员全局主提示词、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 项目记忆 - `我的 > AI 账号` 必须可查看和切换 `主 GPT / 备用 GPT / API 容灾` - `我的 > 技能` 必须按绑定设备展示 Skill,并支持一键复制调用语句 - `设备` 页当前只允许出现生产设备,旧演示脏数据不能回流到正式视图 diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 880e461..4844869 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -141,6 +141,8 @@ - `GET /me/ops/audit` - `GET /me/settings` - `GET /me/skills` +- `GET /me/master-agent` +- `GET /me/master-agent` ## 3. Web API 路由 @@ -812,9 +814,149 @@ - `taskType=conversation_reply` 时,会把目标 Codex 线程的原始回复写回普通单线程会话 - `taskType=dispatch_execution` 时,会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总,并更新对应执行单状态 - `failed` 时写入 relay 失败消息,并更新 AI 账号健康状态 - - 对群聊分发推荐失败的情况,消息入口当前会额外写入一条 `system_notice`,把“没有真实线程”或“成员引用失效”明确回显给用户 +- 对群聊分发推荐失败的情况,消息入口当前会额外写入一条 `system_notice`,把“没有真实线程”或“成员引用失效”明确回显给用户 - 当前保护:要求 `x-boss-device-token` 或匹配登录会话 +#### `GET /api/v1/master-agent/prompt-policy` + +- 用途:读取管理员全局主提示词 +- 当前保护:要求有效 `boss_session` + +#### `POST /api/v1/master-agent/prompt-policy` + +- 用途:管理员更新全局主提示词 +- 输入: + - `globalPrompt` +- 当前保护:要求 `highest_admin` + +#### `GET /api/v1/master-agent/prompt` + +- 用途:读取当前用户的主提示词 +- 当前保护:要求有效 `boss_session` + +#### `POST /api/v1/master-agent/prompt` + +- 用途:保存当前用户的主提示词;空内容会清空当前用户提示词 +- 输入: + - `content` +- 当前保护:要求有效 `boss_session` + +#### `DELETE /api/v1/master-agent/prompt` + +- 用途:清空当前用户的主提示词 +- 当前保护:要求有效 `boss_session` + +#### `GET /api/v1/master-agent/memories` + +- 用途:读取当前用户的主 Agent 记忆 +- 查询参数: + - `includeArchived` + - `scope` + - `projectId` +- 当前保护:要求有效 `boss_session` + +#### `POST /api/v1/master-agent/memories` + +- 用途:新增当前用户的主 Agent 记忆 +- 输入: + - `scope` + - `projectId` + - `title` + - `content` + - `memoryType` + - `tags` + - `sourceMessageId` +- 当前保护:要求有效 `boss_session` + +#### `PATCH /api/v1/master-agent/memories/[memoryId]` + +- 用途:更新当前用户的主 Agent 记忆 +- 输入: + - `scope` + - `projectId` + - `title` + - `content` + - `memoryType` + - `tags` + - `sourceMessageId` + - `lastUsedAt` +- 当前保护:要求有效 `boss_session` + +#### `DELETE /api/v1/master-agent/memories/[memoryId]` + +- 用途:归档当前用户的主 Agent 记忆 +- 当前保护:要求有效 `boss_session` + +#### `GET /api/v1/master-agent/prompt-policy` + +- 用途:读取管理员全局主提示词 +- 当前保护:要求有效 `boss_session` + +#### `POST /api/v1/master-agent/prompt-policy` + +- 用途:管理员更新全局主提示词 +- 输入: + - `globalPrompt` +- 当前保护:要求 `highest_admin` + +#### `GET /api/v1/master-agent/prompt` + +- 用途:读取当前用户的主提示词 +- 当前保护:要求有效 `boss_session` + +#### `POST /api/v1/master-agent/prompt` + +- 用途:保存当前用户的主提示词;空内容会清空当前用户提示词 +- 输入: + - `content` +- 当前保护:要求有效 `boss_session` + +#### `DELETE /api/v1/master-agent/prompt` + +- 用途:清空当前用户的主提示词 +- 当前保护:要求有效 `boss_session` + +#### `GET /api/v1/master-agent/memories` + +- 用途:读取当前用户的主 Agent 记忆 +- 查询参数: + - `includeArchived` + - `scope` + - `projectId` +- 当前保护:要求有效 `boss_session` + +#### `POST /api/v1/master-agent/memories` + +- 用途:新增当前用户的主 Agent 记忆 +- 输入: + - `scope` + - `projectId` + - `title` + - `content` + - `memoryType` + - `tags` + - `sourceMessageId` +- 当前保护:要求有效 `boss_session` + +#### `PATCH /api/v1/master-agent/memories/[memoryId]` + +- 用途:更新当前用户的主 Agent 记忆 +- 输入: + - `scope` + - `projectId` + - `title` + - `content` + - `memoryType` + - `tags` + - `sourceMessageId` + - `lastUsedAt` +- 当前保护:要求有效 `boss_session` + +#### `DELETE /api/v1/master-agent/memories/[memoryId]` + +- 用途:归档当前用户的主 Agent 记忆 +- 当前保护:要求有效 `boss_session` + ## 4. local-agent 接口 ### 4.1 `GET /health` diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index e302c21..5ba8eb3 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -111,6 +111,7 @@ cd /Users/kris/code/boss - 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:原生 Android 会先进入 `OpenAiOnboardingActivity`,自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入 - 当前 `OpenAiOnboardingActivity` 在登录成功后会直接弹出 `测试主 Agent 对话`,可一键进入 `master-agent` 聊天页验证主控链路 - 当前 `AI 账号` 页顶部会直接展示“当前主控身份”,并提供 `校验主控 / 测试主 Agent 对话` 两个入口,切换主控后不必再手动退回会话页验证 +- 当前 `我的 > 主 Agent 提示词 / 记忆` 页面已接通:管理员全局主提示词、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 项目记忆都可以在 Web 端查看和编辑 - 当前如果主控身份还是 `Master Codex Node`,但该节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,不再把失败日志直接原样回给用户 - 当前原生 Android 的聊天发送已收短客户端等待窗口;`master-agent` 单聊依赖服务端快速入队和消息流里的“主 Agent 思考中 / 回复超时 / 重试等待”状态,不再要求客户端长时间同步阻塞 - 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口 diff --git a/src/app/api/v1/master-agent/memories/[memoryId]/route.ts b/src/app/api/v1/master-agent/memories/[memoryId]/route.ts new file mode 100644 index 0000000..cd8009f --- /dev/null +++ b/src/app/api/v1/master-agent/memories/[memoryId]/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { + archiveUserMasterMemory, + updateUserMasterMemory, + type MasterMemoryScope, + type MasterMemoryType, +} from "@/lib/boss-data"; + +export const runtime = "nodejs"; + +const memoryScopes = new Set(["global", "project"]); +const memoryTypes = new Set([ + "user_preference", + "project_progress", + "decision", + "risk", + "blocking_issue", + "research_note", + "workflow_rule", +]); + +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ memoryId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { memoryId } = await context.params; + const rawBody = await request.text().catch(() => ""); + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + return NextResponse.json({ ok: false, message: "INVALID_JSON_PAYLOAD" }, { status: 400 }); + } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_PAYLOAD" }, { status: 400 }); + } + + const payload = body as { + scope?: unknown; + projectId?: unknown; + title?: unknown; + content?: unknown; + memoryType?: unknown; + tags?: unknown; + sourceMessageId?: unknown; + lastUsedAt?: unknown; + }; + const allowedKeys = new Set([ + "scope", + "projectId", + "title", + "content", + "memoryType", + "tags", + "sourceMessageId", + "lastUsedAt", + ]); + if (Object.keys(payload).some((key) => !allowedKeys.has(key))) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_PAYLOAD" }, { status: 400 }); + } + if (payload.scope !== undefined && !memoryScopes.has(payload.scope as MasterMemoryScope)) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_SCOPE" }, { status: 400 }); + } + if (payload.memoryType !== undefined && !memoryTypes.has(payload.memoryType as MasterMemoryType)) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_TYPE" }, { status: 400 }); + } + if (payload.tags !== undefined && !Array.isArray(payload.tags)) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_PAYLOAD" }, { status: 400 }); + } + + try { + const memory = await updateUserMasterMemory(memoryId, session.account, { + ...(payload.scope !== undefined ? { scope: payload.scope as MasterMemoryScope } : {}), + ...(payload.projectId !== undefined ? { projectId: typeof payload.projectId === "string" ? payload.projectId : "" } : {}), + ...(payload.title !== undefined ? { title: typeof payload.title === "string" ? payload.title : "" } : {}), + ...(payload.content !== undefined ? { content: typeof payload.content === "string" ? payload.content : "" } : {}), + ...(payload.memoryType !== undefined ? { memoryType: payload.memoryType as MasterMemoryType } : {}), + ...(payload.tags !== undefined ? { tags: payload.tags as string[] } : {}), + ...(payload.sourceMessageId !== undefined + ? { sourceMessageId: typeof payload.sourceMessageId === "string" ? payload.sourceMessageId : "" } + : {}), + ...(payload.lastUsedAt !== undefined + ? { lastUsedAt: typeof payload.lastUsedAt === "string" ? payload.lastUsedAt : "" } + : {}), + }); + return NextResponse.json({ ok: true, memory }); + } catch (error) { + const message = error instanceof Error ? error.message : "UNKNOWN_ERROR"; + return NextResponse.json( + { + ok: false, + message, + }, + { status: message === "USER_MASTER_MEMORY_NOT_FOUND" ? 404 : 400 }, + ); + } +} + +export async function DELETE( + request: NextRequest, + context: { params: Promise<{ memoryId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { memoryId } = await context.params; + try { + const memory = await archiveUserMasterMemory(memoryId, session.account); + return NextResponse.json({ ok: true, memory }); + } catch (error) { + const message = error instanceof Error ? error.message : "UNKNOWN_ERROR"; + return NextResponse.json( + { + ok: false, + message, + }, + { status: message === "USER_MASTER_MEMORY_NOT_FOUND" ? 404 : 400 }, + ); + } +} diff --git a/src/app/api/v1/master-agent/memories/route.ts b/src/app/api/v1/master-agent/memories/route.ts new file mode 100644 index 0000000..1ec75ad --- /dev/null +++ b/src/app/api/v1/master-agent/memories/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { + createUserMasterMemory, + listUserMasterMemories, + type MasterMemoryScope, + type MasterMemoryType, +} from "@/lib/boss-data"; + +export const runtime = "nodejs"; + +const memoryScopes = new Set(["global", "project"]); +const memoryTypes = new Set([ + "user_preference", + "project_progress", + "decision", + "risk", + "blocking_issue", + "research_note", + "workflow_rule", +]); + +function parseBoolean(value: string | null) { + return value === "1" || value === "true"; +} + +export async function GET(request: NextRequest) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const includeArchived = parseBoolean(searchParams.get("includeArchived")); + const scope = searchParams.get("scope") as MasterMemoryScope | null; + const projectId = searchParams.get("projectId")?.trim() || undefined; + if (scope && !memoryScopes.has(scope)) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_SCOPE" }, { status: 400 }); + } + + const memories = await listUserMasterMemories(session.account, { + includeArchived, + ...(scope ? { scope } : {}), + ...(projectId ? { projectId } : {}), + }); + return NextResponse.json({ ok: true, memories }); +} + +export async function POST(request: NextRequest) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const rawBody = await request.text().catch(() => ""); + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + return NextResponse.json({ ok: false, message: "INVALID_JSON_PAYLOAD" }, { status: 400 }); + } + + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_PAYLOAD" }, { status: 400 }); + } + + const payload = body as { + scope?: unknown; + projectId?: unknown; + title?: unknown; + content?: unknown; + memoryType?: unknown; + tags?: unknown; + sourceMessageId?: unknown; + }; + const allowedKeys = new Set([ + "scope", + "projectId", + "title", + "content", + "memoryType", + "tags", + "sourceMessageId", + ]); + if (Object.keys(payload).some((key) => !allowedKeys.has(key))) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_PAYLOAD" }, { status: 400 }); + } + if (!memoryScopes.has(payload.scope as MasterMemoryScope)) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_SCOPE" }, { status: 400 }); + } + if (typeof payload.title !== "string" || typeof payload.content !== "string") { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_PAYLOAD" }, { status: 400 }); + } + if (!memoryTypes.has(payload.memoryType as MasterMemoryType)) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_TYPE" }, { status: 400 }); + } + if (payload.scope === "project" && (typeof payload.projectId !== "string" || !payload.projectId.trim())) { + return NextResponse.json({ ok: false, message: "USER_MASTER_MEMORY_PROJECT_ID_REQUIRED" }, { status: 400 }); + } + if (payload.tags !== undefined && !Array.isArray(payload.tags)) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_PAYLOAD" }, { status: 400 }); + } + if ( + payload.sourceMessageId !== undefined && + payload.sourceMessageId !== null && + typeof payload.sourceMessageId !== "string" + ) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_PAYLOAD" }, { status: 400 }); + } + + try { + const memory = await createUserMasterMemory({ + account: session.account, + scope: payload.scope as MasterMemoryScope, + projectId: typeof payload.projectId === "string" ? payload.projectId : undefined, + title: payload.title, + content: payload.content, + memoryType: payload.memoryType as MasterMemoryType, + tags: (payload.tags as string[] | undefined) ?? [], + sourceMessageId: + typeof payload.sourceMessageId === "string" ? payload.sourceMessageId : undefined, + }); + return NextResponse.json({ ok: true, memory }); + } catch (error) { + return NextResponse.json( + { + ok: false, + message: error instanceof Error ? error.message : "UNKNOWN_ERROR", + }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/v1/master-agent/prompt-policy/route.ts b/src/app/api/v1/master-agent/prompt-policy/route.ts new file mode 100644 index 0000000..68e1b27 --- /dev/null +++ b/src/app/api/v1/master-agent/prompt-policy/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { + getMasterAgentPromptPolicy, + updateMasterAgentPromptPolicy, +} from "@/lib/boss-data"; + +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const policy = await getMasterAgentPromptPolicy(); + return NextResponse.json({ ok: true, policy }); +} + +export async function POST(request: NextRequest) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + if (session.role !== "highest_admin") { + return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 }); + } + + const rawBody = await request.text().catch(() => ""); + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + return NextResponse.json({ ok: false, message: "INVALID_JSON_PAYLOAD" }, { status: 400 }); + } + + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ ok: false, message: "INVALID_PROMPT_POLICY_PAYLOAD" }, { status: 400 }); + } + + const payload = body as { + globalPrompt?: unknown; + }; + const allowedKeys = new Set(["globalPrompt"]); + if (Object.keys(payload).some((key) => !allowedKeys.has(key))) { + return NextResponse.json({ ok: false, message: "INVALID_PROMPT_POLICY_PAYLOAD" }, { status: 400 }); + } + if (typeof payload.globalPrompt !== "string") { + return NextResponse.json({ ok: false, message: "INVALID_PROMPT_POLICY_PAYLOAD" }, { status: 400 }); + } + + try { + const policy = await updateMasterAgentPromptPolicy({ + globalPrompt: payload.globalPrompt, + updatedBy: session.account, + }); + return NextResponse.json({ ok: true, policy }); + } catch (error) { + return NextResponse.json( + { + ok: false, + message: error instanceof Error ? error.message : "UNKNOWN_ERROR", + }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/v1/master-agent/prompt/route.ts b/src/app/api/v1/master-agent/prompt/route.ts new file mode 100644 index 0000000..57fea4e --- /dev/null +++ b/src/app/api/v1/master-agent/prompt/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { + clearUserMasterPrompt, + getUserMasterPrompt, + updateUserMasterPrompt, +} from "@/lib/boss-data"; + +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const prompt = await getUserMasterPrompt(session.account); + return NextResponse.json({ ok: true, prompt }); +} + +export async function POST(request: NextRequest) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const rawBody = await request.text().catch(() => ""); + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + return NextResponse.json({ ok: false, message: "INVALID_JSON_PAYLOAD" }, { status: 400 }); + } + + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ ok: false, message: "INVALID_USER_PROMPT_PAYLOAD" }, { status: 400 }); + } + + const payload = body as { + content?: unknown; + }; + const allowedKeys = new Set(["content"]); + if (Object.keys(payload).some((key) => !allowedKeys.has(key))) { + return NextResponse.json({ ok: false, message: "INVALID_USER_PROMPT_PAYLOAD" }, { status: 400 }); + } + if (payload.content !== undefined && payload.content !== null && typeof payload.content !== "string") { + return NextResponse.json({ ok: false, message: "INVALID_USER_PROMPT_PAYLOAD" }, { status: 400 }); + } + + try { + const content = typeof payload.content === "string" ? payload.content : ""; + if (!content.trim()) { + const result = await clearUserMasterPrompt(session.account); + return NextResponse.json({ ok: true, prompt: null, ...result }); + } + const prompt = await updateUserMasterPrompt(session.account, content); + return NextResponse.json({ ok: true, prompt }); + } catch (error) { + return NextResponse.json( + { + ok: false, + message: error instanceof Error ? error.message : "UNKNOWN_ERROR", + }, + { status: 400 }, + ); + } +} + +export async function DELETE(request: NextRequest) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const result = await clearUserMasterPrompt(session.account); + return NextResponse.json({ ok: true, ...result }); +} diff --git a/src/app/api/v1/projects/[projectId]/agent-controls/route.ts b/src/app/api/v1/projects/[projectId]/agent-controls/route.ts index 5c684d5..175a759 100644 --- a/src/app/api/v1/projects/[projectId]/agent-controls/route.ts +++ b/src/app/api/v1/projects/[projectId]/agent-controls/route.ts @@ -62,15 +62,17 @@ export async function POST( const payload = body as { modelOverride?: unknown; reasoningEffortOverride?: unknown; + promptOverride?: unknown; }; const hasModelOverride = Object.prototype.hasOwnProperty.call(payload, "modelOverride"); const hasReasoningEffortOverride = Object.prototype.hasOwnProperty.call( payload, "reasoningEffortOverride", ); - const allowedKeys = new Set(["modelOverride", "reasoningEffortOverride"]); + const hasPromptOverride = Object.prototype.hasOwnProperty.call(payload, "promptOverride"); + const allowedKeys = new Set(["modelOverride", "reasoningEffortOverride", "promptOverride"]); const hasUnsupportedKeys = Object.keys(payload).some((key) => !allowedKeys.has(key)); - if ((!hasModelOverride && !hasReasoningEffortOverride) || hasUnsupportedKeys) { + if ((!hasModelOverride && !hasReasoningEffortOverride && !hasPromptOverride) || hasUnsupportedKeys) { return NextResponse.json({ ok: false, message: "INVALID_AGENT_CONTROLS_PAYLOAD" }, { status: 400 }); } @@ -89,6 +91,9 @@ export async function POST( { status: 400 }, ); } + if (hasPromptOverride && payload.promptOverride !== undefined && payload.promptOverride !== null && typeof payload.promptOverride !== "string") { + return NextResponse.json({ ok: false, message: "INVALID_PROMPT_OVERRIDE" }, { status: 400 }); + } try { const controls = await updateProjectAgentControls( @@ -96,6 +101,7 @@ export async function POST( { ...(hasModelOverride ? { modelOverride: payload.modelOverride } : {}), ...(hasReasoningEffortOverride ? { reasoningEffortOverride: payload.reasoningEffortOverride } : {}), + ...(hasPromptOverride ? { promptOverride: payload.promptOverride } : {}), }, ); return NextResponse.json({ ok: true, controls: controls ?? null }); diff --git a/src/app/api/v1/projects/[projectId]/memories/[memoryId]/route.ts b/src/app/api/v1/projects/[projectId]/memories/[memoryId]/route.ts new file mode 100644 index 0000000..43edf72 --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/memories/[memoryId]/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { archiveUserMasterMemory, hasPersistedProject, updateUserMasterMemory } from "@/lib/boss-data"; + +const memoryTypeValues = new Set([ + "user_preference", + "project_progress", + "decision", + "risk", + "blocking_issue", + "research_note", + "workflow_rule", +]); +const memoryScopeValues = new Set(["global", "project"]); + +function normalizeTags(input: unknown) { + if (!Array.isArray(input)) return [] as string[]; + return input.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean); +} + +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ projectId: string; memoryId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId, memoryId } = await context.params; + const projectExists = await hasPersistedProject(projectId); + if (!projectExists) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + + const body = (await request.json().catch(() => ({}))) as { + scope?: string; + projectId?: string; + title?: string; + content?: string; + memoryType?: string; + tags?: unknown; + sourceMessageId?: string; + lastUsedAt?: string; + }; + + try { + const patch: Parameters[2] = {}; + if (Object.prototype.hasOwnProperty.call(body, "scope")) { + if (!body.scope || !memoryScopeValues.has(body.scope)) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_SCOPE" }, { status: 400 }); + } + patch.scope = body.scope as "global" | "project"; + } + if (Object.prototype.hasOwnProperty.call(body, "projectId")) { + patch.projectId = body.projectId; + } + if (Object.prototype.hasOwnProperty.call(body, "title")) { + patch.title = body.title ?? ""; + } + if (Object.prototype.hasOwnProperty.call(body, "content")) { + patch.content = body.content ?? ""; + } + if (Object.prototype.hasOwnProperty.call(body, "memoryType")) { + if (!body.memoryType || !memoryTypeValues.has(body.memoryType)) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_TYPE" }, { status: 400 }); + } + patch.memoryType = body.memoryType as + | "user_preference" + | "project_progress" + | "decision" + | "risk" + | "blocking_issue" + | "research_note" + | "workflow_rule"; + } + if (Object.prototype.hasOwnProperty.call(body, "tags")) { + patch.tags = normalizeTags(body.tags); + } + if (Object.prototype.hasOwnProperty.call(body, "sourceMessageId")) { + patch.sourceMessageId = body.sourceMessageId; + } + if (Object.prototype.hasOwnProperty.call(body, "lastUsedAt")) { + patch.lastUsedAt = body.lastUsedAt; + } + + const memory = await updateUserMasterMemory(memoryId, session.account, patch); + return NextResponse.json({ ok: true, memory }); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { status: error instanceof Error && error.message === "USER_MASTER_MEMORY_NOT_FOUND" ? 404 : 400 }, + ); + } +} + +export async function DELETE( + request: NextRequest, + context: { params: Promise<{ projectId: string; memoryId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId, memoryId } = await context.params; + const projectExists = await hasPersistedProject(projectId); + if (!projectExists) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + + try { + const memory = await archiveUserMasterMemory(memoryId, session.account); + return NextResponse.json({ ok: true, memory }); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { status: error instanceof Error && error.message === "USER_MASTER_MEMORY_NOT_FOUND" ? 404 : 400 }, + ); + } +} diff --git a/src/app/api/v1/projects/[projectId]/memories/route.ts b/src/app/api/v1/projects/[projectId]/memories/route.ts new file mode 100644 index 0000000..68d166a --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/memories/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { + createUserMasterMemory, + hasPersistedProject, + listUserMasterMemories, +} from "@/lib/boss-data"; + +const memoryTypeValues = new Set([ + "user_preference", + "project_progress", + "decision", + "risk", + "blocking_issue", + "research_note", + "workflow_rule", +]); +const memoryScopeValues = new Set(["global", "project"]); + +function normalizeTags(input: unknown) { + if (!Array.isArray(input)) return [] as string[]; + return input.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean); +} + +export async function GET( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId } = await context.params; + const projectExists = await hasPersistedProject(projectId); + if (!projectExists) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + + const [globalMemories, projectMemories] = await Promise.all([ + listUserMasterMemories(session.account, { scope: "global" }), + listUserMasterMemories(session.account, { scope: "project", projectId }), + ]); + + return NextResponse.json({ + ok: true, + projectId, + memories: { + global: globalMemories, + project: projectMemories, + }, + }); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId } = await context.params; + const projectExists = await hasPersistedProject(projectId); + if (!projectExists) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + + const body = (await request.json().catch(() => ({}))) as { + scope?: string; + projectId?: string; + title?: string; + content?: string; + memoryType?: string; + tags?: unknown; + sourceMessageId?: string; + }; + + if (!body.scope || !memoryScopeValues.has(body.scope)) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_SCOPE" }, { status: 400 }); + } + if (!body.title?.trim()) { + return NextResponse.json({ ok: false, message: "USER_MASTER_MEMORY_TITLE_REQUIRED" }, { status: 400 }); + } + if (!body.content?.trim()) { + return NextResponse.json({ ok: false, message: "USER_MASTER_MEMORY_CONTENT_REQUIRED" }, { status: 400 }); + } + if (!body.memoryType || !memoryTypeValues.has(body.memoryType)) { + return NextResponse.json({ ok: false, message: "INVALID_MEMORY_TYPE" }, { status: 400 }); + } + + const targetProjectId = body.scope === "project" ? (body.projectId?.trim() || projectId) : undefined; + if (body.scope === "project" && !targetProjectId) { + return NextResponse.json({ ok: false, message: "USER_MASTER_MEMORY_PROJECT_ID_REQUIRED" }, { status: 400 }); + } + + try { + const memory = await createUserMasterMemory({ + account: session.account, + scope: body.scope as "global" | "project", + projectId: targetProjectId, + title: body.title, + content: body.content, + memoryType: body.memoryType as + | "user_preference" + | "project_progress" + | "decision" + | "risk" + | "blocking_issue" + | "research_note" + | "workflow_rule", + tags: normalizeTags(body.tags), + sourceMessageId: typeof body.sourceMessageId === "string" && body.sourceMessageId.trim() ? body.sourceMessageId.trim() : undefined, + }); + return NextResponse.json({ ok: true, memory }); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/v1/projects/[projectId]/prompt-profile/route.ts b/src/app/api/v1/projects/[projectId]/prompt-profile/route.ts new file mode 100644 index 0000000..09838d1 --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/prompt-profile/route.ts @@ -0,0 +1,126 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { + clearUserMasterPrompt, + getMasterAgentPromptPolicy, + getProjectAgentControls, + getUserMasterPrompt, + hasPersistedProject, + updateProjectAgentControls, + updateUserMasterPrompt, +} from "@/lib/boss-data"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId } = await context.params; + const projectExists = await hasPersistedProject(projectId); + if (!projectExists) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + + const [promptPolicy, userPrompt, projectControls] = await Promise.all([ + getMasterAgentPromptPolicy(), + getUserMasterPrompt(session.account), + getProjectAgentControls(projectId), + ]); + + return NextResponse.json({ + ok: true, + projectId, + promptPolicy, + userPrompt, + projectControls, + projectPromptOverride: projectControls?.promptOverride ?? null, + account: session.account, + }); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId } = await context.params; + const projectExists = await hasPersistedProject(projectId); + if (!projectExists) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + + const rawBody = await request.text().catch(() => ""); + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + return NextResponse.json({ ok: false, message: "INVALID_JSON_PAYLOAD" }, { status: 400 }); + } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ ok: false, message: "INVALID_PROMPT_PROFILE_PAYLOAD" }, { status: 400 }); + } + + const payload = body as { + userPromptContent?: unknown; + promptOverride?: unknown; + }; + const hasUserPromptContent = Object.prototype.hasOwnProperty.call(payload, "userPromptContent"); + const hasPromptOverride = Object.prototype.hasOwnProperty.call(payload, "promptOverride"); + const allowedKeys = new Set(["userPromptContent", "promptOverride"]); + const hasUnsupportedKeys = Object.keys(payload).some((key) => !allowedKeys.has(key)); + if ((!hasUserPromptContent && !hasPromptOverride) || hasUnsupportedKeys) { + return NextResponse.json({ ok: false, message: "INVALID_PROMPT_PROFILE_PAYLOAD" }, { status: 400 }); + } + if (hasUserPromptContent && payload.userPromptContent !== undefined && payload.userPromptContent !== null && typeof payload.userPromptContent !== "string") { + return NextResponse.json({ ok: false, message: "INVALID_USER_PROMPT_CONTENT" }, { status: 400 }); + } + if (hasPromptOverride && payload.promptOverride !== undefined && payload.promptOverride !== null && typeof payload.promptOverride !== "string") { + return NextResponse.json({ ok: false, message: "INVALID_PROMPT_OVERRIDE" }, { status: 400 }); + } + + try { + if (hasUserPromptContent) { + const userPromptContent = typeof payload.userPromptContent === "string" ? payload.userPromptContent.trim() : ""; + if (userPromptContent) { + await updateUserMasterPrompt(session.account, userPromptContent); + } else { + await clearUserMasterPrompt(session.account); + } + } + + if (hasPromptOverride) { + await updateProjectAgentControls(projectId, { + promptOverride: payload.promptOverride, + }); + } + + const [promptPolicy, userPrompt, projectControls] = await Promise.all([ + getMasterAgentPromptPolicy(), + getUserMasterPrompt(session.account), + getProjectAgentControls(projectId), + ]); + + return NextResponse.json({ + ok: true, + projectId, + promptPolicy, + userPrompt, + projectControls, + projectPromptOverride: projectControls?.promptOverride ?? null, + account: session.account, + }); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { status: error instanceof Error && error.message === "PROJECT_NOT_FOUND" ? 404 : 400 }, + ); + } +} diff --git a/src/app/me/master-agent/page.tsx b/src/app/me/master-agent/page.tsx new file mode 100644 index 0000000..d58fe61 --- /dev/null +++ b/src/app/me/master-agent/page.tsx @@ -0,0 +1,53 @@ +import { AppShell, PageNav, StatusBar } from "@/components/app-ui"; +import { MasterAgentPromptMemoryClient } from "@/components/master-agent-prompt-memory-client"; +import { requirePageSession } from "@/lib/boss-auth"; +import { + getMasterAgentPromptPolicy, + getProjectAgentControls, + getUserMasterPrompt, + listUserMasterMemories, +} from "@/lib/boss-data"; + +export const dynamic = "force-dynamic"; + +export default async function MasterAgentPromptMemoryPage() { + const session = await requirePageSession(); + const [promptPolicy, userPrompt, projectControls, globalMemories, projectMemories] = + await Promise.all([ + getMasterAgentPromptPolicy(), + getUserMasterPrompt(session.account), + getProjectAgentControls("master-agent"), + listUserMasterMemories(session.account, { includeArchived: false, scope: "global" }), + listUserMasterMemories(session.account, { + includeArchived: false, + scope: "project", + projectId: "master-agent", + }), + ]); + + return ( + + + +
+
+ 管理员全局主提示词会作为最高层系统规则;用户提示词和项目记忆会按当前账号和当前项目分别生效。 + 当前登录账号:{session.account} +
+ {session.role === "highest_admin" + ? "你是管理员,可以编辑全局主提示词与当前对话附加提示词。" + : "你可以编辑自己的提示词与记忆;管理员全局主提示词只读。"} +
+
+ +
+ ); +} diff --git a/src/app/me/page.tsx b/src/app/me/page.tsx index 0346815..6db2ff9 100644 --- a/src/app/me/page.tsx +++ b/src/app/me/page.tsx @@ -21,6 +21,11 @@ export default async function MePage() {
+ = [ + { value: "global", label: "通用记忆" }, + { value: "project", label: "项目记忆" }, +]; + +const memoryTypeOptions: Array<{ value: MasterMemoryType; label: string }> = [ + { value: "user_preference", label: "用户偏好" }, + { value: "project_progress", label: "项目进度" }, + { value: "decision", label: "决策" }, + { value: "risk", label: "风险" }, + { value: "blocking_issue", label: "阻塞" }, + { value: "research_note", label: "调研" }, + { value: "workflow_rule", label: "工作规则" }, +]; + +function memoryTypeLabel(value: MasterMemoryType) { + return memoryTypeOptions.find((item) => item.value === value)?.label ?? value; +} + +function memoryScopeLabel(value: MasterMemoryScope) { + return memoryScopeOptions.find((item) => item.value === value)?.label ?? value; +} + +function tagsToText(tags: string[]) { + return tags.join(", "); +} + +function textToTags(value: string) { + return value + .split(/[,,、\n]/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function draftFromMemory(memory: MasterAgentMemory): MemoryDraft { + return { + scope: memory.scope, + projectId: memory.projectId ?? "master-agent", + title: memory.title, + content: memory.content, + memoryType: memory.memoryType, + tags: tagsToText(memory.tags), + sourceMessageId: memory.sourceMessageId ?? "", + }; +} + +function makeNewMemoryDraft(): MemoryDraft { + return { + scope: "global", + projectId: "master-agent", + title: "", + content: "", + memoryType: "user_preference", + tags: "", + sourceMessageId: "", + }; +} + +function Field({ + label, + value, + onChange, + placeholder, + type = "text", +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: "text" | "password"; +}) { + return ( + + ); +} + +function TextArea({ + label, + value, + onChange, + placeholder, + readOnly = false, +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + readOnly?: boolean; +}) { + return ( +