feat: add master-agent prompts and memory management
This commit is contained in:
@@ -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 节点`
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
<activity android:name=".SettingsActivity" android:exported="false" />
|
||||
<activity android:name=".AiAccountsActivity" android:exported="false" />
|
||||
<activity android:name=".OpenAiOnboardingActivity" android:exported="false" />
|
||||
<activity android:name=".MasterAgentPromptActivity" android:exported="false" />
|
||||
<activity android:name=".MasterAgentMemoryActivity" android:exported="false" />
|
||||
<activity android:name=".OpsCenterActivity" android:exported="false" />
|
||||
<activity android:name=".AboutActivity" android:exported="false" />
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String> scopeAdapter = new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
MEMORY_SCOPE_LABELS
|
||||
);
|
||||
scopeSpinner.setAdapter(scopeAdapter);
|
||||
|
||||
final Spinner typeSpinner = new Spinner(this);
|
||||
ArrayAdapter<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private @Nullable JSONObject currentRejectedDispatchPlan;
|
||||
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
|
||||
private ActivityResultLauncher<Intent> conversationInfoLauncher;
|
||||
private ActivityResultLauncher<Intent> masterAgentPromptLauncher;
|
||||
private ActivityResultLauncher<Intent> masterAgentMemoryLauncher;
|
||||
private ActivityResultLauncher<Intent> forwardTargetLauncher;
|
||||
private ActivityResultLauncher<String> imagePickerLauncher;
|
||||
private ActivityResultLauncher<String> 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:
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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<Runnable> 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<String, RecordingConnection> connections;
|
||||
private final RecordingConnection connection;
|
||||
|
||||
private static final class InMemorySharedPreferences implements android.content.SharedPreferences {
|
||||
private final Map<String, Object> values = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public Map<String, ?> 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<String> getStringSet(String key, java.util.Set<String> defValues) {
|
||||
Object value = values.get(key);
|
||||
if (!(value instanceof java.util.Set)) {
|
||||
return defValues;
|
||||
}
|
||||
// noinspection unchecked
|
||||
return (java.util.Set<String>) 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<String> 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<String, Object> values = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public Map<String, ?> 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<String> getStringSet(String key, java.util.Set<String> defValues) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof java.util.Set ? (java.util.Set<String>) 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<String> 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<String, String> 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); }
|
||||
}
|
||||
}
|
||||
@@ -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<Runnable> 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<String, RecordingConnection> 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<String, Object> values = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public Map<String, ?> 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<String> getStringSet(String key, Set<String> defValues) {
|
||||
Object value = values.get(key);
|
||||
if (!(value instanceof Set)) {
|
||||
return defValues;
|
||||
}
|
||||
// noinspection unchecked
|
||||
return (Set<String>) 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<String> 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<String, String> 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); }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
- `项目目标` 支持用户编辑、主 Agent 复核、完成项自动划线
|
||||
- `版本迭代记录` 只读,由主 Agent 汇总
|
||||
- `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
|
||||
- `我的 > 主 Agent 提示词 / 记忆` 当前可编辑管理员全局主提示词、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 项目记忆
|
||||
- `我的 > AI 账号` 必须可查看和切换 `主 GPT / 备用 GPT / API 容灾`
|
||||
- `我的 > 技能` 必须按绑定设备展示 Skill,并支持一键复制调用语句
|
||||
- `设备` 页当前只允许出现生产设备,旧演示脏数据不能回流到正式视图
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口
|
||||
|
||||
128
src/app/api/v1/master-agent/memories/[memoryId]/route.ts
Normal file
128
src/app/api/v1/master-agent/memories/[memoryId]/route.ts
Normal file
@@ -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<MasterMemoryScope>(["global", "project"]);
|
||||
const memoryTypes = new Set<MasterMemoryType>([
|
||||
"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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
133
src/app/api/v1/master-agent/memories/route.ts
Normal file
133
src/app/api/v1/master-agent/memories/route.ts
Normal file
@@ -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<MasterMemoryScope>(["global", "project"]);
|
||||
const memoryTypes = new Set<MasterMemoryType>([
|
||||
"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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
67
src/app/api/v1/master-agent/prompt-policy/route.ts
Normal file
67
src/app/api/v1/master-agent/prompt-policy/route.ts
Normal file
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
77
src/app/api/v1/master-agent/prompt/route.ts
Normal file
77
src/app/api/v1/master-agent/prompt/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
121
src/app/api/v1/projects/[projectId]/memories/[memoryId]/route.ts
Normal file
121
src/app/api/v1/projects/[projectId]/memories/[memoryId]/route.ts
Normal file
@@ -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<typeof updateUserMasterMemory>[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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
123
src/app/api/v1/projects/[projectId]/memories/route.ts
Normal file
123
src/app/api/v1/projects/[projectId]/memories/route.ts
Normal file
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
126
src/app/api/v1/projects/[projectId]/prompt-profile/route.ts
Normal file
126
src/app/api/v1/projects/[projectId]/prompt-profile/route.ts
Normal file
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/app/me/master-agent/page.tsx
Normal file
53
src/app/me/master-agent/page.tsx
Normal file
@@ -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 (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="主 Agent 提示词 / 记忆" backHref="/me" />
|
||||
<div className="px-[18px] pb-3">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-[13px] leading-6 text-[#57606A]">
|
||||
管理员全局主提示词会作为最高层系统规则;用户提示词和项目记忆会按当前账号和当前项目分别生效。
|
||||
当前登录账号:<span className="font-semibold text-[#111111]">{session.account}</span>
|
||||
<br />
|
||||
{session.role === "highest_admin"
|
||||
? "你是管理员,可以编辑全局主提示词与当前对话附加提示词。"
|
||||
: "你可以编辑自己的提示词与记忆;管理员全局主提示词只读。"}
|
||||
</div>
|
||||
</div>
|
||||
<MasterAgentPromptMemoryClient
|
||||
key={`${promptPolicy?.updatedAt ?? "none"}:${userPrompt?.updatedAt ?? "none"}:${projectControls?.updatedAt ?? "none"}:${globalMemories.length}:${projectMemories.length}`}
|
||||
isAdmin={session.role === "highest_admin"}
|
||||
promptPolicy={promptPolicy}
|
||||
userPrompt={userPrompt}
|
||||
projectControls={projectControls}
|
||||
globalMemories={globalMemories}
|
||||
projectMemories={projectMemories}
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,11 @@ export default async function MePage() {
|
||||
<HeaderTitle title="我的" />
|
||||
<div className="flex flex-col gap-3 px-[18px] pb-5">
|
||||
<ProfileHero user={state.user} />
|
||||
<MenuRow
|
||||
href="/me/master-agent"
|
||||
title="主 Agent 提示词 / 记忆"
|
||||
description="配置全局主提示词、当前主提示词和用户记忆"
|
||||
/>
|
||||
<MenuRow
|
||||
href="/me/storage"
|
||||
title="附件与存储"
|
||||
|
||||
771
src/components/master-agent-prompt-memory-client.tsx
Normal file
771
src/components/master-agent-prompt-memory-client.tsx
Normal file
@@ -0,0 +1,771 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import type {
|
||||
MasterAgentMemory,
|
||||
MasterAgentPromptPolicy,
|
||||
MasterMemoryScope,
|
||||
MasterMemoryType,
|
||||
ProjectAgentControls,
|
||||
UserMasterPrompt,
|
||||
} from "@/lib/boss-data";
|
||||
import { formatTimestampLabel } from "@/lib/boss-projections";
|
||||
|
||||
type MemoryDraft = {
|
||||
scope: MasterMemoryScope;
|
||||
projectId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
memoryType: MasterMemoryType;
|
||||
tags: string;
|
||||
sourceMessageId: string;
|
||||
};
|
||||
|
||||
const memoryScopeOptions: Array<{ value: MasterMemoryScope; label: string }> = [
|
||||
{ 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 (
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">{label}</div>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TextArea({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">{label}</div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
readOnly={readOnly}
|
||||
placeholder={placeholder}
|
||||
rows={6}
|
||||
className={clsx(
|
||||
"w-full rounded-xl border px-3 py-2 text-[13px] leading-6 text-[#111111] outline-none",
|
||||
readOnly ? "border-[#E5E5EA] bg-[#F7F8FA] text-[#57606A]" : "border-[#E5E5EA] bg-white",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function MasterAgentPromptMemoryClient({
|
||||
isAdmin,
|
||||
promptPolicy,
|
||||
userPrompt,
|
||||
projectControls,
|
||||
globalMemories,
|
||||
projectMemories,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
promptPolicy: MasterAgentPromptPolicy | null;
|
||||
userPrompt: UserMasterPrompt | null;
|
||||
projectControls: ProjectAgentControls | null;
|
||||
globalMemories: MasterAgentMemory[];
|
||||
projectMemories: MasterAgentMemory[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [globalPrompt, setGlobalPrompt] = useState(promptPolicy?.globalPrompt ?? "");
|
||||
const [userPromptContent, setUserPromptContent] = useState(userPrompt?.content ?? "");
|
||||
const [modelOverride, setModelOverride] = useState(projectControls?.modelOverride ?? "");
|
||||
const [reasoningEffortOverride, setReasoningEffortOverride] = useState(
|
||||
projectControls?.reasoningEffortOverride ?? "",
|
||||
);
|
||||
const [promptOverride, setPromptOverride] = useState(projectControls?.promptOverride ?? "");
|
||||
const [newMemory, setNewMemory] = useState<MemoryDraft>(makeNewMemoryDraft());
|
||||
const [memoryDrafts, setMemoryDrafts] = useState<Record<string, MemoryDraft>>(() => {
|
||||
const next: Record<string, MemoryDraft> = {};
|
||||
[...globalMemories, ...projectMemories].forEach((memory) => {
|
||||
next[memory.memoryId] = draftFromMemory(memory);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
const allMemories = useMemo(() => [...projectMemories, ...globalMemories], [projectMemories, globalMemories]);
|
||||
|
||||
function updateMemoryDraft(memoryId: string, updater: (draft: MemoryDraft) => MemoryDraft) {
|
||||
setMemoryDrafts((current) => ({
|
||||
...current,
|
||||
[memoryId]: updater(current[memoryId] ?? draftFromMemory(allMemories.find((item) => item.memoryId === memoryId)!)),
|
||||
}));
|
||||
}
|
||||
|
||||
async function saveGlobalPrompt() {
|
||||
if (!isAdmin) {
|
||||
setMessage("只有管理员可以编辑全局主提示词。");
|
||||
return;
|
||||
}
|
||||
setBusyKey("global_prompt");
|
||||
const response = await fetch("/api/v1/master-agent/prompt-policy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ globalPrompt }),
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
setBusyKey(null);
|
||||
setMessage(result.ok ? "管理员全局主提示词已保存。" : result.message ?? "保存失败。");
|
||||
if (result.ok) router.refresh();
|
||||
}
|
||||
|
||||
async function saveUserPrompt() {
|
||||
setBusyKey("user_prompt");
|
||||
const response = await fetch("/api/v1/master-agent/prompt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: userPromptContent }),
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
setBusyKey(null);
|
||||
setMessage(result.ok ? "用户主提示词已保存。" : result.message ?? "保存失败。");
|
||||
if (result.ok) router.refresh();
|
||||
}
|
||||
|
||||
async function clearUserPrompt() {
|
||||
setBusyKey("user_prompt_clear");
|
||||
const response = await fetch("/api/v1/master-agent/prompt", {
|
||||
method: "DELETE",
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
setBusyKey(null);
|
||||
setMessage(result.ok ? "用户主提示词已清空。" : result.message ?? "清空失败。");
|
||||
if (result.ok) {
|
||||
setUserPromptContent("");
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConversationPrompt() {
|
||||
if (!isAdmin) {
|
||||
setMessage("只有管理员可以修改当前对话附加提示词。");
|
||||
return;
|
||||
}
|
||||
setBusyKey("conversation_prompt");
|
||||
const response = await fetch("/api/v1/projects/master-agent/agent-controls", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
modelOverride: modelOverride.trim() || null,
|
||||
reasoningEffortOverride: reasoningEffortOverride.trim() || null,
|
||||
promptOverride: promptOverride.trim() || null,
|
||||
}),
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
setBusyKey(null);
|
||||
setMessage(result.ok ? "当前对话覆盖已保存。" : result.message ?? "保存失败。");
|
||||
if (result.ok) router.refresh();
|
||||
}
|
||||
|
||||
async function createMemory() {
|
||||
if (!newMemory.title.trim() || !newMemory.content.trim()) {
|
||||
setMessage("记忆标题和内容不能为空。");
|
||||
return;
|
||||
}
|
||||
if (newMemory.scope === "project" && !newMemory.projectId.trim()) {
|
||||
setMessage("项目记忆必须填写 projectId。");
|
||||
return;
|
||||
}
|
||||
setBusyKey("memory_create");
|
||||
const response = await fetch("/api/v1/master-agent/memories", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
scope: newMemory.scope,
|
||||
projectId: newMemory.scope === "project" ? newMemory.projectId.trim() : undefined,
|
||||
title: newMemory.title.trim(),
|
||||
content: newMemory.content.trim(),
|
||||
memoryType: newMemory.memoryType,
|
||||
tags: textToTags(newMemory.tags),
|
||||
sourceMessageId: newMemory.sourceMessageId.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
setBusyKey(null);
|
||||
setMessage(result.ok ? "记忆已新增。" : result.message ?? "新增失败。");
|
||||
if (result.ok) {
|
||||
setNewMemory(makeNewMemoryDraft());
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMemory(memoryId: string) {
|
||||
const draft = memoryDrafts[memoryId];
|
||||
if (!draft?.title.trim() || !draft.content.trim()) {
|
||||
setMessage("记忆标题和内容不能为空。");
|
||||
return;
|
||||
}
|
||||
if (draft.scope === "project" && !draft.projectId.trim()) {
|
||||
setMessage("项目记忆必须填写 projectId。");
|
||||
return;
|
||||
}
|
||||
setBusyKey(`memory_save:${memoryId}`);
|
||||
const response = await fetch(`/api/v1/master-agent/memories/${memoryId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
scope: draft.scope,
|
||||
projectId: draft.scope === "project" ? draft.projectId.trim() : null,
|
||||
title: draft.title.trim(),
|
||||
content: draft.content.trim(),
|
||||
memoryType: draft.memoryType,
|
||||
tags: textToTags(draft.tags),
|
||||
sourceMessageId: draft.sourceMessageId.trim() || null,
|
||||
}),
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
setBusyKey(null);
|
||||
setMessage(result.ok ? "记忆已保存。" : result.message ?? "保存失败。");
|
||||
if (result.ok) router.refresh();
|
||||
}
|
||||
|
||||
async function archiveMemory(memoryId: string) {
|
||||
setBusyKey(`memory_delete:${memoryId}`);
|
||||
const response = await fetch(`/api/v1/master-agent/memories/${memoryId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
setBusyKey(null);
|
||||
setMessage(result.ok ? "记忆已归档。" : result.message ?? "删除失败。");
|
||||
if (result.ok) router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-[18px] pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">主 Agent 提示词</div>
|
||||
<div className="mt-2 text-[12px] leading-6 text-[#8C8C8C]">
|
||||
管理员全局主提示词不可被覆盖;用户提示词和当前对话提示词只会追加在后面。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[16px] font-semibold text-[#111111]">管理员全局主提示词</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">系统级规则,仅管理员可编辑。</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-[#EEF5FF] px-3 py-1 text-[11px] font-semibold text-[#2457C5]">
|
||||
不可覆盖
|
||||
</span>
|
||||
</div>
|
||||
<TextArea
|
||||
label="全局主提示词"
|
||||
value={globalPrompt}
|
||||
onChange={setGlobalPrompt}
|
||||
placeholder="请输入管理员全局主提示词"
|
||||
readOnly={!isAdmin}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveGlobalPrompt()}
|
||||
disabled={!isAdmin || busyKey === "global_prompt"}
|
||||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{busyKey === "global_prompt" ? "保存中" : isAdmin ? "保存全局主提示词" : "仅管理员可修改"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">我的主提示词</div>
|
||||
<div className="text-[12px] leading-6 text-[#8C8C8C]">只影响当前用户自己的主 Agent 长期偏好。</div>
|
||||
<TextArea
|
||||
label="用户私有主提示词"
|
||||
value={userPromptContent}
|
||||
onChange={setUserPromptContent}
|
||||
placeholder="例如:回复要简洁、直接、中文优先"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveUserPrompt()}
|
||||
disabled={busyKey === "user_prompt"}
|
||||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{busyKey === "user_prompt" ? "保存中" : "保存用户提示词"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clearUserPrompt()}
|
||||
disabled={busyKey === "user_prompt_clear"}
|
||||
className="rounded-full border border-[#E5E5EA] bg-white px-4 py-2 text-[13px] font-semibold text-[#111111] disabled:opacity-60"
|
||||
>
|
||||
{busyKey === "user_prompt_clear" ? "清空中" : "清空"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[16px] font-semibold text-[#111111]">当前对话附加提示词</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">只作用于 master-agent 当前对话。</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-[#FFF5E8] px-3 py-1 text-[11px] font-semibold text-[#B54708]">
|
||||
当前对话
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">模型</div>
|
||||
<select
|
||||
value={modelOverride}
|
||||
onChange={(event) => setModelOverride(event.target.value)}
|
||||
disabled={!isAdmin}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
|
||||
>
|
||||
<option value="">默认</option>
|
||||
<option value="gpt-5.4">gpt-5.4</option>
|
||||
<option value="gpt-4.1">gpt-4.1</option>
|
||||
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">推理强度</div>
|
||||
<select
|
||||
value={reasoningEffortOverride}
|
||||
onChange={(event) => setReasoningEffortOverride(event.target.value)}
|
||||
disabled={!isAdmin}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
|
||||
>
|
||||
<option value="">默认</option>
|
||||
<option value="low">low</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="high">high</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<TextArea
|
||||
label="当前对话附加提示词"
|
||||
value={promptOverride}
|
||||
onChange={setPromptOverride}
|
||||
placeholder="例如:这轮先输出结论,再输出执行计划"
|
||||
readOnly={!isAdmin}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveConversationPrompt()}
|
||||
disabled={!isAdmin || busyKey === "conversation_prompt"}
|
||||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{busyKey === "conversation_prompt" ? "保存中" : isAdmin ? "保存当前对话设置" : "仅管理员可修改"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">新增记忆</div>
|
||||
<div className="mt-2 text-[12px] leading-6 text-[#8C8C8C]">
|
||||
支持自动沉淀后的手动增补、编辑和归档。项目记忆默认绑定到当前项目。
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">记忆类型</div>
|
||||
<select
|
||||
value={newMemory.memoryType}
|
||||
onChange={(event) =>
|
||||
setNewMemory((current) => ({
|
||||
...current,
|
||||
memoryType: event.target.value as MasterMemoryType,
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
|
||||
>
|
||||
{memoryTypeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">范围</div>
|
||||
<select
|
||||
value={newMemory.scope}
|
||||
onChange={(event) =>
|
||||
setNewMemory((current) => ({
|
||||
...current,
|
||||
scope: event.target.value as MasterMemoryScope,
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
|
||||
>
|
||||
{memoryScopeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{newMemory.scope === "project" ? (
|
||||
<Field
|
||||
label="projectId"
|
||||
value={newMemory.projectId}
|
||||
onChange={(value) => setNewMemory((current) => ({ ...current, projectId: value }))}
|
||||
placeholder="例如 master-agent"
|
||||
/>
|
||||
) : null}
|
||||
<Field
|
||||
label="标题"
|
||||
value={newMemory.title}
|
||||
onChange={(value) => setNewMemory((current) => ({ ...current, title: value }))}
|
||||
placeholder="例如:项目进度"
|
||||
/>
|
||||
<TextArea
|
||||
label="内容"
|
||||
value={newMemory.content}
|
||||
onChange={(value) => setNewMemory((current) => ({ ...current, content: value }))}
|
||||
placeholder="例如:主 Agent 提示词与记忆链路已经接通。"
|
||||
/>
|
||||
<Field
|
||||
label="标签(逗号分隔)"
|
||||
value={newMemory.tags}
|
||||
onChange={(value) => setNewMemory((current) => ({ ...current, tags: value }))}
|
||||
placeholder="例如:主Agent, 记忆"
|
||||
/>
|
||||
<Field
|
||||
label="sourceMessageId(可选)"
|
||||
value={newMemory.sourceMessageId}
|
||||
onChange={(value) => setNewMemory((current) => ({ ...current, sourceMessageId: value }))}
|
||||
placeholder="可留空"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void createMemory()}
|
||||
disabled={busyKey === "memory_create"}
|
||||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{busyKey === "memory_create" ? "新增中" : "新增记忆"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">项目记忆</div>
|
||||
<div className="text-[12px] text-[#8C8C8C]">当前 master-agent 项目相关记忆。</div>
|
||||
{projectMemories.length === 0 ? (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] text-[#57606A]">暂无项目记忆。</div>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
{projectMemories.map((memory) => {
|
||||
const draft = memoryDrafts[memory.memoryId] ?? draftFromMemory(memory);
|
||||
return (
|
||||
<div key={memory.memoryId} className="rounded-2xl border border-[#E5E5EA] bg-[#FCFCFD] px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-[#111111]">{memory.title}</div>
|
||||
<div className="mt-1 text-[11px] text-[#8C8C8C]">
|
||||
{memoryScopeLabel(memory.scope)} · {memoryTypeLabel(memory.memoryType)}
|
||||
{memory.projectId ? ` · ${memory.projectId}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-[#8C8C8C]">
|
||||
{memory.archived ? "已归档" : formatTimestampLabel(memory.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<Field
|
||||
label="标题"
|
||||
value={draft.title}
|
||||
onChange={(value) =>
|
||||
updateMemoryDraft(memory.memoryId, (current) => ({ ...current, title: value }))
|
||||
}
|
||||
placeholder="记忆标题"
|
||||
/>
|
||||
<TextArea
|
||||
label="内容"
|
||||
value={draft.content}
|
||||
onChange={(value) =>
|
||||
updateMemoryDraft(memory.memoryId, (current) => ({ ...current, content: value }))
|
||||
}
|
||||
placeholder="记忆内容"
|
||||
/>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">范围</div>
|
||||
<select
|
||||
value={draft.scope}
|
||||
onChange={(event) =>
|
||||
updateMemoryDraft(memory.memoryId, (current) => ({
|
||||
...current,
|
||||
scope: event.target.value as MasterMemoryScope,
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
|
||||
>
|
||||
{memoryScopeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">记忆类型</div>
|
||||
<select
|
||||
value={draft.memoryType}
|
||||
onChange={(event) =>
|
||||
updateMemoryDraft(memory.memoryId, (current) => ({
|
||||
...current,
|
||||
memoryType: event.target.value as MasterMemoryType,
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
|
||||
>
|
||||
{memoryTypeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{draft.scope === "project" ? (
|
||||
<Field
|
||||
label="projectId"
|
||||
value={draft.projectId}
|
||||
onChange={(value) =>
|
||||
updateMemoryDraft(memory.memoryId, (current) => ({ ...current, projectId: value }))
|
||||
}
|
||||
placeholder="例如 master-agent"
|
||||
/>
|
||||
) : null}
|
||||
<Field
|
||||
label="标签"
|
||||
value={draft.tags}
|
||||
onChange={(value) =>
|
||||
updateMemoryDraft(memory.memoryId, (current) => ({ ...current, tags: value }))
|
||||
}
|
||||
placeholder="逗号分隔"
|
||||
/>
|
||||
<Field
|
||||
label="sourceMessageId"
|
||||
value={draft.sourceMessageId}
|
||||
onChange={(value) =>
|
||||
updateMemoryDraft(memory.memoryId, (current) => ({ ...current, sourceMessageId: value }))
|
||||
}
|
||||
placeholder="可选"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveMemory(memory.memoryId)}
|
||||
disabled={busyKey === `memory_save:${memory.memoryId}`}
|
||||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{busyKey === `memory_save:${memory.memoryId}` ? "保存中" : "保存"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void archiveMemory(memory.memoryId)}
|
||||
disabled={busyKey === `memory_delete:${memory.memoryId}`}
|
||||
className="rounded-full border border-[#E5E5EA] bg-white px-4 py-2 text-[13px] font-semibold text-[#111111] disabled:opacity-60"
|
||||
>
|
||||
{busyKey === `memory_delete:${memory.memoryId}` ? "归档中" : "删除"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">通用记忆</div>
|
||||
<div className="text-[12px] text-[#8C8C8C]">当前用户自己的长期偏好与稳定约束。</div>
|
||||
{globalMemories.length === 0 ? (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] text-[#57606A]">暂无通用记忆。</div>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
{globalMemories.map((memory) => {
|
||||
const draft = memoryDrafts[memory.memoryId] ?? draftFromMemory(memory);
|
||||
return (
|
||||
<div key={memory.memoryId} className="rounded-2xl border border-[#E5E5EA] bg-[#FCFCFD] px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-[#111111]">{memory.title}</div>
|
||||
<div className="mt-1 text-[11px] text-[#8C8C8C]">
|
||||
{memoryScopeLabel(memory.scope)} · {memoryTypeLabel(memory.memoryType)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-[#8C8C8C]">
|
||||
{memory.archived ? "已归档" : formatTimestampLabel(memory.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<Field
|
||||
label="标题"
|
||||
value={draft.title}
|
||||
onChange={(value) =>
|
||||
updateMemoryDraft(memory.memoryId, (current) => ({ ...current, title: value }))
|
||||
}
|
||||
placeholder="记忆标题"
|
||||
/>
|
||||
<TextArea
|
||||
label="内容"
|
||||
value={draft.content}
|
||||
onChange={(value) =>
|
||||
updateMemoryDraft(memory.memoryId, (current) => ({ ...current, content: value }))
|
||||
}
|
||||
placeholder="记忆内容"
|
||||
/>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">记忆类型</div>
|
||||
<select
|
||||
value={draft.memoryType}
|
||||
onChange={(event) =>
|
||||
updateMemoryDraft(memory.memoryId, (current) => ({
|
||||
...current,
|
||||
memoryType: event.target.value as MasterMemoryType,
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
|
||||
>
|
||||
{memoryTypeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Field
|
||||
label="标签"
|
||||
value={draft.tags}
|
||||
onChange={(value) =>
|
||||
updateMemoryDraft(memory.memoryId, (current) => ({ ...current, tags: value }))
|
||||
}
|
||||
placeholder="逗号分隔"
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label="sourceMessageId"
|
||||
value={draft.sourceMessageId}
|
||||
onChange={(value) =>
|
||||
updateMemoryDraft(memory.memoryId, (current) => ({ ...current, sourceMessageId: value }))
|
||||
}
|
||||
placeholder="可选"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveMemory(memory.memoryId)}
|
||||
disabled={busyKey === `memory_save:${memory.memoryId}`}
|
||||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{busyKey === `memory_save:${memory.memoryId}` ? "保存中" : "保存"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void archiveMemory(memory.memoryId)}
|
||||
disabled={busyKey === `memory_delete:${memory.memoryId}`}
|
||||
className="rounded-full border border-[#E5E5EA] bg-white px-4 py-2 text-[13px] font-semibold text-[#111111] disabled:opacity-60"
|
||||
>
|
||||
{busyKey === `memory_delete:${memory.memoryId}` ? "归档中" : "删除"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">{message}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -218,6 +218,44 @@ export interface UserAttachmentStorageConfig {
|
||||
validatedAt?: string;
|
||||
}
|
||||
|
||||
export interface MasterAgentPromptPolicy {
|
||||
globalPrompt: string;
|
||||
updatedAt: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface UserMasterPrompt {
|
||||
account: string;
|
||||
content: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type MasterMemoryScope = "global" | "project";
|
||||
export type MasterMemoryType =
|
||||
| "user_preference"
|
||||
| "project_progress"
|
||||
| "decision"
|
||||
| "risk"
|
||||
| "blocking_issue"
|
||||
| "research_note"
|
||||
| "workflow_rule";
|
||||
|
||||
export interface MasterAgentMemory {
|
||||
memoryId: string;
|
||||
account: string;
|
||||
scope: MasterMemoryScope;
|
||||
projectId?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
memoryType: MasterMemoryType;
|
||||
tags: string[];
|
||||
sourceMessageId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt?: string;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export interface GoalItem {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -328,6 +366,7 @@ function buildCollaborationGate(project: Pick<Project, "isGroup" | "collaboratio
|
||||
export interface ProjectAgentControls {
|
||||
modelOverride?: string;
|
||||
reasoningEffortOverride?: ReasoningEffort;
|
||||
promptOverride?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -809,6 +848,9 @@ export interface BossState {
|
||||
deviceSkills: DeviceSkill[];
|
||||
appLogs: AppLogEntry[];
|
||||
userAttachmentStorageConfigs: UserAttachmentStorageConfig[];
|
||||
masterAgentPromptPolicy: MasterAgentPromptPolicy | null;
|
||||
userMasterPrompts: UserMasterPrompt[];
|
||||
masterAgentMemories: MasterAgentMemory[];
|
||||
threadContextSnapshots: ThreadContextSnapshot[];
|
||||
threadHandoffPackages: ThreadHandoffPackage[];
|
||||
threadContextAlerts: ThreadContextAlert[];
|
||||
@@ -1223,6 +1265,9 @@ const initialState: BossState = {
|
||||
updatedAt: nowIso(),
|
||||
},
|
||||
],
|
||||
masterAgentPromptPolicy: null,
|
||||
userMasterPrompts: [],
|
||||
masterAgentMemories: [],
|
||||
masterAgentTasks: [],
|
||||
dispatchPlans: [],
|
||||
dispatchExecutions: [],
|
||||
@@ -2079,14 +2124,16 @@ function normalizeProjectAgentControls(
|
||||
const reasoningEffortOverride = isReasoningEffort(raw?.reasoningEffortOverride)
|
||||
? raw.reasoningEffortOverride
|
||||
: undefined;
|
||||
const promptOverride = trimToDefined(raw?.promptOverride);
|
||||
|
||||
if (!modelOverride && !reasoningEffortOverride) {
|
||||
if (!modelOverride && !reasoningEffortOverride && !promptOverride) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
modelOverride,
|
||||
reasoningEffortOverride,
|
||||
promptOverride,
|
||||
updatedAt: raw?.updatedAt ?? nowIso(),
|
||||
};
|
||||
}
|
||||
@@ -2512,6 +2559,67 @@ function normalizeAttachmentStorageConfig(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMasterAgentPromptPolicy(
|
||||
raw: Partial<MasterAgentPromptPolicy> | null | undefined,
|
||||
fallback?: MasterAgentPromptPolicy | null,
|
||||
): MasterAgentPromptPolicy | null {
|
||||
if (!raw) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
const globalPrompt = raw.globalPrompt?.trim();
|
||||
if (!globalPrompt) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
return {
|
||||
globalPrompt,
|
||||
updatedAt: raw.updatedAt ?? fallback?.updatedAt ?? nowIso(),
|
||||
updatedBy: raw.updatedBy?.trim() || fallback?.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUserMasterPrompt(
|
||||
raw: Partial<UserMasterPrompt>,
|
||||
fallback?: UserMasterPrompt,
|
||||
): UserMasterPrompt {
|
||||
const account = raw.account ?? fallback?.account ?? "";
|
||||
return {
|
||||
account,
|
||||
content: raw.content?.trim() ?? fallback?.content ?? "",
|
||||
updatedAt: raw.updatedAt ?? fallback?.updatedAt ?? nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMasterMemoryTags(values: string[] | undefined) {
|
||||
return dedupeStrings(
|
||||
(values ?? [])
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => Boolean(value)),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeUserMasterMemory(
|
||||
raw: Partial<MasterAgentMemory>,
|
||||
fallback?: MasterAgentMemory,
|
||||
): MasterAgentMemory {
|
||||
const scope = raw.scope ?? fallback?.scope ?? "global";
|
||||
const projectId = scope === "project" ? raw.projectId ?? fallback?.projectId : undefined;
|
||||
return {
|
||||
memoryId: raw.memoryId ?? fallback?.memoryId ?? randomToken("memory"),
|
||||
account: raw.account ?? fallback?.account ?? "",
|
||||
scope,
|
||||
projectId,
|
||||
title: raw.title?.trim() ?? fallback?.title ?? "",
|
||||
content: raw.content?.trim() ?? fallback?.content ?? "",
|
||||
memoryType: raw.memoryType ?? fallback?.memoryType ?? "user_preference",
|
||||
tags: normalizeMasterMemoryTags(raw.tags ?? fallback?.tags ?? []),
|
||||
sourceMessageId: raw.sourceMessageId ?? fallback?.sourceMessageId,
|
||||
createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(),
|
||||
updatedAt: raw.updatedAt ?? fallback?.updatedAt ?? nowIso(),
|
||||
lastUsedAt: raw.lastUsedAt ?? fallback?.lastUsedAt,
|
||||
archived: raw.archived ?? fallback?.archived ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
|
||||
const base = fallback ?? cloneInitialState().projects[0];
|
||||
const projectId = raw.id ?? base.id;
|
||||
@@ -2745,6 +2853,24 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
base.userAttachmentStorageConfigs[index % base.userAttachmentStorageConfigs.length],
|
||||
),
|
||||
),
|
||||
masterAgentPromptPolicy: normalizeMasterAgentPromptPolicy(
|
||||
raw.masterAgentPromptPolicy,
|
||||
base.masterAgentPromptPolicy,
|
||||
),
|
||||
userMasterPrompts: ensureArray(raw.userMasterPrompts, base.userMasterPrompts).map(
|
||||
(prompt, index) =>
|
||||
normalizeUserMasterPrompt(
|
||||
prompt,
|
||||
base.userMasterPrompts[index % Math.max(1, base.userMasterPrompts.length)],
|
||||
),
|
||||
),
|
||||
masterAgentMemories: ensureArray(raw.masterAgentMemories, base.masterAgentMemories).map(
|
||||
(memory, index) =>
|
||||
normalizeUserMasterMemory(
|
||||
memory,
|
||||
base.masterAgentMemories[index % Math.max(1, base.masterAgentMemories.length)],
|
||||
),
|
||||
),
|
||||
threadContextSnapshots: ensureArray(raw.threadContextSnapshots, base.threadContextSnapshots).map(
|
||||
(snapshot, index) => ({
|
||||
...base.threadContextSnapshots[index % base.threadContextSnapshots.length],
|
||||
@@ -3410,6 +3536,7 @@ export async function updateProjectAgentControls(
|
||||
payload: {
|
||||
modelOverride?: unknown;
|
||||
reasoningEffortOverride?: unknown;
|
||||
promptOverride?: unknown;
|
||||
},
|
||||
) {
|
||||
if (projectId !== "master-agent") {
|
||||
@@ -3422,12 +3549,18 @@ export async function updateProjectAgentControls(
|
||||
const reasoningEffortInput = Object.prototype.hasOwnProperty.call(payload, "reasoningEffortOverride")
|
||||
? parseReasoningEffortOverride(payload.reasoningEffortOverride)
|
||||
: { kind: "preserve" as const };
|
||||
const promptOverrideInput = Object.prototype.hasOwnProperty.call(payload, "promptOverride")
|
||||
? parseControlTextOverride(payload.promptOverride)
|
||||
: { kind: "preserve" as const };
|
||||
if (modelOverrideInput.kind === "invalid") {
|
||||
throw new Error("INVALID_MODEL_OVERRIDE");
|
||||
}
|
||||
if (reasoningEffortInput.kind === "invalid") {
|
||||
throw new Error("INVALID_REASONING_EFFORT_OVERRIDE");
|
||||
}
|
||||
if (promptOverrideInput.kind === "invalid") {
|
||||
throw new Error("INVALID_PROMPT_OVERRIDE");
|
||||
}
|
||||
|
||||
return mutateStateIfChanged((state) => {
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
@@ -3446,16 +3579,28 @@ export async function updateProjectAgentControls(
|
||||
: reasoningEffortInput.kind === "clear"
|
||||
? undefined
|
||||
: currentControls?.reasoningEffortOverride;
|
||||
const promptOverride =
|
||||
promptOverrideInput.kind === "set"
|
||||
? promptOverrideInput.value
|
||||
: promptOverrideInput.kind === "clear"
|
||||
? undefined
|
||||
: currentControls?.promptOverride;
|
||||
|
||||
const currentModelOverride = currentControls?.modelOverride;
|
||||
const currentReasoningEffortOverride = currentControls?.reasoningEffortOverride;
|
||||
if (currentModelOverride === modelOverride && currentReasoningEffortOverride === reasoningEffortOverride) {
|
||||
const currentPromptOverride = currentControls?.promptOverride;
|
||||
if (
|
||||
currentModelOverride === modelOverride &&
|
||||
currentReasoningEffortOverride === reasoningEffortOverride &&
|
||||
currentPromptOverride === promptOverride
|
||||
) {
|
||||
return { result: currentControls, changed: false };
|
||||
}
|
||||
|
||||
const nextControls = {
|
||||
modelOverride,
|
||||
reasoningEffortOverride,
|
||||
promptOverride,
|
||||
updatedAt: nowIso(),
|
||||
} satisfies ProjectAgentControls;
|
||||
|
||||
@@ -3496,6 +3641,423 @@ export async function upsertAttachmentStorageConfig(config: UserAttachmentStorag
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMasterAgentPromptPolicy() {
|
||||
const state = await readState();
|
||||
return state.masterAgentPromptPolicy ?? null;
|
||||
}
|
||||
|
||||
export async function updateMasterAgentPromptPolicy(input: {
|
||||
globalPrompt: string;
|
||||
updatedBy?: string;
|
||||
}) {
|
||||
const globalPrompt = input.globalPrompt.trim();
|
||||
if (!globalPrompt) {
|
||||
throw new Error("MASTER_AGENT_PROMPT_REQUIRED");
|
||||
}
|
||||
|
||||
return mutateState((state) => {
|
||||
const policy: MasterAgentPromptPolicy = {
|
||||
globalPrompt,
|
||||
updatedBy: input.updatedBy?.trim() || undefined,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
state.masterAgentPromptPolicy = policy;
|
||||
return policy;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserMasterPrompt(account: string) {
|
||||
const state = await readState();
|
||||
return state.userMasterPrompts.find((item) => item.account === account) ?? null;
|
||||
}
|
||||
|
||||
export async function updateUserMasterPrompt(account: string, content: string) {
|
||||
const trimmedContent = content.trim();
|
||||
if (!trimmedContent) {
|
||||
throw new Error("USER_MASTER_PROMPT_REQUIRED");
|
||||
}
|
||||
|
||||
return mutateState((state) => {
|
||||
const next: UserMasterPrompt = {
|
||||
account,
|
||||
content: trimmedContent,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
const existing = state.userMasterPrompts.find((item) => item.account === account);
|
||||
if (existing) {
|
||||
Object.assign(existing, next);
|
||||
} else {
|
||||
state.userMasterPrompts.unshift(next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearUserMasterPrompt(account: string) {
|
||||
return mutateState((state) => {
|
||||
const before = state.userMasterPrompts.length;
|
||||
state.userMasterPrompts = state.userMasterPrompts.filter((item) => item.account !== account);
|
||||
return { cleared: before !== state.userMasterPrompts.length };
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUserMasterMemories(
|
||||
account: string,
|
||||
options?: { includeArchived?: boolean; scope?: MasterMemoryScope; projectId?: string },
|
||||
) {
|
||||
const state = await readState();
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
return [...state.masterAgentMemories]
|
||||
.filter((memory) => {
|
||||
if (memory.account !== account) return false;
|
||||
if (!includeArchived && memory.archived) return false;
|
||||
if (options?.scope && memory.scope !== options.scope) return false;
|
||||
if (options?.projectId && memory.projectId !== options.projectId) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const timeDiff =
|
||||
messageTimeValue(b.lastUsedAt ?? b.updatedAt ?? b.createdAt) -
|
||||
messageTimeValue(a.lastUsedAt ?? a.updatedAt ?? a.createdAt);
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return b.memoryId.localeCompare(a.memoryId);
|
||||
});
|
||||
}
|
||||
|
||||
export async function createUserMasterMemory(input: {
|
||||
account: string;
|
||||
scope: MasterMemoryScope;
|
||||
projectId?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
memoryType: MasterMemoryType;
|
||||
tags?: string[];
|
||||
sourceMessageId?: string;
|
||||
}) {
|
||||
const title = input.title.trim();
|
||||
const content = input.content.trim();
|
||||
if (!title) {
|
||||
throw new Error("USER_MASTER_MEMORY_TITLE_REQUIRED");
|
||||
}
|
||||
if (!content) {
|
||||
throw new Error("USER_MASTER_MEMORY_CONTENT_REQUIRED");
|
||||
}
|
||||
if (input.scope === "project" && !input.projectId?.trim()) {
|
||||
throw new Error("USER_MASTER_MEMORY_PROJECT_ID_REQUIRED");
|
||||
}
|
||||
|
||||
return mutateState((state) => {
|
||||
const now = nowIso();
|
||||
const memory: MasterAgentMemory = {
|
||||
memoryId: randomToken("memory"),
|
||||
account: input.account,
|
||||
scope: input.scope,
|
||||
projectId: input.scope === "project" ? input.projectId?.trim() : undefined,
|
||||
title,
|
||||
content,
|
||||
memoryType: input.memoryType,
|
||||
tags: normalizeMasterMemoryTags(input.tags),
|
||||
sourceMessageId: input.sourceMessageId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastUsedAt: now,
|
||||
archived: false,
|
||||
};
|
||||
state.masterAgentMemories.unshift(memory);
|
||||
return memory;
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUserMasterMemory(
|
||||
memoryId: string,
|
||||
account: string,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
MasterAgentMemory,
|
||||
"scope" | "projectId" | "title" | "content" | "memoryType" | "tags" | "sourceMessageId" | "lastUsedAt"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
return mutateState((state) => {
|
||||
const memory = state.masterAgentMemories.find(
|
||||
(item) => item.memoryId === memoryId && item.account === account,
|
||||
);
|
||||
if (!memory) {
|
||||
throw new Error("USER_MASTER_MEMORY_NOT_FOUND");
|
||||
}
|
||||
|
||||
if (patch.scope) {
|
||||
memory.scope = patch.scope;
|
||||
}
|
||||
if (memory.scope === "project" && patch.projectId !== undefined) {
|
||||
memory.projectId = patch.projectId.trim() || undefined;
|
||||
}
|
||||
if (memory.scope !== "project") {
|
||||
memory.projectId = undefined;
|
||||
}
|
||||
if (patch.title !== undefined) {
|
||||
const title = patch.title.trim();
|
||||
if (!title) throw new Error("USER_MASTER_MEMORY_TITLE_REQUIRED");
|
||||
memory.title = title;
|
||||
}
|
||||
if (patch.content !== undefined) {
|
||||
const content = patch.content.trim();
|
||||
if (!content) throw new Error("USER_MASTER_MEMORY_CONTENT_REQUIRED");
|
||||
memory.content = content;
|
||||
}
|
||||
if (patch.memoryType) {
|
||||
memory.memoryType = patch.memoryType;
|
||||
}
|
||||
if (patch.tags) {
|
||||
memory.tags = normalizeMasterMemoryTags(patch.tags);
|
||||
}
|
||||
if (patch.sourceMessageId !== undefined) {
|
||||
memory.sourceMessageId = patch.sourceMessageId;
|
||||
}
|
||||
if (patch.lastUsedAt !== undefined) {
|
||||
memory.lastUsedAt = patch.lastUsedAt;
|
||||
}
|
||||
memory.updatedAt = nowIso();
|
||||
return memory;
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveUserMasterMemory(memoryId: string, account: string) {
|
||||
return mutateState((state) => {
|
||||
const memory = state.masterAgentMemories.find(
|
||||
(item) => item.memoryId === memoryId && item.account === account,
|
||||
);
|
||||
if (!memory) {
|
||||
throw new Error("USER_MASTER_MEMORY_NOT_FOUND");
|
||||
}
|
||||
|
||||
memory.archived = true;
|
||||
memory.updatedAt = nowIso();
|
||||
return memory;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAutoMemoryText(value: string | undefined) {
|
||||
return (value ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/[。;;!!]+$/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function inferAutoMemoryType(text: string): MasterMemoryType | null {
|
||||
if (!text.trim()) return null;
|
||||
if (/(微信|wechat|中文回复|中文沟通|UI风格|交互风格|偏好|习惯|默认)/i.test(text)) {
|
||||
return "user_preference";
|
||||
}
|
||||
if (/(规则|约束|优先|先.*再|必须|不要|需要|流程|逻辑)/i.test(text)) {
|
||||
return "workflow_rule";
|
||||
}
|
||||
if (/(阻塞|卡住|失败|异常|报错|问题|bug|未打通)/i.test(text)) {
|
||||
return "blocking_issue";
|
||||
}
|
||||
if (/(风险|隐患|告警)/i.test(text)) {
|
||||
return "risk";
|
||||
}
|
||||
if (/(决定|改成|采用|统一|确定|方案)/i.test(text)) {
|
||||
return "decision";
|
||||
}
|
||||
if (/(调研|研究|结论)/i.test(text)) {
|
||||
return "research_note";
|
||||
}
|
||||
if (/(进度|完成|已接通|已打通|上线|当前.*状态|回归|发布)/i.test(text)) {
|
||||
return "project_progress";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferProjectAutoMemoryType(text: string): Exclude<MasterMemoryType, "user_preference"> | null {
|
||||
if (!text.trim()) return null;
|
||||
if (/(阻塞|卡住|失败|异常|报错|问题|bug|未打通)/i.test(text)) {
|
||||
return "blocking_issue";
|
||||
}
|
||||
if (/(风险|隐患|告警)/i.test(text)) {
|
||||
return "risk";
|
||||
}
|
||||
if (/(决定|改成|采用|统一|确定|方案)/i.test(text)) {
|
||||
return "decision";
|
||||
}
|
||||
if (/(调研|研究|结论)/i.test(text)) {
|
||||
return "research_note";
|
||||
}
|
||||
if (/(进度|完成|已接通|已打通|上线|当前.*状态|回归|发布)/i.test(text)) {
|
||||
return "project_progress";
|
||||
}
|
||||
if (/(规则|约束|优先|先.*再|必须|不要|需要|流程|逻辑)/i.test(text)) {
|
||||
return "workflow_rule";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildAutoMemoryTitle(memoryType: MasterMemoryType, label?: string) {
|
||||
const typeLabel =
|
||||
memoryType === "user_preference"
|
||||
? "偏好"
|
||||
: memoryType === "workflow_rule"
|
||||
? "工作规则"
|
||||
: memoryType === "blocking_issue"
|
||||
? "阻塞"
|
||||
: memoryType === "risk"
|
||||
? "风险"
|
||||
: memoryType === "decision"
|
||||
? "决策"
|
||||
: memoryType === "research_note"
|
||||
? "调研结论"
|
||||
: "项目进度";
|
||||
return label ? `${label} · ${typeLabel}` : typeLabel;
|
||||
}
|
||||
|
||||
function detectReferencedProjectForMemory(state: BossState, text: string) {
|
||||
const lowered = text.toLowerCase();
|
||||
const candidates = state.projects
|
||||
.filter((project) => project.id !== "master-agent")
|
||||
.flatMap((project) => {
|
||||
const rawAliases = [
|
||||
project.id,
|
||||
project.name,
|
||||
project.threadMeta.folderName,
|
||||
project.threadMeta.threadDisplayName,
|
||||
]
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const aliases = Array.from(
|
||||
new Set(
|
||||
rawAliases.flatMap((alias) => {
|
||||
const normalized = alias.trim();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tokenCandidates = normalized
|
||||
.split(/[\s\-_/]+/)
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 3);
|
||||
|
||||
return [normalized, ...tokenCandidates];
|
||||
}),
|
||||
),
|
||||
);
|
||||
return aliases.map((alias) => ({
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
alias,
|
||||
}));
|
||||
})
|
||||
.sort((left, right) => right.alias.length - left.alias.length);
|
||||
|
||||
return candidates.find((candidate) => lowered.includes(candidate.alias.toLowerCase())) ?? null;
|
||||
}
|
||||
|
||||
function upsertAutoMasterMemoryInState(
|
||||
state: BossState,
|
||||
input: {
|
||||
account: string;
|
||||
scope: MasterMemoryScope;
|
||||
projectId?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
memoryType: MasterMemoryType;
|
||||
tags: string[];
|
||||
sourceMessageId?: string;
|
||||
},
|
||||
) {
|
||||
const now = nowIso();
|
||||
const existing = state.masterAgentMemories.find(
|
||||
(memory) =>
|
||||
memory.account === input.account &&
|
||||
memory.scope === input.scope &&
|
||||
(memory.projectId ?? undefined) === (input.projectId ?? undefined) &&
|
||||
memory.title === input.title,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.content = input.content;
|
||||
existing.memoryType = input.memoryType;
|
||||
existing.tags = normalizeMasterMemoryTags(input.tags);
|
||||
existing.sourceMessageId = input.sourceMessageId ?? existing.sourceMessageId;
|
||||
existing.archived = false;
|
||||
existing.updatedAt = now;
|
||||
existing.lastUsedAt = now;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const memory: MasterAgentMemory = {
|
||||
memoryId: randomToken("memory"),
|
||||
account: input.account,
|
||||
scope: input.scope,
|
||||
projectId: input.scope === "project" ? input.projectId : undefined,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
memoryType: input.memoryType,
|
||||
tags: normalizeMasterMemoryTags(input.tags),
|
||||
sourceMessageId: input.sourceMessageId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastUsedAt: now,
|
||||
archived: false,
|
||||
};
|
||||
state.masterAgentMemories.unshift(memory);
|
||||
return memory;
|
||||
}
|
||||
|
||||
function autoCaptureMasterAgentMemoriesInState(
|
||||
state: BossState,
|
||||
input: {
|
||||
account: string;
|
||||
requestText: string;
|
||||
replyText: string;
|
||||
sourceMessageId?: string;
|
||||
},
|
||||
) {
|
||||
const requestText = normalizeAutoMemoryText(input.requestText);
|
||||
const replyText = normalizeAutoMemoryText(input.replyText);
|
||||
if (!requestText && !replyText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const createdOrUpdated: MasterAgentMemory[] = [];
|
||||
const combined = [requestText, replyText].filter(Boolean).join(" ");
|
||||
const preferenceType = inferAutoMemoryType(requestText);
|
||||
|
||||
if (preferenceType === "user_preference" || preferenceType === "workflow_rule") {
|
||||
createdOrUpdated.push(
|
||||
upsertAutoMasterMemoryInState(state, {
|
||||
account: input.account,
|
||||
scope: "global",
|
||||
title: buildAutoMemoryTitle(preferenceType),
|
||||
content: requestText,
|
||||
memoryType: preferenceType,
|
||||
tags: preferenceType === "user_preference" ? ["用户偏好"] : ["工作方式"],
|
||||
sourceMessageId: input.sourceMessageId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const referencedProject = detectReferencedProjectForMemory(state, combined);
|
||||
const projectType = inferProjectAutoMemoryType(replyText) ?? inferProjectAutoMemoryType(combined);
|
||||
if (referencedProject && projectType) {
|
||||
createdOrUpdated.push(
|
||||
upsertAutoMasterMemoryInState(state, {
|
||||
account: input.account,
|
||||
scope: "project",
|
||||
projectId: referencedProject.projectId,
|
||||
title: buildAutoMemoryTitle(projectType, referencedProject.projectName),
|
||||
content: replyText || requestText,
|
||||
memoryType: projectType,
|
||||
tags: [referencedProject.projectName, referencedProject.alias],
|
||||
sourceMessageId: input.sourceMessageId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return createdOrUpdated;
|
||||
}
|
||||
|
||||
function preferredDeviceForAccount(
|
||||
state: BossState,
|
||||
account: string,
|
||||
@@ -5078,6 +5640,12 @@ export async function completeMasterAgentTask(payload: {
|
||||
body: task.replyBody,
|
||||
kind: "text",
|
||||
});
|
||||
autoCaptureMasterAgentMemoriesInState(state, {
|
||||
account: task.requestedByAccount,
|
||||
requestText: task.requestText,
|
||||
replyText: task.replyBody,
|
||||
sourceMessageId: task.requestMessageId,
|
||||
});
|
||||
}
|
||||
} else if (!attachmentProjectId && payload.status === "failed") {
|
||||
const isThreadConversationReply =
|
||||
|
||||
@@ -22,6 +22,11 @@ import type { DispatchPlanTarget, Project, ProjectAgentControls, ReasoningEffort
|
||||
import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments";
|
||||
import { readAliyunOssObjectBuffer } from "@/lib/boss-storage-aliyun-oss";
|
||||
import { readServerFileAttachmentBuffer } from "@/lib/boss-storage-server-file";
|
||||
import {
|
||||
getMasterAgentPromptPolicyView,
|
||||
getUserMasterPromptView,
|
||||
listUserMasterMemoriesView,
|
||||
} from "@/lib/boss-projections";
|
||||
|
||||
type MasterAgentReplyState = "queued" | "running" | "completed";
|
||||
const OPENAI_MASTER_AGENT_DEVICE_ID = "master-agent-openai";
|
||||
@@ -38,28 +43,83 @@ type QueuedMasterAgentReplyEnvelope = {
|
||||
};
|
||||
};
|
||||
|
||||
export async function resolveMasterAgentExecutionConfig(projectId: string) {
|
||||
export async function resolveMasterAgentExecutionConfig(
|
||||
projectId: string,
|
||||
accountId?: string,
|
||||
requestText?: string,
|
||||
) {
|
||||
const runtime = await getMasterAgentRuntimeAccount();
|
||||
if (!runtime?.account) {
|
||||
throw new Error("NO_MASTER_AGENT_RUNTIME_ACCOUNT");
|
||||
}
|
||||
|
||||
const agentControls = await getProjectAgentControls(projectId);
|
||||
const state = await readState();
|
||||
const resolvedAccountId = accountId?.trim() || state.user.account || runtime.account.accountId;
|
||||
const reasoningEffort =
|
||||
agentControls?.reasoningEffortOverride ||
|
||||
(runtime.account as typeof runtime.account & { reasoningEffort?: ReasoningEffort }).reasoningEffort ||
|
||||
"medium";
|
||||
const promptPolicy = getMasterAgentPromptPolicyView(state);
|
||||
const userPrompt = getUserMasterPromptView(state, resolvedAccountId);
|
||||
const memoryScope = listUserMasterMemoriesView(state, resolvedAccountId, { includeArchived: false });
|
||||
const projectMemories = selectRelevantProjectMemories(memoryScope, projectId, requestText);
|
||||
const userMemories = memoryScope.filter((memory) => memory.scope === "global");
|
||||
|
||||
return {
|
||||
runtime,
|
||||
account: runtime.account,
|
||||
agentControls,
|
||||
projectPromptOverride: agentControls?.promptOverride ?? null,
|
||||
provider: runtime.account.provider,
|
||||
model: agentControls?.modelOverride || runtime.account.model || "gpt-5.4",
|
||||
reasoningEffort,
|
||||
promptPolicy,
|
||||
userPrompt,
|
||||
projectMemories,
|
||||
userMemories,
|
||||
executionPrompt: buildMasterAgentExecutionPrompt({
|
||||
state,
|
||||
projectId,
|
||||
requestText: requestText ?? "",
|
||||
currentSessionExpiresAt: undefined,
|
||||
agentControls,
|
||||
accountId: resolvedAccountId,
|
||||
promptPolicy,
|
||||
userPrompt,
|
||||
projectMemories,
|
||||
userMemories,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function selectRelevantProjectMemories(
|
||||
memories: Awaited<ReturnType<typeof listUserMasterMemoriesView>>,
|
||||
projectId: string,
|
||||
requestText?: string,
|
||||
) {
|
||||
const projectScoped = memories.filter((memory) => memory.scope === "project");
|
||||
if (projectId !== "master-agent") {
|
||||
return projectScoped.filter((memory) => memory.projectId === projectId);
|
||||
}
|
||||
if (projectScoped.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowered = requestText?.trim().toLowerCase() ?? "";
|
||||
if (!lowered) {
|
||||
return projectScoped.slice(0, 6);
|
||||
}
|
||||
|
||||
const matched = projectScoped.filter((memory) => {
|
||||
const haystacks = [memory.title, memory.content, ...(memory.tags ?? [])]
|
||||
.map((value) => value.toLowerCase());
|
||||
return haystacks.some((value) => lowered.includes(value) || value.includes(lowered));
|
||||
});
|
||||
|
||||
return (matched.length > 0 ? matched : projectScoped).slice(0, 6);
|
||||
}
|
||||
|
||||
function buildAgentControlsDigest(agentControls?: ProjectAgentControls | null) {
|
||||
if (!agentControls) {
|
||||
return "当前对话覆盖:无";
|
||||
@@ -69,9 +129,59 @@ function buildAgentControlsDigest(agentControls?: ProjectAgentControls | null) {
|
||||
"当前对话覆盖:",
|
||||
`model=${agentControls.modelOverride ?? "默认"}`,
|
||||
`reasoning=${agentControls.reasoningEffortOverride ?? "默认"}`,
|
||||
`prompt=${agentControls.promptOverride ? "已配置" : "默认"}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function buildPromptPolicyDigest(promptPolicy: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>) {
|
||||
return promptPolicy?.globalPrompt?.trim()
|
||||
? [`管理员全局主提示词:`, promptPolicy.globalPrompt.trim()].join("\n")
|
||||
: "管理员全局主提示词:无";
|
||||
}
|
||||
|
||||
function buildUserPromptDigest(userPrompt: Awaited<ReturnType<typeof getUserMasterPromptView>>) {
|
||||
return userPrompt?.content?.trim()
|
||||
? [`用户私有主提示词:`, userPrompt.content.trim()].join("\n")
|
||||
: "用户私有主提示词:无";
|
||||
}
|
||||
|
||||
function buildMemoryDigest(title: string, memories: Awaited<ReturnType<typeof listUserMasterMemoriesView>>) {
|
||||
if (memories.length === 0) {
|
||||
return `${title}:无`;
|
||||
}
|
||||
return [
|
||||
`${title}:`,
|
||||
...memories.map((memory) => `- ${memory.title}:${memory.content}`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildMasterAgentExecutionPrompt(params: {
|
||||
state: Awaited<ReturnType<typeof readState>>;
|
||||
projectId: string;
|
||||
requestText: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
agentControls?: ProjectAgentControls | null;
|
||||
accountId: string;
|
||||
promptPolicy: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>;
|
||||
userPrompt: Awaited<ReturnType<typeof getUserMasterPromptView>>;
|
||||
projectMemories: Awaited<ReturnType<typeof listUserMasterMemoriesView>>;
|
||||
userMemories: Awaited<ReturnType<typeof listUserMasterMemoriesView>>;
|
||||
}) {
|
||||
return [
|
||||
buildMasterAgentInstructions(),
|
||||
buildPromptPolicyDigest(params.promptPolicy),
|
||||
buildUserPromptDigest(params.userPrompt),
|
||||
params.agentControls?.promptOverride?.trim()
|
||||
? ["当前对话附加提示词:", params.agentControls.promptOverride.trim()].join("\n")
|
||||
: "当前对话附加提示词:无",
|
||||
buildMemoryDigest("项目记忆", params.projectMemories),
|
||||
buildMemoryDigest("用户记忆", params.userMemories),
|
||||
buildAgentControlsDigest(params.agentControls),
|
||||
"",
|
||||
buildRuntimeDigest(params.state, params.requestText, params.currentSessionExpiresAt, params.agentControls),
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
function buildMasterAgentInstructions() {
|
||||
return [
|
||||
"你是 Boss 控制台的主 Agent。",
|
||||
@@ -262,6 +372,10 @@ async function replyViaOpenAiAccount(params: {
|
||||
currentSessionExpiresAt?: string;
|
||||
senderLabel: string;
|
||||
agentControls?: ProjectAgentControls | null;
|
||||
promptPolicy?: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>;
|
||||
userPrompt?: Awaited<ReturnType<typeof getUserMasterPromptView>>;
|
||||
projectMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>;
|
||||
userMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>;
|
||||
}) {
|
||||
if (!params.account?.apiKey?.trim()) {
|
||||
throw new Error("OPENAI_ACCOUNT_NOT_CONFIGURED");
|
||||
@@ -274,6 +388,10 @@ async function replyViaOpenAiAccount(params: {
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
agentControls: params.agentControls,
|
||||
promptPolicy: params.promptPolicy,
|
||||
userPrompt: params.userPrompt,
|
||||
projectMemories: params.projectMemories,
|
||||
userMemories: params.userMemories,
|
||||
});
|
||||
|
||||
await appendMasterAgentSystemReply(generated.content, params.senderLabel);
|
||||
@@ -298,8 +416,22 @@ async function generateOpenAiReply(params: {
|
||||
requestText: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
agentControls?: ProjectAgentControls | null;
|
||||
promptPolicy?: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>;
|
||||
userPrompt?: Awaited<ReturnType<typeof getUserMasterPromptView>>;
|
||||
projectMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>;
|
||||
userMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>;
|
||||
}) {
|
||||
const state = await readState();
|
||||
const effectiveProjectMemories =
|
||||
params.projectMemories && params.projectMemories.length > 0
|
||||
? params.projectMemories
|
||||
: selectRelevantProjectMemories(
|
||||
listUserMasterMemoriesView(state, params.userPrompt?.account ?? state.user.account, {
|
||||
includeArchived: false,
|
||||
}),
|
||||
"master-agent",
|
||||
params.requestText,
|
||||
);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("https://api.openai.com/v1/responses", {
|
||||
@@ -311,13 +443,19 @@ async function generateOpenAiReply(params: {
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
reasoning: { effort: params.reasoningEffort },
|
||||
instructions: buildMasterAgentInstructions(),
|
||||
input: buildRuntimeDigest(
|
||||
instructions: buildMasterAgentExecutionPrompt({
|
||||
state,
|
||||
params.requestText,
|
||||
params.currentSessionExpiresAt,
|
||||
params.agentControls,
|
||||
),
|
||||
projectId: "master-agent",
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
agentControls: params.agentControls,
|
||||
accountId: "master-agent",
|
||||
promptPolicy: params.promptPolicy ?? null,
|
||||
userPrompt: params.userPrompt ?? null,
|
||||
projectMemories: effectiveProjectMemories,
|
||||
userMemories: params.userMemories ?? [],
|
||||
}),
|
||||
input: params.requestText,
|
||||
}),
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
});
|
||||
@@ -362,12 +500,23 @@ function buildMasterOpenAiReplyPrompt(
|
||||
requestText: string,
|
||||
currentSessionExpiresAt?: string,
|
||||
agentControls?: ProjectAgentControls | null,
|
||||
promptPolicy?: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>,
|
||||
userPrompt?: Awaited<ReturnType<typeof getUserMasterPromptView>>,
|
||||
projectMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>,
|
||||
userMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>,
|
||||
) {
|
||||
return [
|
||||
buildMasterAgentInstructions(),
|
||||
"",
|
||||
buildRuntimeDigest(state, requestText, currentSessionExpiresAt, agentControls),
|
||||
].join("\n");
|
||||
return buildMasterAgentExecutionPrompt({
|
||||
state,
|
||||
projectId: "master-agent",
|
||||
requestText,
|
||||
currentSessionExpiresAt,
|
||||
agentControls,
|
||||
accountId: "master-agent",
|
||||
promptPolicy: promptPolicy ?? null,
|
||||
userPrompt: userPrompt ?? null,
|
||||
projectMemories: projectMemories ?? [],
|
||||
userMemories: userMemories ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
async function queueAndStartOpenAiMasterAgentReply(params: {
|
||||
@@ -379,6 +528,10 @@ async function queueAndStartOpenAiMasterAgentReply(params: {
|
||||
model: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
agentControls?: ProjectAgentControls | null;
|
||||
promptPolicy?: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>;
|
||||
userPrompt?: Awaited<ReturnType<typeof getUserMasterPromptView>>;
|
||||
projectMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>;
|
||||
userMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>;
|
||||
}) {
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
@@ -395,6 +548,10 @@ async function queueAndStartOpenAiMasterAgentReply(params: {
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
agentControls: params.agentControls,
|
||||
promptPolicy: params.promptPolicy,
|
||||
userPrompt: params.userPrompt,
|
||||
projectMemories: params.projectMemories,
|
||||
userMemories: params.userMemories,
|
||||
});
|
||||
|
||||
await completeMasterAgentTask({
|
||||
@@ -429,6 +586,10 @@ async function enqueueOpenAiMasterAgentReply(params: {
|
||||
model: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
agentControls?: ProjectAgentControls | null;
|
||||
promptPolicy?: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>;
|
||||
userPrompt?: Awaited<ReturnType<typeof getUserMasterPromptView>>;
|
||||
projectMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>;
|
||||
userMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>;
|
||||
}) {
|
||||
const state = await readState();
|
||||
const task = await queueMasterAgentTask({
|
||||
@@ -439,6 +600,10 @@ async function enqueueOpenAiMasterAgentReply(params: {
|
||||
params.requestText,
|
||||
params.currentSessionExpiresAt,
|
||||
params.agentControls,
|
||||
params.promptPolicy,
|
||||
params.userPrompt,
|
||||
params.projectMemories,
|
||||
params.userMemories,
|
||||
),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
@@ -455,6 +620,10 @@ async function enqueueOpenAiMasterAgentReply(params: {
|
||||
model: params.model,
|
||||
reasoningEffort: params.reasoningEffort,
|
||||
agentControls: params.agentControls,
|
||||
promptPolicy: params.promptPolicy,
|
||||
userPrompt: params.userPrompt,
|
||||
projectMemories: params.projectMemories,
|
||||
userMemories: params.userMemories,
|
||||
});
|
||||
|
||||
const queuedReply: QueuedMasterAgentReplyEnvelope = {
|
||||
@@ -542,16 +711,23 @@ function buildMasterCodexNodePrompt(
|
||||
requestText: string,
|
||||
currentSessionExpiresAt?: string,
|
||||
agentControls?: ProjectAgentControls | null,
|
||||
promptPolicy?: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>,
|
||||
userPrompt?: Awaited<ReturnType<typeof getUserMasterPromptView>>,
|
||||
projectMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>,
|
||||
userMemories?: Awaited<ReturnType<typeof listUserMasterMemoriesView>>,
|
||||
) {
|
||||
return [
|
||||
"你是 Boss 控制台的主 Agent,运行在用户自己的 Master Codex Node 上。",
|
||||
"请结合下面的运行时状态和用户消息,直接给出中文回复。",
|
||||
"如果你认为需要继续在当前仓库里推进实现、排障或验证,可以直接说明你下一步会做什么;如果必须先做交接或收尾,也要明确说出原因。",
|
||||
"保持简洁,优先给出结论、动作、验证点。",
|
||||
buildAgentControlsDigest(agentControls),
|
||||
"",
|
||||
buildRuntimeDigest(state, requestText, currentSessionExpiresAt, agentControls),
|
||||
].join("\n");
|
||||
return buildMasterAgentExecutionPrompt({
|
||||
state,
|
||||
projectId: "master-agent",
|
||||
requestText,
|
||||
currentSessionExpiresAt,
|
||||
agentControls,
|
||||
accountId: "master-agent",
|
||||
promptPolicy: promptPolicy ?? null,
|
||||
userPrompt: userPrompt ?? null,
|
||||
projectMemories: projectMemories ?? [],
|
||||
userMemories: userMemories ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeDispatchRequest(requestText: string) {
|
||||
@@ -1159,7 +1335,11 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
return { ok: false as const, reason: "NO_AI_ACCOUNT" };
|
||||
}
|
||||
|
||||
const executionConfig = await resolveMasterAgentExecutionConfig("master-agent");
|
||||
const executionConfig = await resolveMasterAgentExecutionConfig(
|
||||
"master-agent",
|
||||
params.requestedByAccount,
|
||||
params.requestText,
|
||||
);
|
||||
const agentControls = executionConfig.agentControls;
|
||||
|
||||
if (params.mode === "enqueue") {
|
||||
@@ -1195,6 +1375,10 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
model: agentControls?.modelOverride || fallbackAccount.model || "gpt-5.4",
|
||||
reasoningEffort: agentControls?.reasoningEffortOverride || "medium",
|
||||
agentControls,
|
||||
promptPolicy: executionConfig.promptPolicy,
|
||||
userPrompt: executionConfig.userPrompt,
|
||||
projectMemories: executionConfig.projectMemories,
|
||||
userMemories: executionConfig.userMemories,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1213,6 +1397,10 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
params.requestText,
|
||||
params.currentSessionExpiresAt,
|
||||
agentControls,
|
||||
executionConfig.promptPolicy,
|
||||
executionConfig.userPrompt,
|
||||
executionConfig.projectMemories,
|
||||
executionConfig.userMemories,
|
||||
),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
@@ -1248,6 +1436,10 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
model: executionConfig.model,
|
||||
reasoningEffort: executionConfig.reasoningEffort,
|
||||
agentControls,
|
||||
promptPolicy: executionConfig.promptPolicy,
|
||||
userPrompt: executionConfig.userPrompt,
|
||||
projectMemories: executionConfig.projectMemories,
|
||||
userMemories: executionConfig.userMemories,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1278,6 +1470,10 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
senderLabel: `主 Agent · ${fallbackAccount.label || aiRoleLabel(fallbackAccount.role)}`,
|
||||
agentControls,
|
||||
promptPolicy: executionConfig.promptPolicy,
|
||||
userPrompt: executionConfig.userPrompt,
|
||||
projectMemories: executionConfig.projectMemories,
|
||||
userMemories: executionConfig.userMemories,
|
||||
});
|
||||
} catch {
|
||||
// Fall through to the original offline guidance when the fallback API account cannot respond.
|
||||
@@ -1298,6 +1494,10 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
params.requestText,
|
||||
params.currentSessionExpiresAt,
|
||||
agentControls,
|
||||
executionConfig.promptPolicy,
|
||||
executionConfig.userPrompt,
|
||||
executionConfig.projectMemories,
|
||||
executionConfig.userMemories,
|
||||
),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
@@ -1324,6 +1524,10 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
senderLabel: `主 Agent · ${fallbackAccount.label || aiRoleLabel(fallbackAccount.role)}`,
|
||||
agentControls,
|
||||
promptPolicy: executionConfig.promptPolicy,
|
||||
userPrompt: executionConfig.userPrompt,
|
||||
projectMemories: executionConfig.projectMemories,
|
||||
userMemories: executionConfig.userMemories,
|
||||
});
|
||||
} catch {
|
||||
// Preserve the original execution failure below if the fallback account also fails.
|
||||
@@ -1367,6 +1571,10 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
agentControls,
|
||||
promptPolicy: executionConfig.promptPolicy,
|
||||
userPrompt: executionConfig.userPrompt,
|
||||
projectMemories: executionConfig.projectMemories,
|
||||
userMemories: executionConfig.userMemories,
|
||||
});
|
||||
|
||||
await appendMasterAgentSystemReply(
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
DeviceImportResolution,
|
||||
DeviceSkill,
|
||||
MasterIdentitySummary,
|
||||
MasterAgentMemory,
|
||||
MasterAgentPromptPolicy,
|
||||
OpsFault,
|
||||
OpsRepairTicket,
|
||||
OpsRepairVerification,
|
||||
@@ -23,6 +25,7 @@ import type {
|
||||
ThreadContextAlert,
|
||||
ThreadContextSnapshot,
|
||||
ThreadHandoffPackage,
|
||||
UserMasterPrompt,
|
||||
} from "@/lib/boss-data";
|
||||
|
||||
export interface ContextIndicator {
|
||||
@@ -305,6 +308,30 @@ function getProjectMasterIdentity(state: BossState): MasterIdentitySummary {
|
||||
};
|
||||
}
|
||||
|
||||
export function getMasterAgentPromptPolicyView(state: BossState): MasterAgentPromptPolicy | null {
|
||||
return state.masterAgentPromptPolicy ?? null;
|
||||
}
|
||||
|
||||
export function getUserMasterPromptView(state: BossState, account: string): UserMasterPrompt | null {
|
||||
return state.userMasterPrompts.find((item) => item.account === account) ?? null;
|
||||
}
|
||||
|
||||
export function listUserMasterMemoriesView(
|
||||
state: BossState,
|
||||
account: string,
|
||||
options?: { includeArchived?: boolean },
|
||||
): MasterAgentMemory[] {
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
return [...state.masterAgentMemories]
|
||||
.filter((memory) => memory.account === account && (includeArchived || !memory.archived))
|
||||
.sort((a, b) => {
|
||||
const aTime = Date.parse(a.lastUsedAt ?? a.updatedAt ?? a.createdAt) || 0;
|
||||
const bTime = Date.parse(b.lastUsedAt ?? b.updatedAt ?? b.createdAt) || 0;
|
||||
if (aTime !== bTime) return bTime - aTime;
|
||||
return b.memoryId.localeCompare(a.memoryId);
|
||||
});
|
||||
}
|
||||
|
||||
function threadViewsForProject(state: BossState, projectId: string) {
|
||||
return state.threadContextSnapshots
|
||||
.filter((snapshot) => snapshot.projectId === projectId)
|
||||
|
||||
@@ -7,6 +7,8 @@ import { mkdtemp, rm } from "node:fs/promises";
|
||||
let runtimeRoot = "";
|
||||
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
|
||||
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
|
||||
let updateMasterAgentPromptPolicy: (typeof import("../src/lib/boss-data"))["updateMasterAgentPromptPolicy"];
|
||||
let updateUserMasterPrompt: (typeof import("../src/lib/boss-data"))["updateUserMasterPrompt"];
|
||||
let resolveMasterAgentExecutionConfig: (typeof import("../src/lib/boss-master-agent"))["resolveMasterAgentExecutionConfig"];
|
||||
|
||||
async function setup() {
|
||||
@@ -23,6 +25,8 @@ async function setup() {
|
||||
|
||||
saveAiAccount = data.saveAiAccount;
|
||||
updateProjectAgentControls = data.updateProjectAgentControls;
|
||||
updateMasterAgentPromptPolicy = data.updateMasterAgentPromptPolicy;
|
||||
updateUserMasterPrompt = data.updateUserMasterPrompt;
|
||||
resolveMasterAgentExecutionConfig = masterAgent.resolveMasterAgentExecutionConfig;
|
||||
}
|
||||
|
||||
@@ -63,3 +67,39 @@ test("当前对话 override 优先于主控账号默认值", async () => {
|
||||
assert.equal(resolved.account.accountId, "master-codex-primary");
|
||||
assert.equal(resolved.account.model, "gpt-4.1-mini");
|
||||
});
|
||||
|
||||
test("主 Agent 执行配置会合成管理员提示词、用户提示词和当前对话提示词", async () => {
|
||||
await setup();
|
||||
|
||||
await saveAiAccount({
|
||||
accountId: "master-codex-primary",
|
||||
label: "主 GPT",
|
||||
role: "primary",
|
||||
provider: "master_codex_node",
|
||||
displayName: "Mac 上的 Master Codex Node",
|
||||
nodeId: "local-codex-node",
|
||||
nodeLabel: "本机 Codex",
|
||||
model: "gpt-4.1-mini",
|
||||
enabled: true,
|
||||
setActive: true,
|
||||
loginStatusNote: "通过绑定的 Master Codex Node 对话。",
|
||||
});
|
||||
|
||||
await updateMasterAgentPromptPolicy({
|
||||
globalPrompt: "全局主提示词",
|
||||
updatedBy: "17600003315",
|
||||
});
|
||||
await updateUserMasterPrompt("17600003315", "用户私有主提示词");
|
||||
await updateProjectAgentControls("master-agent", {
|
||||
modelOverride: "gpt-5.4",
|
||||
reasoningEffortOverride: "high",
|
||||
promptOverride: "当前对话提示词",
|
||||
});
|
||||
|
||||
const resolved = await resolveMasterAgentExecutionConfig("master-agent");
|
||||
|
||||
assert.equal(resolved.promptPolicy?.globalPrompt, "全局主提示词");
|
||||
assert.equal(resolved.userPrompt?.content, "用户私有主提示词");
|
||||
assert.equal(resolved.projectPromptOverride, "当前对话提示词");
|
||||
assert.equal(resolved.promptPolicy?.updatedBy, "17600003315");
|
||||
});
|
||||
|
||||
59
tests/master-agent-memory-ingestion.test.ts
Normal file
59
tests/master-agent-memory-ingestion.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let queueMasterAgentTask: (typeof import("../src/lib/boss-data"))["queueMasterAgentTask"];
|
||||
let completeMasterAgentTask: (typeof import("../src/lib/boss-data"))["completeMasterAgentTask"];
|
||||
let listUserMasterMemories: (typeof import("../src/lib/boss-data"))["listUserMasterMemories"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-memory-ingestion-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const data = await import("../src/lib/boss-data.ts");
|
||||
queueMasterAgentTask = data.queueMasterAgentTask;
|
||||
completeMasterAgentTask = data.completeMasterAgentTask;
|
||||
listUserMasterMemories = data.listUserMasterMemories;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("主 Agent 完成对话后会自动沉淀用户偏好和项目记忆", async () => {
|
||||
await setup();
|
||||
|
||||
const task = await queueMasterAgentTask({
|
||||
projectId: "master-agent",
|
||||
requestMessageId: "msg-user-1",
|
||||
requestText: "boss 项目后续都按微信式交互来做,并且默认中文回复。",
|
||||
executionPrompt: "prompt",
|
||||
requestedBy: "17600003315",
|
||||
requestedByAccount: "17600003315",
|
||||
deviceId: "master-agent-openai",
|
||||
});
|
||||
|
||||
await completeMasterAgentTask({
|
||||
taskId: task.taskId,
|
||||
deviceId: "master-agent-openai",
|
||||
status: "completed",
|
||||
replyBody: "boss 项目当前进度已更新:会话页会继续按微信式交互推进。",
|
||||
});
|
||||
|
||||
const memories = await listUserMasterMemories("17600003315", { includeArchived: false });
|
||||
const globalMemory = memories.find((memory) => memory.scope === "global");
|
||||
const projectMemory = memories.find((memory) => memory.scope === "project" && memory.projectId === "boss-console");
|
||||
|
||||
assert.ok(globalMemory, "expected a global user memory");
|
||||
assert.ok(projectMemory, "expected a project-scoped memory");
|
||||
assert.match(globalMemory?.content ?? "", /微信式交互|中文回复/);
|
||||
assert.match(projectMemory?.content ?? "", /boss 项目当前进度已更新/);
|
||||
});
|
||||
131
tests/master-agent-prompts-memory-routes.test.ts
Normal file
131
tests/master-agent-prompts-memory-routes.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let AUTH_SESSION_COOKIE = "";
|
||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||
let getMasterAgentPromptPolicyRoute: typeof import("../src/app/api/v1/master-agent/prompt-policy/route");
|
||||
let getUserMasterPromptRoute: typeof import("../src/app/api/v1/master-agent/prompt/route");
|
||||
let getUserMasterMemoriesRoute: typeof import("../src/app/api/v1/master-agent/memories/route");
|
||||
let patchUserMasterMemoryRoute: typeof import("../src/app/api/v1/master-agent/memories/[memoryId]/route");
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-prompts-memory-routes-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [data, auth, promptPolicyRoute, userPromptRoute, memoriesRoute, memoryRoute] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/master-agent/prompt-policy/route.ts"),
|
||||
import("../src/app/api/v1/master-agent/prompt/route.ts"),
|
||||
import("../src/app/api/v1/master-agent/memories/route.ts"),
|
||||
import("../src/app/api/v1/master-agent/memories/[memoryId]/route.ts"),
|
||||
]);
|
||||
|
||||
createAuthSession = data.createAuthSession;
|
||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
||||
getMasterAgentPromptPolicyRoute = promptPolicyRoute;
|
||||
getUserMasterPromptRoute = userPromptRoute;
|
||||
getUserMasterMemoriesRoute = memoriesRoute;
|
||||
patchUserMasterMemoryRoute = memoryRoute;
|
||||
}
|
||||
|
||||
async function createAuthedRequest(account = "17600003315", role: "member" | "admin" | "highest_admin" = "highest_admin") {
|
||||
await setup();
|
||||
const session = await createAuthSession({
|
||||
account,
|
||||
role,
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
|
||||
return {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("master-agent prompt and memory routes support admin prompt, user prompt, and memory CRUD", async () => {
|
||||
await setup();
|
||||
|
||||
const adminRequest = await createAuthedRequest();
|
||||
const promptGet = await getMasterAgentPromptPolicyRoute.GET(
|
||||
new NextRequest("http://127.0.0.1:3000/api/v1/master-agent/prompt-policy", {
|
||||
method: "GET",
|
||||
headers: adminRequest.headers,
|
||||
}),
|
||||
);
|
||||
assert.equal(promptGet.status, 200);
|
||||
|
||||
const promptPost = await getMasterAgentPromptPolicyRoute.POST(
|
||||
new NextRequest("http://127.0.0.1:3000/api/v1/master-agent/prompt-policy", {
|
||||
method: "POST",
|
||||
headers: adminRequest.headers,
|
||||
body: JSON.stringify({ globalPrompt: "管理员全局主提示词" }),
|
||||
}),
|
||||
);
|
||||
assert.equal(promptPost.status, 200);
|
||||
|
||||
const userPromptPost = await getUserMasterPromptRoute.POST(
|
||||
new NextRequest("http://127.0.0.1:3000/api/v1/master-agent/prompt", {
|
||||
method: "POST",
|
||||
headers: adminRequest.headers,
|
||||
body: JSON.stringify({ content: "用户私有主提示词" }),
|
||||
}),
|
||||
);
|
||||
assert.equal(userPromptPost.status, 200);
|
||||
|
||||
const memoriesPost = await getUserMasterMemoriesRoute.POST(
|
||||
new NextRequest("http://127.0.0.1:3000/api/v1/master-agent/memories", {
|
||||
method: "POST",
|
||||
headers: adminRequest.headers,
|
||||
body: JSON.stringify({
|
||||
scope: "project",
|
||||
projectId: "master-agent",
|
||||
title: "项目进度",
|
||||
content: "主 Agent 提示词与记忆链路完成。",
|
||||
memoryType: "project_progress",
|
||||
tags: ["主Agent", "记忆"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(memoriesPost.status, 200);
|
||||
|
||||
const memoriesPayload = (await memoriesPost.json()) as {
|
||||
ok: boolean;
|
||||
memory?: { memoryId: string };
|
||||
};
|
||||
assert.equal(memoriesPayload.ok, true);
|
||||
assert.ok(memoriesPayload.memory?.memoryId);
|
||||
|
||||
const patchResponse = await patchUserMasterMemoryRoute.PATCH(
|
||||
new NextRequest(
|
||||
`http://127.0.0.1:3000/api/v1/master-agent/memories/${memoriesPayload.memory?.memoryId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: adminRequest.headers,
|
||||
body: JSON.stringify({
|
||||
content: "主 Agent 提示词与记忆链路已完成。",
|
||||
tags: ["提示词", "记忆"],
|
||||
}),
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ memoryId: memoriesPayload.memory?.memoryId ?? "" }) },
|
||||
);
|
||||
assert.equal(patchResponse.status, 200);
|
||||
});
|
||||
87
tests/master-agent-prompts-memory-state.test.ts
Normal file
87
tests/master-agent-prompts-memory-state.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let getMasterAgentPromptPolicy: (typeof import("../src/lib/boss-data"))["getMasterAgentPromptPolicy"];
|
||||
let updateMasterAgentPromptPolicy: (typeof import("../src/lib/boss-data"))["updateMasterAgentPromptPolicy"];
|
||||
let getUserMasterPrompt: (typeof import("../src/lib/boss-data"))["getUserMasterPrompt"];
|
||||
let updateUserMasterPrompt: (typeof import("../src/lib/boss-data"))["updateUserMasterPrompt"];
|
||||
let listUserMasterMemories: (typeof import("../src/lib/boss-data"))["listUserMasterMemories"];
|
||||
let createUserMasterMemory: (typeof import("../src/lib/boss-data"))["createUserMasterMemory"];
|
||||
let updateUserMasterMemory: (typeof import("../src/lib/boss-data"))["updateUserMasterMemory"];
|
||||
let archiveUserMasterMemory: (typeof import("../src/lib/boss-data"))["archiveUserMasterMemory"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-prompts-memory-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const data = await import("../src/lib/boss-data.ts");
|
||||
readState = data.readState;
|
||||
getMasterAgentPromptPolicy = data.getMasterAgentPromptPolicy;
|
||||
updateMasterAgentPromptPolicy = data.updateMasterAgentPromptPolicy;
|
||||
getUserMasterPrompt = data.getUserMasterPrompt;
|
||||
updateUserMasterPrompt = data.updateUserMasterPrompt;
|
||||
listUserMasterMemories = data.listUserMasterMemories;
|
||||
createUserMasterMemory = data.createUserMasterMemory;
|
||||
updateUserMasterMemory = data.updateUserMasterMemory;
|
||||
archiveUserMasterMemory = data.archiveUserMasterMemory;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("主 Agent 提示词与用户记忆可读写", async () => {
|
||||
await setup();
|
||||
|
||||
await updateMasterAgentPromptPolicy({
|
||||
globalPrompt: "全局主提示词",
|
||||
updatedBy: "17600003315",
|
||||
});
|
||||
await updateUserMasterPrompt("17600003315", "用户私有主提示词");
|
||||
|
||||
const created = await createUserMasterMemory({
|
||||
account: "17600003315",
|
||||
scope: "project",
|
||||
projectId: "master-agent",
|
||||
title: "项目进度",
|
||||
content: "当前主链优先打通聊天闭环。",
|
||||
memoryType: "project_progress",
|
||||
tags: ["聊天", "主链"],
|
||||
});
|
||||
|
||||
await updateUserMasterMemory(created.memoryId, "17600003315", {
|
||||
content: "当前主链优先打通主 Agent 聊天闭环。",
|
||||
tags: ["聊天", "主Agent"],
|
||||
});
|
||||
|
||||
const policy = await getMasterAgentPromptPolicy();
|
||||
const userPrompt = await getUserMasterPrompt("17600003315");
|
||||
const memories = await listUserMasterMemories("17600003315", {
|
||||
includeArchived: false,
|
||||
});
|
||||
|
||||
assert.equal(policy?.globalPrompt, "全局主提示词");
|
||||
assert.equal(userPrompt?.content, "用户私有主提示词");
|
||||
assert.equal(memories.length, 1);
|
||||
assert.equal(memories[0]?.content, "当前主链优先打通主 Agent 聊天闭环。");
|
||||
assert.deepEqual(memories[0]?.tags, ["聊天", "主Agent"]);
|
||||
|
||||
await archiveUserMasterMemory(created.memoryId, "17600003315");
|
||||
const visible = await listUserMasterMemories("17600003315", { includeArchived: false });
|
||||
const all = await listUserMasterMemories("17600003315", { includeArchived: true });
|
||||
const archived = all.find((item) => item.memoryId === created.memoryId);
|
||||
|
||||
assert.equal(visible.length, 0);
|
||||
assert.equal(archived?.archived, true);
|
||||
assert.equal((await readState()).masterAgentMemories.length, 1);
|
||||
});
|
||||
Reference in New Issue
Block a user