feat: group imported threads into project archives

This commit is contained in:
kris
2026-03-30 13:50:26 +08:00
parent 98dd0e3cd5
commit 03ac40f427
23 changed files with 1207 additions and 83 deletions

View File

@@ -28,6 +28,7 @@
</activity>
<activity android:name=".ProjectDetailActivity" android:exported="false" />
<activity android:name=".ConversationFolderActivity" android:exported="false" />
<activity android:name=".ProjectGoalsActivity" android:exported="false" />
<activity android:name=".ProjectVersionsActivity" android:exported="false" />
<activity android:name=".ProjectForwardActivity" android:exported="false" />
@@ -38,6 +39,7 @@
<activity android:name=".GroupCreateActivity" android:exported="false" />
<activity android:name=".DeviceDetailActivity" android:exported="false" />
<activity android:name=".DeviceEnrollmentActivity" android:exported="false" />
<activity android:name=".DeviceImportDraftActivity" android:exported="false" />
<activity android:name=".SkillInventoryActivity" android:exported="false" />
<activity android:name=".SecurityActivity" android:exported="false" />
<activity android:name=".SettingsActivity" android:exported="false" />

View File

@@ -8,6 +8,7 @@ import androidx.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONArray;
import java.io.BufferedReader;
import java.io.BufferedWriter;
@@ -77,6 +78,14 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/conversations", null);
}
public ApiResponse getConversationHome() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/conversations/home", null);
}
public ApiResponse getConversationFolder(String folderKey) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/conversation-folders/" + encode(folderKey), null);
}
public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
}
@@ -232,6 +241,24 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/devices/enrollments", payload);
}
public ApiResponse getDeviceImportDraft(String deviceId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/import-draft", null);
}
public ApiResponse selectDeviceImportCandidates(String deviceId, JSONArray selectedCandidateIds) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("selectedCandidateIds", (Object) (selectedCandidateIds == null ? new JSONArray() : selectedCandidateIds));
return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/import-draft/select", payload);
}
public ApiResponse reviewDeviceImportDraft(String deviceId) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/import-draft/review", new JSONObject());
}
public ApiResponse applyDeviceImportDraft(String deviceId) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/import-draft/apply", new JSONObject());
}
public ApiResponse getAccounts() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/accounts", null);
}

View File

@@ -0,0 +1,102 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
public class ConversationFolderActivity extends BossScreenActivity {
public static final String EXTRA_FOLDER_KEY = "folder_key";
public static final String EXTRA_FOLDER_NAME = "folder_name";
private String folderKey;
private String folderName;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
folderKey = getIntent().getStringExtra(EXTRA_FOLDER_KEY);
folderName = getIntent().getStringExtra(EXTRA_FOLDER_NAME);
configureScreen(folderName == null || folderName.isEmpty() ? "项目线程" : folderName, "同一项目下的线程列表");
reload();
}
@Override
protected void reload() {
if (folderKey == null || folderKey.isEmpty()) {
replaceContent(BossUi.buildEmptyCard(this, "缺少项目标识。"));
setRefreshing(false);
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getConversationFolder(folderKey);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> renderFolder(response.json.optJSONObject("folder")));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "项目线程加载失败:" + error.getMessage()));
});
}
});
}
private void renderFolder(@Nullable JSONObject folder) {
replaceContent();
if (folder == null) {
appendContent(BossUi.buildEmptyCard(this, "未找到项目线程。"));
setRefreshing(false);
return;
}
String resolvedFolderName = folder.optString("folderLabel", folderName == null ? "项目线程" : folderName);
String deviceName = folder.optString("deviceName", "");
int threadCount = folder.optInt("threadCount", 0);
configureScreen(resolvedFolderName, deviceName.isEmpty() ? "项目线程" : deviceName);
appendContent(BossUi.buildSoftPanel(
this,
resolvedFolderName,
(deviceName.isEmpty() ? "当前设备" : deviceName) + "\n共 " + threadCount + " 个线程",
"点击线程后进入具体聊天窗口。"
));
JSONArray threads = folder.optJSONArray("threads");
if (threads == null || threads.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前项目下没有线程。"));
setRefreshing(false);
return;
}
for (int i = 0; i < threads.length(); i++) {
JSONObject item = threads.optJSONObject(i);
if (item == null) continue;
String projectId = item.optString("projectId", "");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
appendContent(BossUi.buildConversationRow(
this,
row,
v -> {
if (projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
openProject(projectId, row.threadTitle);
}
));
}
setRefreshing(false);
}
private void openProject(String projectId, String projectName) {
Intent intent = new Intent(this, ProjectDetailActivity.class);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, projectId);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, projectName);
startActivity(intent);
}
}

