feat: sync project understanding for imported devices

This commit is contained in:
kris
2026-04-04 08:29:17 +08:00
parent 01f438e3af
commit 432cf97541
9 changed files with 587 additions and 18 deletions

View File

@@ -399,6 +399,10 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/import-draft/apply", new JSONObject());
}
public ApiResponse syncDeviceProjectUnderstanding(String deviceId) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/project-understanding-sync", new JSONObject());
}
public ApiResponse getAccounts() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/accounts", null);
}

View File

@@ -75,6 +75,7 @@ public class DeviceDetailActivity extends BossScreenActivity {
));
}
appendContent(BossUi.buildMenuRow(this, "导入项目", "勾选这台设备上要暴露到会话首页的项目和线程", null, v -> openImportDraft()));
appendContent(BossUi.buildMenuRow(this, "同步项目理解", "让主 Agent 主动询问这台设备上的活跃项目目标、进度和架构", null, v -> syncProjectUnderstanding()));
appendContent(BossUi.buildMenuRow(this, "查看技能", "查看当前设备同步的 Skill 清单", null, v -> openSkills()));
setRefreshing(false);
}
@@ -93,6 +94,33 @@ public class DeviceDetailActivity extends BossScreenActivity {
startActivity(intent);
}
private void syncProjectUnderstanding() {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.syncDeviceProjectUnderstanding(deviceId);
if (!response.ok()) throw new IllegalStateException(response.message());
JSONObject payload = response.json;
JSONArray queuedTasks = payload.optJSONArray("queuedTasks");
int queuedCount = queuedTasks == null ? 0 : queuedTasks.length();
runOnUiThread(() -> {
setRefreshing(false);
if (queuedCount <= 0) {
showMessage("当前设备没有可同步的活跃线程。");
} else {
showMessage("主 Agent 已开始同步 " + queuedCount + " 个项目理解。");
}
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("同步失败:" + error.getMessage());
});
}
});
}
private void openEditDialog() {
executor.execute(() -> {
try {

View File

@@ -0,0 +1,178 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowToast;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class DeviceDetailActivityTest {
@Test
public void renderDeviceShowsSyncProjectUnderstandingEntry() {
TestDeviceDetailActivity activity = Robolectric
.buildActivity(
TestDeviceDetailActivity.class,
new Intent()
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "同步项目理解"));
assertTrue(viewTreeContainsText(content, "让主 Agent 主动询问这台设备上的活跃项目目标、进度和架构"));
}
@Test
public void tappingSyncProjectUnderstandingCallsApiAndShowsQueuedCount() {
TestDeviceDetailActivity activity = Robolectric
.buildActivity(
TestDeviceDetailActivity.class,
new Intent()
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
View syncLabel = findViewWithText(activity.findViewById(R.id.screen_content), "同步项目理解");
syncLabel.getParent().getParent();
View clickable = findClickableAncestor(syncLabel);
clickable.performClick();
org.robolectric.Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.fakeClient.syncCalls);
assertEquals("主 Agent 已开始同步 2 个项目理解。", ShadowToast.getTextOfLatestToast());
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
@Nullable
private static View findViewWithText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return root;
}
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
View match = findViewWithText(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
private static View findClickableAncestor(View view) {
View current = view;
while (current != null && !current.isClickable()) {
if (!(current.getParent() instanceof View)) {
break;
}
current = (View) current.getParent();
}
return current == null ? view : current;
}
public static class TestDeviceDetailActivity extends DeviceDetailActivity {
FakeBossApiClient fakeClient;
@Override
protected void reload() {
if (fakeClient == null) {
fakeClient = new FakeBossApiClient(this);
}
this.apiClient = fakeClient;
try {
ReflectionHelpers.callInstanceMethod(
this,
"renderDevice",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDevicePayload())
);
} catch (Exception error) {
throw new RuntimeException(error);
}
}
private static JSONObject buildDevicePayload() throws Exception {
return new JSONObject()
.put("workspace", new JSONObject()
.put("selectedDevice", new JSONObject()
.put("id", "device-1")
.put("name", "Mac Studio")
.put("avatar", "M")
.put("account", "17600003315")
.put("status", "online")
.put("quota5h", 75)
.put("quota7d", 88)
.put("projects", new JSONArray().put("Boss"))
.put("endpoint", "mac://studio.local")
.put("note", "测试设备")));
}
}
private static class FakeBossApiClient extends BossApiClient {
int syncCalls = 0;
FakeBossApiClient(DeviceDetailActivity activity) {
super(activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE), "https://boss.hyzq.net");
}
@Override
public ApiResponse syncDeviceProjectUnderstanding(String deviceId) {
syncCalls += 1;
try {
return new ApiResponse(
200,
new JSONObject()
.put("ok", true)
.put("queuedTasks", new JSONArray()
.put(new JSONObject().put("projectId", "project-1"))
.put(new JSONObject().put("projectId", "project-2")))
);
} catch (Exception error) {
throw new RuntimeException(error);
}
}
}
}