feat: harden ai onboarding and approval chat flows

This commit is contained in:
kris
2026-03-31 04:18:57 +08:00
parent 4336dc22a7
commit 0cb2171dd3
16 changed files with 551 additions and 27 deletions

View File

@@ -107,10 +107,12 @@ Android APK
- 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页
- 当前群聊调度主链已补上第一轮业务闭环:群聊文字消息会先进入主 Agent 生成推荐下发方案,用户确认后创建真正的线程执行单,执行完成后会把线程原始结果回写到群聊,再追加一条主 Agent 汇总
- 当前 `approval_required` 群聊已补齐两条审批动作:可以确认主 Agent 推荐,也可以明确拒绝;拒绝后会把群审批状态写成 `rejected`,并在群里追加系统提示,不会继续下发到线程
- 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝`,刷新后也能恢复最近一条待确认推荐
- 当前三条聊天主链都已接入真实等待链路:`主 Agent 单聊 / 普通线程单聊 / 群聊确认下发` 当前都会返回任务信息,原生 Android 会保持等待直到收到真实回写或明确超时提示
- 当前 `我的 > AI 账号` 已补 `登录 OpenAI 平台账号``绑定 Master Codex Node` 两条显式入口OpenAI API 登录成功后会立即设为当前主控
- 当前主控若还是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,避免聊天直接掉成失败日志
- 当前群资料页已经支持“修复群成员”:如果历史脏群里混入了 `master-agent` 或失效线程引用,前台会明确提示并允许重新选择真实线程成员,修复后会正式写回群成员账本
- 当前原生聊天页也会直接提示“修复群成员”:当群里存在失效线程或不可下发成员时,`ProjectDetailActivity` 会在消息流上方直接给出 `去修复` 入口,并跳到群资料页完成修复
- 当前 Web 群聊页也已补上待确认推荐的刷新恢复:群聊详情会在服务端读取最近一条 `pending_user_confirmation` 的 dispatch plan并在刷新或重新进入页面后继续显示“等待你确认主 Agent 推荐”
- 当前设备导入主链已补上第一轮后端闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户可提交勾选结果、触发主 Agent 风格的导入决议,并把选中的线程真正落成聊天窗口
- 当前新设备导入前台已经接通Web `添加设备` 成功后会直接进入“导入项目”步骤;设备页详情里也可再次打开导入草稿。原生 Android 端同样已补 `DeviceImportDraftActivity`,可完成 `勾选 -> 预览决议 -> 应用导入`

View File

@@ -306,27 +306,17 @@ public class AiAccountsActivity extends BossScreenActivity {
BossApiClient.ApiResponse response = apiClient.onboardOpenAiApiAccount(payload);
if (!response.ok()) throw new IllegalStateException(response.message());
String accountId = extractAccountId(response.json);
if (accountId.isEmpty()) {
runOnUiThread(() -> {
showMessage("OpenAI 平台账号已登录,并设为当前主控。");
reload();
});
return;
}
BossApiClient.ApiResponse validation = apiClient.validateAccount(accountId);
runOnUiThread(() -> {
showMessage(validation.ok()
? validation.message()
: "登录完成,但校验失败:" + validation.message());
showMessage("OpenAI 平台账号已登录,并设为当前主控。");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("登录失败:" + error.getMessage());
String detail = error.getMessage();
showMessage(detail == null || detail.trim().isEmpty()
? "OpenAI 平台账号登录失败,请稍后重试。"
: "OpenAI 平台账号登录失败:" + detail);
});
}
});

View File

@@ -111,10 +111,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
private static final class ProjectSnapshot {
final JSONObject payload;
final @Nullable JSONArray dispatchPlans;
final @Nullable JSONObject participantsPayload;
ProjectSnapshot(JSONObject payload, @Nullable JSONArray dispatchPlans) {
ProjectSnapshot(JSONObject payload, @Nullable JSONArray dispatchPlans, @Nullable JSONObject participantsPayload) {
this.payload = payload;
this.dispatchPlans = dispatchPlans;
this.participantsPayload = participantsPayload;
}
}
@@ -228,7 +230,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
executor.execute(() -> {
try {
ProjectSnapshot snapshot = fetchProjectSnapshot();
runOnUiThread(() -> renderProject(snapshot.payload, snapshot.dispatchPlans));
runOnUiThread(() -> renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -257,7 +259,11 @@ public class ProjectDetailActivity extends BossScreenActivity {
updateSelectionUi();
}
private void renderProject(JSONObject payload, @Nullable JSONArray dispatchPlans) {
private void renderProject(
JSONObject payload,
@Nullable JSONArray dispatchPlans,
@Nullable JSONObject participantsPayload
) {
JSONObject project = payload.optJSONObject("project");
JSONArray devices = payload.optJSONArray("devices");
JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta");
@@ -278,6 +284,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (currentPendingDispatchPlan != null) {
appendContent(buildPendingDispatchPlanView(currentPendingDispatchPlan));
}
if (projectIsGroup && participantsPayload != null && participantsPayload.optBoolean("repairRequired", false)) {
appendContent(buildRepairGroupMembersView(participantsPayload));
}
JSONArray messages = project == null ? null : project.optJSONArray("messages");
selectionState = ProjectChatUiState.reconcileSelection(selectionState, collectMessageIds(messages));
@@ -585,7 +594,29 @@ public class ProjectDetailActivity extends BossScreenActivity {
));
Button confirmButton = BossUi.buildMiniActionButton(this, "确认下发", true);
confirmButton.setOnClickListener(v -> showDispatchPlanConfirmation(dispatchPlan));
container.addView(BossUi.buildInlineActionRow(this, confirmButton));
Button rejectButton = BossUi.buildMiniActionButton(this, "拒绝", false);
rejectButton.setOnClickListener(v -> rejectDispatchPlan(dispatchPlan));
container.addView(BossUi.buildInlineActionRow(this, confirmButton, rejectButton));
return container;
}
private View buildRepairGroupMembersView(JSONObject participantsPayload) {
String repairReason = participantsPayload.optString("repairReason", "当前群聊里有失效线程,请先修复群成员。");
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
String meta = invalidParticipantCount > 0
? "存在 " + invalidParticipantCount + " 个失效成员"
: "当前群聊还没有可下发的真实线程";
LinearLayout container = new LinearLayout(this);
container.setOrientation(LinearLayout.VERTICAL);
container.addView(BossUi.buildCard(
this,
"修复群成员",
repairReason,
meta
));
Button repairButton = BossUi.buildMiniActionButton(this, "去修复", true);
repairButton.setOnClickListener(v -> openConversationInfo());
container.addView(BossUi.buildInlineActionRow(this, repairButton));
return container;
}
@@ -1532,6 +1563,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
throw new IllegalStateException(detailResponse.message());
}
JSONArray dispatchPlans = null;
JSONObject participantsPayload = null;
if (includeDispatchPlans) {
try {
BossApiClient.ApiResponse dispatchPlansResponse = apiClient.getDispatchPlans(projectId);
@@ -1541,8 +1573,16 @@ public class ProjectDetailActivity extends BossScreenActivity {
} catch (Exception ignored) {
dispatchPlans = null;
}
try {
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
if (participantsResponse.ok()) {
participantsPayload = participantsResponse.json;
}
} catch (Exception ignored) {
participantsPayload = null;
}
}
return new ProjectSnapshot(detailResponse.json, dispatchPlans);
return new ProjectSnapshot(detailResponse.json, dispatchPlans, participantsPayload);
}
private void startReplyWait(
@@ -1571,7 +1611,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (!renderedInitialSnapshot || hasReply) {
runOnUiThread(() -> {
renderProject(snapshot.payload, snapshot.dispatchPlans);
renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload);
if (!hasReply) {
composerSending = true;
updateComposerSendButtonState();

View File

@@ -0,0 +1,345 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.SharedPreferences;
import android.os.Looper;
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.shadows.ShadowToast;
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 AiAccountsActivityTest {
@Test
public void submitOpenAiOnboarding_reportsExplicitPrimaryControllerSuccessAndRefreshesSummary() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
200,
"{\"ok\":true,\"accountId\":\"acc-1\"}",
"{\"ok\":false,\"message\":\"ONBOARD_FAILED\"}"
),
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/acc-1/validate"),
200,
"{\"ok\":true,\"message\":\"校验通过\"}",
"{\"ok\":false,\"message\":\"VALIDATION_FAILED\"}"
)
));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
int initialReloadCount = activity.reloadCount;
ReflectionHelpers.callInstanceMethod(
activity,
"submitOpenAiOnboarding",
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test-key")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("OpenAI 平台账号已登录,并设为当前主控。", ShadowToast.getTextOfLatestToast());
assertEquals(initialReloadCount + 1, activity.reloadCount);
}
@Test
public void submitOpenAiOnboarding_showsClearChineseFailurePrefix() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
403,
"{\"ok\":false,\"message\":\"API Key 无效\"}",
"{\"ok\":false,\"message\":\"API Key 无效\"}"
)
));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"submitOpenAiOnboarding",
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, "bad-key")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("OpenAI 平台账号登录失败API Key 无效", ShadowToast.getTextOfLatestToast());
assertEquals(1, activity.reloadCount);
}
private static final class TestAiAccountsActivity extends AiAccountsActivity {
private int reloadCount = 0;
@Override
protected void reload() {
reloadCount += 1;
}
}
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;
ScriptedBossApiClient(RecordingConnection... connections) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connections = new HashMap<>();
for (RecordingConnection connection : connections) {
this.connections.put(connection.getURL().getPath(), connection);
}
}
@Override
HttpURLConnection openConnection(String path) {
RecordingConnection connection = connections.get(path);
if (connection == null) {
throw new IllegalStateException("Missing scripted connection for " + path);
}
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// JVM 单测不需要落 Android 侧身份缓存。
}
}
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";
private String contentTypeValue = "";
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);
if ("Content-Type".equalsIgnoreCase(key)) {
contentTypeValue = 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() {
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
}
@Override
public Map<String, List<String>> getHeaderFields() {
return Collections.emptyMap();
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(String key, int defValue) {
throw new UnsupportedOperationException();
}
@Override
public long getLong(String key, long defValue) {
throw new UnsupportedOperationException();
}
@Override
public float getFloat(String key, float defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean getBoolean(String key, boolean defValue) {
throw new UnsupportedOperationException();
}
@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 remove(String key) {
values.remove(key);
return this;
}
@Override
public Editor clear() {
values.clear();
return this;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
throw new UnsupportedOperationException();
}
@Override
public Editor putInt(String key, int value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putLong(String key, long value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putFloat(String key, float value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putBoolean(String key, boolean value) {
throw new UnsupportedOperationException();
}
@Override
public boolean commit() {
return true;
}
@Override
public void apply() {}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
}

View File

@@ -3,6 +3,7 @@ package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertNotNull;
import android.content.Intent;
import android.view.Gravity;
@@ -226,6 +227,103 @@ public class ProjectDetailActivityUiTest {
assertFalse(viewTreeContainsText(attachmentView, "你 · 09:26"));
}
@Test
public void pendingDispatchPlanViewShowsConfirmAndRejectActions() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-group")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "协作群");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "projectCollaborationMode", "approval_required");
ReflectionHelpers.setField(activity, "projectApprovalState", "pending_user");
JSONObject dispatchPlan = new JSONObject()
.put("planId", "dispatch-plan-1")
.put("summary", "主 Agent 建议先把任务下发给 Boss 移动控制台。")
.put("targets", new JSONArray().put(new JSONObject()
.put("projectId", "thread-1")
.put("threadDisplayName", "Boss 移动控制台")
.put("reason", "最近活跃")));
View card = ReflectionHelpers.callInstanceMethod(
activity,
"buildPendingDispatchPlanView",
ReflectionHelpers.ClassParameter.from(JSONObject.class, dispatchPlan)
);
assertTrue(viewTreeContainsText(card, "确认下发"));
assertTrue(viewTreeContainsText(card, "拒绝"));
}
@Test
public void renderProjectShowsRepairEntryForDirtyGroupAndOpensGroupInfo() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildGroupProjectPayload()),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildRepairParticipantsPayload())
);
View repairView = findClickableViewContainingText(
activity.findViewById(R.id.screen_content),
"去修复"
);
assertNotNull(repairView);
assertTrue(viewTreeContainsText(activity.findViewById(R.id.screen_content), "修复群成员"));
repairView.performClick();
Intent nextIntent = org.robolectric.Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(GroupInfoActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("group-1", nextIntent.getStringExtra(GroupInfoActivity.EXTRA_PROJECT_ID));
}
private static JSONObject buildGroupProjectPayload() throws Exception {
JSONObject threadMeta = new JSONObject()
.put("threadId", "group-thread-3")
.put("folderName", "Boss");
JSONObject project = new JSONObject()
.put("id", "group-1")
.put("name", "巡检协作群")
.put("isGroup", true)
.put("collaborationMode", "approval_required")
.put("approvalState", "pending_user")
.put("threadMeta", threadMeta)
.put("messages", new JSONArray());
return new JSONObject().put("project", project);
}
private static JSONObject buildRepairParticipantsPayload() throws Exception {
return new JSONObject()
.put("participants", new JSONArray()
.put(new JSONObject()
.put("projectId", "master-agent")
.put("threadDisplayName", "主 Agent 汇总")
.put("folderName", "主控线程")
.put("deviceId", "Mac Studio")
.put("threadId", "master-agent-thread")
.put("status", "invalid_target")
.put("statusLabel", "不是可下发线程")
.put("canOpenProject", true)))
.put("repairRequired", true)
.put("repairReason", "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。")
.put("validParticipantCount", 0)
.put("invalidParticipantCount", 1);
}
private static View buildBoundMessageView(TestProjectDetailActivity activity, String messageId, String body) {
TextView messageView = new TextView(activity);
messageView.setText(body);
@@ -260,6 +358,26 @@ public class ProjectDetailActivityUiTest {
return false;
}
private static View findClickableViewContainingText(View root, String expectedText) {
if (root == null) {
return null;
}
if (viewTreeContainsText(root, expectedText) && root.isClickable()) {
return root;
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
View match = findClickableViewContainingText(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
public static class TestProjectDetailActivity extends ProjectDetailActivity {
@Override
boolean shouldLoadOnCreate() {

View File

@@ -45,6 +45,8 @@
- `ProjectDetailActivity` 已改成聊天优先布局
- 主面只保留 `项目目标 / 版本记录`
- 右上角会进入微信式 `会话信息 / 群资料`
- `approval_required` 群聊的待确认推荐会直接显示在主消息流里,支持 `确认下发 / 拒绝`
- 脏群会在主消息流上方直接显示 `去修复` 入口,并跳转到 `GroupInfoActivity`
- 单线程会话支持按微信最新逻辑改线程名
- 当前已经支持从单线程会话发起独立群聊,群聊创建后作为新会话保留,原会话不升级
- 当前已经支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`