View File

@@ -74,10 +74,18 @@ public class DeviceDetailActivity extends BossScreenActivity {
null
));
}
appendContent(BossUi.buildMenuRow(this, "导入项目", "勾选这台设备上要暴露到会话首页的项目和线程", null, v -> openImportDraft()));
appendContent(BossUi.buildMenuRow(this, "查看技能", "查看当前设备同步的 Skill 清单", null, v -> openSkills()));
setRefreshing(false);
}
private void openImportDraft() {
Intent intent = new Intent(this, DeviceImportDraftActivity.class);
intent.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, deviceId);
intent.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, deviceName);
startActivity(intent);
}
private void openSkills() {
Intent intent = new Intent(this, SkillInventoryActivity.class);
intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, deviceId);

View File

@@ -1,5 +1,6 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.widget.EditText;
@@ -80,6 +81,8 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
runOnUiThread(() -> {
JSONObject enrollment = response.json.optJSONObject("enrollment");
JSONObject device = response.json.optJSONObject("device");
android.widget.Button importButton = BossUi.buildSecondaryButton(this, "继续导入项目");
importButton.setOnClickListener(v -> openImportDraft(device));
replaceContent(
BossUi.buildSoftPanel(
this,
@@ -89,7 +92,8 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
+ "\ntoken " + (enrollment == null ? "-" : enrollment.optString("token", "-")),
enrollment == null ? "ready" : enrollment.optString("status", "ready")
+ " · 到期 " + enrollment.optString("expiresAt", "-")
)
),
importButton
);
setRefreshing(false);
});
@@ -101,4 +105,20 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
}
});
}
private void openImportDraft(@Nullable JSONObject device) {
if (device == null) {
showMessage("设备草稿未生成完成");
return;
}
String deviceId = device.optString("id", "");
if (deviceId.isEmpty()) {
showMessage("缺少 deviceId");
return;
}
Intent intent = new Intent(this, DeviceImportDraftActivity.class);
intent.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, deviceId);
intent.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, device.optString("name", nameInput.getText().toString().trim()));
startActivity(intent);
}
}

View File

