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

@@ -95,16 +95,19 @@ Android APK
- 当前原生活动页已经覆盖会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab会话首页是简单聊天列表`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角仍是 `+添加`
- 当前聊天列表已切到“线程 = 会话窗口”的结构:主标题显示线程名,副标题显示所属文件夹名,右下角显示后台活跃数量动态图标;同一文件夹多个线程会显示成多个独立聊天窗口
- 当前会话首页已升级成“项目聚合 + 线程下钻”的结构:如果某个 Codex 文件夹只导入了 1 个线程,会话列表直接显示这个线程;如果同一文件夹导入了多个线程,会话首页只显示该文件夹归档项,点进去再看这个项目下的全部线程
- 当前会话首页的数据源已分成两层:`/api/v1/conversations` 继续保留平铺线程视图给群聊创建、转发等内部能力使用;首页和原生根页改走 `/api/v1/conversations/home`,文件夹归档详情走 `/api/v1/conversation-folders/[folderKey]`
- 当前会话信息页已经支持按微信最新逻辑改线程名;群聊会作为独立新会话创建,默认自动命名,创建后可在群资料页改名
- 原生顶部安全区当前已补齐状态栏 inset 处理,并把首页 / 会话信息 / 群资料 / 发起群聊 / 转发目标等页面的顶部操作区域收回到可点击安全区内
- 当前消息转发已经切到微信式链路:长按消息可直接 `转发 / 多选 / 复制 / 删除`,多选后底部只保留 `转发`,统一进入原生会话选择页
- 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页
- 当前群聊调度主链已补上第一轮业务闭环:群聊文字消息会先进入主 Agent 生成推荐下发方案,用户确认后创建真正的线程执行单,执行完成后会把线程原始结果回写到群聊,再追加一条主 Agent 汇总
- 当前设备导入主链已补上第一轮后端闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户可提交勾选结果、触发主 Agent 风格的导入决议,并把选中的线程真正落成聊天窗口
- 当前新设备导入前台已经接通Web `添加设备` 成功后会直接进入“导入项目”步骤;设备页详情里也可再次打开导入草稿。原生 Android 端同样已补 `DeviceImportDraftActivity`,可完成 `勾选 -> 预览决议 -> 应用导入`
- 当前 `dispatch_execution` 完成回写已补幂等:同一个执行单重复完成,不会再向群聊重复追加线程原始回复和主 Agent 汇总
- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `import draft`,不再绕过勾选/应用阶段直接把旧项目目录导入为聊天窗口
- 当前设备导入 `review` 已补 owner/admin 鉴权,并会留下 `device_import_resolution` master task 轨迹,再把决议写回草稿和会话账本
- 当前原生 APP 会话页的“刷新失败”已按当前 tab 的主数据源独立判错:`会话` 只看会话请求本身,`设备` 只看设备请求,`我的` 只在 `settings + ota` 同时失败时才提示刷新失败
- 当前 `设备``我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的``审计对话` 作为置顶会话保留在会话首页
- 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API不再打开 WebView
- `2.0.1` 已修复华为真机上因 `Theme.SplashScreen``AppCompatActivity` 不兼容导致的启动闪退
@@ -279,6 +282,7 @@ npm run aab:release
- 设备页当前只展示已接入生产链路的设备,历史演示脏数据已经从正式设备视图、运维视图和审计视图中剔除
- 本机 `local-agent` 现在会直接从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 动态发现真实 Codex 线程,并在 heartbeat 里上报 `projectCandidates`
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;因此会话页会自动出现这台设备当前真实运行的 Codex 线程窗口
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;如果某个项目下存在多个线程,会话首页会先显示项目归档项,而不是把所有线程平铺在首页
- 登录页当前已临时切到免验证模式,点击“登录”会直接进入会话首页
- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话
- 新增 `GET /api/auth/session``POST /api/auth/logout``POST /api/auth/restore`

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));
}
}

View File

@@ -90,6 +90,8 @@
- `POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm` 正常,已支持把推荐目标确认成真正的线程执行单
- `GET /api/v1/devices/[deviceId]/import-draft` 正常,已支持读取设备导入草稿与最新决议
- `POST /api/v1/devices/[deviceId]/import-draft/select|review|apply` 正常,已支持设备候选线程勾选、导入决议和落地成真实聊天窗口
- `GET /api/v1/conversations/home` 正常,已支持会话首页使用“项目聚合 + 线程下钻”视图
- `GET /api/v1/conversation-folders/[folderKey]` 正常,已支持读取某个项目归档下的全部线程
- 这些设备导入接口当前仅允许 `highest_admin` 或设备所属账号访问
- `GET /api/v1/attachments/[attachmentId]/download` 正常,已支持会话鉴权下载和 task token 下载
- `POST /api/auth/login` 正常,会写入 `boss_session`
@@ -128,7 +130,7 @@
## 5. 当前最重要的产品逻辑
- 一级导航固定:`会话 / 设备 / 我的`
- `会话` 页当前按“线程 = 聊天窗口”渲染聊天列表`主 Agent / 审计对话` 以普通置顶会话样式固定在最上面
- `会话` 页当前按“项目聚合 + 线程下钻”渲染聊天列表:单线程项目直接显示线程,多线程项目先显示文件夹归档`主 Agent / 审计对话` 以普通置顶会话样式固定在最上面
- 单线程会话主标题显示线程名,第二行显示所属文件夹名,第三行显示最后一条消息预览,右下角显示后台活跃数量动态图标
- 单设备项目显示单头像,多线程群聊显示群聊式组合头像
- 项目聊天页当前已经改成聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口线程预算、handoff、运维与转发能力仍保留数据和深层活动页但不再出现在主聊天面
@@ -138,6 +140,8 @@
- 当前设备导入主链已经补到第一阶段:设备 heartbeat 可上报真实候选线程,系统会生成导入草稿;用户勾选后可生成导入决议,并把选中的线程真正落成聊天窗口
- 当前设备导入草稿不会再被旧 `projects` 字段绕过;只有 `apply` 之后,候选线程才会真正变成聊天窗口
- 当前设备导入 `review` 已经会留下 `device_import_resolution` master task 轨迹,但决议内容仍是服务端 heuristic 版,尚未真正交给 `local-agent -> codex exec`
- Web 和原生 Android 当前都已经接上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台页面;已绑定生产设备继续保留 heartbeat 自动导入链路
- 原生首页的刷新失败策略当前已改成按当前 tab 独立判错,不会再因为 `设备 / 设置 / OTA` 的旁路请求失败把会话页刷新一并判成失败
- 当前群聊 `dispatch_execution` 完成回写已补幂等,重复完成不会再向群聊重复追加结果
- 当前已支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,单条消息转发显示为普通转发消息,多条消息转发显示为聊天记录卡片
- 当前已支持聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本默认自动进入主 Agent 附件分析,视频 / Office / 大文件默认手动触发
@@ -215,7 +219,7 @@ npm run apk:debug
- 服务器邮件栈已部署完成,应用内也已经支持 email 模式,但默认开关还没切到 email
- OTA 版本中心、检查更新、执行升级和 APK 包下载已接通,但当前仍是文件型状态驱动的 MVP
- APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通,但日志检索、告警和远程 Skill 管理仍未做
- 设备导入主链已补上后端闭环,但前台页面还没有把“候选勾选 / 决议预览 / 应用导入”完整串到 Web 和原生 Android需要后续 UI 接线
- 设备导入主链当前已经具备后端闭环和 Web/Android 前台接线,后续重点改成继续细化导入筛选规则和主 Agent 理解策略,而不是再从 0 接页面
- 数据库尚未替代文件存储
- 域名入口的代理 / 分裂 DNS 结构仍未完全摸清
- 当前只支持服务器文件存储和阿里 OSS尚未接更多对象存储或更丰富的附件详情页

View File

@@ -267,7 +267,7 @@
#### `GET /api/v1/conversations`
- 用途:返回首页会话列表聚合结果
- 用途:返回平铺线程视图,供群聊创建、消息转发等内部能力使用
- 关键字段:
- `conversationType`
- `manualPinned`
@@ -278,6 +278,32 @@
- `topPinnedLabel`
- `groupMembers`
#### `GET /api/v1/conversations/home`
- 用途:返回会话首页使用的“项目聚合 + 线程下钻”视图
- 当前行为:
- 单线程项目直接返回单线程会话项
- 同一设备 / 同一 Codex 文件夹下存在多个线程时,会聚合成 `folder_archive`
- `master-agent``audit-collab`、群聊等特殊会话会保持普通置顶会话样式直接返回
- 关键字段:
- `conversationType`
- `folderKey`
- `threadCount`
- `threadTitle`
- `folderLabel`
- `lastMessagePreview`
#### `GET /api/v1/conversation-folders/[folderKey]`
- 用途:读取某个项目归档项下的线程列表
- 当前行为:
- 返回指定 `folderKey` 的汇总信息
- 返回该文件夹下全部线程会话,供 Web 和原生 Android 的项目线程列表页使用
- 关键字段:
- `folder.folderKey`
- `folder.threadCount`
- `folder.threads[]`
#### `POST /api/v1/conversations/[projectId]/actions`
- 用途:会话置顶 / 标记已读

View File

@@ -91,7 +91,8 @@ cd /Users/kris/code/boss
- UI 外壳已收口为真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部一级导航固定在视口底部,背景图按手机 viewport 全屏 coverWebView 不再显示外层圆角矩形预览壳
- 原生 Android 当前也和这套产品方向对齐:`会话 / 设备 / 我的` 为固定底部 tab一级面维持微信式简单列表和聊天优先`主 Agent / 审计对话` 以普通置顶会话样式固定在会话首页顶部
- 会话首页右上角当前已改成微信式 `+` 入口:直接从会话列表发起独立群聊;设备页右上角仍保留 `+添加`
- 会话列表当前已切到“线程 = 聊天窗口”:主标题显示线程名,第二行显示所属文件夹名,第三行显示最后一条消息预览,右下角显示后台活跃数量动态图标;同一文件夹下多个线程会渲染成多个独立聊天窗口
- 会话首页当前已升级成“项目聚合 + 线程下钻”:如果某个 Codex 文件夹只导入了 1 个线程,会话首页直接显示这个线程;如果同一文件夹下导入了多个线程,会话首页只显示文件夹归档项,进入后再看到该项目下的全部线程
- 会话首页与内部线程视图当前已分层:原首页和原生根页改走 `GET /api/v1/conversations/home`,文件夹详情改走 `GET /api/v1/conversation-folders/[folderKey]`;原有 `GET /api/v1/conversations` 继续保留给群聊创建、消息转发等需要平铺线程列表的内部能力使用
- 项目详情页右上角当前会进入微信式会话信息页:单线程会话支持改名和发起群聊,群聊会进入群资料页并支持改群名
- 原生顶部安全区当前已统一补上状态栏 inset首页、项目详情、会话信息、群资料、发起群聊和转发目标页的顶部按钮都已退回真机可点击区域
- 项目详情页当前已补齐微信式消息转发:长按消息会弹出 `转发 / 多选 / 复制 / 删除 / 取消`;单条消息直接进入统一会话选择页,多选消息会进入合并转发链路
@@ -99,9 +100,11 @@ cd /Users/kris/code/boss
- 当前单条消息转发会在目标会话中显示为普通转发消息,并保留 `forwardSource`;多条消息会落成 `forward_bundle` 聊天记录卡片,并保留来源会话、时间范围和摘要条目
- 当前群聊编排主链已补上第一轮闭环:群聊文本消息会先进入主 Agent 生成推荐下发方案;用户确认后会创建真正的线程执行单,并写入系统通知;执行完成后会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总
- 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口
- Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链
- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口
- 当前 `dispatch_execution` 完成回写已补幂等,重复完成同一个线程执行单不会再重复向群聊追加线程原始回复和主 Agent 汇总
- 当前设备导入 `review` 已补 owner/admin 鉴权,并会留下 `device_import_resolution` master task 轨迹;导入草稿在 `apply` 后再次 heartbeat 也不会从 `applied` 回退成 `resolved`
- 原生会话页当前的刷新失败策略已改成按当前 tab 独立判错:`会话` 不会再因为 `设备 / OTA / 设置` 的旁路请求失败而整体提示“刷新失败”
- 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新
- 我的页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` 六个一级入口;`AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex``Master Codex Node`
- 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本`
@@ -227,7 +230,7 @@ cd /Users/kris/code/boss
- 新接入设备继续走 `import draft -> 勾选 -> review -> apply`
- 已绑定的生产设备如果 heartbeat 带上真实 `projectCandidates[]`,服务端会自动选中建议项、生成导入决议并直接应用,让会话页自动出现当前运行中的 Codex 线程
- 本机 `mac-studio` 当前已经验证可通过 `local-agent` 直接从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 扫描真实 Codex 线程,并通过 heartbeat 自动导入到会话列表
- Web / Android 仍未把“新设备候选项目勾选与导入应用”完整接进前台页面;当前新设备主要通过 API 验证,已绑定生产设备则已能自动同步到会话页
- 会话首页当前已经不再简单平铺所有线程;如果某个设备导入了大量同文件夹线程,首页会优先显示项目归档项,降低会话页噪音
- API 容灾当前由用户在 APP 的 `我的 > AI 账号` 页面自行配置 `OpenAI API` 账号,不再依赖服务器预置 Key
- 原生 Android 的二级深层页虽然仍保留 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等能力,但它们已经退出主 UI 正面;后续如再加入口,需继续遵守“一级微信式,复杂能力下沉”的规则
- Android 本地 Gradle 验证当前必须串行执行;如果并发跑 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug`,会导致中间产物互踩并出现假失败