View File

@@ -103,6 +103,7 @@ cd /Users/kris/code/boss
- 当前单条消息转发会在目标会话中显示为普通转发消息,并保留 `forwardSource`;多条消息会落成 `forward_bundle` 聊天记录卡片,并保留来源会话、时间范围和摘要条目
- 当前群聊编排主链已补上第一轮闭环:群聊文本消息会先进入主 Agent 生成推荐下发方案;用户确认后会创建真正的线程执行单,并写入系统通知;执行完成后会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总
- 当前 `approval_required` 群聊已补齐“确认 / 拒绝”两条审批动作:确认后才会创建 `dispatchExecution`,拒绝后会把群审批状态写成 `rejected`,并在群里追加明确系统提示
- 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝` 操作,且刷新后仍能恢复最近一条待确认推荐
- 当前普通单线程聊天也已补上真实执行链:`POST /api/v1/projects/[projectId]/messages` 不再只写用户消息,而是会追加 `conversation_reply` 任务;绑定设备上的 `local-agent` 认领后会继续恢复到真实 Codex 线程,再把线程原始回复回写到该聊天窗口
- 当前 Web 群聊详情页也已补上待确认推荐的刷新恢复:服务端会在页面渲染时读取最近一条 `pending_user_confirmation` 的 dispatch plan聊天输入区会继续显示“等待你确认主 Agent 推荐”,不再因刷新丢失确认入口
- 当前 `AI 账号` 页面已分成两条显式接入链:`登录 OpenAI 平台账号API Key``绑定 Master Codex Node`OpenAI API 登录成功后会立即切成当前主控
@@ -110,6 +111,7 @@ cd /Users/kris/code/boss
- 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口
- Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链
- 当前群资料页已补上“修复群成员”入口:当群里存在失效线程引用、`master-agent` 这类不可下发成员,或真实线程成员少于 2 个时,前台会明确提示并允许重新选择真实线程成员
- 当前原生聊天页也已前移“修复群成员”入口:脏群会在消息流上方直接显示 `去修复` 按钮,并跳转到群资料页完成成员替换
- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口
- 当前 `dispatch_execution` 完成回写已补幂等,重复完成同一个线程执行单不会再重复向群聊追加线程原始回复和主 Agent 汇总
- 当前原生 Android 已把三条聊天主链统一成等待真实回写:`主 Agent 单聊 / 普通线程单聊 / 群聊确认下发` 都会保持等待,直到收到实际回复或明确超时提示

View File

@@ -2,9 +2,9 @@
"artifactType": "aab",
"fileName": "boss-android-v2.5.5-release.aab",
"urlPath": "/downloads/boss-android-v2.5.5-release.aab",
"sizeBytes": 2928015,
"updatedAt": "2026-03-30T19:53:34Z",
"sha256": "1178102370885d5a2e43ca177daf09d1f2416aa36c2f6312c3a04e4158cb4b44",
"sizeBytes": 2928280,
"updatedAt": "2026-03-30T20:15:46Z",
"sha256": "5bc794884a621a2e970bf1a235bf07d0338bcec4205963ca442b70fcd75f9f23",
"versionName": "2.5.5",
"versionCode": 18,
"buildFlavor": "release"

View File

@@ -1,9 +1,9 @@
{
"fileName": "boss-android-v2.5.5-release.apk",
"urlPath": "/api/v1/user/ota/package",
"sizeBytes": 3104744,
"updatedAt": "2026-03-30T19:53:27Z",
"sha256": "c8ab4c48d17504c0ac55b621ef6623940b0da73a59f079dd5f3db37a470216f6",
"sizeBytes": 3105013,
"updatedAt": "2026-03-30T20:15:32Z",
"sha256": "f2da722d8ea57e7bd6e16687ae161c45332cc0ba00304725f9e57ddb5b20293e",
"versionName": "2.5.5",
"versionCode": 18,
"buildFlavor": "release"

View File

@@ -4111,6 +4111,18 @@ function upsertDispatchPlanInState(
state.dispatchPlans.unshift(plan);
if (groupProject.collaborationMode === "approval_required") {
groupProject.approvalState = "pending_user";
const targetSummary = validatedTargets
.map((target) => {
const project = state.projects.find((item) => item.id === target.projectId);
return `${project?.threadMeta.threadDisplayName ?? project?.name ?? target.projectId}`;
})
.join("、");
pushProjectLedgerMessage(state, groupProjectId, {
sender: "master",
senderLabel: "主 Agent",
body: `主 Agent 已生成推荐,等待你确认后再下发到 ${validatedTargets.length} 个线程:${targetSummary}`,
kind: "system_notice",
});
} else {
groupProject.approvalState = "not_required";
}

View File

@@ -187,6 +187,7 @@ test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms
ok: boolean;
plan: { planId: string; status: string; confirmedTargetProjectIds: string[] };
executions: Array<{ planId: string; targetProjectId: string; status: string }>;
notice: { kind: string; body: string } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.plan.planId, dispatchPlan.planId);
@@ -196,6 +197,9 @@ test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms
assert.equal(payload.executions[0]?.planId, dispatchPlan.planId);
assert.equal(payload.executions[0]?.targetProjectId, approvedTargetProjectId);
assert.equal(payload.executions[0]?.status, "queued");
assert.ok(payload.notice, "expected a confirmation notice in the response");
assert.equal(payload.notice?.kind, "system_notice");
assert.equal(payload.notice?.body, "已确认下发到 1 个线程:《北区试产线回归》。");
const nextState = await readState();
const notice = nextState.projects
@@ -280,6 +284,7 @@ test("rejecting a dispatch plan marks approval_required groups as rejected and w
assert.equal(payload.plan.planId, dispatchPlan.planId);
assert.equal(payload.plan.status, "rejected");
assert.equal(payload.notice.kind, "system_notice");
assert.equal(payload.notice.body, "已拒绝主 Agent 推荐,本次不会下发到任何线程。");
const nextState = await readState();
const nextGroupProject = nextState.projects.find((project) => project.id === groupProject.id);

View File

@@ -270,6 +270,14 @@ test("POST /api/v1/projects/[projectId]/messages marks approval_required groups
const persistedGroup = nextState.projects.find((project) => project.id === groupProject.id);
assert.ok(persistedGroup, "expected group project to persist");
assert.equal(persistedGroup?.approvalState, "pending_user");
const pendingNotice = persistedGroup?.messages.find(
(message) =>
message.sender === "master" &&
message.kind === "system_notice" &&
message.body.includes("等待你确认"),
);
assert.ok(pendingNotice, "expected an approval notice to be persisted in the group ledger");
assert.match(pendingNotice?.body ?? "", /等待你确认|待审批|待确认/);
});
test("POST /api/v1/projects/[projectId]/messages keeps message success when group dispatch recommendation fails", async () => {