@@ -0,0 +1,249 @@
package com.hyzq.boss;
import android.os.Bundle;
import android.widget.Button;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
public class DeviceImportDraftActivity extends BossScreenActivity {
public static final String EXTRA_DEVICE_ID = "device_id";
public static final String EXTRA_DEVICE_NAME = "device_name";
private String deviceId;
private String deviceName;
private @Nullable JSONObject currentDraft;
private @Nullable JSONObject currentResolution;
private final LinkedHashSet<String> selectedCandidateIds = new LinkedHashSet<>();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID);
deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
configureScreen("导入项目", deviceName == null ? "选择要导入的 Codex 项目与线程" : deviceName);
reload();
}
@Override
protected void reload() {
if (deviceId == null || deviceId.isEmpty()) {
replaceContent(BossUi.buildEmptyCard(this, "缺少 deviceId。"));
setRefreshing(false);
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getDeviceImportDraft(deviceId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution")));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "导入草稿加载失败:" + error.getMessage()));
});
}
});
}
private void applyPayload(@Nullable JSONObject draft, @Nullable JSONObject resolution) {
currentDraft = draft;
currentResolution = resolution;
selectedCandidateIds.clear();
JSONArray selected = draft == null ? null : draft.optJSONArray("selectedCandidateIds");
if (selected != null) {
for (int i = 0; i < selected.length(); i++) {
String candidateId = selected.optString(i, "");
if (!candidateId.isEmpty()) {
selectedCandidateIds.add(candidateId);
}
}
}
renderCurrentState();
}
private void renderCurrentState() {
JSONObject draft = currentDraft;
JSONObject resolution = currentResolution;
replaceContent();
appendContent(BossUi.buildSoftPanel(
this,
"导入 Codex 项目",
(deviceName == null ? "当前设备" : deviceName) + "\n勾选要暴露到会话首页的项目和线程。",
draft == null
? "等待设备完成首次 heartbeat"
: "候选 " + (draft.optJSONArray("candidates") == null ? 0 : draft.optJSONArray("candidates").length()) + " · 状态 " + draft.optString("status", "-")
));
if (draft == null) {
appendContent(BossUi.buildEmptyCard(this, "设备完成配对并上报项目候选后,这里会出现可导入项目。"));
setRefreshing(false);
return;
}
JSONArray candidates = draft.optJSONArray("candidates");
if (candidates == null || candidates.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前还没有可导入的线程。"));
setRefreshing(false);
return;
}
Map<String, JSONArray> grouped = new LinkedHashMap<>();
for (int i = 0; i < candidates.length(); i++) {
JSONObject candidate = candidates.optJSONObject(i);
if (candidate == null) continue;
String groupKey = candidate.optString("codexFolderRef", candidate.optString("folderRef", candidate.optString("folderName", "未命名项目")));
JSONArray bucket = grouped.get(groupKey);
if (bucket == null) {
bucket = new JSONArray();
grouped.put(groupKey, bucket);
}
bucket.put(candidate);
}
for (JSONArray items : grouped.values()) {
JSONObject first = items.optJSONObject(0);
if (first == null) continue;
String folderName = first.optString("folderName", "未命名项目");
appendContent(BossUi.buildWechatMenuRow(
this,
folderName,
items.length() + " 个线程",
"勾选后会进入主 Agent 导入建议",
null,
null
));
for (int i = 0; i < items.length(); i++) {
JSONObject candidate = items.optJSONObject(i);
if (candidate == null) continue;
String candidateId = candidate.optString("candidateId", "");
boolean selectedState = selectedCandidateIds.contains(candidateId);
appendContent(BossUi.buildWechatMenuRow(
this,
candidate.optString("threadDisplayName", "未命名线程"),
"最近活跃:" + candidate.optString("lastActiveAt", "-"),
null,
selectedState ? "已选" : (candidate.optBoolean("suggestedImport", false) ? "推荐" : null),
v -> toggleSelection(candidateId)
));
}
}
if (resolution != null) {
appendContent(BossUi.buildCard(
this,
"导入建议",
resolution.optString("summary", "已生成导入建议。"),
"应用后会把选中的线程映射成正式聊天窗口。"
));
JSONArray items = resolution.optJSONArray("items");
if (items != null) {
for (int i = 0; i < items.length(); i++) {
JSONObject item = items.optJSONObject(i);
if (item == null) continue;
appendContent(BossUi.buildWechatMenuRow(
this,
item.optString("threadDisplayName", "未命名线程"),
item.optString("folderName", ""),
item.optString("action", "") + " · " + item.optString("reason", ""),
null,
null
));
}
}
}
Button reviewButton = BossUi.buildMiniActionButton(this, "生成导入建议", true);
reviewButton.setEnabled(!selectedCandidateIds.isEmpty());
reviewButton.setOnClickListener(v -> reviewSelection());
Button applyButton = BossUi.buildMiniActionButton(
this,
"applied".equals(draft.optString("status", "")) ? "已导入" : "应用导入",
false
);
applyButton.setEnabled(resolution != null && !"applied".equals(draft.optString("status", "")));
applyButton.setOnClickListener(v -> applyResolution());
appendContent(BossUi.buildInlineActionRow(this, reviewButton, applyButton));
setRefreshing(false);
}
private void toggleSelection(String candidateId) {
if (candidateId == null || candidateId.isEmpty()) {
return;
}
if (selectedCandidateIds.contains(candidateId)) {
selectedCandidateIds.remove(candidateId);
} else {
selectedCandidateIds.add(candidateId);
}
renderCurrentState();
}
private void reviewSelection() {
if (deviceId == null || deviceId.isEmpty()) {
showMessage("缺少 deviceId");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
JSONArray selected = new JSONArray();
for (String candidateId : selectedCandidateIds) {
selected.put(candidateId);
}
BossApiClient.ApiResponse selectResponse = apiClient.selectDeviceImportCandidates(deviceId, selected);
if (!selectResponse.ok()) {
throw new IllegalStateException(selectResponse.message());
}
BossApiClient.ApiResponse reviewResponse = apiClient.reviewDeviceImportDraft(deviceId);
if (!reviewResponse.ok()) {
throw new IllegalStateException(reviewResponse.message());
}
runOnUiThread(() -> {
showMessage("已生成导入建议");
applyPayload(reviewResponse.json.optJSONObject("draft"), reviewResponse.json.optJSONObject("resolution"));
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("导入建议生成失败:" + error.getMessage());
});
}
});
}
private void applyResolution() {
if (deviceId == null || deviceId.isEmpty()) {
showMessage("缺少 deviceId");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.applyDeviceImportDraft(deviceId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showMessage("已应用导入");
applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution"));
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("导入应用失败:" + error.getMessage());
});
}
});
}
}