View File

@@ -824,9 +824,11 @@
- 新设备导入主链已经具备 `heartbeat -> import draft -> select -> review -> apply` 的后端闭环,并补了 owner/admin 鉴权与幂等保护
- 已绑定的生产设备当前新增自动同步链路:如果 heartbeat 携带真实 `projectCandidates[]`,服务端会自动完成建议项选择、导入决议和应用,把真实 Codex 线程直接落成会话窗口
- 本机 `mac-studio` 当前已经验证可通过 `local-agent` 直接从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 扫描真实 Codex 线程,并通过 heartbeat 自动导入到远端会话列表
- 会话首页当前已切到“项目聚合 + 线程下钻”视图:单线程项目直接显示线程,多线程项目先聚合成文件夹归档,再进入线程列表页
- Web / Android 当前都已补上“新设备候选项目勾选、决议预览、应用导入”的前台页面
- 原生首页刷新失败策略当前已改成按当前 tab 独立判错,不会再因为旁路接口失败把会话刷新一并判成失败
当前仍待前台接线:
- Web / Android 的“新设备候选项目勾选、决议预览、应用导入”页面
- 群聊 `development / approval_required` 审批闸口的前台确认页
- 真机逐页把新增业务流接入现有微信式 UI

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { getConversationFolderView } from "@/lib/boss-projections";
import { readState } from "@/lib/boss-data";
export async function GET(
request: NextRequest,
context: { params: Promise<{ folderKey: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { folderKey } = await context.params;
const state = await readState();
const folder = getConversationFolderView(state, decodeURIComponent(folderKey));
if (!folder) {
return NextResponse.json({ ok: false, message: "FOLDER_NOT_FOUND" }, { status: 404 });
}
return NextResponse.json({ ok: true, folder });
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { getConversationHomeItems } from "@/lib/boss-projections";
import { readState } from "@/lib/boss-data";
export async function GET(request: NextRequest) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const state = await readState();
return NextResponse.json({
ok: true,
conversations: getConversationHomeItems(state),
});
}

View File

@@ -0,0 +1,46 @@
import {
AppShell,
ConversationList,
PageNav,
StatusBar,
} from "@/components/app-ui";
import { requirePageSession } from "@/lib/boss-auth";
import { getConversationFolderView } from "@/lib/boss-projections";
import { readState } from "@/lib/boss-data";
export const dynamic = "force-dynamic";
export default async function ConversationFolderPage({
params,
}: {
params: Promise<{ folderKey: string }>;
}) {
await requirePageSession();
const { folderKey } = await params;
const state = await readState();
const folder = getConversationFolderView(state, decodeURIComponent(folderKey));
return (
<AppShell bottomNav={false}>
<StatusBar />
<PageNav title={folder?.folderLabel ?? "项目线程"} backHref="/conversations" />
<div className="space-y-3 px-[18px] pb-6">
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[16px] font-semibold text-[#111111]">
{folder?.folderLabel ?? "未命名项目"}
</div>
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
{folder
? `${folder.deviceName ?? "当前设备"} · ${folder.threadCount} 个线程`
: "当前项目下没有可显示的线程。"}
</div>
</div>
</div>
{folder ? (
<ConversationList conversations={folder.threads} />
) : (
<div className="px-[18px] pb-6 text-[13px] text-[#8C8C8C]">线</div>
)}
</AppShell>
);
}

View File

@@ -7,7 +7,7 @@ import {
StatusBar,
} from "@/components/app-ui";
import { requirePageSession } from "@/lib/boss-auth";
import { getConversationItems } from "@/lib/boss-projections";
import { getConversationHomeItems } from "@/lib/boss-projections";
import { readState } from "@/lib/boss-data";
export const dynamic = "force-dynamic";
@@ -15,7 +15,7 @@ export const dynamic = "force-dynamic";
export default async function ConversationsPage() {
await requirePageSession();
const state = await readState();
const conversations = getConversationItems(state);
const conversations = getConversationHomeItems(state);
return (
<AppShell>

View File

@@ -7,6 +7,7 @@ import {
HeaderTitle,
StatusBar,
} from "@/components/app-ui";
import { DeviceImportDraftManager } from "@/components/device-import-draft-manager";
import { requirePageSession } from "@/lib/boss-auth";
import { getDeviceWorkspaceView } from "@/lib/boss-projections";
import { readState } from "@/lib/boss-data";
@@ -55,6 +56,12 @@ export default async function DevicesPage({
relatedThreads={workspace.relatedThreads}
activeEnrollment={workspace.activeEnrollment}
/>
<div className="mt-3">
<DeviceImportDraftManager
deviceId={workspace.selectedDevice.id}
deviceName={workspace.selectedDevice.name}
/>
</div>
</div>
) : null}
</AppShell>

View File

@@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import clsx from "clsx";
import { sendAppLog } from "@/components/app-runtime";
import { DeviceImportDraftManager } from "@/components/device-import-draft-manager";
import {
clearNativeSessionSnapshot,
currentAppLocation,
@@ -353,6 +354,10 @@ function ConversationActionButtons({
const router = useRouter();
const [loading, setLoading] = useState<"toggle_pin" | "mark_read" | null>(null);
if (conversation.conversationType === "folder_archive") {
return <div className="min-h-[24px]" />;
}
async function runAction(action: "toggle_pin" | "mark_read") {
setLoading(action);
await fetch(conversationActionsPath(conversation.projectId), {
@@ -405,7 +410,14 @@ export function ConversationList({
<div className="mb-2 flex justify-end">
<ConversationActionButtons conversation={conversation} />
</div>
<Link href={`/conversations/${conversation.projectId}`} className="flex items-start gap-3">
<Link
href={
conversation.conversationType === "folder_archive" && conversation.folderKey
? `/conversations/folders/${encodeURIComponent(conversation.folderKey)}`
: `/conversations/${conversation.projectId}`
}
className="flex items-start gap-3"
>
<AvatarStack
primary={conversation.avatar.primary}
secondary={conversation.avatar.secondary}
@@ -416,20 +428,28 @@ export function ConversationList({
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-[17px] font-medium text-[#111111]">
{conversation.projectTitle}
{conversation.conversationType === "folder_archive"
? conversation.threadTitle
: conversation.projectTitle}
</div>
<span
className={clsx(
"rounded-full px-2 py-0.5 text-[10px] font-semibold",
riskBadgeColor(conversation.riskLevel),
)}
>
{conversation.riskLevel === "high"
? "高风险"
: conversation.riskLevel === "medium"
? "关注"
: "稳定"}
</span>
{conversation.conversationType === "folder_archive" ? (
<span className="rounded-full bg-[#F4F5F7] px-2 py-0.5 text-[10px] font-semibold text-[#57606A]">
{conversation.threadCount ?? 0} 线
</span>
) : (
<span
className={clsx(
"rounded-full px-2 py-0.5 text-[10px] font-semibold",
riskBadgeColor(conversation.riskLevel),
)}
>
{conversation.riskLevel === "high"
? "高风险"
: conversation.riskLevel === "medium"
? "关注"
: "稳定"}
</span>
)}
{conversation.unreadCount > 0 ? (
<span className="rounded-full bg-[#FF4D4F] px-2 py-0.5 text-[10px] font-semibold text-white">
{conversation.unreadCount}
@@ -437,7 +457,9 @@ export function ConversationList({
) : null}
</div>
<div className="mt-1 text-[13px] text-[#8C8C8C]">
{conversation.deviceNamesPreview.join(" / ")}
{conversation.conversationType === "folder_archive"
? conversation.folderLabel
: conversation.deviceNamesPreview.join(" / ")}
</div>
<div className="mt-1 truncate text-[14px] text-[#57606A]">
{conversation.preview}
@@ -1450,6 +1472,9 @@ export function DeviceEnrollmentBuilder() {
{configSnippet}
</pre>
) : null}
{result.device?.id ? (
<DeviceImportDraftManager deviceId={result.device.id} deviceName={result.device.name} />
) : null}
</div>
);
}

View File

@@ -0,0 +1,243 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { DeviceImportDraft, DeviceImportResolution } from "@/lib/boss-data";
type ImportDraftResponse = {
ok: boolean;
draft?: DeviceImportDraft | null;
resolution?: DeviceImportResolution | null;
message?: string;
};
function groupCandidates(draft: DeviceImportDraft | null) {
const groups = new Map<
string,
Array<DeviceImportDraft["candidates"][number]>
>();
for (const candidate of draft?.candidates ?? []) {
const key = candidate.codexFolderRef?.trim() || candidate.folderRef?.trim() || candidate.folderName;
const bucket = groups.get(key) ?? [];
bucket.push(candidate);
groups.set(key, bucket);
}
return [...groups.entries()].map(([key, items]) => ({
key,
folderName: items[0]?.folderName ?? "未命名项目",
items: [...items].sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt)),
}));
}
export function DeviceImportDraftManager({
deviceId,
deviceName,
}: {
deviceId: string;
deviceName?: string;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [draft, setDraft] = useState<DeviceImportDraft | null>(null);
const [resolution, setResolution] = useState<DeviceImportResolution | null>(null);
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const loadDraft = useCallback(async () => {
setLoading(true);
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft`, { cache: "no-store" });
const data = (await response.json()) as ImportDraftResponse;
setLoading(false);
setDraft(data.draft ?? null);
setResolution(data.resolution ?? null);
setSelectedCandidateIds(data.draft?.selectedCandidateIds ?? []);
setMessage(data.ok ? "" : data.message ?? "导入草稿加载失败");
}, [deviceId]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadDraft();
}, 0);
return () => window.clearTimeout(timer);
}, [loadDraft]);
const groups = useMemo(() => groupCandidates(draft), [draft]);
function toggle(candidateId: string) {
setSelectedCandidateIds((current) =>
current.includes(candidateId)
? current.filter((item) => item !== candidateId)
: [...current, candidateId],
);
}
async function reviewSelection() {
setLoading(true);
const selectResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/select`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selectedCandidateIds }),
});
const selectResult = (await selectResponse.json()) as { ok: boolean; message?: string; draft?: DeviceImportDraft };
if (!selectResult.ok) {
setLoading(false);
setMessage(selectResult.message ?? "勾选保存失败");
return;
}
const reviewResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const reviewResult = (await reviewResponse.json()) as {
ok: boolean;
message?: string;
draft?: DeviceImportDraft;
resolution?: DeviceImportResolution;
};
setLoading(false);
if (!reviewResult.ok) {
setMessage(reviewResult.message ?? "导入建议生成失败");
return;
}
setDraft(reviewResult.draft ?? selectResult.draft ?? null);
setResolution(reviewResult.resolution ?? null);
setMessage("已生成导入建议。");
}
async function applyResolution() {
setLoading(true);
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const result = (await response.json()) as {
ok: boolean;
message?: string;
draft?: DeviceImportDraft;
resolution?: DeviceImportResolution;
};
setLoading(false);
if (!result.ok) {
setMessage(result.message ?? "导入应用失败");
return;
}
setDraft(result.draft ?? draft);
setResolution(result.resolution ?? resolution);
setMessage("已把选中的项目线程导入到会话首页。");
router.refresh();
}
return (
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-center justify-between">
<div>
<div className="text-[16px] font-semibold text-[#111111]"> Codex </div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">
{deviceName ?? deviceId} heartbeat 线
</div>
</div>
<button
type="button"
onClick={() => void loadDraft()}
disabled={loading}
className="rounded-full border border-[#D9D9D9] px-3 py-1 text-[12px] text-[#57606A]"
>
{loading ? "刷新中" : "刷新"}
</button>
</div>
{draft ? (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
线{draft.candidates.length}
<br />
{draft.status}
<br />
{selectedCandidateIds.length}
</div>
) : (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
稿线
</div>
)}
{groups.map((group) => (
<div key={group.key} className="rounded-2xl border border-[#EAECEF] bg-[#FCFCFD] px-3 py-3">
<div className="text-[14px] font-semibold text-[#111111]">{group.folderName}</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">{group.items.length} 线</div>
<div className="mt-3 space-y-2">
{group.items.map((candidate) => {
const selected = selectedCandidateIds.includes(candidate.candidateId);
return (
<label
key={candidate.candidateId}
className="flex cursor-pointer items-start gap-3 rounded-2xl border border-[#E5E5EA] bg-white px-3 py-3"
>
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-[#07C160]"
checked={selected}
onChange={() => toggle(candidate.candidateId)}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-medium text-[#111111]">
{candidate.threadDisplayName}
</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">
{candidate.lastActiveAt}
</div>
</div>
{candidate.suggestedImport ? (
<span className="rounded-full bg-[#EAF7F0] px-2 py-0.5 text-[10px] font-semibold text-[#215B39]">
</span>
) : null}
</label>
);
})}
</div>
</div>
))}
{resolution ? (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
<div className="font-semibold text-[#111111]">{resolution.summary}</div>
<div className="mt-2 space-y-1">
{resolution.items.map((item) => (
<div key={item.candidateId}>
{item.threadDisplayName} · {item.folderName} · {item.action}
</div>
))}
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void reviewSelection()}
disabled={loading || selectedCandidateIds.length === 0}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
>
</button>
<button
type="button"
onClick={() => void applyResolution()}
disabled={loading || !resolution || draft?.status === "applied"}
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A] disabled:text-[#B8B8B8]"
>
{draft?.status === "applied" ? "已导入" : "应用导入"}
</button>
</div>
{message ? (
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] leading-6 text-[#215B39]">
{message}
</div>
) : null}
</div>
);
}

View File

@@ -33,11 +33,13 @@ export interface ContextIndicator {
export interface ConversationItem {
conversationId: string;
conversationType: "master_agent" | "single_device" | "group";
conversationType: "master_agent" | "single_device" | "group" | "folder_archive";
projectId: string;
projectTitle: string;
threadTitle: string;
folderLabel: string;
folderKey?: string;
threadCount?: number;
preview: string;
lastMessagePreview: string;
activityIconCount: number;
@@ -184,6 +186,14 @@ function projectType(project: Project): ConversationItem["conversationType"] {
return project.isGroup ? "group" : "single_device";
}
function buildFolderKey(project: Project) {
if (project.id === "master-agent" || project.isGroup) return undefined;
const deviceId = project.deviceIds[0];
const folderRef = project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim();
if (!deviceId || !folderRef) return undefined;
return `${deviceId}:${folderRef}`;
}
function isTopPinnedConversation(project: Project) {
return Boolean(project.pinned || project.systemPinned || project.id === "audit-collab");
}
@@ -306,63 +316,64 @@ function threadViewsForProject(state: BossState, projectId: string) {
}));
}
export function getConversationItems(state: BossState): ConversationItem[] {
const conversations = state.projects.map((project) => {
const devices = state.devices.filter((device) => project.deviceIds.includes(device.id));
const threadViews = threadViewsForProject(state, project.id);
const topThread = threadViews[0]?.snapshot;
const threadTitle = project.threadMeta?.threadDisplayName ?? project.name;
const folderLabel = project.threadMeta?.folderName ?? "";
const activityIconCount = project.threadMeta?.activityIconCount ?? 1;
const topPinnedLabel = isTopPinnedConversation(project) ? "置顶" : undefined;
const groupMembers = project.isGroup
? project.groupMembers.map((member) => ({
threadId: member.threadId,
avatar: getGroupMemberAvatar(
member,
state.devices.find((device) => device.id === member.deviceId),
),
title: member.threadDisplayName,
}))
: undefined;
function buildConversationItem(state: BossState, project: Project): ConversationItem {
const devices = state.devices.filter((device) => project.deviceIds.includes(device.id));
const threadViews = threadViewsForProject(state, project.id);
const topThread = threadViews[0]?.snapshot;
const threadTitle = project.threadMeta?.threadDisplayName ?? project.name;
const folderLabel = project.threadMeta?.folderName ?? "";
const activityIconCount = project.threadMeta?.activityIconCount ?? 1;
const topPinnedLabel = isTopPinnedConversation(project) ? "置顶" : undefined;
const groupMembers = project.isGroup
? project.groupMembers.map((member) => ({
threadId: member.threadId,
avatar: getGroupMemberAvatar(
member,
state.devices.find((device) => device.id === member.deviceId),
),
title: member.threadDisplayName,
}))
: undefined;
return {
conversationId: `conv-${project.id}`,
conversationType: projectType(project),
projectId: project.id,
projectTitle: project.name,
threadTitle,
folderLabel,
preview: project.preview,
lastMessagePreview: project.preview,
activityIconCount,
topPinnedLabel,
manualPinned: Boolean(project.pinned && !project.systemPinned),
latestReplyAt: project.lastMessageAt,
latestReplyLabel: formatTimestampLabel(project.lastMessageAt),
unreadCount: project.unreadCount,
riskLevel: project.riskLevel,
activeDeviceCount: devices.length,
deviceNamesPreview: devices.map((device) => device.name),
avatar: {
primary: devices[0]?.avatar ?? "A",
secondary: project.isGroup ? devices[1]?.avatar : undefined,
overflowCount: Math.max(0, devices.length - 2) || undefined,
},
groupMembers,
contextBudgetIndicator: {
visible: !project.isGroup && Boolean(topThread),
style: "ring_percent",
percent: !project.isGroup ? topThread?.contextBudgetRemainingPct : undefined,
level: !project.isGroup ? topThread?.contextBudgetLevel : undefined,
},
contextBudgetSourceNodeId: !project.isGroup ? topThread?.nodeId : undefined,
contextBudgetUpdatedAt: !project.isGroup ? topThread?.capturedAt : undefined,
mustFinishBeforeCompaction: Boolean(topThread?.mustFinishBeforeCompaction),
} satisfies ConversationItem;
});
return {
conversationId: `conv-${project.id}`,
conversationType: projectType(project),
projectId: project.id,
projectTitle: project.name,
threadTitle,
folderLabel,
folderKey: buildFolderKey(project),
preview: project.preview,
lastMessagePreview: project.preview,
activityIconCount,
topPinnedLabel,
manualPinned: Boolean(project.pinned && !project.systemPinned),
latestReplyAt: project.lastMessageAt,
latestReplyLabel: formatTimestampLabel(project.lastMessageAt),
unreadCount: project.unreadCount,
riskLevel: project.riskLevel,
activeDeviceCount: devices.length,
deviceNamesPreview: devices.map((device) => device.name),
avatar: {
primary: devices[0]?.avatar ?? "A",
secondary: project.isGroup ? devices[1]?.avatar : undefined,
overflowCount: Math.max(0, devices.length - 2) || undefined,
},
groupMembers,
contextBudgetIndicator: {
visible: !project.isGroup && Boolean(topThread),
style: "ring_percent",
percent: !project.isGroup ? topThread?.contextBudgetRemainingPct : undefined,
level: !project.isGroup ? topThread?.contextBudgetLevel : undefined,
},
contextBudgetSourceNodeId: !project.isGroup ? topThread?.nodeId : undefined,
contextBudgetUpdatedAt: !project.isGroup ? topThread?.capturedAt : undefined,
mustFinishBeforeCompaction: Boolean(topThread?.mustFinishBeforeCompaction),
} satisfies ConversationItem;
}
return conversations.sort((a, b) => {
function sortConversationItems(items: ConversationItem[]) {
return items.sort((a, b) => {
if (a.projectId === "master-agent") return -1;
if (b.projectId === "master-agent") return 1;
const aPinned = Boolean(a.topPinnedLabel);
@@ -372,6 +383,128 @@ export function getConversationItems(state: BossState): ConversationItem[] {
});
}
export function getConversationItems(state: BossState): ConversationItem[] {
const conversations = state.projects.map((project) => buildConversationItem(state, project));
return sortConversationItems(conversations);
}
export interface ConversationFolderView {
folderKey: string;
folderLabel: string;
deviceId?: string;
deviceName?: string;
threadCount: number;
threads: ConversationItem[];
}
export function getConversationHomeItems(state: BossState): ConversationItem[] {
const flatItems = getConversationItems(state);
const projectMap = new Map(state.projects.map((project) => [project.id, project]));
const grouped = new Map<string, ConversationItem[]>();
const passthrough: ConversationItem[] = [];
for (const item of flatItems) {
const project = projectMap.get(item.projectId);
if (!project || item.conversationType !== "single_device") {
passthrough.push(item);
continue;
}
const folderKey = buildFolderKey(project);
if (!folderKey) {
passthrough.push(item);
continue;
}
const bucket = grouped.get(folderKey) ?? [];
bucket.push(item);
grouped.set(folderKey, bucket);
}
for (const [folderKey, items] of grouped) {
if (items.length <= 1) {
passthrough.push(items[0]);
continue;
}
const latestItem = [...items].sort((a, b) => b.latestReplyAt.localeCompare(a.latestReplyAt))[0];
const project = projectMap.get(latestItem.projectId);
const device = project?.deviceIds[0]
? state.devices.find((entry) => entry.id === project.deviceIds[0])
: undefined;
passthrough.push({
conversationId: `folder-${folderKey}`,
conversationType: "folder_archive",
projectId: folderKey,
projectTitle:
project?.threadMeta.folderName ??
(latestItem.folderLabel || latestItem.projectTitle),
threadTitle:
project?.threadMeta.folderName ??
(latestItem.folderLabel || latestItem.threadTitle),
folderLabel: `${device?.name ?? latestItem.deviceNamesPreview[0] ?? "设备"} · ${items.length} 个线程`,
folderKey,
threadCount: items.length,
preview:
latestItem.preview || `包含 ${items.length} 个线程,最近活跃:《${latestItem.threadTitle}`,
lastMessagePreview:
latestItem.lastMessagePreview ||
latestItem.preview ||
`包含 ${items.length} 个线程,最近活跃:《${latestItem.threadTitle}`,
activityIconCount: Math.max(
1,
Math.min(
4,
items.reduce((sum, entry) => sum + Math.max(1, entry.activityIconCount), 0),
),
),
manualPinned: false,
topPinnedLabel: undefined,
latestReplyAt: latestItem.latestReplyAt,
latestReplyLabel: latestItem.latestReplyLabel,
unreadCount: items.reduce((sum, entry) => sum + entry.unreadCount, 0),
riskLevel: items.some((entry) => entry.riskLevel === "high")
? "high"
: items.some((entry) => entry.riskLevel === "medium")
? "medium"
: "low",
activeDeviceCount: 1,
deviceNamesPreview: device ? [device.name] : latestItem.deviceNamesPreview,
avatar: {
primary: device?.avatar ?? latestItem.avatar.primary,
},
contextBudgetIndicator: {
visible: false,
style: "ring_percent",
},
mustFinishBeforeCompaction: false,
});
}
return sortConversationItems(passthrough);
}
export function getConversationFolderView(
state: BossState,
folderKey: string,
): ConversationFolderView | null {
const flatItems = getConversationItems(state).filter(
(item) => item.conversationType === "single_device" && item.folderKey === folderKey,
);
if (flatItems.length === 0) {
return null;
}
const project = state.projects.find((entry) => buildFolderKey(entry) === folderKey);
const deviceId = project?.deviceIds[0];
const device = deviceId ? state.devices.find((entry) => entry.id === deviceId) : undefined;
return {
folderKey,
folderLabel: project?.threadMeta.folderName ?? flatItems[0].folderLabel,
deviceId,
deviceName: device?.name,
threadCount: flatItems.length,
threads: sortConversationItems(flatItems),
};
}
export function getProjectDetailView(state: BossState, projectId: string): ProjectDetailView | null {
const project = state.projects.find((item) => item.id === projectId);
if (!project) return null;

View File

@@ -0,0 +1,121 @@
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
let runtimeRoot = "";
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let getConversationHomeItems: (typeof import("../src/lib/boss-projections"))["getConversationHomeItems"];
let getConversationFolderView: (typeof import("../src/lib/boss-projections"))["getConversationFolderView"];
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-conversation-home-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [data, projections] = await Promise.all([
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-projections.ts"),
]);
readState = data.readState;
getConversationHomeItems = projections.getConversationHomeItems;
getConversationFolderView = projections.getConversationFolderView;
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
function buildImportedThreadProject(deviceId: string, id: string, folderName: string, codexFolderRef: string, threadName: string, threadId: string, lastMessageAt: string) {
return {
id,
name: threadName,
pinned: false,
systemPinned: false,
deviceIds: [deviceId],
preview: `最近消息:${threadName}`,
updatedAt: lastMessageAt,
lastMessageAt,
isGroup: false,
threadMeta: {
projectId: id,
threadId,
threadDisplayName: threadName,
folderName,
activityIconCount: 1,
updatedAt: lastMessageAt,
codexFolderRef,
codexThreadRef: threadId,
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development" as const,
approvalState: "not_required" as const,
unreadCount: 0,
riskLevel: "low" as const,
messages: [],
goals: [],
versions: [],
};
}
test("conversation home groups multiple imported threads by folder while keeping single-thread projects direct", async () => {
await setup();
const state = await readState();
state.projects = state.projects.filter((project) => project.id === "master-agent");
state.projects.push(
buildImportedThreadProject(
"mac-studio",
"boss-thread-1",
"Boss",
"boss",
"归档确认",
"thread-1",
"2026-03-30T11:00:00+08:00",
),
buildImportedThreadProject(
"mac-studio",
"boss-thread-2",
"Boss",
"boss",
"发布回滚",
"thread-2",
"2026-03-30T12:00:00+08:00",
),
buildImportedThreadProject(
"mac-studio",
"yuandi-thread-1",
"源地",
"yuandi",
"首页回归",
"thread-3",
"2026-03-30T10:00:00+08:00",
),
);
const homeItems = getConversationHomeItems(state);
const bossFolder = homeItems.find((item) => item.conversationType === "folder_archive");
const directThread = homeItems.find((item) => item.projectId === "yuandi-thread-1");
assert.ok(bossFolder, "expected grouped folder item for multi-thread project");
assert.equal(bossFolder?.threadTitle, "Boss");
assert.equal(bossFolder?.threadCount, 2);
assert.equal(bossFolder?.folderKey, "mac-studio:boss");
assert.ok(directThread, "expected single-thread project to stay direct");
assert.equal(directThread?.conversationType, "single_device");
assert.equal(directThread?.threadTitle, "首页回归");
const folderView = getConversationFolderView(state, "mac-studio:boss");
assert.ok(folderView, "expected folder detail view");
assert.equal(folderView?.threadCount, 2);
assert.deepEqual(
folderView?.threads.map((item) => item.threadTitle),
["发布回滚", "归档确认"],
);
});