From 908ad8858b0f89ef869142315657b214bc1502ef Mon Sep 17 00:00:00 2001 From: kris Date: Sat, 4 Apr 2026 07:53:27 +0800 Subject: [PATCH] feat: add realtime sync and import project understanding --- .../com/hyzq/boss/BossRealtimeClient.java | 157 ++++++++++++++++++ .../java/com/hyzq/boss/BossRealtimeEvent.java | 15 ++ .../hyzq/boss/DeviceImportDraftActivity.java | 68 +++++++- .../main/java/com/hyzq/boss/MainActivity.java | 61 ++++++- .../com/hyzq/boss/ProjectDetailActivity.java | 57 +++++++ .../com/hyzq/boss/BossRealtimeClientTest.java | 30 ++++ .../boss/DeviceImportDraftActivityTest.java | 36 +++- .../hyzq/boss/MainActivityRealtimeTest.java | 56 +++++++ .../ProjectDetailActivityRealtimeTest.java | 77 +++++++++ src/lib/boss-data.ts | 137 ++++++++++++++- src/lib/boss-master-agent.ts | 65 ++++++++ tests/device-import-draft.test.ts | 62 +++++++ 12 files changed, 809 insertions(+), 12 deletions(-) create mode 100644 android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java create mode 100644 android/app/src/main/java/com/hyzq/boss/BossRealtimeEvent.java create mode 100644 android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java create mode 100644 android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java create mode 100644 android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java diff --git a/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java b/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java new file mode 100644 index 0000000..f34a9fe --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java @@ -0,0 +1,157 @@ +package com.hyzq.boss; + +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; + +final class BossRealtimeClient { + interface Listener { + void onRealtimeEvent(BossRealtimeEvent event); + } + + private final BossApiClient apiClient; + private final Listener listener; + private volatile boolean running; + private @Nullable Thread workerThread; + private @Nullable HttpURLConnection activeConnection; + + BossRealtimeClient(BossApiClient apiClient, Listener listener) { + this.apiClient = apiClient; + this.listener = listener; + } + + synchronized void start() { + if (running) { + return; + } + running = true; + workerThread = new Thread(this::runLoop, "boss-realtime"); + workerThread.start(); + } + + synchronized void stop() { + running = false; + if (activeConnection != null) { + activeConnection.disconnect(); + activeConnection = null; + } + if (workerThread != null) { + workerThread.interrupt(); + workerThread = null; + } + } + + private void runLoop() { + long backoffMs = 800L; + while (running) { + try { + openAndConsumeStream(); + backoffMs = 800L; + } catch (Exception ignored) { + if (!running) { + return; + } + try { + Thread.sleep(backoffMs); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + backoffMs = Math.min(backoffMs + 1200L, 5000L); + } + } + } + + private void openAndConsumeStream() throws IOException { + HttpURLConnection connection = apiClient.openConnection("/api/v1/events"); + activeConnection = connection; + connection.setRequestMethod("GET"); + connection.setConnectTimeout(12_000); + connection.setReadTimeout(60_000); + connection.setUseCaches(false); + connection.setDoInput(true); + connection.setRequestProperty("Accept", "text/event-stream"); + connection.setRequestProperty("Cache-Control", "no-cache"); + connection.setRequestProperty("x-boss-native-app", "1"); + String cookie = apiClient.getSessionCookie(); + if (!cookie.isEmpty()) { + connection.setRequestProperty("Cookie", cookie); + } + + int statusCode = connection.getResponseCode(); + if (statusCode < 200 || statusCode >= 300) { + throw new IOException("REALTIME_STREAM_HTTP_" + statusCode); + } + + try (InputStream inputStream = connection.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + StringBuilder block = new StringBuilder(); + String line; + while (running && (line = reader.readLine()) != null) { + if (line.isEmpty()) { + dispatchEventBlock(block.toString()); + block.setLength(0); + continue; + } + block.append(line).append('\n'); + } + if (block.length() > 0) { + dispatchEventBlock(block.toString()); + } + } finally { + activeConnection = null; + connection.disconnect(); + } + } + + private void dispatchEventBlock(String rawBlock) { + BossRealtimeEvent event = parseEventBlock(rawBlock); + if (event == null || event.eventName.isEmpty()) { + return; + } + listener.onRealtimeEvent(event); + } + + static @Nullable BossRealtimeEvent parseEventBlock(String rawBlock) { + if (rawBlock == null) { + return null; + } + String trimmed = rawBlock.trim(); + if (trimmed.isEmpty() || trimmed.startsWith(":")) { + return null; + } + + String eventName = ""; + StringBuilder dataBuilder = new StringBuilder(); + for (String line : rawBlock.split("\n")) { + if (line.startsWith("event:")) { + eventName = line.substring("event:".length()).trim(); + } else if (line.startsWith("data:")) { + if (dataBuilder.length() > 0) { + dataBuilder.append('\n'); + } + dataBuilder.append(line.substring("data:".length()).trim()); + } + } + if (eventName.isEmpty()) { + return null; + } + JSONObject payload = new JSONObject(); + if (dataBuilder.length() > 0) { + try { + payload = new JSONObject(dataBuilder.toString()); + } catch (JSONException ignored) { + payload = new JSONObject(); + } + } + return new BossRealtimeEvent(eventName, payload); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/BossRealtimeEvent.java b/android/app/src/main/java/com/hyzq/boss/BossRealtimeEvent.java new file mode 100644 index 0000000..a8860b0 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/BossRealtimeEvent.java @@ -0,0 +1,15 @@ +package com.hyzq.boss; + +import androidx.annotation.Nullable; + +import org.json.JSONObject; + +public final class BossRealtimeEvent { + public final String eventName; + public final JSONObject payload; + + public BossRealtimeEvent(String eventName, @Nullable JSONObject payload) { + this.eventName = eventName == null ? "" : eventName.trim(); + this.payload = payload == null ? new JSONObject() : payload; + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java index acd8e88..74df1fd 100644 --- a/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java @@ -24,6 +24,8 @@ public class DeviceImportDraftActivity extends BossScreenActivity { private @Nullable JSONObject currentDraft; private @Nullable JSONObject currentResolution; private @Nullable JSONObject currentReviewTask; + private @Nullable JSONArray currentUnderstandingTasks; + private @Nullable JSONArray currentProjectUnderstandings; private final LinkedHashSet selectedCandidateIds = new LinkedHashSet<>(); private final Runnable reviewPollRunnable = this::reload; @@ -53,7 +55,9 @@ public class DeviceImportDraftActivity extends BossScreenActivity { runOnUiThread(() -> applyPayload( response.json.optJSONObject("draft"), response.json.optJSONObject("resolution"), - response.json.optJSONObject("reviewTask") + response.json.optJSONObject("reviewTask"), + response.json.optJSONArray("understandingTasks"), + response.json.optJSONArray("projectUnderstandings") )); } catch (Exception error) { runOnUiThread(() -> { @@ -64,10 +68,18 @@ public class DeviceImportDraftActivity extends BossScreenActivity { }); } - private void applyPayload(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) { + private void applyPayload( + @Nullable JSONObject draft, + @Nullable JSONObject resolution, + @Nullable JSONObject reviewTask, + @Nullable JSONArray understandingTasks, + @Nullable JSONArray projectUnderstandings + ) { currentDraft = draft; currentResolution = resolution; currentReviewTask = reviewTask; + currentUnderstandingTasks = understandingTasks; + currentProjectUnderstandings = projectUnderstandings; selectedCandidateIds.clear(); JSONArray selected = draft == null ? null : draft.optJSONArray("selectedCandidateIds"); if (selected != null) { @@ -105,6 +117,8 @@ public class DeviceImportDraftActivity extends BossScreenActivity { JSONObject draft = currentDraft; JSONObject resolution = currentResolution; JSONObject reviewTask = currentReviewTask; + JSONArray understandingTasks = currentUnderstandingTasks; + JSONArray projectUnderstandings = currentProjectUnderstandings; contentLayout.removeCallbacks(reviewPollRunnable); replaceContent(); appendContent(BossUi.buildSoftPanel( @@ -223,6 +237,40 @@ public class DeviceImportDraftActivity extends BossScreenActivity { )); } + if (understandingTasks != null && understandingTasks.length() > 0) { + int completedCount = 0; + for (int i = 0; i < understandingTasks.length(); i++) { + JSONObject task = understandingTasks.optJSONObject(i); + if (task != null && "completed".equals(task.optString("status", ""))) { + completedCount += 1; + } + } + appendContent(BossUi.buildCard( + this, + "项目理解", + completedCount == understandingTasks.length() + ? "主 Agent 已经拿到活跃线程的项目目标、进度和技术架构。" + : "主 Agent 正在向活跃线程追问项目目标、进度和技术架构。", + "已完成 " + completedCount + " / " + understandingTasks.length() + )); + } + + if (projectUnderstandings != null) { + for (int i = 0; i < projectUnderstandings.length(); i++) { + JSONObject understanding = projectUnderstandings.optJSONObject(i); + if (understanding == null) continue; + appendContent(BossUi.buildCard( + this, + understanding.optString("threadDisplayName", "项目理解"), + "目标:" + understanding.optString("projectGoal", "未提供") + + "\n进度:" + understanding.optString("currentProgress", "未提供") + + "\n架构:" + understanding.optString("technicalArchitecture", "未提供"), + "阻塞:" + understanding.optString("currentBlockers", "无") + + " · 下一步:" + understanding.optString("recommendedNextStep", "继续联调") + )); + } + } + JSONArray appliedProjectNames = draft.optJSONArray("appliedProjectNames"); if (appliedProjectNames != null && appliedProjectNames.length() > 0) { appendContent(BossUi.buildCard( @@ -369,14 +417,16 @@ public class DeviceImportDraftActivity extends BossScreenActivity { reviewResponse.json.optJSONObject("resolution"), reviewResponse.json.optJSONObject("reviewTask") != null ? reviewResponse.json.optJSONObject("reviewTask") - : reviewResponse.json.optJSONObject("task") + : reviewResponse.json.optJSONObject("task"), + reviewResponse.json.optJSONArray("understandingTasks"), + reviewResponse.json.optJSONArray("projectUnderstandings") ); }); } catch (Exception error) { final JSONObject fallbackDraft = selectedDraft; runOnUiThread(() -> { if (fallbackDraft != null) { - applyPayload(fallbackDraft, null, null); + applyPayload(fallbackDraft, null, null, null, null); } else { setRefreshing(false); } @@ -400,7 +450,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity { } runOnUiThread(() -> { showMessage("已清空当前勾选"); - applyPayload(response.json.optJSONObject("draft"), null, null); + applyPayload(response.json.optJSONObject("draft"), null, null, null, null); }); } catch (Exception error) { runOnUiThread(() -> { @@ -425,7 +475,13 @@ public class DeviceImportDraftActivity extends BossScreenActivity { } runOnUiThread(() -> { showMessage("已应用导入"); - applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution"), null); + applyPayload( + response.json.optJSONObject("draft"), + response.json.optJSONObject("resolution"), + null, + null, + response.json.optJSONArray("projectUnderstandings") + ); }); } catch (Exception error) { runOnUiThread(() -> { diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java index f2d684a..6f1e915 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -45,11 +45,13 @@ public class MainActivity extends AppCompatActivity { private static final String KEY_LAST_ROOT_TAB = "last_root_tab"; private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L; private static final long CONVERSATION_AUTO_REFRESH_MS = 12_000L; + private static final long REALTIME_REFRESH_THROTTLE_MS = 900L; private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final Handler uiHandler = new Handler(Looper.getMainLooper()); private BossApiClient apiClient; + private BossRealtimeClient realtimeClient; private View loginPanel; private View contentPanel; @@ -100,6 +102,7 @@ public class MainActivity extends AppCompatActivity { private boolean conversationQuickActionsVisible = false; private boolean conversationAutoRefreshArmed = false; private boolean conversationAutoRefreshEnabled = false; + private long lastRealtimeRefreshAt = 0L; private final Set selectedConversationProjectIds = new LinkedHashSet<>(); private @Nullable RootPagerAdapter rootPagerAdapter; private boolean syncingRootPagerSelection = false; @@ -122,6 +125,7 @@ public class MainActivity extends AppCompatActivity { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); apiClient = new BossApiClient(this); + realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); bindViews(); bindActions(); applyInitialTab(getIntent()); @@ -170,6 +174,7 @@ public class MainActivity extends AppCompatActivity { @Override protected void onDestroy() { cancelConversationAutoRefresh(); + stopRealtimeUpdates(); executor.shutdownNow(); super.onDestroy(); } @@ -179,12 +184,14 @@ public class MainActivity extends AppCompatActivity { super.onResume(); conversationAutoRefreshEnabled = true; updateConversationAutoRefresh(); + updateRealtimeSubscription(); } @Override protected void onPause() { conversationAutoRefreshEnabled = false; cancelConversationAutoRefresh(); + stopRealtimeUpdates(); super.onPause(); } @@ -352,10 +359,43 @@ public class MainActivity extends AppCompatActivity { }); } - private void refreshCurrentTab() { + void refreshCurrentTab() { refreshAllData(sessionData); } + void handleRealtimeEvent(BossRealtimeEvent event) { + if (event == null || event.eventName.isEmpty()) { + return; + } + if (contentPanel == null || contentPanel.getVisibility() != View.VISIBLE) { + return; + } + boolean shouldRefresh = false; + if ("conversations".equals(activeTab)) { + shouldRefresh = + "conversation.updated".equals(event.eventName) || + "project.messages.updated".equals(event.eventName) || + "master_agent.task.updated".equals(event.eventName) || + "conversation.context_indicator.updated".equals(event.eventName); + } else if ("devices".equals(activeTab)) { + shouldRefresh = + "devices.updated".equals(event.eventName) || + "devices.skills.updated".equals(event.eventName) || + "conversation.updated".equals(event.eventName); + } else if ("me".equals(activeTab)) { + shouldRefresh = "ota.updated".equals(event.eventName) || "app.logs.updated".equals(event.eventName); + } + if (!shouldRefresh) { + return; + } + long now = System.currentTimeMillis(); + if (now - lastRealtimeRefreshAt < REALTIME_REFRESH_THROTTLE_MS) { + return; + } + lastRealtimeRefreshAt = now; + runOnUiThread(this::refreshCurrentTab); + } + private void refreshAllData(@Nullable JSONObject initialSession) { startRefreshing(true); topSubtitle.setText(""); @@ -490,6 +530,7 @@ public class MainActivity extends AppCompatActivity { loginPanel.setVisibility(View.VISIBLE); contentPanel.setVisibility(View.GONE); setLoginLoading(false, hint); + stopRealtimeUpdates(); } private void showContent() { @@ -497,6 +538,7 @@ public class MainActivity extends AppCompatActivity { contentPanel.setVisibility(View.VISIBLE); setActiveTab(activeTab, false); updateConversationAutoRefresh(); + updateRealtimeSubscription(); } private void setLoginLoading(boolean loading, String hint) { @@ -1212,6 +1254,23 @@ public class MainActivity extends AppCompatActivity { Toast.makeText(this, text, Toast.LENGTH_SHORT).show(); } + private void updateRealtimeSubscription() { + if (contentPanel != null + && contentPanel.getVisibility() == View.VISIBLE + && apiClient != null + && apiClient.hasSessionHints()) { + realtimeClient.start(); + return; + } + stopRealtimeUpdates(); + } + + private void stopRealtimeUpdates() { + if (realtimeClient != null) { + realtimeClient.stop(); + } + } + private void openMeEntry(String key) { Intent intent; switch (key) { 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 23d513e..8f06d30 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -43,6 +43,7 @@ public class ProjectDetailActivity extends BossScreenActivity { public static final String EXTRA_PROJECT_NAME = "project_name"; private static final long REPLY_WAIT_TIMEOUT_MS = 55_000L; private static final long REPLY_WAIT_POLL_INTERVAL_MS = 1_500L; + private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private String projectId; private String initialProjectName; @@ -82,6 +83,8 @@ public class ProjectDetailActivity extends BossScreenActivity { private ActivityResultLauncher videoPickerLauncher; private ActivityResultLauncher filePickerLauncher; private final ExecutorService replyWaitExecutor = Executors.newSingleThreadExecutor(); + private @Nullable BossRealtimeClient realtimeClient; + private long lastRealtimeReloadAt; static final class ChromeBindings { final boolean multiSelecting; @@ -205,6 +208,7 @@ public class ProjectDetailActivity extends BossScreenActivity { new ActivityResultContracts.GetContent(), uri -> onAttachmentPicked(uri, "file") ); + realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); BossWindowInsets.applyKeyboardAvoidingInset(composerRow); BossWindowInsets.applyKeyboardAvoidingInset(multiSelectActionsLayout); @@ -242,10 +246,23 @@ public class ProjectDetailActivity extends BossScreenActivity { @Override protected void onDestroy() { + stopRealtimeUpdates(); replyWaitExecutor.shutdownNow(); super.onDestroy(); } + @Override + protected void onResume() { + super.onResume(); + updateRealtimeSubscription(); + } + + @Override + protected void onPause() { + stopRealtimeUpdates(); + super.onPause(); + } + boolean shouldLoadOnCreate() { return true; } @@ -255,6 +272,32 @@ public class ProjectDetailActivity extends BossScreenActivity { reload(false); } + void handleRealtimeEvent(BossRealtimeEvent event) { + if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) { + return; + } + boolean shouldReload = false; + String payloadProjectId = event.payload.optString("projectId", ""); + if ("project.messages.updated".equals(event.eventName) || "conversation.updated".equals(event.eventName)) { + shouldReload = projectId.equals(payloadProjectId); + } else if ("master_agent.task.updated".equals(event.eventName)) { + shouldReload = "master-agent".equals(projectId); + } + if (!shouldReload) { + return; + } + long now = System.currentTimeMillis(); + if (now - lastRealtimeReloadAt < REALTIME_RELOAD_THROTTLE_MS) { + return; + } + lastRealtimeReloadAt = now; + runOnUiThread(this::triggerRealtimeReload); + } + + void triggerRealtimeReload() { + reload(); + } + private void reload(boolean forcedScrollToBottom) { if (projectId == null || projectId.isEmpty()) { showMessage("缺少 projectId"); @@ -368,6 +411,20 @@ public class ProjectDetailActivity extends BossScreenActivity { } } + private void updateRealtimeSubscription() { + if (apiClient != null && apiClient.hasSessionHints()) { + realtimeClient.start(); + return; + } + stopRealtimeUpdates(); + } + + private void stopRealtimeUpdates() { + if (realtimeClient != null) { + realtimeClient.stop(); + } + } + private void renderQuickActions() { if (quickActionsLayout == null) { return; diff --git a/android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java b/android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java new file mode 100644 index 0000000..c0ed89a --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java @@ -0,0 +1,30 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +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 BossRealtimeClientTest { + @Test + public void parseEventBlockExtractsEventNameAndJsonPayload() { + BossRealtimeEvent event = BossRealtimeClient.parseEventBlock( + "event: project.messages.updated\n" + + "data: {\"projectId\":\"project-1\",\"status\":\"completed\"}\n\n" + ); + + assertEquals("project.messages.updated", event.eventName); + assertEquals("project-1", event.payload.optString("projectId")); + assertEquals("completed", event.payload.optString("status")); + } + + @Test + public void parseEventBlockReturnsNullForKeepaliveComment() { + assertNull(BossRealtimeClient.parseEventBlock(": keepalive\n\n")); + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java b/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java index 962f5c0..a54c998 100644 --- a/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java @@ -37,7 +37,9 @@ public class DeviceImportDraftActivityTest { "applyPayload", ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingDraft()), ReflectionHelpers.ClassParameter.from(JSONObject.class, null), - ReflectionHelpers.ClassParameter.from(JSONObject.class, null) + ReflectionHelpers.ClassParameter.from(JSONObject.class, null), + ReflectionHelpers.ClassParameter.from(JSONArray.class, null), + ReflectionHelpers.ClassParameter.from(JSONArray.class, null) ); View content = activity.findViewById(R.id.screen_content); @@ -64,7 +66,9 @@ public class DeviceImportDraftActivityTest { "applyPayload", ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedDraft()), ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedResolution()), - ReflectionHelpers.ClassParameter.from(JSONObject.class, null) + ReflectionHelpers.ClassParameter.from(JSONObject.class, null), + ReflectionHelpers.ClassParameter.from(JSONArray.class, null), + ReflectionHelpers.ClassParameter.from(JSONArray.class, null) ); View content = activity.findViewById(R.id.screen_content); @@ -92,13 +96,18 @@ public class DeviceImportDraftActivityTest { "applyPayload", ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingResolutionDraft()), ReflectionHelpers.ClassParameter.from(JSONObject.class, null), - ReflectionHelpers.ClassParameter.from(JSONObject.class, buildQueuedReviewTask()) + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildQueuedReviewTask()), + ReflectionHelpers.ClassParameter.from(JSONArray.class, buildUnderstandingTasks()), + ReflectionHelpers.ClassParameter.from(JSONArray.class, buildProjectUnderstandings()) ); View content = activity.findViewById(R.id.screen_content); assertTrue(viewTreeContainsText(content, "主 Agent 审核中")); assertTrue(viewTreeContainsText(content, "审核任务")); assertTrue(viewTreeContainsText(content, "状态:queued")); + assertTrue(viewTreeContainsText(content, "项目理解")); + assertTrue(viewTreeContainsText(content, "北区试产线回归")); + assertTrue(viewTreeContainsText(content, "树莓派二代接入与联调")); } private static JSONObject buildPendingDraft() throws Exception { @@ -200,6 +209,27 @@ public class DeviceImportDraftActivityTest { .put("status", "queued"); } + private static JSONArray buildUnderstandingTasks() throws Exception { + return new JSONArray() + .put(new JSONObject() + .put("taskId", "mastertask-understanding-1") + .put("candidateId", "candidate-1") + .put("threadDisplayName", "北区试产线回归") + .put("status", "completed")); + } + + private static JSONArray buildProjectUnderstandings() throws Exception { + return new JSONArray() + .put(new JSONObject() + .put("candidateId", "candidate-1") + .put("threadDisplayName", "北区试产线回归") + .put("projectGoal", "完成树莓派二代接入与联调") + .put("currentProgress", "正在核对接线和控制链路") + .put("technicalArchitecture", "Next.js 控制台 + local-agent + Codex 线程") + .put("currentBlockers", "串口稳定性待验证") + .put("recommendedNextStep", "先确认串口日志")); + } + private static boolean viewTreeContainsText(View root, String expectedText) { if (root instanceof TextView) { CharSequence text = ((TextView) root).getText(); diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java new file mode 100644 index 0000000..0e5d9c5 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java @@ -0,0 +1,56 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; + +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; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class MainActivityRealtimeTest { + @Test + public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception { + TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1")) + ) + ); + + assertEquals(1, activity.refreshCount); + } + + @Test + public void devicesRealtimeEventDoesNotRefreshConversationTab() throws Exception { + TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio")) + ) + ); + + assertEquals(0, activity.refreshCount); + } + + public static class TestMainActivity extends MainActivity { + int refreshCount; + + @Override + void refreshCurrentTab() { + refreshCount += 1; + } + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java new file mode 100644 index 0000000..7b38fbd --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java @@ -0,0 +1,77 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; + +import android.content.Intent; + +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; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class ProjectDetailActivityRealtimeTest { + @Test + public void matchingProjectMessageEventTriggersReload() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线"); + TestRealtimeProjectDetailActivity activity = Robolectric + .buildActivity(TestRealtimeProjectDetailActivity.class, intent) + .setup() + .resume() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1")) + ) + ); + + assertEquals(1, activity.reloadCount); + } + + @Test + public void unrelatedProjectMessageEventDoesNotTriggerReload() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线"); + TestRealtimeProjectDetailActivity activity = Robolectric + .buildActivity(TestRealtimeProjectDetailActivity.class, intent) + .setup() + .resume() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2")) + ) + ); + + assertEquals(0, activity.reloadCount); + } + + public static class TestRealtimeProjectDetailActivity extends ProjectDetailActivity { + int reloadCount; + + @Override + boolean shouldLoadOnCreate() { + return false; + } + + @Override + protected void reload() { + reloadCount += 1; + } + } +} diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index ab2a9cd..4aaef40 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -482,6 +482,19 @@ export interface DeviceImportResolution { appliedBy?: string; } +export interface DeviceImportProjectUnderstanding { + candidateId: string; + threadDisplayName: string; + folderName: string; + projectGoal: string; + currentProgress: string; + technicalArchitecture: string; + currentBlockers: string; + recommendedNextStep: string; + sourceTaskId: string; + updatedAt: string; +} + export interface VerificationCode { id: string; account: string; @@ -646,6 +659,8 @@ export interface MasterAgentTask { orchestrationBackendId?: OrchestrationBackendId; orchestrationBackendLabel?: string; deviceImportDraftId?: string; + deviceImportCandidateId?: string; + deviceImportCandidateFolderName?: string; status: MasterAgentTaskStatus; requestedAt: string; claimedAt?: string; @@ -2926,6 +2941,8 @@ function normalizeState(raw: Partial | undefined): BossState { : undefined, orchestrationBackendLabel: task.orchestrationBackendLabel, deviceImportDraftId: task.deviceImportDraftId, + deviceImportCandidateId: task.deviceImportCandidateId, + deviceImportCandidateFolderName: task.deviceImportCandidateFolderName, status: task.status ?? "queued", requestedAt: task.requestedAt ?? nowIso(), claimedAt: task.claimedAt, @@ -5130,6 +5147,8 @@ export async function queueMasterAgentTask(payload: { targetCodexFolderRef?: string; orchestrationBackendId?: OrchestrationBackendId; orchestrationBackendLabel?: string; + deviceImportCandidateId?: string; + deviceImportCandidateFolderName?: string; }) { const task = await mutateState((state) => { const task: MasterAgentTask = { @@ -5159,6 +5178,8 @@ export async function queueMasterAgentTask(payload: { targetCodexFolderRef: payload.targetCodexFolderRef, orchestrationBackendId: payload.orchestrationBackendId, orchestrationBackendLabel: payload.orchestrationBackendLabel, + deviceImportCandidateId: payload.deviceImportCandidateId, + deviceImportCandidateFolderName: payload.deviceImportCandidateFolderName, status: "queued", requestedAt: nowIso(), }; @@ -6114,11 +6135,20 @@ export async function completeMasterAgentTask(payload: { masterSummary: payload.replyBody?.trim(), }); } else if (!attachmentProjectId && payload.status === "completed" && task.replyBody) { + const isDeviceImportUnderstanding = + task.taskType === "conversation_reply" && + task.projectId === "master-agent" && + Boolean(task.deviceImportDraftId && task.deviceImportCandidateId); const isThreadConversationReply = task.taskType === "conversation_reply" && task.projectId !== "master-agent" && Boolean(task.targetProjectId && task.targetThreadId); - if (isThreadConversationReply) { + if (isDeviceImportUnderstanding) { + const draft = state.deviceImportDrafts.find((item) => item.draftId === task.deviceImportDraftId); + if (draft) { + publishBossEvent("devices.updated", { deviceId: draft.deviceId }); + } + } else if (isThreadConversationReply) { const threadProject = state.projects.find( (item) => item.id === (task.targetProjectId ?? task.projectId), ); @@ -7009,7 +7039,110 @@ export async function getLatestDeviceImportDraft(deviceId: string) { item.deviceImportDraftId === draft.draftId, ) ?? null : null; - return { draft, resolution, reviewTask }; + const understandingTasks = draft + ? listDeviceImportUnderstandingTasks(state, draft.draftId) + : []; + const projectUnderstandings = draft + ? deriveDeviceImportProjectUnderstandings(state, draft.draftId) + : []; + return { draft, resolution, reviewTask, understandingTasks, projectUnderstandings }; +} + +function listDeviceImportUnderstandingTasks(state: BossState, draftId: string) { + const latestByCandidate = new Map(); + for (const task of state.masterAgentTasks) { + if ( + task.taskType !== "conversation_reply" || + task.deviceImportDraftId !== draftId || + !task.deviceImportCandidateId + ) { + continue; + } + const existing = latestByCandidate.get(task.deviceImportCandidateId); + if (!existing || existing.requestedAt < task.requestedAt) { + latestByCandidate.set(task.deviceImportCandidateId, task); + } + } + return [...latestByCandidate.values()].map((task) => ({ + taskId: task.taskId, + candidateId: task.deviceImportCandidateId ?? "", + threadDisplayName: task.targetThreadDisplayName ?? "", + folderName: task.deviceImportCandidateFolderName ?? "", + status: task.status, + updatedAt: task.completedAt ?? task.claimedAt ?? task.requestedAt, + })); +} + +function parseDeviceImportUnderstandingReply( + task: Pick, +): DeviceImportProjectUnderstanding | null { + const candidateId = task.deviceImportCandidateId?.trim(); + const replyBody = task.replyBody?.trim(); + if (!candidateId || !replyBody) { + return null; + } + + const fencedMatch = replyBody.match(/```(?:json)?\s*([\s\S]*?)```/i); + const jsonCandidate = fencedMatch?.[1]?.trim() ?? replyBody; + let parsed: + | { + projectGoal?: string; + currentProgress?: string; + technicalArchitecture?: string; + currentBlockers?: string; + recommendedNextStep?: string; + } + | null = null; + try { + parsed = JSON.parse(jsonCandidate); + } catch { + return null; + } + + const projectGoal = parsed?.projectGoal?.trim() ?? ""; + const currentProgress = parsed?.currentProgress?.trim() ?? ""; + const technicalArchitecture = parsed?.technicalArchitecture?.trim() ?? ""; + const currentBlockers = parsed?.currentBlockers?.trim() ?? ""; + const recommendedNextStep = parsed?.recommendedNextStep?.trim() ?? ""; + if (!projectGoal && !currentProgress && !technicalArchitecture && !currentBlockers && !recommendedNextStep) { + return null; + } + + return { + candidateId, + threadDisplayName: task.targetThreadDisplayName?.trim() || "未命名线程", + folderName: task.deviceImportCandidateFolderName?.trim() || "", + projectGoal, + currentProgress, + technicalArchitecture, + currentBlockers, + recommendedNextStep, + sourceTaskId: task.taskId, + updatedAt: task.completedAt ?? task.requestedAt, + }; +} + +function deriveDeviceImportProjectUnderstandings(state: BossState, draftId: string) { + const latestByCandidate = new Map(); + for (const task of state.masterAgentTasks) { + if ( + task.taskType !== "conversation_reply" || + task.deviceImportDraftId !== draftId || + task.status !== "completed" || + !task.deviceImportCandidateId + ) { + continue; + } + const understanding = parseDeviceImportUnderstandingReply(task); + if (!understanding) { + continue; + } + const existing = latestByCandidate.get(understanding.candidateId); + if (!existing || existing.updatedAt < understanding.updatedAt) { + latestByCandidate.set(understanding.candidateId, understanding); + } + } + return [...latestByCandidate.values()]; } export async function previewDeviceImportResolution(input: { deviceId: string }) { diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index c02d70f..67b3bb9 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -1579,6 +1579,35 @@ function buildDeviceImportResolutionPrompt(params: { ].join("\n"); } +function buildDeviceImportUnderstandingPrompt(params: { + deviceName: string; + draftId: string; + candidateId: string; + threadDisplayName: string; + folderName: string; + lastActiveAt: string; +}) { + return [ + "你正在协助 Boss 控制台完成新设备导入前的项目理解。", + "请以当前活跃线程的视角,直接总结这个项目当前最关键的信息。", + "输出必须是 JSON,对象结构如下:", + '{ "projectGoal": "一句中文目标", "currentProgress": "一句中文进度", "technicalArchitecture": "一句中文架构说明", "currentBlockers": "一句中文阻塞说明", "recommendedNextStep": "一句中文建议动作" }', + "要求:", + "1. 只输出 JSON,不要输出额外解释。", + "2. 用中文,句子短而明确。", + "3. 如果某项信息不确定,也要明确写出当前已知范围,不要留空。", + "", + `deviceName: ${params.deviceName}`, + `draftId: ${params.draftId}`, + `candidateId: ${params.candidateId}`, + `threadDisplayName: ${params.threadDisplayName}`, + `folderName: ${params.folderName}`, + `lastActiveAt: ${params.lastActiveAt}`, + "", + "请回答当前项目:目标、进度、技术架构、阻塞点、下一步建议。", + ].join("\n"); +} + export async function queueDeviceImportResolutionTask(params: { deviceId: string; reviewedBy: string; @@ -1599,6 +1628,35 @@ export async function queueDeviceImportResolutionTask(params: { const selectedCandidates = draft.candidates.filter((candidate) => draft.selectedCandidateIds.includes(candidate.candidateId), ); + const understandingTasks = await Promise.all( + selectedCandidates + .filter((candidate) => candidate.codexThreadRef?.trim()) + .map((candidate) => + queueMasterAgentTask({ + projectId: "master-agent", + taskType: "conversation_reply", + requestMessageId: draft.draftId, + requestText: `请总结线程 ${candidate.threadDisplayName} 当前项目目标与进度`, + executionPrompt: buildDeviceImportUnderstandingPrompt({ + deviceName: device.name, + draftId: draft.draftId, + candidateId: candidate.candidateId, + threadDisplayName: candidate.threadDisplayName, + folderName: candidate.folderName, + lastActiveAt: candidate.lastActiveAt, + }), + requestedBy: params.reviewedBy, + requestedByAccount: params.reviewedBy, + deviceId: device.id, + deviceImportDraftId: draft.draftId, + deviceImportCandidateId: candidate.candidateId, + deviceImportCandidateFolderName: candidate.folderName, + targetThreadDisplayName: candidate.threadDisplayName, + targetCodexThreadRef: candidate.codexThreadRef, + targetCodexFolderRef: candidate.codexFolderRef ?? candidate.folderRef, + }), + ), + ); const task = await queueMasterAgentTask({ projectId: "master-agent", taskType: "device_import_resolution", @@ -1639,6 +1697,13 @@ export async function queueDeviceImportResolutionTask(params: { deviceImportDraftId: task.deviceImportDraftId, }, draft: latest.draft ?? undefined, + understandingTasks: understandingTasks.map((item) => ({ + taskId: item.taskId, + taskType: item.taskType, + status: item.status, + candidateId: item.deviceImportCandidateId, + threadDisplayName: item.targetThreadDisplayName, + })), ...(latest.resolution ? { resolution: latest.resolution } : {}), }; } diff --git a/tests/device-import-draft.test.ts b/tests/device-import-draft.test.ts index dac9ae3..d8088eb 100644 --- a/tests/device-import-draft.test.ts +++ b/tests/device-import-draft.test.ts @@ -184,11 +184,14 @@ test("device import draft review queues a master-agent task, then completion wri const reviewPayload = (await reviewResponse.json()) as { draft?: { status: string; selectedCandidateIds: string[] }; resolution?: { summary: string; items: Array<{ action: string; threadDisplayName: string }> }; + understandingTasks?: Array<{ taskId: string; status: "queued" | "running" | "completed" | "failed" }>; task: { taskId: string; status: "queued" | "running" | "completed" | "failed" }; }; assert.equal(reviewPayload.task.status, "queued"); assert.equal(reviewPayload.draft?.status, "pending_resolution"); assert.equal(reviewPayload.resolution, undefined); + assert.equal(reviewPayload.understandingTasks?.length, 1); + assert.equal(reviewPayload.understandingTasks?.[0]?.status, "queued"); const reviewedState = await readState(); const resolutionTask = reviewedState.masterAgentTasks.find( @@ -198,6 +201,14 @@ test("device import draft review queues a master-agent task, then completion wri task.status === "queued", ); assert.ok(resolutionTask, "expected import review to leave a queued master-agent task trace"); + const understandingTask = reviewedState.masterAgentTasks.find( + (task) => + task.taskType === "conversation_reply" && + task.deviceImportDraftId === draftPayload.draft?.draftId && + task.status === "queued", + ); + assert.ok(understandingTask, "expected import review to leave a queued thread understanding task trace"); + assert.equal(understandingTask?.targetThreadDisplayName, "北区试产线回归"); const completionResponse = await completeMasterTaskRoute( await createAuthedRequest( @@ -226,6 +237,30 @@ test("device import draft review queues a master-agent task, then completion wri ); assert.equal(completionResponse.status, 200); + const understandingCompletionResponse = await completeMasterTaskRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/master-agent/tasks/${understandingTask?.taskId}/complete`, + "POST", + { + deviceId: enrollmentPayload.device.id, + status: "completed", + replyBody: JSON.stringify( + { + projectGoal: "完成北区试产线树莓派二代接入与联调。", + currentProgress: "已经完成线程导入准备,当前在核对设备接线和控制链路。", + technicalArchitecture: "前台是 Next.js 控制台,设备端通过 local-agent 与 Codex 线程联动。", + currentBlockers: "还缺少树莓派二代的串口稳定性验证。", + recommendedNextStep: "先确认接线和串口日志,再继续设备控制指令联调。", + }, + null, + 2, + ), + }, + ), + { params: Promise.resolve({ taskId: understandingTask?.taskId ?? "" }) }, + ); + assert.equal(understandingCompletionResponse.status, 200); + const completedState = await readState(); const completedDraft = completedState.deviceImportDrafts.find( (draft) => draft.deviceId === enrollmentPayload.device.id, @@ -237,6 +272,33 @@ test("device import draft review queues a master-agent task, then completion wri assert.equal(completedResolution?.status, "ready"); assert.match(completedResolution?.summary ?? "", /MacBook Pro 导入建议/); + const refreshedDraftResponse = await getImportDraftRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft`, + "GET", + ), + { params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) }, + ); + assert.equal(refreshedDraftResponse.status, 200); + const refreshedDraftPayload = (await refreshedDraftResponse.json()) as { + projectUnderstandings?: Array<{ + threadDisplayName: string; + projectGoal: string; + currentProgress: string; + technicalArchitecture: string; + currentBlockers: string; + recommendedNextStep: string; + }>; + understandingTasks?: Array<{ taskId: string; status: string }>; + }; + assert.equal(refreshedDraftPayload.projectUnderstandings?.length, 1); + assert.equal(refreshedDraftPayload.projectUnderstandings?.[0]?.threadDisplayName, "北区试产线回归"); + assert.match( + refreshedDraftPayload.projectUnderstandings?.[0]?.projectGoal ?? "", + /树莓派二代接入与联调/, + ); + assert.equal(refreshedDraftPayload.understandingTasks?.[0]?.status, "completed"); + const applyResponse = await applyImportDraftRoute( await createAuthedRequest( `http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/apply`,