View File

@@ -251,7 +251,7 @@ public class MainActivity extends AppCompatActivity {
boolean settingsOk = false;
try {
conversations = apiClient.getConversations();
conversations = apiClient.getConversationHome();
conversationsOk = conversations.ok();
} catch (Exception ignored) {
conversationsOk = false;
@@ -312,7 +312,7 @@ public class MainActivity extends AppCompatActivity {
maybeApplyPreferredEntry();
renderCurrentTab();
startRefreshing(false);
if (!finalConversationsOk || !finalDevicesOk || !finalOtaOk || !finalSettingsOk) {
if (RootRefreshPolicy.shouldShowFailure(activeTab, finalConversationsOk, finalDevicesOk, finalOtaOk, finalSettingsOk)) {
showMessage("刷新失败,请稍后重试");
}
});
@@ -452,11 +452,21 @@ public class MainActivity extends AppCompatActivity {
JSONObject item = conversationsData.optJSONObject(i);
if (item == null) continue;
String projectId = item.optString("projectId", "");
String conversationType = item.optString("conversationType", "");
String folderKey = item.optString("folderKey", "");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
screenContent.addView(BossUi.buildConversationRow(
this,
row,
v -> {
if ("folder_archive".equals(conversationType)) {
if (folderKey.isEmpty()) {
showMessage("缺少 folderKey");
return;
}
openConversationFolder(folderKey, row.threadTitle);
return;
}
if (projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
@@ -550,6 +560,13 @@ public class MainActivity extends AppCompatActivity {
startActivity(intent);
}
private void openConversationFolder(String folderKey, String folderName) {
Intent intent = new Intent(this, ConversationFolderActivity.class);
intent.putExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY, folderKey);
intent.putExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME, folderName);
startActivity(intent);
}
private void openDevice(String deviceId, String deviceName) {
Intent intent = new Intent(this, DeviceDetailActivity.class);
intent.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, deviceId);

View File

@@ -0,0 +1,21 @@
package com.hyzq.boss;
public final class RootRefreshPolicy {
private RootRefreshPolicy() {}
public static boolean shouldShowFailure(
String activeTab,
boolean conversationsOk,
boolean devicesOk,
boolean otaOk,
boolean settingsOk
) {
if ("devices".equals(activeTab)) {
return !devicesOk;
}
if ("me".equals(activeTab)) {
return !settingsOk && !otaOk;
}
return !conversationsOk;
}
}

View File

@@ -0,0 +1,27 @@
package com.hyzq.boss;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class MainActivityRefreshPolicyTest {
@Test
public void conversationsTabOnlyFailsWhenConversationRequestFails() {
assertFalse(RootRefreshPolicy.shouldShowFailure("conversations", true, false, false, false));
assertTrue(RootRefreshPolicy.shouldShowFailure("conversations", false, true, true, true));
}
@Test
public void devicesTabDependsOnDeviceRequestOnly() {
assertFalse(RootRefreshPolicy.shouldShowFailure("devices", false, true, false, false));
assertTrue(RootRefreshPolicy.shouldShowFailure("devices", true, false, true, true));
}
@Test
public void meTabAllowsOneSupportingRequestToSucceed() {
assertFalse(RootRefreshPolicy.shouldShowFailure("me", false, false, false, true));
assertFalse(RootRefreshPolicy.shouldShowFailure("me", false, false, true, false));
assertTrue(RootRefreshPolicy.shouldShowFailure("me", false, false, false, false));
}
}