diff --git a/README.md b/README.md index 1b9496c..95788ec 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ Android APK: - 当前消息转发已经切到微信式链路:长按消息可直接 `转发 / 多选 / 复制 / 删除`,多选后底部只保留 `转发`,统一进入原生会话选择页 - 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页 - 当前群聊调度主链已补上第一轮业务闭环:群聊文字消息会先进入主 Agent 生成推荐下发方案,用户确认后创建真正的线程执行单,执行完成后会把线程原始结果回写到群聊,再追加一条主 Agent 汇总 +- 当前 Web 群聊页也已补上待确认推荐的刷新恢复:群聊详情会在服务端读取最近一条 `pending_user_confirmation` 的 dispatch plan,并在刷新或重新进入页面后继续显示“等待你确认主 Agent 推荐” - 当前设备导入主链已补上第一轮后端闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户可提交勾选结果、触发主 Agent 风格的导入决议,并把选中的线程真正落成聊天窗口 - 当前新设备导入前台已经接通:Web `添加设备` 成功后会直接进入“导入项目”步骤;设备页详情里也可再次打开导入草稿。原生 Android 端同样已补 `DeviceImportDraftActivity`,可完成 `勾选 -> 预览决议 -> 应用导入` - 当前 `dispatch_execution` 完成回写已补幂等:同一个执行单重复完成,不会再向群聊重复追加线程原始回复和主 Agent 汇总 diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index 62d9c00..fe9fd74 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -90,6 +90,23 @@ public class BossApiClient { return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null); } + public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans", null); + } + + public ApiResponse confirmDispatchPlan(String projectId, String planId, JSONArray approvedTargetProjectIds) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put( + "approvedTargetProjectIds", + approvedTargetProjectIds == null ? new JSONArray() : approvedTargetProjectIds + ); + return requestWithRestore( + "POST", + "/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/confirm", + payload + ); + } + public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException { JSONObject payload = new JSONObject(); payload.put("name", name); diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java index efa34a6..9ffc3bb 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java @@ -2,6 +2,9 @@ package com.hyzq.boss; import androidx.annotation.Nullable; +import org.json.JSONArray; +import org.json.JSONObject; + import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; @@ -215,6 +218,73 @@ public final class ProjectChatUiState { return "文件"; } + @Nullable + public static JSONObject latestPendingDispatchPlan(@Nullable JSONArray plans) { + if (plans == null || plans.length() == 0) { + return null; + } + for (int i = 0; i < plans.length(); i++) { + JSONObject plan = plans.optJSONObject(i); + if (plan == null) { + continue; + } + if ("pending_user_confirmation".equals(plan.optString("status", ""))) { + return plan; + } + } + return null; + } + + public static List dispatchPlanApprovedTargetIds(@Nullable JSONObject plan) { + ArrayList approved = new ArrayList<>(); + if (plan == null) { + return approved; + } + JSONArray targets = plan.optJSONArray("targets"); + if (targets == null) { + return approved; + } + for (int i = 0; i < targets.length(); i++) { + JSONObject target = targets.optJSONObject(i); + if (target == null) { + continue; + } + String projectId = target.optString("projectId", "").trim(); + if (!projectId.isEmpty()) { + approved.add(projectId); + } + } + return approved; + } + + public static String summarizeDispatchPlan(@Nullable JSONObject plan) { + if (plan == null) { + return "主 Agent 暂未生成推荐线程。"; + } + String summary = plan.optString("summary", "").trim(); + List targetTitles = new ArrayList<>(); + JSONArray targets = plan.optJSONArray("targets"); + if (targets != null) { + for (int i = 0; i < targets.length(); i++) { + JSONObject target = targets.optJSONObject(i); + if (target == null) { + continue; + } + String title = target.optString("threadDisplayName", "").trim(); + if (!title.isEmpty()) { + targetTitles.add(title); + } + } + } + StringBuilder builder = new StringBuilder(); + builder.append(isBlank(summary) ? "主 Agent 已生成推荐线程。" : summary); + if (!targetTitles.isEmpty()) { + builder.append("\n推荐线程:"); + builder.append(String.join("、", targetTitles)); + } + return builder.toString(); + } + public static String formatAttachmentSize(long fileSizeBytes) { if (fileSizeBytes >= 1024L * 1024L) { return String.format(java.util.Locale.US, "%.1f MB", fileSizeBytes / (1024f * 1024f)); diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index f82bb7f..10cba07 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -59,6 +59,9 @@ public class ProjectDetailActivity extends BossScreenActivity { private boolean conversationInfoReady; private String currentScreenTitle; private String currentScreenSubtitle; + private String projectCollaborationMode = "development"; + private String projectApprovalState = "not_required"; + private @Nullable JSONObject currentPendingDispatchPlan; private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection(); private ActivityResultLauncher conversationInfoLauncher; private ActivityResultLauncher forwardTargetLauncher; @@ -216,7 +219,20 @@ public class ProjectDetailActivity extends BossScreenActivity { if (!response.ok()) { throw new IllegalStateException(response.message()); } - runOnUiThread(() -> renderProject(response.json)); + JSONObject project = response.json.optJSONObject("project"); + JSONArray dispatchPlans = null; + if (project != null && project.optBoolean("isGroup", false)) { + try { + BossApiClient.ApiResponse dispatchPlansResponse = apiClient.getDispatchPlans(projectId); + if (dispatchPlansResponse.ok()) { + dispatchPlans = dispatchPlansResponse.json.optJSONArray("plans"); + } + } catch (Exception ignored) { + dispatchPlans = null; + } + } + JSONArray finalDispatchPlans = dispatchPlans; + runOnUiThread(() -> renderProject(response.json, finalDispatchPlans)); } catch (Exception error) { runOnUiThread(() -> { setRefreshing(false); @@ -245,7 +261,7 @@ public class ProjectDetailActivity extends BossScreenActivity { updateSelectionUi(); } - private void renderProject(JSONObject payload) { + private void renderProject(JSONObject payload, @Nullable JSONArray dispatchPlans) { JSONObject project = payload.optJSONObject("project"); JSONArray devices = payload.optJSONArray("devices"); JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta"); @@ -254,12 +270,18 @@ public class ProjectDetailActivity extends BossScreenActivity { initialProjectName = title; projectIsGroup = project != null && project.optBoolean("isGroup", false); projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); + projectCollaborationMode = project == null ? "development" : project.optString("collaborationMode", "development"); + projectApprovalState = project == null ? "not_required" : project.optString("approvalState", "not_required"); + currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans); conversationInfoReady = project != null; updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices)); renderQuickActions(); replaceContent(); pendingOutgoingBubble = null; + if (currentPendingDispatchPlan != null) { + appendContent(buildPendingDispatchPlanView(currentPendingDispatchPlan)); + } JSONArray messages = project == null ? null : project.optJSONArray("messages"); selectionState = ProjectChatUiState.reconcileSelection(selectionState, collectMessageIds(messages)); @@ -437,11 +459,29 @@ public class ProjectDetailActivity extends BossScreenActivity { if (!response.ok()) { throw new IllegalStateException(response.message()); } + JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan"); + JSONObject collaborationGate = response.json.optJSONObject("collaborationGate"); runOnUiThread(() -> { composerSending = false; composerInput.setText(""); - showMessage("消息已发送"); + if (collaborationGate != null) { + projectCollaborationMode = collaborationGate.optString("collaborationMode", projectCollaborationMode); + projectApprovalState = collaborationGate.optString("approvalState", projectApprovalState); + } + currentPendingDispatchPlan = dispatchPlan; + if (dispatchPlan != null) { + showMessage( + "approval_required".equals(projectCollaborationMode) + ? "消息已发送,等待你批准主 Agent 下发。" + : "消息已发送,主 Agent 已给出推荐线程。" + ); + } else { + showMessage("消息已发送"); + } reload(true); + if (dispatchPlan != null) { + showDispatchPlanConfirmation(dispatchPlan); + } }); } catch (Exception error) { runOnUiThread(() -> { @@ -530,6 +570,74 @@ public class ProjectDetailActivity extends BossScreenActivity { conversationInfoLauncher.launch(intent); } + private View buildPendingDispatchPlanView(JSONObject dispatchPlan) { + LinearLayout container = new LinearLayout(this); + container.setOrientation(LinearLayout.VERTICAL); + container.addView(BossUi.buildCard( + this, + "approval_required".equals(projectCollaborationMode) ? "等待你批准主 Agent 下发" : "主 Agent 推荐下发", + ProjectChatUiState.summarizeDispatchPlan(dispatchPlan), + "当前确认状态:" + projectApprovalState + )); + Button confirmButton = BossUi.buildMiniActionButton(this, "确认下发", true); + confirmButton.setOnClickListener(v -> showDispatchPlanConfirmation(dispatchPlan)); + container.addView(BossUi.buildInlineActionRow(this, confirmButton)); + return container; + } + + private void showDispatchPlanConfirmation(JSONObject dispatchPlan) { + String title = "approval_required".equals(projectCollaborationMode) + ? "批准主 Agent 下发" + : "确认主 Agent 推荐"; + String message = ProjectChatUiState.summarizeDispatchPlan(dispatchPlan) + + "\n\n确认后会把任务下发到推荐线程,并把线程原始回复与主 Agent 汇总一起回到群聊。"; + new AlertDialog.Builder(this) + .setTitle(title) + .setMessage(message) + .setNegativeButton("稍后", null) + .setPositiveButton("确认下发", (dialog, which) -> confirmDispatchPlan(dispatchPlan)) + .show(); + } + + private void confirmDispatchPlan(JSONObject dispatchPlan) { + String planId = dispatchPlan.optString("planId", "").trim(); + if (planId.isEmpty()) { + showMessage("缺少调度方案 ID"); + return; + } + List approvedTargetProjectIds = ProjectChatUiState.dispatchPlanApprovedTargetIds(dispatchPlan); + if (approvedTargetProjectIds.isEmpty()) { + showMessage("当前没有可下发的目标线程"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + JSONArray approved = new JSONArray(); + for (String approvedTargetProjectId : approvedTargetProjectIds) { + approved.put(approvedTargetProjectId); + } + BossApiClient.ApiResponse response = apiClient.confirmDispatchPlan(projectId, planId, approved); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + JSONArray executions = response.json.optJSONArray("executions"); + int executionCount = executions == null ? approvedTargetProjectIds.size() : executions.length(); + runOnUiThread(() -> { + currentPendingDispatchPlan = null; + projectApprovalState = "approval_required".equals(projectCollaborationMode) ? "approved" : "not_required"; + showMessage("已确认下发到 " + executionCount + " 个线程"); + reload(true); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("确认下发失败:" + error.getMessage()); + }); + } + }); + } + private View buildMessageView(JSONObject message) { String messageId = message.optString("id", ""); String senderLabel = message.optString("senderLabel", "消息"); diff --git a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java new file mode 100644 index 0000000..2ec537a --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java @@ -0,0 +1,241 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; + +import android.content.SharedPreferences; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +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.Map; +import java.util.Set; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class BossApiClientDispatchPlansTest { + @Test + public void getDispatchPlansUsesProjectScopedEndpoint() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + BossApiClient.ApiResponse response = apiClient.getDispatchPlans("p1"); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/p1/dispatch-plans", apiClient.lastPath); + assertEquals("GET", connection.requestMethodValue); + } + + @Test + public void confirmDispatchPlanWritesApprovedTargetProjectIds() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/confirm")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + BossApiClient.ApiResponse response = apiClient.confirmDispatchPlan("p1", "plan-1", new JSONArray().put("target-1").put("target-2")); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/confirm", apiClient.lastPath); + assertEquals("POST", connection.requestMethodValue); + assertEquals("{\"approvedTargetProjectIds\":[\"target-1\",\"target-2\"]}", connection.requestBody()); + } + + private static final class RecordingBossApiClient extends BossApiClient { + private final RecordingConnection connection; + private String lastPath = ""; + + RecordingBossApiClient(RecordingConnection connection) { + super(new InMemorySharedPreferences(), "https://boss.hyzq.net"); + this.connection = connection; + } + + @Override + HttpURLConnection openConnection(String path) { + lastPath = path; + return connection; + } + + @Override + String encode(String value) { + return value; + } + + @Override + void rememberIdentity(JSONObject json) { + // no-op for JVM unit test + } + } + + private static final class RecordingConnection extends HttpURLConnection { + private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream(); + private final Map requestHeaders = new HashMap<>(); + private String requestMethodValue = "GET"; + + RecordingConnection(URL url) { + super(url); + } + + @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 String getRequestProperty(String key) { + return requestHeaders.get(key); + } + + @Override + public OutputStream getOutputStream() { + return requestBody; + } + + @Override + public int getResponseCode() { + return 200; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8)); + } + + String requestBody() { + return requestBody.toString(StandardCharsets.UTF_8); + } + } + + private static final class InMemorySharedPreferences implements SharedPreferences { + private final Map values = new HashMap<>(); + + @Override + public Map getAll() { + return Collections.unmodifiableMap(values); + } + + @Override + public String getString(String key, String defValue) { + return values.getOrDefault(key, defValue); + } + + @Override + public Set getStringSet(String key, Set 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 void apply() {} + + @Override + public boolean commit() { + return true; + } + + @Override + public Editor putStringSet(String key, Set 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 void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {} + + @Override + public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {} + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java index 18122d6..63b1458 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java @@ -6,11 +6,18 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import org.json.JSONArray; +import org.json.JSONObject; import java.util.ArrayList; import java.util.List; import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) public class ProjectChatUiStateTest { @Test public void sendEnabled_requiresTextAndNotBusy() { @@ -158,4 +165,33 @@ public class ProjectChatUiStateTest { assertTrue(summary.endsWith("…")); assertTrue(summary.contains("这是一条很长很长很长的转发消息摘要")); } + + @Test + public void dispatchPlanSummaryShowsRecommendedTargetNames() throws Exception { + JSONObject plan = new JSONObject() + .put("summary", "主 Agent 建议先同步 UI 和设备线程") + .put("targets", new JSONArray() + .put(new JSONObject() + .put("projectId", "p1") + .put("threadDisplayName", "Boss UI 主线程")) + .put(new JSONObject() + .put("projectId", "p2") + .put("threadDisplayName", "设备接入线程"))); + + String summary = ProjectChatUiState.summarizeDispatchPlan(plan); + + assertEquals("主 Agent 建议先同步 UI 和设备线程\n推荐线程:Boss UI 主线程、设备接入线程", summary); + } + + @Test + public void dispatchPlanApprovedTargetIdsFollowRecommendedOrder() throws Exception { + JSONObject plan = new JSONObject() + .put("targets", new JSONArray() + .put(new JSONObject().put("projectId", "p2").put("threadDisplayName", "设备接入线程")) + .put(new JSONObject().put("projectId", "p1").put("threadDisplayName", "Boss UI 主线程"))); + + List approvedTargetIds = ProjectChatUiState.dispatchPlanApprovedTargetIds(plan); + + assertEquals(List.of("p2", "p1"), approvedTargetIds); + } } diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 897e24f..b735b6d 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -383,6 +383,7 @@ - 当前行为: - 只返回当前群聊关联的 dispatch plan - 会附带目标线程列表、审批状态、已确认目标和最近一次确认人 + - Web/原生前台会用它恢复“等待你确认主 Agent 推荐”的待处理状态;当前 Web 群聊详情页在刷新后会继续渲染最近一条 `pending_user_confirmation` 计划 #### `POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm` diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index 63cdc03..de6957a 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -99,6 +99,7 @@ cd /Users/kris/code/boss - 原生转发目标页当前统一由 `ForwardTargetActivity` 承接;一次只允许选择一个目标会话,目标可为单线程会话、群聊、`主 Agent` 或 `审计对话` - 当前单条消息转发会在目标会话中显示为普通转发消息,并保留 `forwardSource`;多条消息会落成 `forward_bundle` 聊天记录卡片,并保留来源会话、时间范围和摘要条目 - 当前群聊编排主链已补上第一轮闭环:群聊文本消息会先进入主 Agent 生成推荐下发方案;用户确认后会创建真正的线程执行单,并写入系统通知;执行完成后会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总 +- 当前 Web 群聊详情页也已补上待确认推荐的刷新恢复:服务端会在页面渲染时读取最近一条 `pending_user_confirmation` 的 dispatch plan,聊天输入区会继续显示“等待你确认主 Agent 推荐”,不再因刷新丢失确认入口 - 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口 - Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链 - 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口 diff --git a/src/app/conversations/[projectId]/page.tsx b/src/app/conversations/[projectId]/page.tsx index 4faedd7..9635156 100644 --- a/src/app/conversations/[projectId]/page.tsx +++ b/src/app/conversations/[projectId]/page.tsx @@ -11,8 +11,9 @@ import { StatusBar, } from "@/components/app-ui"; import { requirePageSession } from "@/lib/boss-auth"; +import { listDispatchPlansByProject, readState } from "@/lib/boss-data"; +import { latestPendingDispatchPlan } from "@/lib/dispatch-plan-ui"; import { formatTimestampLabel, getProjectDetailView } from "@/lib/boss-projections"; -import { readState } from "@/lib/boss-data"; export const dynamic = "force-dynamic"; @@ -25,6 +26,9 @@ export default async function ProjectChatPage({ const { projectId } = await params; const state = await readState(); const detail = getProjectDetailView(state, projectId); + const pendingDispatchPlan = detail?.project.isGroup + ? latestPendingDispatchPlan(await listDispatchPlansByProject(projectId)) + : null; if (!detail) notFound(); @@ -147,7 +151,21 @@ export default async function ProjectChatPage({ - + ({ + projectId: target.projectId, + threadDisplayName: target.threadDisplayName, + })), + } + : null + } + /> ); } diff --git a/src/components/app-ui.tsx b/src/components/app-ui.tsx index dc12c8f..66636dc 100644 --- a/src/components/app-ui.tsx +++ b/src/components/app-ui.tsx @@ -14,6 +14,10 @@ import { popAppHistoryEntry, resolveAppBackAction, } from "@/lib/boss-app-client"; +import { + extractApprovedTargetProjectIds, + summarizeDispatchPlan, +} from "@/lib/dispatch-plan-ui"; import type { Device, DeviceEnrollment, @@ -806,11 +810,64 @@ export function MasterIdentityPill({ identity }: { identity: MasterIdentitySumma ); } -export function ChatComposer({ projectId }: { projectId: string }) { +type PendingDispatchPlanState = { + planId: string; + summary?: string; + targets: Array<{ projectId: string; threadDisplayName: string }>; +}; + +export function ChatComposer({ + projectId, + initialPendingDispatchPlan, +}: { + projectId: string; + initialPendingDispatchPlan?: PendingDispatchPlanState | null; +}) { const router = useRouter(); const [value, setValue] = useState(""); const [message, setMessage] = useState(""); + const [messageTone, setMessageTone] = useState<"success" | "error">("success"); const [loading, setLoading] = useState(false); + const [localPendingDispatchPlan, setLocalPendingDispatchPlan] = + useState(null); + const [dismissedPendingPlanId, setDismissedPendingPlanId] = useState(null); + const pendingDispatchPlan = + localPendingDispatchPlan ?? + (initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId + ? initialPendingDispatchPlan + : null); + + async function confirmDispatchPlan() { + if (!pendingDispatchPlan) return; + setLoading(true); + const response = await fetch( + `/api/v1/projects/${projectId}/dispatch-plans/${pendingDispatchPlan.planId}/confirm`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + approvedTargetProjectIds: extractApprovedTargetProjectIds(pendingDispatchPlan), + }), + }, + ); + const result = (await response.json()) as { + ok: boolean; + executions?: Array; + message?: string; + }; + setLoading(false); + if (!result.ok) { + setMessageTone("error"); + setMessage(result.message ?? "确认下发失败,请重试。"); + return; + } + const executionCount = result.executions?.length ?? extractApprovedTargetProjectIds(pendingDispatchPlan).length; + setLocalPendingDispatchPlan(null); + setDismissedPendingPlanId(pendingDispatchPlan.planId); + setMessageTone("success"); + setMessage(`已确认下发到 ${executionCount} 个线程。`); + router.refresh(); + } async function send(kind: "text" | "voice_intent" | "image_intent" | "video_intent") { setLoading(true); @@ -819,7 +876,19 @@ export function ChatComposer({ projectId }: { projectId: string }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body: kind === "text" ? value : undefined, kind }), }); - const result = (await response.json()) as { ok: boolean; message?: { body: string } }; + const result = (await response.json()) as { + ok: boolean; + message?: { body: string }; + dispatchPlan?: { + planId: string; + summary?: string; + targets?: Array<{ projectId: string; threadDisplayName: string }>; + } | null; + collaborationGate?: { + requiresMasterAgentApproval?: boolean; + }; + messageText?: string; + }; setLoading(false); if (result.ok) { void sendAppLog({ @@ -832,7 +901,23 @@ export function ChatComposer({ projectId }: { projectId: string }) { mirrorToMaster: false, }); setValue(""); - setMessage(""); + if (result.dispatchPlan) { + setLocalPendingDispatchPlan({ + planId: result.dispatchPlan.planId, + summary: result.dispatchPlan.summary, + targets: result.dispatchPlan.targets ?? [], + }); + setDismissedPendingPlanId(null); + setMessage( + result.collaborationGate?.requiresMasterAgentApproval + ? "消息已发送,等待你批准主 Agent 下发。" + : "消息已发送,主 Agent 已给出推荐线程。", + ); + setMessageTone("success"); + } else { + setLocalPendingDispatchPlan(null); + setMessage(""); + } router.refresh(); return; } @@ -845,6 +930,7 @@ export function ChatComposer({ projectId }: { projectId: string }) { detail: "Boss 会话消息接口返回失败。", mirrorToMaster: true, }); + setMessageTone("error"); setMessage("消息发送失败,请重试。"); } @@ -887,10 +973,44 @@ export function ChatComposer({ projectId }: { projectId: string }) { 转发 {message ? ( -
+
{message}
) : null} + {pendingDispatchPlan ? ( +
+
等待你确认主 Agent 推荐
+
{summarizeDispatchPlan(pendingDispatchPlan)}
+
+ + +
+
+ ) : null}
); } diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 5131a25..9f3429e 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -4058,6 +4058,9 @@ function upsertDispatchPlanInState( if (!groupProjectId) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_REQUIRED"); if (!requestMessageId) throw new Error("DISPATCH_PLAN_REQUEST_MESSAGE_REQUIRED"); if (!requestedBy) throw new Error("DISPATCH_PLAN_REQUESTED_BY_REQUIRED"); + const groupProject = state.projects.find((item) => item.id === groupProjectId); + if (!groupProject) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_NOT_FOUND"); + if (!groupProject.isGroup) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_INVALID"); const validatedTargets = normalizeDispatchPlanTargetsForCreate(state, input.targets); const existing = state.dispatchPlans.find( @@ -4071,6 +4074,9 @@ function upsertDispatchPlanInState( if (!payloadMatches) { throw new Error("DISPATCH_PLAN_RETRY_MISMATCH"); } + if (groupProject.collaborationMode === "approval_required") { + groupProject.approvalState = "pending_user"; + } return existing; } @@ -4085,6 +4091,11 @@ function upsertDispatchPlanInState( createdAt: nowIso(), }; state.dispatchPlans.unshift(plan); + if (groupProject.collaborationMode === "approval_required") { + groupProject.approvalState = "pending_user"; + } else { + groupProject.approvalState = "not_required"; + } return plan; } @@ -4163,6 +4174,8 @@ export async function createDispatchExecutionsFromPlan(input: { if (plan.confirmedBy !== confirmedBy) { throw new Error("DISPATCH_PLAN_CONFIRMED_BY_MISMATCH"); } + const groupProject = state.projects.find((item) => item.id === plan.groupProjectId); + if (!groupProject) throw new Error("PROJECT_NOT_FOUND"); const canonicalTargetProjectIds = normalizeStringSet(plan.confirmedTargetProjectIds); const existingExecutions = state.dispatchExecutions.filter((item) => item.planId === plan.planId); @@ -4174,6 +4187,8 @@ export async function createDispatchExecutionsFromPlan(input: { if (plan.status !== "dispatched") { plan.status = "dispatched"; } + groupProject.approvalState = + groupProject.collaborationMode === "approval_required" ? "approved" : "not_required"; ensureDispatchExecutionTasksInState(state, plan, existingExecutions); return existingExecutions; } @@ -4201,6 +4216,8 @@ export async function createDispatchExecutionsFromPlan(input: { }); ensureDispatchExecutionTasksInState(state, plan, executions); plan.status = "dispatched"; + groupProject.approvalState = + groupProject.collaborationMode === "approval_required" ? "approved" : "not_required"; return executions; }); } @@ -4344,6 +4361,8 @@ export async function confirmDispatchPlanAndCreateExecutions(input: { if (plan.status !== "dispatched") { plan.status = "dispatched"; } + groupProject.approvalState = + groupProject.collaborationMode === "approval_required" ? "approved" : "not_required"; executions = existingExecutions; } else { const targets = plan.targets.filter((target) => @@ -4368,6 +4387,8 @@ export async function confirmDispatchPlanAndCreateExecutions(input: { return execution; }); plan.status = "dispatched"; + groupProject.approvalState = + groupProject.collaborationMode === "approval_required" ? "approved" : "not_required"; const targetSummary = executions .map((execution) => { const project = state.projects.find((item) => item.id === execution.targetProjectId); diff --git a/src/lib/dispatch-plan-ui.ts b/src/lib/dispatch-plan-ui.ts new file mode 100644 index 0000000..2dfbaa2 --- /dev/null +++ b/src/lib/dispatch-plan-ui.ts @@ -0,0 +1,35 @@ +export type DispatchPlanUiTarget = { + projectId: string; + threadDisplayName: string; +}; + +export type DispatchPlanUiPayload = { + planId: string; + status?: string; + summary?: string; + targets?: DispatchPlanUiTarget[]; +}; + +export function latestPendingDispatchPlan(plans: DispatchPlanUiPayload[] | null | undefined) { + return (plans ?? []).find((plan) => plan.status === "pending_user_confirmation") ?? null; +} + +export function summarizeDispatchPlan(plan: DispatchPlanUiPayload | null | undefined) { + if (!plan) { + return "主 Agent 暂未生成推荐线程。"; + } + const summary = plan.summary?.trim() || "主 Agent 已生成推荐线程。"; + const titles = (plan.targets ?? []) + .map((target) => target.threadDisplayName?.trim() || "") + .filter(Boolean); + if (!titles.length) { + return summary; + } + return `${summary}\n推荐线程:${titles.join("、")}`; +} + +export function extractApprovedTargetProjectIds(plan: DispatchPlanUiPayload | null | undefined) { + return (plan?.targets ?? []) + .map((target) => target.projectId?.trim() || "") + .filter(Boolean); +} diff --git a/tests/dispatch-plan-confirmation.test.ts b/tests/dispatch-plan-confirmation.test.ts index 27e6ec1..d8732f2 100644 --- a/tests/dispatch-plan-confirmation.test.ts +++ b/tests/dispatch-plan-confirmation.test.ts @@ -203,3 +203,38 @@ test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms ); assert.ok(notice, "expected a master-agent notice in the group chat after confirmation"); }); + +test("confirming a dispatch plan marks approval_required groups as approved", async () => { + const { groupProject, dispatchPlan } = await createDispatchPlanForTest(); + const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId; + assert.ok(approvedTargetProjectId, "expected a recommended target project"); + + const state = await readState(); + await writeState({ + ...state, + projects: state.projects.map((project) => + project.id === groupProject.id + ? { + ...project, + collaborationMode: "approval_required" as const, + approvalState: "pending_user" as const, + } + : project, + ), + }); + + const response = await confirmDispatchPlanRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/confirm`, + "POST", + { approvedTargetProjectIds: [approvedTargetProjectId] }, + ), + { params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) }, + ); + assert.equal(response.status, 200); + + const nextState = await readState(); + const nextGroupProject = nextState.projects.find((project) => project.id === groupProject.id); + assert.ok(nextGroupProject, "expected group project to remain present"); + assert.equal(nextGroupProject?.approvalState, "approved"); +}); diff --git a/tests/dispatch-plan-ui.test.ts b/tests/dispatch-plan-ui.test.ts new file mode 100644 index 0000000..ede5d8e --- /dev/null +++ b/tests/dispatch-plan-ui.test.ts @@ -0,0 +1,56 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + extractApprovedTargetProjectIds, + latestPendingDispatchPlan, + summarizeDispatchPlan, +} from "@/lib/dispatch-plan-ui"; + +test("summarizeDispatchPlan combines summary and recommended target titles", () => { + const summary = summarizeDispatchPlan({ + planId: "dispatch-plan-0", + summary: "主 Agent 建议先同步 UI 和设备线程", + targets: [ + { projectId: "p1", threadDisplayName: "Boss UI 主线程" }, + { projectId: "p2", threadDisplayName: "设备接入线程" }, + ], + }); + + assert.equal(summary, "主 Agent 建议先同步 UI 和设备线程\n推荐线程:Boss UI 主线程、设备接入线程"); +}); + +test("extractApprovedTargetProjectIds keeps target order and drops blanks", () => { + const ids = extractApprovedTargetProjectIds({ + planId: "dispatch-plan-0", + targets: [ + { projectId: "p2", threadDisplayName: "设备接入线程" }, + { projectId: "p1", threadDisplayName: "Boss UI 主线程" }, + ], + }); + + assert.deepEqual(ids, ["p2", "p1"]); +}); + +test("latestPendingDispatchPlan returns the latest waiting confirmation item", () => { + const plan = latestPendingDispatchPlan([ + { + planId: "dispatch-plan-1", + status: "dispatched", + summary: "已完成的推荐", + targets: [{ projectId: "p1", threadDisplayName: "Boss UI 主线程" }], + }, + { + planId: "dispatch-plan-2", + status: "pending_user_confirmation", + summary: "等待确认的推荐", + targets: [{ projectId: "p2", threadDisplayName: "设备接入线程" }], + }, + ]); + + assert.deepEqual(plan, { + planId: "dispatch-plan-2", + status: "pending_user_confirmation", + summary: "等待确认的推荐", + targets: [{ projectId: "p2", threadDisplayName: "设备接入线程" }], + }); +}); diff --git a/tests/group-message-dispatch-plan.test.ts b/tests/group-message-dispatch-plan.test.ts index 80aa25b..9b7b086 100644 --- a/tests/group-message-dispatch-plan.test.ts +++ b/tests/group-message-dispatch-plan.test.ts @@ -190,6 +190,60 @@ test("POST /api/v1/projects/[projectId]/messages keeps dispatchPlan null for sin assert.equal(payload.collaborationGate.isGroup, false); }); +test("POST /api/v1/projects/[projectId]/messages marks approval_required groups as pending user approval", async () => { + await setup(); + const memberProjects = await ensureTwoSingleThreadProjects(); + assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects"); + + const groupProject = await createProjectGroupChat({ + sourceProjectId: memberProjects[0].id, + memberProjectIds: [memberProjects[1].id], + createdBy: "17600003315", + }); + + const state = await readState(); + await writeState({ + ...state, + projects: state.projects.map((project) => + project.id === groupProject.id + ? { + ...project, + collaborationMode: "approval_required" as const, + approvalState: "not_required" as const, + } + : project, + ), + }); + + const response = await POST(await createAuthedRequest(groupProject.id, { body: "请协调两个线程确认上线方案" }), { + params: Promise.resolve({ projectId: groupProject.id }), + }); + assert.equal(response.status, 200); + + const payload = (await response.json()) as { + ok: boolean; + dispatchPlan: { planId: string } | null; + collaborationGate: { + isGroup: boolean; + collaborationMode: "development" | "approval_required"; + requiresMasterAgentApproval: boolean; + approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected"; + }; + }; + + assert.equal(payload.ok, true); + assert.ok(payload.dispatchPlan, "expected dispatch plan"); + assert.equal(payload.collaborationGate.isGroup, true); + assert.equal(payload.collaborationGate.collaborationMode, "approval_required"); + assert.equal(payload.collaborationGate.requiresMasterAgentApproval, true); + assert.equal(payload.collaborationGate.approvalState, "pending_user"); + + const nextState = await readState(); + const persistedGroup = nextState.projects.find((project) => project.id === groupProject.id); + assert.ok(persistedGroup, "expected group project to persist"); + assert.equal(persistedGroup?.approvalState, "pending_user"); +}); + test("POST /api/v1/projects/[projectId]/messages keeps message success when group dispatch recommendation fails", async () => { await setup(); const memberProjects = await ensureTwoSingleThreadProjects();