Compare commits
47 Commits
codex/nati
...
e051a49f7a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e051a49f7a | ||
|
|
5fb75b50b4 | ||
|
|
88ab2d011a | ||
|
|
18dc7c6120 | ||
|
|
1e476a2097 | ||
|
|
9e4b64ba9e | ||
|
|
8273340f7f | ||
|
|
3307f79162 | ||
|
|
de23a6e921 | ||
|
|
aa75506364 | ||
|
|
c3900a11ec | ||
|
|
4262c8fb5c | ||
|
|
e4ff24a18f | ||
|
|
3cb4405b14 | ||
|
|
a5e8ba2b7e | ||
|
|
cc08ca28aa | ||
|
|
a3a7f43626 | ||
|
|
64ad401d0c | ||
|
|
d2291af32c | ||
|
|
7109f1d3db | ||
|
|
200fc18210 | ||
|
|
13c67425ab | ||
|
|
0783f4da14 | ||
|
|
42063db78f | ||
|
|
c90dea4b7c | ||
|
|
9613c3c154 | ||
|
|
227d270505 | ||
|
|
b606af66f6 | ||
|
|
a9e8bb9ddd | ||
|
|
f0735b31e5 | ||
|
|
afa7e79ad2 | ||
|
|
e27ea1e071 | ||
|
|
0a3390b132 | ||
|
|
4dbf4ac1de | ||
|
|
6559ad5bce | ||
|
|
ae571a76ff | ||
|
|
63ceef9871 | ||
|
|
8da592bddf | ||
|
|
9e0b5b223f | ||
|
|
ff56617fdb | ||
|
|
05e26afbf1 | ||
|
|
b794ba05fa | ||
|
|
ce8dcad41c | ||
|
|
17300c49ea | ||
|
|
efcefd8a62 | ||
|
|
785db90a7a | ||
|
|
8439428479 |
28
README.md
28
README.md
@@ -33,7 +33,7 @@
|
||||
- `src/boss_control`:空占位目录,不参与当前运行
|
||||
- `src/boss_device_agent`:空占位目录,不参与当前运行
|
||||
|
||||
## 当前运行状态(2026-03-26)
|
||||
## 当前运行状态(2026-03-29)
|
||||
|
||||
本地:
|
||||
|
||||
@@ -90,13 +90,24 @@ Android APK:
|
||||
- 已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
- 已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk`
|
||||
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
|
||||
- 当前最新 release 构建版本:`2.1.1`(`versionCode=8`)
|
||||
- 当前最新 release 构建版本:`2.5.0`(`versionCode=13`)
|
||||
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
|
||||
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
|
||||
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
|
||||
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
|
||||
- 当前聊天列表已切到“线程 = 会话窗口”的结构:主标题显示线程名,副标题显示所属文件夹名,右下角显示后台活跃数量动态图标;同一文件夹下多个线程会显示成多个独立聊天窗口
|
||||
- 当前会话信息页已经支持按微信最新逻辑改线程名;群聊会作为独立新会话创建,默认自动命名,创建后可在群资料页改名
|
||||
- 当前消息转发已经切到微信式链路:长按消息可直接 `转发 / 多选 / 复制 / 删除`,多选后底部只保留 `转发`,统一进入原生会话选择页
|
||||
- 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页
|
||||
- 当前 `设备` 和 `我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的`,`审计对话` 作为置顶会话保留在会话首页
|
||||
- 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API,不再打开 WebView
|
||||
- `2.0.1` 已修复华为真机上因 `Theme.SplashScreen` 与 `AppCompatActivity` 不兼容导致的启动闪退
|
||||
- `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通
|
||||
- `2.1.1` 已补上原生 OTA 下载链路:关于页会直接请求受保护的 `/api/v1/user/ota/package`,下载完成后可拉起系统安装器
|
||||
- `2.2.0` 已把原生 UI 从控制台风格回退到微信式简单列表和聊天优先视图,并复核了设备页 / 我的页 / 深层高级入口
|
||||
- `2.2.1` 已继续补齐原生交互细节:聊天页发送后会先出现本地“发送中”气泡,关于页会展示 OTA 下载进度 / 重试 / 安装授权提示,根 tab 会记住用户上次停留位置并改成“再按一次返回进入后台”
|
||||
- `2.3.0` 已把会话模型切到“线程 = 聊天窗口”,补上文件夹名副信息、后台活跃数量动态图标、微信式会话信息页、线程改名、独立群聊创建、群资料页,以及 `主 Agent / 审计对话` 普通置顶会话化
|
||||
- `2.4.0` 已把消息转发切到微信式原生链路:聊天页支持长按消息操作、多选合并转发、统一目标会话选择页;单条消息转发显示为普通转发消息,多条消息转发显示为“聊天记录”卡片
|
||||
- `2.5.0` 已补齐聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;默认走服务器文件存储,`我的 > 附件与存储` 可切到阿里 OSS 私有桶;附件消息已支持下载 / 打开、手动分析、自动分析状态,以及带 task token 的主 Agent 附件分析链接
|
||||
|
||||
## 本地启动
|
||||
|
||||
@@ -180,6 +191,8 @@ device-agent 当前职责:
|
||||
- Android 原生会话页:`android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Android 原生设备页:`android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- Android 原生我的页:`android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`、`android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`、`android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
|
||||
- Android 微信式 surface contract:`android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Android 聊天页布局:`android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- 服务器环境示例:`.env.server.example`
|
||||
|
||||
当前 `scripts/deploy-server.sh`:
|
||||
@@ -244,6 +257,8 @@ npm run aab:release
|
||||
- Web 端根布局当前仍保留 `NativeAppBridge`,用于浏览器态与历史桥接兼容;当前正式 APK 已改为原生 Activity + 原生 API 客户端,不再依赖 WebView
|
||||
- APP 日志桥已经改成会话感知:只会按当前登录账号解析绑定设备,不再在未登录页默认按全局管理员设备写日志
|
||||
- APP 外壳已经从“桌面预览卡片”切回真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部 `会话 / 设备 / 我的` 导航固定在视口底部,背景改为全屏 cover,不再出现圆角矩形外壳
|
||||
- 原生 Android 当前也和这套产品口径对齐:根页采用微信式简单列表,项目聊天页改成消息流优先,`设备 / 我的` 页不再展示控制台式统计卡片
|
||||
- 原生聊天页当前会即时渲染本地发送中消息,并且只有在用户接近底部或本次发送是主动触发时才自动滚到底
|
||||
- 登录成功后的进入首页链路已做稳态处理:会先确认 `/api/auth/session` 可读,再执行 `replace(/conversations)`,并附带一次原生级兜底跳转,避免真机 WebView 偶发停留在“正在进入会话首页”
|
||||
- `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新
|
||||
- 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill,并支持一键复制调用语句
|
||||
@@ -259,6 +274,9 @@ npm run aab:release
|
||||
- 当前登录页已临时放开成“一键进入”,账号密码和验证码输入暂时不作为拦截条件
|
||||
- `POST /api/auth/send-code` 与固定验证码 `000000` 仍保留给注册 / 重置密码和后续认证收口,不作为当前登录页前置条件
|
||||
- 新注册和重置密码现在使用 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
|
||||
- 原生 Android 当前把 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等复杂能力下沉到二级或更深层入口,不再把线程预算 / 转发 / 运维说明堆在主聊天页和一级我的页
|
||||
- 原生 OTA 当前除了整包下载和系统安装器拉起,还会在关于页保留本地下载状态;离开关于页再回来时,仍能看到进行中 / 失败 / 待授权 / 可安装状态
|
||||
- Android 本地 Gradle 验证当前必须串行执行,避免并发 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug` 相互踩坏中间产物
|
||||
- 当前默认最高管理员账号:`17600003315`
|
||||
- 当前默认测试密码:`boss123456`
|
||||
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `17600003315`
|
||||
@@ -270,6 +288,8 @@ npm run aab:release
|
||||
- `GET /api/v1/app-logs` 现在已支持登录态下按 `deviceId / projectId / level / category / source / cursor` 查询日志分页
|
||||
- 设备写接口 `POST /api/v1/app-logs`、`POST /api/v1/devices/[deviceId]/skills`、`POST /api/v1/workers/[workerId]/thread-context` 现在都要求有效设备 token 或匹配登录会话
|
||||
- 当前认证仍是 MVP:已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、吊销审计和 CSRF 防护
|
||||
- 当前图片 / 视频入口会写入消息账本,但真实文件上传还没有接对象存储
|
||||
- 聊天附件当前已支持真实上传、消息落账本、受保护下载和原生打开;默认存储后端为服务器文件存储
|
||||
- 当前用户已可在 `我的 > 附件与存储` 切到阿里 OSS 私有桶,下载链会按附件快照生成签名地址,避免用户后续修改配置后旧附件失效
|
||||
- 图片 / PDF / 文本默认自动进入主 Agent 附件分析;视频 / Office / 大文件默认手动触发
|
||||
- 当前采用“极轻云 + 本地设备端”的路线,云端只承载 Web、轻 API 和状态文件
|
||||
- 服务器侧主 Agent 对话能否返回真实大模型回复,依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;服务器本身不直接持有主 GPT 会话
|
||||
|
||||
@@ -13,6 +13,9 @@ android {
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources = true
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
@@ -33,8 +36,8 @@ android {
|
||||
applicationId "com.hyzq.boss"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 8
|
||||
versionName "2.1.1"
|
||||
versionCode 13
|
||||
versionName "2.5.0"
|
||||
buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\""
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -56,6 +59,7 @@ dependencies {
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
testImplementation "org.robolectric:robolectric:4.14.1"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
}
|
||||
|
||||
@@ -31,7 +31,11 @@
|
||||
<activity android:name=".ProjectGoalsActivity" android:exported="false" />
|
||||
<activity android:name=".ProjectVersionsActivity" android:exported="false" />
|
||||
<activity android:name=".ProjectForwardActivity" android:exported="false" />
|
||||
<activity android:name=".ForwardTargetActivity" android:exported="false" />
|
||||
<activity android:name=".ThreadDetailActivity" android:exported="false" />
|
||||
<activity android:name=".ConversationInfoActivity" android:exported="false" />
|
||||
<activity android:name=".GroupInfoActivity" android:exported="false" />
|
||||
<activity android:name=".GroupCreateActivity" android:exported="false" />
|
||||
<activity android:name=".DeviceDetailActivity" android:exported="false" />
|
||||
<activity android:name=".DeviceEnrollmentActivity" android:exported="false" />
|
||||
<activity android:name=".SkillInventoryActivity" android:exported="false" />
|
||||
|
||||
@@ -9,8 +9,9 @@ import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.provider.Settings;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -19,8 +20,34 @@ import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class AboutActivity extends BossScreenActivity {
|
||||
private static final long OTA_PROGRESS_POLL_INTERVAL_MS = 1_000L;
|
||||
private static final String OTA_UI_PREFS = "boss_native_client";
|
||||
private static final String KEY_ACTIVE_DOWNLOAD_ID = "ota_active_download_id";
|
||||
private static final String KEY_COMPLETED_DOWNLOAD_ID = "ota_completed_download_id";
|
||||
private static final String KEY_LAST_DOWNLOAD_FILE_NAME = "ota_last_download_file_name";
|
||||
private static final String KEY_LAST_DOWNLOAD_VERSION = "ota_last_download_version";
|
||||
private static final String KEY_LAST_DOWNLOAD_STATUS = "ota_last_download_status";
|
||||
|
||||
private long activeDownloadId = -1L;
|
||||
private long completedDownloadId = -1L;
|
||||
private @Nullable JSONObject otaPayload;
|
||||
private @Nullable LinearLayout otaDownloadStateSection;
|
||||
private @Nullable Uri downloadedApkUri;
|
||||
private @Nullable String lastDownloadFileName;
|
||||
private @Nullable String lastDownloadVersion;
|
||||
private int lastDownloadStatus = -1;
|
||||
private long lastDownloadedBytes = 0L;
|
||||
private long lastTotalBytes = -1L;
|
||||
private final Handler otaProgressHandler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable otaProgressPoller = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
refreshDownloadStateSection();
|
||||
if (activeDownloadId > 0) {
|
||||
otaProgressHandler.postDelayed(this, OTA_PROGRESS_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final BroadcastReceiver otaDownloadReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
@@ -32,7 +59,6 @@ public class AboutActivity extends BossScreenActivity {
|
||||
if (downloadId <= 0 || downloadId != activeDownloadId) {
|
||||
return;
|
||||
}
|
||||
activeDownloadId = -1L;
|
||||
handleCompletedDownload(downloadId);
|
||||
}
|
||||
};
|
||||
@@ -40,7 +66,8 @@ public class AboutActivity extends BossScreenActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("关于 / OTA", "原生版本中心");
|
||||
configureScreen("关于", "版本与 OTA 更新");
|
||||
restoreDownloadUiState();
|
||||
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(otaDownloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
|
||||
@@ -52,6 +79,7 @@ public class AboutActivity extends BossScreenActivity {
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
try {
|
||||
unregisterReceiver(otaDownloadReceiver);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
@@ -67,14 +95,12 @@ public class AboutActivity extends BossScreenActivity {
|
||||
try {
|
||||
BossApiClient.ApiResponse settings = apiClient.getSettings();
|
||||
BossApiClient.ApiResponse ota = apiClient.getOtaStatus();
|
||||
BossApiClient.ApiResponse session = apiClient.getSession();
|
||||
if (!settings.ok() || !ota.ok() || !session.ok()) {
|
||||
if (!settings.ok() || !ota.ok()) {
|
||||
throw new IllegalStateException("PROFILE_OR_OTA_LOAD_FAILED");
|
||||
}
|
||||
runOnUiThread(() -> renderAbout(
|
||||
settings.json.optJSONObject("user"),
|
||||
ota.json,
|
||||
session.json.optJSONObject("session")
|
||||
ota.json
|
||||
));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
@@ -85,69 +111,164 @@ public class AboutActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void renderAbout(@Nullable JSONObject user, JSONObject ota, @Nullable JSONObject session) {
|
||||
private void renderAbout(@Nullable JSONObject user, JSONObject ota) {
|
||||
replaceContent();
|
||||
otaPayload = ota;
|
||||
if (user != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"当前版本",
|
||||
user.optString("version", "-")
|
||||
+ "\n当前账号:" + user.optString("account", "-")
|
||||
+ "\n绑定 Codex:" + user.optString("boundCodexNodeLabel", "未绑定"),
|
||||
session == null ? "-" : "会话到期 " + session.optString("expiresAt", "-")
|
||||
));
|
||||
}
|
||||
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
String otaBody = availableRelease == null
|
||||
? "当前已经是最新版本。"
|
||||
: availableRelease.optString("version", "未知版本")
|
||||
+ "\n" + availableRelease.optString("summary", "暂无摘要")
|
||||
+ "\n文件:" + availableRelease.optString("packageFileName", "-");
|
||||
appendContent(BossUi.buildCard(
|
||||
invalidateStaleDownloadedApk(ota.optJSONObject("availableRelease"));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"OTA 状态",
|
||||
otaBody,
|
||||
"当前版本 " + ota.optString("currentVersion", "-")
|
||||
"当前版本",
|
||||
user == null ? ota.optString("currentVersion", "-") : user.optString("version", ota.optString("currentVersion", "-")),
|
||||
"已安装版本",
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
LinearLayout actionCard = BossUi.buildCard(this, "OTA 操作", "可在原生页直接检查更新、登记 OTA 并下载 APK。", "当前接口:/api/v1/user/ota");
|
||||
Button check = BossUi.buildPrimaryButton(this, "检查更新");
|
||||
check.setOnClickListener(v -> performOtaAction("check"));
|
||||
actionCard.addView(check);
|
||||
Button apply = BossUi.buildSecondaryButton(this, "登记应用 OTA");
|
||||
apply.setOnClickListener(v -> performOtaAction("apply"));
|
||||
actionCard.addView(apply);
|
||||
Button download = BossUi.buildSecondaryButton(this, "应用内下载 APK");
|
||||
download.setOnClickListener(v -> downloadLatestApk());
|
||||
actionCard.addView(download);
|
||||
appendContent(actionCard);
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"OTA 状态",
|
||||
buildOtaStatusSubtitle(ota),
|
||||
buildOtaStatusMeta(ota),
|
||||
availableRelease == null ? null : "OTA",
|
||||
null
|
||||
));
|
||||
|
||||
JSONArray logs = ota.optJSONArray("logs");
|
||||
if (logs != null) {
|
||||
for (int i = 0; i < logs.length(); i++) {
|
||||
JSONObject log = logs.optJSONObject(i);
|
||||
if (log == null) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
log.optString("version", "OTA"),
|
||||
log.optString("summary", ""),
|
||||
log.optString("status", "-") + " · " + log.optString("createdAt", "-")
|
||||
));
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"OTA 更新内容",
|
||||
buildOtaContentBody(ota),
|
||||
availableRelease == null ? "没有可下载的新版本时,可直接点按钮检查更新。" : "下载完成后会自动拉起系统安装器。"
|
||||
));
|
||||
|
||||
android.widget.Button otaButton = BossUi.buildPrimaryButton(this, resolvePrimaryOtaActionLabel(availableRelease));
|
||||
otaButton.setEnabled(activeDownloadId <= 0);
|
||||
otaButton.setOnClickListener(v -> performPrimaryOtaAction(availableRelease));
|
||||
appendContent(otaButton);
|
||||
|
||||
appendContent(BossUi.buildMenuRow(this, "重新检查更新", "拉取最新 OTA 状态", null, v -> performOtaAction("check")));
|
||||
if (downloadedApkUri != null || completedDownloadId > 0) {
|
||||
appendContent(BossUi.buildMenuRow(
|
||||
this,
|
||||
"同步已应用状态",
|
||||
"安装完成后点这里,把服务端 OTA 状态更新为已应用",
|
||||
null,
|
||||
v -> performOtaAction("apply")
|
||||
));
|
||||
}
|
||||
otaDownloadStateSection = new LinearLayout(this);
|
||||
otaDownloadStateSection.setOrientation(LinearLayout.VERTICAL);
|
||||
appendContent(otaDownloadStateSection);
|
||||
refreshDownloadStateSection();
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private static String buildOtaStatusSubtitle(JSONObject ota) {
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
if (availableRelease == null) {
|
||||
return "当前已经是最新版本。";
|
||||
}
|
||||
return "发现新版本 " + availableRelease.optString("version", "未知版本");
|
||||
}
|
||||
|
||||
private static String buildOtaStatusMeta(JSONObject ota) {
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
if (availableRelease == null) {
|
||||
return "当前版本 " + ota.optString("currentVersion", "-");
|
||||
}
|
||||
String summaryLine = firstSummaryLine(availableRelease.optJSONArray("summary"));
|
||||
return availableRelease.optString("packageFileName", "boss-android-latest.apk")
|
||||
+ (summaryLine.isEmpty() ? "" : " · " + summaryLine);
|
||||
}
|
||||
|
||||
private static String buildOtaContentBody(JSONObject ota) {
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
if (availableRelease != null) {
|
||||
JSONArray lines = availableRelease.optJSONArray("summary");
|
||||
if (lines != null && lines.length() > 0) {
|
||||
StringBuilder builder = new StringBuilder("版本 ").append(availableRelease.optString("version", "-"));
|
||||
for (int i = 0; i < lines.length(); i++) {
|
||||
String line = lines.optString(i);
|
||||
if (line == null || line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
builder.append("\n").append(i + 1).append(". ").append(line);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
String note = availableRelease.optString("note", "");
|
||||
if (!note.isEmpty()) {
|
||||
return "版本 " + availableRelease.optString("version", "-") + "\n" + note;
|
||||
}
|
||||
}
|
||||
setRefreshing(false);
|
||||
|
||||
JSONArray logs = ota.optJSONArray("logs");
|
||||
if (logs != null && logs.length() > 0) {
|
||||
JSONObject latest = logs.optJSONObject(0);
|
||||
if (latest != null) {
|
||||
String note = latest.optString("note", "");
|
||||
if (!note.isEmpty()) {
|
||||
return latest.optString("version", "当前版本") + "\n" + note;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "当前没有待更新内容,点击下方按钮可重新检查更新。";
|
||||
}
|
||||
|
||||
private static String firstSummaryLine(@Nullable JSONArray lines) {
|
||||
if (lines == null || lines.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
for (int i = 0; i < lines.length(); i++) {
|
||||
String line = lines.optString(i);
|
||||
if (line != null && !line.isEmpty()) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String resolvePrimaryOtaActionLabel(@Nullable JSONObject availableRelease) {
|
||||
if (activeDownloadId > 0) {
|
||||
return "下载中…";
|
||||
}
|
||||
if (downloadedApkUri != null) {
|
||||
return "安装更新";
|
||||
}
|
||||
if (availableRelease != null) {
|
||||
return "立即 OTA";
|
||||
}
|
||||
return "检查更新";
|
||||
}
|
||||
|
||||
private void performPrimaryOtaAction(@Nullable JSONObject availableRelease) {
|
||||
if (activeDownloadId > 0) {
|
||||
return;
|
||||
}
|
||||
if (downloadedApkUri != null) {
|
||||
installDownloadedApk();
|
||||
return;
|
||||
}
|
||||
if (availableRelease != null) {
|
||||
downloadLatestApk();
|
||||
return;
|
||||
}
|
||||
performOtaAction("check");
|
||||
}
|
||||
|
||||
private void performOtaAction(String action) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = "check".equals(action) ? apiClient.checkOta() : apiClient.applyOta();
|
||||
BossApiClient.ApiResponse response = "apply".equals(action)
|
||||
? apiClient.applyOta()
|
||||
: apiClient.checkOta();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("check".equals(action) ? "已完成版本检查" : "已登记 OTA 应用");
|
||||
if ("apply".equals(action)) {
|
||||
clearLocalOtaDownloadState();
|
||||
}
|
||||
showMessage("apply".equals(action) ? "已同步 OTA 应用状态" : "已完成版本检查");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
@@ -159,6 +280,19 @@ public class AboutActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void clearLocalOtaDownloadState() {
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
downloadedApkUri = null;
|
||||
lastDownloadFileName = null;
|
||||
lastDownloadVersion = null;
|
||||
lastDownloadStatus = -1;
|
||||
lastDownloadedBytes = 0L;
|
||||
lastTotalBytes = -1L;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
}
|
||||
|
||||
private void downloadLatestApk() {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
@@ -187,6 +321,9 @@ public class AboutActivity extends BossScreenActivity {
|
||||
String fileName = availableRelease == null
|
||||
? "boss-android-latest.apk"
|
||||
: availableRelease.optString("packageFileName", "boss-android-latest.apk");
|
||||
String releaseVersion = availableRelease == null
|
||||
? null
|
||||
: availableRelease.optString("version", null);
|
||||
|
||||
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(apiClient.getProtectedOtaPackageUrl()));
|
||||
request.setTitle(fileName);
|
||||
@@ -198,7 +335,18 @@ public class AboutActivity extends BossScreenActivity {
|
||||
request.addRequestHeader("x-boss-native-app", "1");
|
||||
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName);
|
||||
|
||||
downloadedApkUri = null;
|
||||
lastDownloadFileName = fileName;
|
||||
lastDownloadVersion = releaseVersion;
|
||||
lastDownloadStatus = DownloadManager.STATUS_PENDING;
|
||||
lastDownloadedBytes = 0L;
|
||||
lastTotalBytes = -1L;
|
||||
completedDownloadId = -1L;
|
||||
activeDownloadId = manager.enqueue(request);
|
||||
persistDownloadUiState();
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
otaProgressHandler.post(otaProgressPoller);
|
||||
refreshDownloadStateSection();
|
||||
showMessage("已开始下载,完成后会自动拉起安装。");
|
||||
}
|
||||
|
||||
@@ -213,10 +361,23 @@ public class AboutActivity extends BossScreenActivity {
|
||||
try (android.database.Cursor cursor = manager.query(query)) {
|
||||
if (cursor == null || !cursor.moveToFirst()) {
|
||||
showMessage("下载完成,但无法读取文件信息");
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
lastDownloadStatus = DownloadManager.STATUS_FAILED;
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
return;
|
||||
}
|
||||
int status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
|
||||
if (status != DownloadManager.STATUS_SUCCESSFUL) {
|
||||
lastDownloadStatus = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
|
||||
lastDownloadedBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
|
||||
lastTotalBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
|
||||
if (lastDownloadStatus != DownloadManager.STATUS_SUCCESSFUL) {
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
downloadedApkUri = null;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
showMessage("下载未成功完成");
|
||||
return;
|
||||
}
|
||||
@@ -224,10 +385,23 @@ public class AboutActivity extends BossScreenActivity {
|
||||
|
||||
Uri apkUri = manager.getUriForDownloadedFile(downloadId);
|
||||
if (apkUri == null) {
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
lastDownloadStatus = DownloadManager.STATUS_FAILED;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
showMessage("下载完成,但找不到安装包");
|
||||
return;
|
||||
}
|
||||
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = downloadId;
|
||||
downloadedApkUri = apkUri;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。");
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
@@ -241,4 +415,262 @@ public class AboutActivity extends BossScreenActivity {
|
||||
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(installIntent);
|
||||
}
|
||||
|
||||
private void refreshDownloadStateSection() {
|
||||
if (otaDownloadStateSection == null) {
|
||||
return;
|
||||
}
|
||||
otaDownloadStateSection.removeAllViews();
|
||||
OtaDownloadStateMapper.UiState uiState = resolveDownloadUiState();
|
||||
if (uiState == null) {
|
||||
return;
|
||||
}
|
||||
otaDownloadStateSection.addView(BossUi.buildListRow(
|
||||
this,
|
||||
uiState.title,
|
||||
uiState.subtitle,
|
||||
uiState.meta,
|
||||
uiState.badge,
|
||||
null
|
||||
));
|
||||
if (uiState.actionKind != OtaDownloadStateMapper.ActionKind.NONE) {
|
||||
otaDownloadStateSection.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
uiState.actionLabel,
|
||||
uiState.subtitle,
|
||||
null,
|
||||
v -> performDownloadStateAction(uiState.actionKind)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private OtaDownloadStateMapper.UiState resolveDownloadUiState() {
|
||||
JSONObject availableRelease = otaPayload == null ? null : otaPayload.optJSONObject("availableRelease");
|
||||
invalidateStaleDownloadedApk(availableRelease);
|
||||
String fileName = resolveDownloadFileName();
|
||||
if (activeDownloadId > 0) {
|
||||
DownloadProgressSnapshot snapshot = queryDownloadProgress(activeDownloadId);
|
||||
if (snapshot != null) {
|
||||
lastDownloadStatus = snapshot.status;
|
||||
lastDownloadedBytes = snapshot.bytesDownloaded;
|
||||
lastTotalBytes = snapshot.totalBytes;
|
||||
boolean hasKnownTotal = snapshot.totalBytes > 0;
|
||||
int percent = hasKnownTotal
|
||||
? (int) Math.round((snapshot.bytesDownloaded * 100.0d) / snapshot.totalBytes)
|
||||
: 0;
|
||||
if (snapshot.status == DownloadManager.STATUS_RUNNING
|
||||
|| snapshot.status == DownloadManager.STATUS_PENDING
|
||||
|| snapshot.status == DownloadManager.STATUS_PAUSED) {
|
||||
return OtaDownloadStateMapper.active(fileName, percent, hasKnownTotal, snapshot.bytesDownloaded, snapshot.totalBytes);
|
||||
}
|
||||
if (snapshot.status == DownloadManager.STATUS_FAILED) {
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
return OtaDownloadStateMapper.failed(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastDownloadStatus == DownloadManager.STATUS_FAILED) {
|
||||
return OtaDownloadStateMapper.failed(fileName);
|
||||
}
|
||||
if (downloadedApkUri != null) {
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
return OtaDownloadStateMapper.waitingInstallPermission(fileName);
|
||||
}
|
||||
return OtaDownloadStateMapper.readyToInstall(fileName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void performDownloadStateAction(OtaDownloadStateMapper.ActionKind actionKind) {
|
||||
switch (actionKind) {
|
||||
case RETRY_DOWNLOAD:
|
||||
downloadLatestApk();
|
||||
break;
|
||||
case OPEN_INSTALL_PERMISSION:
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
break;
|
||||
case INSTALL_APK:
|
||||
installDownloadedApk();
|
||||
break;
|
||||
case NONE:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void installDownloadedApk() {
|
||||
if (downloadedApkUri == null) {
|
||||
showMessage("当前没有可安装的更新包");
|
||||
return;
|
||||
}
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
showMessage("请先开启安装未知来源应用权限");
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
return;
|
||||
}
|
||||
Intent installIntent = new Intent(Intent.ACTION_VIEW);
|
||||
installIntent.setDataAndType(downloadedApkUri, "application/vnd.android.package-archive");
|
||||
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(installIntent);
|
||||
}
|
||||
|
||||
private String resolveDownloadFileName() {
|
||||
if (lastDownloadFileName != null && !lastDownloadFileName.isEmpty()) {
|
||||
return lastDownloadFileName;
|
||||
}
|
||||
JSONObject availableRelease = otaPayload == null ? null : otaPayload.optJSONObject("availableRelease");
|
||||
if (availableRelease != null) {
|
||||
return availableRelease.optString("packageFileName", "boss-android-latest.apk");
|
||||
}
|
||||
return "boss-android-latest.apk";
|
||||
}
|
||||
|
||||
private void restoreDownloadUiState() {
|
||||
android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE);
|
||||
activeDownloadId = prefs.getLong(KEY_ACTIVE_DOWNLOAD_ID, -1L);
|
||||
completedDownloadId = prefs.getLong(KEY_COMPLETED_DOWNLOAD_ID, -1L);
|
||||
lastDownloadFileName = prefs.getString(KEY_LAST_DOWNLOAD_FILE_NAME, null);
|
||||
lastDownloadVersion = prefs.getString(KEY_LAST_DOWNLOAD_VERSION, null);
|
||||
lastDownloadStatus = prefs.getInt(KEY_LAST_DOWNLOAD_STATUS, -1);
|
||||
if (completedDownloadId > 0) {
|
||||
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
|
||||
if (manager != null) {
|
||||
downloadedApkUri = manager.getUriForDownloadedFile(completedDownloadId);
|
||||
}
|
||||
}
|
||||
if (activeDownloadId > 0) {
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
otaProgressHandler.post(otaProgressPoller);
|
||||
}
|
||||
}
|
||||
|
||||
private void persistDownloadUiState() {
|
||||
getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(KEY_ACTIVE_DOWNLOAD_ID, activeDownloadId)
|
||||
.putLong(KEY_COMPLETED_DOWNLOAD_ID, completedDownloadId)
|
||||
.putString(KEY_LAST_DOWNLOAD_FILE_NAME, lastDownloadFileName)
|
||||
.putString(KEY_LAST_DOWNLOAD_VERSION, lastDownloadVersion)
|
||||
.putInt(KEY_LAST_DOWNLOAD_STATUS, lastDownloadStatus)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private void invalidateStaleDownloadedApk(@Nullable JSONObject availableRelease) {
|
||||
long[] downloadIds = collectStaleDownloadIdsForRemoval(
|
||||
availableRelease,
|
||||
lastDownloadFileName,
|
||||
lastDownloadVersion,
|
||||
downloadedApkUri != null || completedDownloadId > 0 || activeDownloadId > 0,
|
||||
activeDownloadId,
|
||||
completedDownloadId
|
||||
);
|
||||
if (downloadIds.length == 0) {
|
||||
return;
|
||||
}
|
||||
removeStaleDownloadTasks(downloadIds);
|
||||
clearLocalOtaDownloadState();
|
||||
}
|
||||
|
||||
private void removeStaleDownloadTasks(long[] downloadIds) {
|
||||
if (downloadIds.length == 0) {
|
||||
return;
|
||||
}
|
||||
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
|
||||
if (manager == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
manager.remove(downloadIds);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Keep UI state recoverable even if DownloadManager cleanup fails.
|
||||
}
|
||||
}
|
||||
|
||||
static long[] collectStaleDownloadIdsForRemoval(
|
||||
@Nullable JSONObject availableRelease,
|
||||
@Nullable String downloadedFileName,
|
||||
@Nullable String downloadedVersion,
|
||||
boolean hasLocalDownload,
|
||||
long activeId,
|
||||
long completedId
|
||||
) {
|
||||
if (!hasLocalDownload) {
|
||||
return new long[0];
|
||||
}
|
||||
if (isDownloadedReleaseCurrent(availableRelease, downloadedFileName, downloadedVersion)) {
|
||||
return new long[0];
|
||||
}
|
||||
return collectDownloadIdsForRemoval(activeId, completedId);
|
||||
}
|
||||
|
||||
private static long[] collectDownloadIdsForRemoval(long activeId, long completedId) {
|
||||
if (activeId > 0 && completedId > 0) {
|
||||
if (activeId == completedId) {
|
||||
return new long[]{activeId};
|
||||
}
|
||||
return new long[]{activeId, completedId};
|
||||
}
|
||||
if (activeId > 0) {
|
||||
return new long[]{activeId};
|
||||
}
|
||||
if (completedId > 0) {
|
||||
return new long[]{completedId};
|
||||
}
|
||||
return new long[0];
|
||||
}
|
||||
|
||||
private static boolean isDownloadedReleaseCurrent(
|
||||
@Nullable JSONObject availableRelease,
|
||||
@Nullable String downloadedFileName,
|
||||
@Nullable String downloadedVersion
|
||||
) {
|
||||
if (availableRelease == null) {
|
||||
return false;
|
||||
}
|
||||
String releaseFileName = availableRelease.optString("packageFileName", "");
|
||||
String releaseVersion = availableRelease.optString("version", "");
|
||||
if (releaseFileName.isEmpty() || releaseVersion.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return releaseFileName.equals(downloadedFileName) && releaseVersion.equals(downloadedVersion);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private DownloadProgressSnapshot queryDownloadProgress(long downloadId) {
|
||||
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
|
||||
if (manager == null) {
|
||||
return null;
|
||||
}
|
||||
DownloadManager.Query query = new DownloadManager.Query().setFilterById(downloadId);
|
||||
try (android.database.Cursor cursor = manager.query(query)) {
|
||||
if (cursor == null || !cursor.moveToFirst()) {
|
||||
return null;
|
||||
}
|
||||
return new DownloadProgressSnapshot(
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DownloadProgressSnapshot {
|
||||
private final int status;
|
||||
private final long bytesDownloaded;
|
||||
private final long totalBytes;
|
||||
|
||||
private DownloadProgressSnapshot(int status, long bytesDownloaded, long totalBytes) {
|
||||
this.status = status;
|
||||
this.bytesDownloaded = bytesDownloaded;
|
||||
this.totalBytes = totalBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
@@ -20,14 +19,12 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
private static final String[] PROVIDER_VALUES = {"master_codex_node", "openai_api"};
|
||||
private static final String[] PROVIDER_LABELS = {"Master Codex Node", "OpenAI API"};
|
||||
|
||||
private LinearLayout accountList;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("AI 账号", "主 GPT / 备用 GPT / API 容灾");
|
||||
setHeaderAction("新增", v -> openAccountEditor(null, null));
|
||||
replaceContent(buildIntroCard(), buildAccountListShell());
|
||||
replaceContent();
|
||||
reload();
|
||||
}
|
||||
|
||||
@@ -48,56 +45,48 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private LinearLayout buildIntroCard() {
|
||||
return BossUi.buildCard(
|
||||
this,
|
||||
"账号说明",
|
||||
"当前页面管理 Boss 的主控 AI 账号。主链路优先使用已绑定电脑上的 Master Codex Node,API 容灾在同页可补充配置。",
|
||||
"支持新增、编辑、激活、校验和删除"
|
||||
);
|
||||
}
|
||||
|
||||
private LinearLayout buildAccountListShell() {
|
||||
LinearLayout wrapper = new LinearLayout(this);
|
||||
wrapper.setOrientation(LinearLayout.VERTICAL);
|
||||
accountList = new LinearLayout(this);
|
||||
accountList.setOrientation(LinearLayout.VERTICAL);
|
||||
wrapper.addView(accountList);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private void renderAccounts(JSONObject payload) {
|
||||
JSONArray accounts = payload.optJSONArray("accounts");
|
||||
JSONObject activeIdentity = payload.optJSONObject("activeIdentity");
|
||||
JSONArray switchHistory = payload.optJSONArray("switchHistory");
|
||||
|
||||
accountList.removeAllViews();
|
||||
replaceContent(buildIntroCard(), buildActiveIdentityCard(activeIdentity), buildAccountsSection(accounts), buildSwitchHistoryCard(switchHistory));
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"AI 账号",
|
||||
"这里统一管理主 GPT、备用 GPT 与 API 容灾账号。",
|
||||
"轻点条目可编辑,按钮可切换、校验或删除。"
|
||||
));
|
||||
appendContent(buildActiveIdentityCard(activeIdentity));
|
||||
appendContent(buildAccountsSection(accounts));
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildActiveIdentityCard(@Nullable JSONObject activeIdentity) {
|
||||
String body = activeIdentity == null
|
||||
? "当前没有可用的主控身份。"
|
||||
: activeIdentity.optString("label", "AI 账号")
|
||||
+ "\n" + activeIdentity.optString("displayName", "-")
|
||||
+ "\n" + activeIdentity.optString("providerLabel", "-")
|
||||
+ (activeIdentity.optString("nodeLabel").isEmpty() ? "" : "\n节点:" + activeIdentity.optString("nodeLabel"));
|
||||
String meta = activeIdentity == null
|
||||
? "请先配置一个可用账号"
|
||||
: activeIdentity.optString("roleLabel", "-") + " · " + activeIdentity.optString("statusLabel", "-");
|
||||
return BossUi.buildCard(this, "当前主控身份", body, meta);
|
||||
if (activeIdentity == null) {
|
||||
return BossUi.buildSoftPanel(this, "当前主控身份", "当前没有可用账号。", "请先新增或启用一个账号。");
|
||||
}
|
||||
String body = activeIdentity.optString("label", "AI 账号")
|
||||
+ " · " + activeIdentity.optString("displayName", "-")
|
||||
+ "\n" + activeIdentity.optString("roleLabel", "-")
|
||||
+ " · " + activeIdentity.optString("providerLabel", "-");
|
||||
return BossUi.buildSoftPanel(
|
||||
this,
|
||||
"当前主控身份",
|
||||
body,
|
||||
activeIdentity.optString("statusLabel", "-")
|
||||
);
|
||||
}
|
||||
|
||||
private LinearLayout buildAccountsSection(@Nullable JSONArray accounts) {
|
||||
LinearLayout section = new LinearLayout(this);
|
||||
section.setOrientation(LinearLayout.VERTICAL);
|
||||
|
||||
section.addView(BossUi.buildCard(
|
||||
section.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"账号列表",
|
||||
accounts == null || accounts.length() == 0 ? "当前还没有 AI 账号。" : "点击卡片可编辑,按钮可激活 / 校验 / 删除。",
|
||||
"当前 API:/api/v1/accounts"
|
||||
accounts == null || accounts.length() == 0 ? "当前还没有 AI 账号。" : "点开可编辑,按钮可激活、校验或删除。",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
if (accounts == null || accounts.length() == 0) {
|
||||
@@ -118,66 +107,38 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
String meta = account.optString("roleLabel", "-")
|
||||
+ " · " + account.optString("providerLabel", "-")
|
||||
+ " · " + statusLabel
|
||||
+ (account.optBoolean("isActive") ? " · 当前主控" : "")
|
||||
+ (account.optBoolean("apiKeyConfigured") ? " · 已配置 Key" : "");
|
||||
String body = account.optString("displayName", "-")
|
||||
+ "\n账号:" + account.optString("accountIdentifier", "-")
|
||||
+ (account.optString("nodeLabel").isEmpty() ? "" : "\n节点:" + account.optString("nodeLabel"))
|
||||
+ (account.optString("loginStatusNote").isEmpty() ? "" : "\n" + account.optString("loginStatusNote"));
|
||||
StringBuilder subtitle = new StringBuilder(account.optString("displayName", "-"));
|
||||
if (!account.optString("accountIdentifier").isEmpty()) {
|
||||
subtitle.append(" · ").append(account.optString("accountIdentifier", "-"));
|
||||
}
|
||||
if (!account.optString("nodeLabel").isEmpty()) {
|
||||
subtitle.append(" · ").append(account.optString("nodeLabel", "-"));
|
||||
}
|
||||
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
LinearLayout card = new LinearLayout(this);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
account.optString("label", "未命名账号"),
|
||||
body,
|
||||
subtitle.toString(),
|
||||
meta,
|
||||
account.optBoolean("isActive") ? "当前" : null,
|
||||
v -> openAccountEditor(account, null)
|
||||
);
|
||||
|
||||
Button activate = BossUi.buildPrimaryButton(this, account.optBoolean("isActive") ? "已激活" : "设为当前主控");
|
||||
activate.setEnabled(!account.optBoolean("isActive"));
|
||||
activate.setOnClickListener(v -> activateAccount(account));
|
||||
card.addView(activate);
|
||||
|
||||
Button validate = BossUi.buildSecondaryButton(this, "校验连接");
|
||||
validate.setOnClickListener(v -> validateAccount(account));
|
||||
card.addView(validate);
|
||||
|
||||
Button edit = BossUi.buildSecondaryButton(this, "编辑账号");
|
||||
edit.setOnClickListener(v -> openAccountEditor(account, null));
|
||||
card.addView(edit);
|
||||
|
||||
Button delete = BossUi.buildSecondaryButton(this, "删除账号");
|
||||
delete.setOnClickListener(v -> confirmDeleteAccount(account));
|
||||
card.addView(delete);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private LinearLayout buildSwitchHistoryCard(@Nullable JSONArray switchHistory) {
|
||||
LinearLayout section = new LinearLayout(this);
|
||||
section.setOrientation(LinearLayout.VERTICAL);
|
||||
section.addView(BossUi.buildCard(
|
||||
this,
|
||||
"切换历史",
|
||||
switchHistory == null || switchHistory.length() == 0 ? "当前没有切换记录。" : "最近切换记录会保留 40 条。",
|
||||
"用于追踪主控身份变化"
|
||||
));
|
||||
|
||||
if (switchHistory == null || switchHistory.length() == 0) {
|
||||
section.addView(BossUi.buildEmptyCard(this, "当前没有 AI 账号切换历史。"));
|
||||
return section;
|
||||
}
|
||||
Button activate = BossUi.buildMiniActionButton(this, account.optBoolean("isActive") ? "当前主控" : "设为当前", !account.optBoolean("isActive"));
|
||||
activate.setEnabled(!account.optBoolean("isActive"));
|
||||
activate.setOnClickListener(v -> activateAccount(account));
|
||||
|
||||
for (int i = 0; i < switchHistory.length(); i++) {
|
||||
JSONObject record = switchHistory.optJSONObject(i);
|
||||
if (record == null) continue;
|
||||
String body = "从 " + record.optString("fromLabel", "无")
|
||||
+ "\n到 " + record.optString("toLabel", "-")
|
||||
+ "\n原因:" + record.optString("reason", "-");
|
||||
String meta = record.optString("role", "-") + " · " + record.optString("switchedAt", "-");
|
||||
section.addView(BossUi.buildCard(this, "切换记录", body, meta));
|
||||
}
|
||||
return section;
|
||||
Button validate = BossUi.buildMiniActionButton(this, "校验连接", false);
|
||||
validate.setOnClickListener(v -> validateAccount(account));
|
||||
|
||||
Button delete = BossUi.buildMiniActionButton(this, "删除账号", false);
|
||||
delete.setOnClickListener(v -> confirmDeleteAccount(account));
|
||||
card.addView(BossUi.buildInlineActionRow(this, activate, validate, delete));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private void openAccountEditor(@Nullable JSONObject existing, @Nullable String apiKeyHint) {
|
||||
@@ -217,18 +178,18 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
|
||||
LinearLayout form = new LinearLayout(this);
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
form.addView(labelInput);
|
||||
form.addView(displayNameInput);
|
||||
form.addView(accountIdentifierInput);
|
||||
form.addView(nodeIdInput);
|
||||
form.addView(nodeLabelInput);
|
||||
form.addView(modelInput);
|
||||
form.addView(apiKeyInput);
|
||||
form.addView(loginStatusInput);
|
||||
form.addView(roleSpinner);
|
||||
form.addView(providerSpinner);
|
||||
form.addView(enabledSwitch);
|
||||
form.addView(setActiveSwitch);
|
||||
form.addView(BossUi.buildFormCell(this, "标签", "例如 主 GPT", labelInput));
|
||||
form.addView(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表中", displayNameInput));
|
||||
form.addView(BossUi.buildFormCell(this, "账号标识", "邮箱、登录名或备注信息", accountIdentifierInput));
|
||||
form.addView(BossUi.buildFormCell(this, "节点 ID", "Master Codex Node 的唯一标识", nodeIdInput));
|
||||
form.addView(BossUi.buildFormCell(this, "节点名称", "用于快速识别节点", nodeLabelInput));
|
||||
form.addView(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput));
|
||||
form.addView(BossUi.buildFormCell(this, "API Key", "仅 OpenAI API 模式需要", apiKeyInput));
|
||||
form.addView(BossUi.buildFormCell(this, "登录状态备注", "可记录 Plus、有无风控等状态", loginStatusInput));
|
||||
form.addView(BossUi.buildFormCell(this, "账号角色", null, roleSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "提供方", null, providerSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "启用状态", null, enabledSwitch));
|
||||
form.addView(BossUi.buildFormCell(this, "保存后动作", null, setActiveSwitch));
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(existing == null ? "新增 AI 账号" : "编辑 AI 账号")
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public final class AttachmentComposerState {
|
||||
private AttachmentComposerState() {}
|
||||
|
||||
public static boolean requiresConfirmation(@Nullable String sourceType) {
|
||||
return ProjectChatUiState.requiresAttachmentConfirmation(sourceType);
|
||||
}
|
||||
|
||||
public static final class PendingAttachment {
|
||||
public final String sourceType;
|
||||
public final String fileName;
|
||||
public final String mimeType;
|
||||
public final long fileSizeBytes;
|
||||
@Nullable public final Uri uri;
|
||||
|
||||
public PendingAttachment(
|
||||
String sourceType,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
long fileSizeBytes,
|
||||
@Nullable Uri uri
|
||||
) {
|
||||
this.sourceType = sourceType == null ? "file" : sourceType;
|
||||
this.fileName = fileName == null || fileName.trim().isEmpty() ? "attachment" : fileName;
|
||||
this.mimeType = mimeType == null || mimeType.trim().isEmpty()
|
||||
? "application/octet-stream"
|
||||
: mimeType;
|
||||
this.fileSizeBytes = Math.max(fileSizeBytes, 0L);
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
public boolean requiresConfirmation() {
|
||||
return AttachmentComposerState.requiresConfirmation(sourceType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,15 @@ import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
@@ -17,6 +21,7 @@ import java.io.OutputStreamWriter;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -31,8 +36,12 @@ public class BossApiClient {
|
||||
private final String baseUrl;
|
||||
|
||||
public BossApiClient(Context context) {
|
||||
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
this.baseUrl = BuildConfig.BOSS_API_BASE_URL;
|
||||
this(context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE), BuildConfig.BOSS_API_BASE_URL);
|
||||
}
|
||||
|
||||
BossApiClient(SharedPreferences prefs, String baseUrl) {
|
||||
this.prefs = prefs;
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public boolean hasSessionHints() {
|
||||
@@ -72,6 +81,21 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
|
||||
}
|
||||
|
||||
public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("name", name);
|
||||
payload.put("mode", group ? "group" : "thread");
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/rename", payload);
|
||||
}
|
||||
|
||||
public ApiResponse createGroupChat(String projectId, JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/group-chat", payload == null ? new JSONObject() : payload);
|
||||
}
|
||||
|
||||
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
|
||||
}
|
||||
|
||||
public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("body", body);
|
||||
@@ -79,11 +103,82 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/messages", payload);
|
||||
}
|
||||
|
||||
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, String note) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("targetProjectId", targetProjectId);
|
||||
payload.put("note", note);
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", payload);
|
||||
public ApiResponse uploadAttachment(
|
||||
String projectId,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
byte[] bytes,
|
||||
String sourceType
|
||||
) throws IOException, JSONException {
|
||||
return uploadAttachment(
|
||||
projectId,
|
||||
fileName,
|
||||
mimeType,
|
||||
new ByteArrayInputStream(bytes == null ? new byte[0] : bytes),
|
||||
sourceType
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse uploadAttachment(
|
||||
String projectId,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
InputStream inputStream,
|
||||
String sourceType
|
||||
) throws IOException, JSONException {
|
||||
HttpURLConnection connection = openConnection("/api/v1/projects/" + encode(projectId) + "/attachments");
|
||||
prepareConnection(connection, "POST");
|
||||
connection.setDoOutput(true);
|
||||
|
||||
String boundary = "BossBoundary" + System.currentTimeMillis();
|
||||
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
|
||||
|
||||
try (OutputStream outputStream = connection.getOutputStream()) {
|
||||
writeMultipartPart(outputStream, boundary, "sourceType", sourceType, null);
|
||||
writeMultipartPart(
|
||||
outputStream,
|
||||
boundary,
|
||||
"file",
|
||||
inputStream,
|
||||
fileName,
|
||||
mimeType
|
||||
);
|
||||
outputStream.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
return executeConnection(connection, true);
|
||||
}
|
||||
|
||||
public ApiResponse analyzeAttachment(String projectId, String attachmentId) throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(
|
||||
"POST",
|
||||
"/api/v1/projects/" + encode(projectId) + "/attachments/" + encode(attachmentId) + "/analyze",
|
||||
"{}"
|
||||
);
|
||||
}
|
||||
|
||||
public DownloadedAttachment downloadAttachment(
|
||||
String attachmentId,
|
||||
String fallbackFileName,
|
||||
String fallbackMimeType
|
||||
) throws IOException {
|
||||
DownloadedAttachment attachment = downloadAttachmentRaw(attachmentId, fallbackFileName, fallbackMimeType, true);
|
||||
if (attachment.statusCode == 401 && !getRestoreToken().isEmpty()) {
|
||||
try {
|
||||
ApiResponse restored = restoreSession();
|
||||
if (restored.ok()) {
|
||||
return downloadAttachmentRaw(attachmentId, fallbackFileName, fallbackMimeType, true);
|
||||
}
|
||||
} catch (JSONException exception) {
|
||||
throw new IOException("SESSION_RESTORE_FAILED", exception);
|
||||
}
|
||||
}
|
||||
return attachment;
|
||||
}
|
||||
|
||||
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload) throws IOException, JSONException {
|
||||
String requestBody = ForwardPayloads.toRequestBody(targetProjectId, payload);
|
||||
return requestWithRestoreRaw("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestBody);
|
||||
}
|
||||
|
||||
public ApiResponse getThreadDetail(String threadId) throws IOException, JSONException {
|
||||
@@ -230,18 +325,45 @@ public class BossApiClient {
|
||||
}
|
||||
|
||||
private ApiResponse requestWithRestore(String method, String path, JSONObject body) throws IOException, JSONException {
|
||||
ApiResponse response = request(method, path, body, true);
|
||||
return requestWithRestoreRaw(method, path, body == null ? null : body.toString());
|
||||
}
|
||||
|
||||
private ApiResponse requestWithRestoreRaw(String method, String path, @Nullable String body) throws IOException, JSONException {
|
||||
ApiResponse response = requestRaw(method, path, body, true);
|
||||
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
|
||||
ApiResponse restored = restoreSession();
|
||||
if (restored.ok()) {
|
||||
return request(method, path, body, true);
|
||||
return requestRaw(method, path, body, true);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private ApiResponse request(String method, String path, JSONObject body, boolean expectProtected) throws IOException, JSONException {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(baseUrl + path).openConnection();
|
||||
return requestRaw(method, path, body == null ? null : body.toString(), expectProtected);
|
||||
}
|
||||
|
||||
private ApiResponse requestRaw(String method, String path, @Nullable String body, boolean expectProtected) throws IOException, JSONException {
|
||||
HttpURLConnection connection = openConnection(path);
|
||||
prepareConnection(connection, method);
|
||||
|
||||
if (body != null) {
|
||||
connection.setDoOutput(true);
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
try (OutputStream outputStream = connection.getOutputStream();
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
|
||||
writer.write(body);
|
||||
}
|
||||
}
|
||||
|
||||
return executeConnection(connection, expectProtected);
|
||||
}
|
||||
|
||||
HttpURLConnection openConnection(String path) throws IOException {
|
||||
return (HttpURLConnection) new URL(baseUrl + path).openConnection();
|
||||
}
|
||||
|
||||
private void prepareConnection(HttpURLConnection connection, String method) throws IOException {
|
||||
connection.setRequestMethod(method);
|
||||
connection.setConnectTimeout(12000);
|
||||
connection.setReadTimeout(12000);
|
||||
@@ -254,16 +376,9 @@ public class BossApiClient {
|
||||
if (!cookie.isEmpty()) {
|
||||
connection.setRequestProperty("Cookie", cookie);
|
||||
}
|
||||
}
|
||||
|
||||
if (body != null) {
|
||||
connection.setDoOutput(true);
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
try (OutputStream outputStream = connection.getOutputStream();
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
|
||||
writer.write(body.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException {
|
||||
int statusCode = connection.getResponseCode();
|
||||
captureSessionCookie(connection.getHeaderFields());
|
||||
JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
||||
@@ -278,6 +393,106 @@ public class BossApiClient {
|
||||
return new ApiResponse(statusCode, json == null ? new JSONObject() : json);
|
||||
}
|
||||
|
||||
private void writeMultipartPart(
|
||||
OutputStream outputStream,
|
||||
String boundary,
|
||||
String fieldName,
|
||||
String value,
|
||||
@Nullable String contentType
|
||||
) throws IOException {
|
||||
outputStream.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.write(
|
||||
("Content-Disposition: form-data; name=\"" + fieldName + "\"\r\n")
|
||||
.getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
if (contentType != null && !contentType.isEmpty()) {
|
||||
outputStream.write(("Content-Type: " + contentType + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.write((value == null ? "" : value).getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private void writeMultipartPart(
|
||||
OutputStream outputStream,
|
||||
String boundary,
|
||||
String fieldName,
|
||||
InputStream inputStream,
|
||||
String fileName,
|
||||
String contentType
|
||||
) throws IOException {
|
||||
outputStream.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.write(
|
||||
String.format(
|
||||
Locale.US,
|
||||
"Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n",
|
||||
fieldName,
|
||||
escapeMultipartValue(fileName)
|
||||
).getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
outputStream.write(
|
||||
("Content-Type: " + (contentType == null || contentType.isEmpty()
|
||||
? "application/octet-stream"
|
||||
: contentType) + "\r\n\r\n").getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
if (inputStream != null) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private DownloadedAttachment downloadAttachmentRaw(
|
||||
String attachmentId,
|
||||
String fallbackFileName,
|
||||
String fallbackMimeType,
|
||||
boolean expectProtected
|
||||
) throws IOException {
|
||||
HttpURLConnection connection = openConnection("/api/v1/attachments/" + encode(attachmentId) + "/download");
|
||||
prepareConnection(connection, "GET");
|
||||
int statusCode = connection.getResponseCode();
|
||||
captureSessionCookie(connection.getHeaderFields());
|
||||
|
||||
if (statusCode >= 400) {
|
||||
String errorBody = readText(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
||||
if (statusCode == 401 && !expectProtected) {
|
||||
clearSession();
|
||||
}
|
||||
return DownloadedAttachment.error(statusCode, errorBody);
|
||||
}
|
||||
|
||||
byte[] bytes;
|
||||
try (InputStream inputStream = connection.getInputStream();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while (inputStream != null && (read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
bytes = outputStream.toByteArray();
|
||||
}
|
||||
|
||||
String contentType = connection.getHeaderField("Content-Type");
|
||||
String fileName = parseDownloadFileName(connection.getHeaderField("Content-Disposition"));
|
||||
return new DownloadedAttachment(
|
||||
statusCode,
|
||||
fileName == null || fileName.isEmpty() ? fallbackFileName : fileName,
|
||||
contentType == null || contentType.isEmpty() ? fallbackMimeType : contentType,
|
||||
bytes,
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
private String escapeMultipartValue(String value) {
|
||||
if (value == null) {
|
||||
return "attachment";
|
||||
}
|
||||
return value.replace("\"", "%22");
|
||||
}
|
||||
|
||||
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
|
||||
if (stream == null) {
|
||||
return new JSONObject();
|
||||
@@ -296,6 +511,35 @@ public class BossApiClient {
|
||||
return new JSONObject(raw);
|
||||
}
|
||||
|
||||
private String readText(InputStream stream) throws IOException {
|
||||
if (stream == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
builder.append(line);
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String parseDownloadFileName(@Nullable String contentDisposition) {
|
||||
if (contentDisposition == null || contentDisposition.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = contentDisposition.split(";");
|
||||
for (String part : parts) {
|
||||
String trimmed = part.trim();
|
||||
if (trimmed.startsWith("filename=")) {
|
||||
return trimmed.substring("filename=".length()).replace("\"", "");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void captureSessionCookie(Map<String, List<String>> headers) {
|
||||
if (headers == null) return;
|
||||
List<String> setCookieHeaders = headers.get("Set-Cookie");
|
||||
@@ -316,7 +560,7 @@ public class BossApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private void rememberIdentity(JSONObject json) {
|
||||
void rememberIdentity(JSONObject json) {
|
||||
if (json == null) return;
|
||||
JSONObject session = json.optJSONObject("session");
|
||||
JSONObject source = session != null ? session : json;
|
||||
@@ -347,7 +591,7 @@ public class BossApiClient {
|
||||
.apply();
|
||||
}
|
||||
|
||||
private String encode(String value) {
|
||||
String encode(String value) {
|
||||
return Uri.encode(value);
|
||||
}
|
||||
|
||||
@@ -372,4 +616,34 @@ public class BossApiClient {
|
||||
return new ApiResponse(statusCode, json);
|
||||
}
|
||||
}
|
||||
|
||||
public static class DownloadedAttachment {
|
||||
public final int statusCode;
|
||||
public final String fileName;
|
||||
public final String mimeType;
|
||||
public final byte[] bytes;
|
||||
public final String errorBody;
|
||||
|
||||
public DownloadedAttachment(
|
||||
int statusCode,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
byte[] bytes,
|
||||
String errorBody
|
||||
) {
|
||||
this.statusCode = statusCode;
|
||||
this.fileName = fileName;
|
||||
this.mimeType = mimeType;
|
||||
this.bytes = bytes == null ? new byte[0] : bytes;
|
||||
this.errorBody = errorBody == null ? "" : errorBody;
|
||||
}
|
||||
|
||||
public boolean ok() {
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
|
||||
public static DownloadedAttachment error(int statusCode, String errorBody) {
|
||||
return new DownloadedAttachment(statusCode, "", "application/octet-stream", new byte[0], errorBody);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public abstract class BossScreenActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_screen);
|
||||
setContentView(getLayoutResId());
|
||||
apiClient = new BossApiClient(this);
|
||||
|
||||
backButton = findViewById(R.id.screen_back_button);
|
||||
@@ -44,6 +44,10 @@ public abstract class BossScreenActivity extends AppCompatActivity {
|
||||
refreshLayout.setOnRefreshListener(this::reload);
|
||||
}
|
||||
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_screen;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
executor.shutdownNow();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,230 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class ConversationInfoActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
private String projectFolderName;
|
||||
private int participantCount;
|
||||
|
||||
@Override
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_conversation_info;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("会话信息", projectName == null ? "单线程会话信息页" : projectName);
|
||||
setHeaderAction("重命名", v -> openRenameDialog());
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId);
|
||||
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
|
||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
|
||||
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
|
||||
runOnUiThread(() -> renderConversation(detailResponse.json, participantsResponse.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "会话信息加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderConversation(JSONObject detail, JSONObject participantsPayload) {
|
||||
replaceContent();
|
||||
JSONObject project = detail.optJSONObject("project");
|
||||
JSONArray participants = participantsPayload.optJSONArray("participants");
|
||||
|
||||
if (project == null) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "会话不存在。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
projectName = project.optString("name", projectName == null ? "会话信息" : projectName);
|
||||
JSONObject threadMeta = project.optJSONObject("threadMeta");
|
||||
projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
participantCount = participants == null ? 0 : participants.length();
|
||||
configureScreen("会话信息", buildSubtitle(threadMeta, participantCount));
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
projectName,
|
||||
buildDetailBody(project, threadMeta),
|
||||
buildDetailMeta(projectId, projectFolderName, participantCount)
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildMenuRow(
|
||||
this,
|
||||
"发起群聊",
|
||||
"从当前会话选择其他线程,创建新的独立群聊",
|
||||
null,
|
||||
v -> openGroupCreate()
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"参与设备 / 线程",
|
||||
"以下线程参与当前会话,点击可查看对应项目详情。",
|
||||
participantCount == 0 ? "当前没有可展示的参与线程。" : "共 " + participantCount + " 个参与线程"
|
||||
));
|
||||
|
||||
if (participants == null || participants.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前没有参与线程信息。"));
|
||||
} else {
|
||||
for (int i = 0; i < participants.length(); i++) {
|
||||
JSONObject participant = participants.optJSONObject(i);
|
||||
if (participant == null) continue;
|
||||
appendContent(buildParticipantRow(participant));
|
||||
}
|
||||
}
|
||||
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildParticipantRow(JSONObject participant) {
|
||||
boolean sourceProject = participant.optBoolean("isSourceProject", false);
|
||||
String participantProjectId = participant.optString("projectId", "");
|
||||
String title = participant.optString("threadDisplayName", "未命名线程");
|
||||
String subtitle = participant.optString("folderName", "");
|
||||
String meta = participant.optString("deviceId", "");
|
||||
if (!participant.optString("threadId", "").isEmpty()) {
|
||||
meta = meta.isEmpty() ? participant.optString("threadId", "") : meta + " · " + participant.optString("threadId", "");
|
||||
}
|
||||
return BossUi.buildListRow(
|
||||
this,
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
sourceProject ? "来源" : null,
|
||||
v -> openProject(participantProjectId, title)
|
||||
);
|
||||
}
|
||||
|
||||
private void openProject(String targetProjectId, String targetProjectName) {
|
||||
if (targetProjectId == null || targetProjectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(this, ProjectDetailActivity.class);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, targetProjectId);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, targetProjectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openGroupCreate() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(this, GroupCreateActivity.class);
|
||||
intent.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
|
||||
intent.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_NAME, projectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openRenameDialog() {
|
||||
final EditText input = BossUi.buildInput(this, "线程名", false);
|
||||
input.setText(projectName == null ? "" : projectName);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("重命名会话")
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) -> saveConversationName(input.getText().toString().trim()))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void saveConversationName(String name) {
|
||||
if (name.isEmpty()) {
|
||||
showMessage("线程名不能为空");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.renameConversation(projectId, name, false);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
Intent result = new Intent();
|
||||
result.putExtra(EXTRA_PROJECT_NAME, name);
|
||||
setResult(RESULT_OK, result);
|
||||
showMessage("线程名已更新");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String buildSubtitle(@Nullable JSONObject threadMeta, int count) {
|
||||
String folder = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
String suffix = count <= 0 ? "暂无参与线程" : count + " 个参与线程";
|
||||
if (folder.isEmpty()) {
|
||||
return suffix;
|
||||
}
|
||||
return folder + " · " + suffix;
|
||||
}
|
||||
|
||||
private String buildDetailBody(JSONObject project, @Nullable JSONObject threadMeta) {
|
||||
String threadId = threadMeta == null ? project.optString("id", "") : threadMeta.optString("threadId", "");
|
||||
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
String deviceCount = project.optJSONArray("deviceIds") == null ? "0" : String.valueOf(project.optJSONArray("deviceIds").length());
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("线程 ID:").append(threadId.isEmpty() ? project.optString("id", "-") : threadId);
|
||||
builder.append("\n文件夹:").append(folderName.isEmpty() ? "未命名文件夹" : folderName);
|
||||
builder.append("\n绑定设备:").append(deviceCount);
|
||||
builder.append("\n群聊状态:").append(project.optBoolean("isGroup", false) ? "群聊" : "单线程");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String buildDetailMeta(String projectId, String folderName, int count) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if (!projectId.isEmpty()) {
|
||||
builder.append("project ").append(projectId);
|
||||
}
|
||||
if (!folderName.isEmpty()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(folderName);
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(count <= 0 ? "暂无参与线程" : "参与线程 " + count);
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -23,7 +22,7 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
super.onCreate(savedInstanceState);
|
||||
deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID);
|
||||
deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
|
||||
configureScreen(deviceName == null ? "设备详情" : deviceName, "原生设备详情");
|
||||
configureScreen(deviceName == null ? "设备详情" : deviceName, "设备状态与绑定项目");
|
||||
setHeaderAction("编辑", v -> openEditDialog());
|
||||
reload();
|
||||
}
|
||||
@@ -48,8 +47,6 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
private void renderDevice(JSONObject payload) {
|
||||
JSONObject workspace = payload.optJSONObject("workspace");
|
||||
JSONObject device = workspace == null ? null : workspace.optJSONObject("selectedDevice");
|
||||
JSONArray relatedThreads = workspace == null ? null : workspace.optJSONArray("relatedThreads");
|
||||
JSONObject enrollment = workspace == null ? null : workspace.optJSONObject("activeEnrollment");
|
||||
|
||||
replaceContent();
|
||||
if (device == null) {
|
||||
@@ -59,56 +56,28 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
deviceName = device.optString("name", deviceId);
|
||||
configureScreen(deviceName, device.optString("endpoint", "设备详情"));
|
||||
appendContent(BossUi.buildCard(
|
||||
configureScreen(deviceName, "设备状态与绑定项目");
|
||||
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(device);
|
||||
appendContent(BossUi.buildDeviceCard(
|
||||
this,
|
||||
device.optString("name", "设备"),
|
||||
device.optString("note", "暂无备注"),
|
||||
"状态 " + device.optString("status", "unknown")
|
||||
+ " · 账号 " + device.optString("account", "-")
|
||||
+ " · 5h " + device.optInt("quota5h", 0)
|
||||
+ " · 7d " + device.optInt("quota7d", 0)
|
||||
WechatSurfaceMapper.toDeviceRow(device),
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
Button skillsButton = BossUi.buildPrimaryButton(this, "查看技能");
|
||||
skillsButton.setOnClickListener(v -> openSkills());
|
||||
appendContent(skillsButton);
|
||||
|
||||
if (relatedThreads != null && relatedThreads.length() > 0) {
|
||||
for (int i = 0; i < relatedThreads.length(); i++) {
|
||||
JSONObject thread = relatedThreads.optJSONObject(i);
|
||||
if (thread == null) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
thread.optString("title", "线程"),
|
||||
thread.optString("summary", ""),
|
||||
thread.optString("workerId", "-")
|
||||
+ " · " + thread.optInt("contextBudgetRemainingPct", 0) + "%"
|
||||
+ " · " + thread.optString("contextBudgetLevel", "safe"),
|
||||
v -> openThread(thread.optString("threadId"))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (enrollment != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
if (summary.meta != null && !summary.meta.isEmpty()) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前绑定草稿",
|
||||
"pairingCode " + enrollment.optString("pairingCode", "-")
|
||||
+ "\ntoken " + enrollment.optString("token", "-"),
|
||||
enrollment.optString("status", "ready")
|
||||
+ " · 到期 " + enrollment.optString("expiresAt", "-")
|
||||
"设备说明",
|
||||
summary.meta,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
appendContent(BossUi.buildMenuRow(this, "查看技能", "查看当前设备同步的 Skill 清单", null, v -> openSkills()));
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void openThread(String threadId) {
|
||||
Intent intent = new Intent(this, ThreadDetailActivity.class);
|
||||
intent.putExtra(ThreadDetailActivity.EXTRA_THREAD_ID, threadId);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openSkills() {
|
||||
Intent intent = new Intent(this, SkillInventoryActivity.class);
|
||||
intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, deviceId);
|
||||
|
||||
@@ -19,7 +19,7 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("添加设备", "通过 pairing code 或 token 把新设备接入");
|
||||
configureScreen("添加设备", "填写设备信息后生成配对草稿");
|
||||
hideHeaderAction();
|
||||
buildForm();
|
||||
}
|
||||
@@ -38,23 +38,24 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
|
||||
noteInput = BossUi.buildInput(this, "备注", true);
|
||||
projectsInput = BossUi.buildInput(this, "项目列表,逗号分隔", true);
|
||||
|
||||
android.widget.Button submitButton = BossUi.buildPrimaryButton(this, "生成绑定草稿");
|
||||
submitButton.setOnClickListener(v -> submitEnrollment());
|
||||
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
BossUi.buildSoftPanel(
|
||||
this,
|
||||
"绑定新设备",
|
||||
"支持通过 pairing code、临时 token 或登录引导把 Mac、Windows、云端节点接入。",
|
||||
"当前原生页会直接调用 /api/v1/devices/enrollments"
|
||||
"接入新设备",
|
||||
"支持通过 pairing code 或 token 接入 Mac、Windows、云端节点。",
|
||||
"生成后把配对码交给设备端即可完成绑定。"
|
||||
),
|
||||
nameInput,
|
||||
avatarInput,
|
||||
accountInput,
|
||||
endpointInput,
|
||||
noteInput,
|
||||
projectsInput,
|
||||
BossUi.buildPrimaryButton(this, "生成绑定草稿")
|
||||
BossUi.buildFormCell(this, "设备名称", "例如 Mac Studio 或 Windows GPU", nameInput),
|
||||
BossUi.buildFormCell(this, "头像字符", "会显示在设备卡片左侧", avatarInput),
|
||||
BossUi.buildFormCell(this, "所属账号", "默认使用当前登录账号", accountInput),
|
||||
BossUi.buildFormCell(this, "设备地址", "例如 mac://kris.local", endpointInput),
|
||||
BossUi.buildFormCell(this, "设备备注", "可填写位置、用途或节点说明", noteInput),
|
||||
BossUi.buildFormCell(this, "项目列表", "多个项目用逗号分隔", projectsInput),
|
||||
submitButton
|
||||
);
|
||||
((android.widget.Button) contentLayout.getChildAt(contentLayout.getChildCount() - 1))
|
||||
.setOnClickListener(v -> submitEnrollment());
|
||||
}
|
||||
|
||||
private void submitEnrollment() {
|
||||
@@ -80,7 +81,7 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
|
||||
JSONObject enrollment = response.json.optJSONObject("enrollment");
|
||||
JSONObject device = response.json.optJSONObject("device");
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
BossUi.buildSoftPanel(
|
||||
this,
|
||||
"绑定草稿已生成",
|
||||
"设备 " + (device == null ? "-" : device.optString("name", "-"))
|
||||
|
||||
248
android/app/src/main/java/com/hyzq/boss/ForwardPayloads.java
Normal file
248
android/app/src/main/java/com/hyzq/boss/ForwardPayloads.java
Normal file
@@ -0,0 +1,248 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class ForwardPayloads {
|
||||
private ForwardPayloads() {}
|
||||
|
||||
public static JSONObject build(
|
||||
String mode,
|
||||
@Nullable String sourceMessageId,
|
||||
@Nullable List<String> sourceMessageIds
|
||||
) throws JSONException {
|
||||
MutableJsonObject payload = new MutableJsonObject();
|
||||
String normalizedMode = isEmpty(mode) ? "single" : mode;
|
||||
payload.put("mode", normalizedMode);
|
||||
|
||||
if (normalizedMode.startsWith("single")) {
|
||||
String resolvedSourceMessageId = sourceMessageId;
|
||||
if (isEmpty(resolvedSourceMessageId) && sourceMessageIds != null && sourceMessageIds.size() == 1) {
|
||||
resolvedSourceMessageId = sourceMessageIds.get(0);
|
||||
}
|
||||
if (isEmpty(resolvedSourceMessageId)) {
|
||||
throw new JSONException("sourceMessageId required");
|
||||
}
|
||||
payload.put("sourceMessageId", resolvedSourceMessageId);
|
||||
return payload;
|
||||
}
|
||||
|
||||
MutableJsonArray orderedIds = new MutableJsonArray();
|
||||
if (sourceMessageIds != null) {
|
||||
for (String messageId : sourceMessageIds) {
|
||||
if (!isEmpty(messageId)) {
|
||||
orderedIds.put(messageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (orderedIds.length() == 0) {
|
||||
throw new JSONException("sourceMessageIds required");
|
||||
}
|
||||
payload.put("sourceMessageIds", orderedIds);
|
||||
return payload;
|
||||
}
|
||||
|
||||
public static String toRequestBody(String targetProjectId, @Nullable JSONObject payload) throws JSONException {
|
||||
MutableJsonObject requestPayload = new MutableJsonObject();
|
||||
requestPayload.put("targetProjectId", targetProjectId);
|
||||
if (payload == null) {
|
||||
return requestPayload.toString();
|
||||
}
|
||||
|
||||
String mode = payload.optString("mode", "");
|
||||
if (!isEmpty(mode)) {
|
||||
requestPayload.put("mode", mode);
|
||||
}
|
||||
|
||||
String sourceMessageId = payload.optString("sourceMessageId", "");
|
||||
if (!isEmpty(sourceMessageId)) {
|
||||
requestPayload.put("sourceMessageId", sourceMessageId);
|
||||
}
|
||||
|
||||
JSONArray sourceMessageIds = payload.optJSONArray("sourceMessageIds");
|
||||
if (sourceMessageIds != null && sourceMessageIds.length() > 0) {
|
||||
MutableJsonArray orderedIds = new MutableJsonArray();
|
||||
for (int i = 0; i < sourceMessageIds.length(); i++) {
|
||||
String messageId = sourceMessageIds.optString(i);
|
||||
if (!isEmpty(messageId)) {
|
||||
orderedIds.put(messageId);
|
||||
}
|
||||
}
|
||||
if (orderedIds.length() > 0) {
|
||||
requestPayload.put("sourceMessageIds", orderedIds);
|
||||
}
|
||||
}
|
||||
|
||||
return requestPayload.toString();
|
||||
}
|
||||
|
||||
public static boolean isApprovalRequired(@Nullable JSONObject responseJson) {
|
||||
return responseJson != null && responseJson.optBoolean("approvalRequired", false);
|
||||
}
|
||||
|
||||
private static boolean isEmpty(@Nullable String value) {
|
||||
return value == null || value.length() == 0;
|
||||
}
|
||||
|
||||
private static final class MutableJsonObject extends JSONObject {
|
||||
private final Map<String, Object> values = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
public JSONObject put(String key, boolean value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject put(String key, int value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject put(String key, long value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject put(String key, Object value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof String ? (String) value : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key, String fallback) {
|
||||
String value = optString(key);
|
||||
return value.isEmpty() ? fallback : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONArray optJSONArray(String key) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof JSONArray ? (JSONArray) value : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean optBoolean(String key, boolean fallback) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof Boolean ? (Boolean) value : fallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder builder = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, Object> entry : values.entrySet()) {
|
||||
if (!first) {
|
||||
builder.append(",");
|
||||
}
|
||||
first = false;
|
||||
builder.append("\"").append(escape(entry.getKey())).append("\":");
|
||||
builder.append(stringify(entry.getValue()));
|
||||
}
|
||||
builder.append("}");
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MutableJsonArray extends JSONArray {
|
||||
private final ArrayList<Object> values = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public JSONArray put(boolean value) {
|
||||
values.add(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONArray put(int value) {
|
||||
values.add(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONArray put(long value) {
|
||||
values.add(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONArray put(Object value) {
|
||||
values.add(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int length() {
|
||||
return values.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject optJSONObject(int index) {
|
||||
if (index < 0 || index >= values.size()) {
|
||||
return null;
|
||||
}
|
||||
Object value = values.get(index);
|
||||
return value instanceof JSONObject ? (JSONObject) value : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(int index) {
|
||||
if (index < 0 || index >= values.size()) {
|
||||
return "";
|
||||
}
|
||||
Object value = values.get(index);
|
||||
return value instanceof String ? (String) value : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder builder = new StringBuilder("[");
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
if (i > 0) {
|
||||
builder.append(",");
|
||||
}
|
||||
builder.append(stringify(values.get(i)));
|
||||
}
|
||||
builder.append("]");
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private static String stringify(@Nullable Object value) {
|
||||
if (value == null) {
|
||||
return "null";
|
||||
}
|
||||
if (value instanceof String) {
|
||||
return "\"" + escape((String) value) + "\"";
|
||||
}
|
||||
if (value instanceof Number || value instanceof Boolean) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
return value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ForwardTargetActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_SOURCE_PROJECT_ID = "source_project_id";
|
||||
public static final String EXTRA_FORWARD_MODE = "forward_mode";
|
||||
public static final String EXTRA_SOURCE_MESSAGE_ID = "source_message_id";
|
||||
public static final String EXTRA_SOURCE_MESSAGE_IDS = "source_message_ids";
|
||||
|
||||
private String sourceProjectId;
|
||||
private String forwardMode;
|
||||
@Nullable
|
||||
private String sourceMessageId;
|
||||
private final ArrayList<String> sourceMessageIds = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_forward_target;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Intent intent = getIntent();
|
||||
sourceProjectId = intent.getStringExtra(EXTRA_SOURCE_PROJECT_ID);
|
||||
forwardMode = intent.getStringExtra(EXTRA_FORWARD_MODE);
|
||||
sourceMessageId = intent.getStringExtra(EXTRA_SOURCE_MESSAGE_ID);
|
||||
String[] messageIds = intent.getStringArrayExtra(EXTRA_SOURCE_MESSAGE_IDS);
|
||||
if (messageIds != null) {
|
||||
for (String messageId : messageIds) {
|
||||
if (!TextUtils.isEmpty(messageId)) {
|
||||
sourceMessageIds.add(messageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configureScreen("选择转发目标", buildSourceMeta());
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (isEmpty(sourceProjectId)) {
|
||||
showMessage("缺少源会话");
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getConversations();
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
JSONArray conversations = response.json.optJSONArray("conversations");
|
||||
List<JSONObject> targets = collectSelectableTargets(conversations, sourceProjectId);
|
||||
runOnUiThread(() -> renderTargets(targets));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "转发目标加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static List<JSONObject> collectSelectableTargets(JSONArray conversations, String sourceProjectId) {
|
||||
ArrayList<JSONObject> result = new ArrayList<>();
|
||||
if (conversations == null) {
|
||||
return result;
|
||||
}
|
||||
for (int i = 0; i < conversations.length(); i++) {
|
||||
JSONObject item = conversations.optJSONObject(i);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (!isEmpty(sourceProjectId) && sourceProjectId.equals(item.optString("projectId", ""))) {
|
||||
continue;
|
||||
}
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static JSONObject buildForwardPayload(String mode, @Nullable String sourceMessageId, List<String> sourceMessageIds)
|
||||
throws JSONException {
|
||||
return ForwardPayloads.build(mode, sourceMessageId, sourceMessageIds);
|
||||
}
|
||||
|
||||
static String resolveForwardResultMessage(JSONObject responseJson) {
|
||||
return ForwardPayloads.isApprovalRequired(responseJson) ? "已提交主 Agent 审批" : "转发成功";
|
||||
}
|
||||
|
||||
private void renderTargets(List<JSONObject> targets) {
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
this,
|
||||
"正在选择转发目标",
|
||||
buildSourceBody(),
|
||||
buildSourceMeta()
|
||||
)
|
||||
);
|
||||
|
||||
if (targets.isEmpty()) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前没有可转发的目标会话。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
for (JSONObject target : targets) {
|
||||
appendContent(BossUi.buildConversationRow(
|
||||
this,
|
||||
WechatSurfaceMapper.toConversationRow(target),
|
||||
v -> forwardToTarget(target)
|
||||
));
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private String buildSourceBody() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("源会话:").append(isEmpty(sourceProjectId) ? "-" : sourceProjectId);
|
||||
builder.append("\n转发模式:").append(isEmpty(forwardMode) ? "single" : forwardMode);
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String buildSourceMeta() {
|
||||
int messageCount = sourceMessageIds.size();
|
||||
if (!isEmpty(sourceMessageId)) {
|
||||
return "source_message_id 已就绪";
|
||||
}
|
||||
if (messageCount > 0) {
|
||||
return "source_message_ids " + messageCount + " 条";
|
||||
}
|
||||
return "等待聊天页入口补充消息选择";
|
||||
}
|
||||
|
||||
private void forwardToTarget(JSONObject target) {
|
||||
if (target == null) {
|
||||
showMessage("目标会话无效");
|
||||
return;
|
||||
}
|
||||
String targetProjectId = target.optString("projectId", "");
|
||||
if (isEmpty(targetProjectId)) {
|
||||
showMessage("目标会话无效");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JSONObject payload = buildForwardPayload(
|
||||
forwardMode,
|
||||
sourceMessageId,
|
||||
sourceMessageIds
|
||||
);
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage(sourceProjectId, targetProjectId, payload);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage(resolveForwardResultMessage(response.json));
|
||||
setResult(RESULT_OK);
|
||||
finish();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("转发失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (JSONException error) {
|
||||
showMessage("缺少源消息,暂无法转发");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isEmpty(@Nullable String value) {
|
||||
return value == null || value.length() == 0;
|
||||
}
|
||||
}
|
||||
356
android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java
Normal file
356
android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java
Normal file
@@ -0,0 +1,356 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
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.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class GroupCreateActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_SOURCE_PROJECT_ID = "source_project_id";
|
||||
public static final String EXTRA_SOURCE_PROJECT_NAME = "source_project_name";
|
||||
|
||||
private final List<CandidateConversation> candidates = new ArrayList<>();
|
||||
private final Set<String> selectedProjectIds = new LinkedHashSet<>();
|
||||
private final Set<String> lastCandidateProjectIds = new LinkedHashSet<>();
|
||||
|
||||
private String sourceProjectId;
|
||||
private String sourceProjectName;
|
||||
private String sourceFolderName;
|
||||
private LinearLayout candidateListLayout;
|
||||
private Button createButton;
|
||||
private boolean creatingGroupChat;
|
||||
private JSONObject cachedParticipantsPayload;
|
||||
private JSONObject cachedConversationsPayload;
|
||||
|
||||
@Override
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_group_create;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
sourceProjectId = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_ID);
|
||||
sourceProjectName = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_NAME);
|
||||
configureScreen("发起群聊", sourceProjectName == null ? "从当前会话出发" : sourceProjectName);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (sourceProjectId == null || sourceProjectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(sourceProjectId);
|
||||
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
|
||||
BossApiClient.ApiResponse conversationsResponse = apiClient.getConversations();
|
||||
if (!conversationsResponse.ok()) throw new IllegalStateException(conversationsResponse.message());
|
||||
runOnUiThread(() -> renderCreatePage(participantsResponse.json, conversationsResponse.json, true));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "群聊创建页加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderCreatePage(JSONObject participantsPayload, JSONObject conversationsPayload, boolean rebuildCandidates) {
|
||||
cachedParticipantsPayload = participantsPayload;
|
||||
cachedConversationsPayload = conversationsPayload;
|
||||
replaceContent();
|
||||
|
||||
JSONObject threadMeta = participantsPayload.optJSONObject("threadMeta");
|
||||
JSONArray participants = participantsPayload.optJSONArray("participants");
|
||||
sourceFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
sourceProjectName = threadMeta == null
|
||||
? sourceProjectName
|
||||
: threadMeta.optString("threadDisplayName", sourceProjectName == null ? "当前会话" : sourceProjectName);
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"新建独立群聊",
|
||||
"群聊不是升级原会话,而是以当前会话为源,新建一个独立线程。",
|
||||
buildSourceMeta(threadMeta, participants)
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
sourceProjectName,
|
||||
buildSourceBody(threadMeta, participants),
|
||||
sourceProjectId + (sourceFolderName.isEmpty() ? "" : " · " + sourceFolderName)
|
||||
));
|
||||
|
||||
if (rebuildCandidates) {
|
||||
List<JSONObject> selectableConversations = collectSelectableConversationItems(conversationsPayload, sourceProjectId);
|
||||
List<CandidateConversation> nextCandidates = new ArrayList<>(selectableConversations.size());
|
||||
Set<String> nextCandidateProjectIds = new LinkedHashSet<>();
|
||||
for (JSONObject item : selectableConversations) {
|
||||
CandidateConversation candidate = new CandidateConversation(
|
||||
item.optString("projectId", ""),
|
||||
item.optString("projectTitle", item.optString("threadTitle", "未命名会话")),
|
||||
item.optString("folderLabel", ""),
|
||||
item.optString("lastMessagePreview", item.optString("preview", "")),
|
||||
item.optString("latestReplyLabel", ""),
|
||||
false
|
||||
);
|
||||
nextCandidates.add(candidate);
|
||||
nextCandidateProjectIds.add(candidate.projectId);
|
||||
}
|
||||
Set<String> currentSelectedProjectIds = new LinkedHashSet<>(selectedProjectIds);
|
||||
candidates.clear();
|
||||
candidates.addAll(nextCandidates);
|
||||
selectedProjectIds.clear();
|
||||
selectedProjectIds.addAll(reconcileSelectedProjectIds(
|
||||
currentSelectedProjectIds,
|
||||
lastCandidateProjectIds,
|
||||
nextCandidateProjectIds
|
||||
));
|
||||
lastCandidateProjectIds.clear();
|
||||
lastCandidateProjectIds.addAll(nextCandidateProjectIds);
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"选择其他线程",
|
||||
candidates.isEmpty()
|
||||
? "当前没有可加入的其他线程。"
|
||||
: selectedProjectIds.isEmpty()
|
||||
? "你已取消全部勾选,可继续手动选择。"
|
||||
: "已保留你当前的勾选状态。",
|
||||
"已选 " + selectedProjectIds.size() + " 个线程"
|
||||
));
|
||||
|
||||
candidateListLayout = new LinearLayout(this);
|
||||
candidateListLayout.setOrientation(LinearLayout.VERTICAL);
|
||||
for (CandidateConversation candidate : candidates) {
|
||||
candidateListLayout.addView(buildCandidateRow(candidate));
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
candidateListLayout.addView(BossUi.buildEmptyCard(this, "当前没有可选择的其他线程。"));
|
||||
}
|
||||
appendContent(candidateListLayout);
|
||||
|
||||
createButton = BossUi.buildPrimaryButton(this, "创建群聊");
|
||||
createButton.setOnClickListener(v -> createGroupChat());
|
||||
appendContent(createButton);
|
||||
|
||||
Button cancelButton = BossUi.buildSecondaryButton(this, "取消");
|
||||
cancelButton.setOnClickListener(v -> finish());
|
||||
appendContent(cancelButton);
|
||||
|
||||
setRefreshing(false);
|
||||
updateCreateButtonState();
|
||||
}
|
||||
|
||||
static List<JSONObject> collectSelectableConversationItems(@Nullable JSONObject conversationsPayload, String sourceProjectId) {
|
||||
List<JSONObject> result = new ArrayList<>();
|
||||
JSONArray conversations = conversationsPayload == null ? null : conversationsPayload.optJSONArray("conversations");
|
||||
if (conversations == null) {
|
||||
return result;
|
||||
}
|
||||
for (int i = 0; i < conversations.length(); i++) {
|
||||
JSONObject item = conversations.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
String projectId = item.optString("projectId", "");
|
||||
if (projectId.isEmpty() || sourceProjectId.equals(projectId) || item.optBoolean("isGroup", false)) {
|
||||
continue;
|
||||
}
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private LinearLayout buildCandidateRow(CandidateConversation candidate) {
|
||||
boolean selected = selectedProjectIds.contains(candidate.projectId);
|
||||
String badge = selected ? "已选" : "未选";
|
||||
String subtitle = candidate.folderLabel.isEmpty() ? candidate.latestReplyLabel : candidate.folderLabel;
|
||||
String meta = candidate.preview;
|
||||
if (!candidate.latestReplyLabel.isEmpty() && !candidate.latestReplyLabel.equals(candidate.preview)) {
|
||||
meta = candidate.latestReplyLabel + (meta.isEmpty() ? "" : " · " + meta);
|
||||
}
|
||||
return BossUi.buildListRow(
|
||||
this,
|
||||
candidate.title,
|
||||
subtitle,
|
||||
meta,
|
||||
badge,
|
||||
v -> toggleSelection(candidate.projectId)
|
||||
);
|
||||
}
|
||||
|
||||
private void toggleSelection(String projectId) {
|
||||
if (selectedProjectIds.contains(projectId)) {
|
||||
selectedProjectIds.remove(projectId);
|
||||
} else {
|
||||
selectedProjectIds.add(projectId);
|
||||
}
|
||||
refreshCandidateRows();
|
||||
updateCreateButtonState();
|
||||
}
|
||||
|
||||
private void refreshCandidateRows() {
|
||||
if (cachedParticipantsPayload == null || cachedConversationsPayload == null) {
|
||||
return;
|
||||
}
|
||||
renderCreatePage(cachedParticipantsPayload, cachedConversationsPayload, false);
|
||||
}
|
||||
|
||||
private void updateCreateButtonState() {
|
||||
if (createButton != null) {
|
||||
boolean refreshing = refreshLayout != null && refreshLayout.isRefreshing();
|
||||
createButton.setEnabled(canCreateGroupChat(refreshing, creatingGroupChat, selectedProjectIds));
|
||||
createButton.setText(creatingGroupChat ? "创建中..." : "创建群聊");
|
||||
}
|
||||
}
|
||||
|
||||
private void createGroupChat() {
|
||||
boolean refreshing = refreshLayout != null && refreshLayout.isRefreshing();
|
||||
if (refreshing || creatingGroupChat) {
|
||||
return;
|
||||
}
|
||||
if (selectedProjectIds.isEmpty()) {
|
||||
showMessage("请至少选择一个其他线程");
|
||||
return;
|
||||
}
|
||||
List<String> memberProjectIdsSnapshot = new ArrayList<>(selectedProjectIds);
|
||||
creatingGroupChat = true;
|
||||
setRefreshing(true);
|
||||
updateCreateButtonState();
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
JSONArray memberProjectIds = new JSONArray();
|
||||
for (String projectId : memberProjectIdsSnapshot) {
|
||||
memberProjectIds.put(projectId);
|
||||
}
|
||||
payload.put("memberProjectIds", memberProjectIds);
|
||||
BossApiClient.ApiResponse response = apiClient.createGroupChat(sourceProjectId, payload);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
JSONObject project = response.json.optJSONObject("project");
|
||||
if (project == null) throw new IllegalStateException("GROUP_CHAT_PROJECT_MISSING");
|
||||
String createdProjectId = project.optString("id", "");
|
||||
if (createdProjectId.isEmpty()) {
|
||||
throw new IllegalStateException("GROUP_CHAT_PROJECT_ID_MISSING");
|
||||
}
|
||||
String createdProjectName = project.optString("name", sourceProjectName == null ? "群聊" : sourceProjectName);
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
creatingGroupChat = false;
|
||||
updateCreateButtonState();
|
||||
showMessage("群聊已创建");
|
||||
Intent intent = new Intent(this, ProjectDetailActivity.class);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, createdProjectId);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, createdProjectName);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
creatingGroupChat = false;
|
||||
setRefreshing(false);
|
||||
showMessage("创建失败:" + error.getMessage());
|
||||
updateCreateButtonState();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static boolean canCreateGroupChat(
|
||||
boolean refreshing,
|
||||
boolean creatingGroupChat,
|
||||
@Nullable Set<String> selectedProjectIds
|
||||
) {
|
||||
return !refreshing
|
||||
&& !creatingGroupChat
|
||||
&& selectedProjectIds != null
|
||||
&& !selectedProjectIds.isEmpty();
|
||||
}
|
||||
|
||||
static Set<String> reconcileSelectedProjectIds(
|
||||
@Nullable Set<String> currentSelectedProjectIds,
|
||||
@Nullable Set<String> previousCandidateProjectIds,
|
||||
@Nullable Set<String> nextCandidateProjectIds
|
||||
) {
|
||||
Set<String> reconciled = new LinkedHashSet<>();
|
||||
if (nextCandidateProjectIds == null || nextCandidateProjectIds.isEmpty()) {
|
||||
return reconciled;
|
||||
}
|
||||
if (previousCandidateProjectIds == null
|
||||
|| previousCandidateProjectIds.isEmpty()
|
||||
|| !previousCandidateProjectIds.equals(nextCandidateProjectIds)) {
|
||||
reconciled.addAll(nextCandidateProjectIds);
|
||||
return reconciled;
|
||||
}
|
||||
if (currentSelectedProjectIds == null || currentSelectedProjectIds.isEmpty()) {
|
||||
return reconciled;
|
||||
}
|
||||
for (String projectId : currentSelectedProjectIds) {
|
||||
if (nextCandidateProjectIds.contains(projectId)) {
|
||||
reconciled.add(projectId);
|
||||
}
|
||||
}
|
||||
return reconciled;
|
||||
}
|
||||
|
||||
private String buildSourceMeta(@Nullable JSONObject threadMeta, @Nullable JSONArray participants) {
|
||||
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
int count = participants == null ? 0 : participants.length();
|
||||
String memberLabel = count <= 0 ? "暂无参与线程" : count + " 个参与线程";
|
||||
if (folderName.isEmpty()) {
|
||||
return memberLabel;
|
||||
}
|
||||
return folderName + " · " + memberLabel;
|
||||
}
|
||||
|
||||
private String buildSourceBody(@Nullable JSONObject threadMeta, @Nullable JSONArray participants) {
|
||||
String threadId = threadMeta == null ? sourceProjectId : threadMeta.optString("threadId", sourceProjectId);
|
||||
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("来源线程:").append(threadId);
|
||||
builder.append("\n文件夹:").append(folderName.isEmpty() ? "未命名文件夹" : folderName);
|
||||
builder.append("\n参与线程:").append(participants == null ? 0 : participants.length());
|
||||
builder.append("\n默认规则:会自动勾选当前会话之外的其他线程");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static final class CandidateConversation {
|
||||
private final String projectId;
|
||||
private final String title;
|
||||
private final String folderLabel;
|
||||
private final String preview;
|
||||
private final String latestReplyLabel;
|
||||
private final boolean isGroup;
|
||||
|
||||
private CandidateConversation(
|
||||
String projectId,
|
||||
String title,
|
||||
String folderLabel,
|
||||
String preview,
|
||||
String latestReplyLabel,
|
||||
boolean isGroup
|
||||
) {
|
||||
this.projectId = projectId;
|
||||
this.title = title;
|
||||
this.folderLabel = folderLabel;
|
||||
this.preview = preview;
|
||||
this.latestReplyLabel = latestReplyLabel;
|
||||
this.isGroup = isGroup;
|
||||
}
|
||||
}
|
||||
}
|
||||
208
android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java
Normal file
208
android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java
Normal file
@@ -0,0 +1,208 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class GroupInfoActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
|
||||
@Override
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_group_info;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("群资料", projectName == null ? "群聊资料页" : projectName);
|
||||
setHeaderAction("重命名", v -> openRenameDialog());
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId);
|
||||
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
|
||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
|
||||
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
|
||||
runOnUiThread(() -> renderGroup(detailResponse.json, participantsResponse.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "群资料加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderGroup(JSONObject detail, JSONObject participantsPayload) {
|
||||
replaceContent();
|
||||
JSONObject project = detail.optJSONObject("project");
|
||||
JSONArray participants = participantsPayload.optJSONArray("participants");
|
||||
|
||||
if (project == null) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "群聊不存在。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
projectName = project.optString("name", projectName == null ? "群聊" : projectName);
|
||||
JSONObject threadMeta = project.optJSONObject("threadMeta");
|
||||
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
int participantCount = participants == null ? 0 : participants.length();
|
||||
configureScreen("群资料", buildSubtitle(folderName, participantCount));
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
projectName,
|
||||
buildDetailBody(project, threadMeta),
|
||||
buildDetailMeta(projectId, folderName, participantCount)
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"成员线程",
|
||||
"群聊成员可点击查看对应项目详情。",
|
||||
participantCount == 0 ? "当前没有成员线程。" : "共 " + participantCount + " 个成员"
|
||||
));
|
||||
|
||||
if (participants == null || participants.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前没有群成员信息。"));
|
||||
} else {
|
||||
for (int i = 0; i < participants.length(); i++) {
|
||||
JSONObject participant = participants.optJSONObject(i);
|
||||
if (participant == null) continue;
|
||||
appendContent(buildMemberRow(participant));
|
||||
}
|
||||
}
|
||||
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildMemberRow(JSONObject participant) {
|
||||
boolean sourceProject = participant.optBoolean("isSourceProject", false);
|
||||
String participantProjectId = participant.optString("projectId", "");
|
||||
String title = participant.optString("threadDisplayName", "未命名线程");
|
||||
String subtitle = participant.optString("folderName", "");
|
||||
String meta = participant.optString("deviceId", "");
|
||||
String threadId = participant.optString("threadId", "");
|
||||
if (!threadId.isEmpty()) {
|
||||
meta = meta.isEmpty() ? threadId : meta + " · " + threadId;
|
||||
}
|
||||
return BossUi.buildListRow(
|
||||
this,
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
sourceProject ? "当前" : null,
|
||||
v -> openProject(participantProjectId, title)
|
||||
);
|
||||
}
|
||||
|
||||
private void openProject(String targetProjectId, String targetProjectName) {
|
||||
if (targetProjectId == null || targetProjectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(this, ProjectDetailActivity.class);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, targetProjectId);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, targetProjectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openRenameDialog() {
|
||||
final EditText input = BossUi.buildInput(this, "群名", false);
|
||||
input.setText(projectName == null ? "" : projectName);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("重命名群聊")
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) -> saveGroupName(input.getText().toString().trim()))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void saveGroupName(String name) {
|
||||
if (name.isEmpty()) {
|
||||
showMessage("群名不能为空");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.renameConversation(projectId, name, true);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
Intent result = new Intent();
|
||||
result.putExtra(EXTRA_PROJECT_NAME, name);
|
||||
setResult(RESULT_OK, result);
|
||||
showMessage("群名已更新");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String buildSubtitle(String folderName, int count) {
|
||||
String memberLabel = count <= 0 ? "暂无成员" : count + " 个成员";
|
||||
if (folderName.isEmpty()) {
|
||||
return memberLabel;
|
||||
}
|
||||
return folderName + " · " + memberLabel;
|
||||
}
|
||||
|
||||
private String buildDetailBody(JSONObject project, @Nullable JSONObject threadMeta) {
|
||||
String threadId = threadMeta == null ? project.optString("id", "") : threadMeta.optString("threadId", "");
|
||||
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("群聊线程:").append(threadId.isEmpty() ? project.optString("id", "-") : threadId);
|
||||
builder.append("\n群聊名称:").append(project.optString("name", "群聊"));
|
||||
builder.append("\n文件夹:").append(folderName.isEmpty() ? "未命名文件夹" : folderName);
|
||||
builder.append("\n协作模式:").append(project.optString("collaborationMode", "development"));
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String buildDetailMeta(String projectId, String folderName, int count) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if (!projectId.isEmpty()) {
|
||||
builder.append("project ").append(projectId);
|
||||
}
|
||||
if (!folderName.isEmpty()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(folderName);
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(count <= 0 ? "暂无成员" : "成员 " + count);
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ import java.util.concurrent.Executors;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
public static final String EXTRA_INITIAL_TAB = "initial_tab";
|
||||
private static final String UI_PREFS = "boss_native_client";
|
||||
private static final String KEY_LAST_ROOT_TAB = "last_root_tab";
|
||||
private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L;
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
|
||||
@@ -29,6 +32,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private View loginPanel;
|
||||
private View contentPanel;
|
||||
private TextView loginTitle;
|
||||
private TextView loginHint;
|
||||
private Button loginButton;
|
||||
private ProgressBar loginProgress;
|
||||
@@ -45,12 +49,15 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private String activeTab = "conversations";
|
||||
private String preferredEntryTab = "conversations";
|
||||
private boolean explicitTabRequest = false;
|
||||
private @Nullable String requestedInitialTab;
|
||||
private boolean userSelectedTab = false;
|
||||
private long lastRootBackPressedAt = 0L;
|
||||
private @Nullable JSONObject sessionData;
|
||||
private @Nullable JSONObject otaData;
|
||||
private @Nullable JSONArray conversationsData;
|
||||
private @Nullable JSONArray devicesData;
|
||||
private @Nullable String boundDeviceId;
|
||||
private @Nullable String boundDeviceName;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
@@ -78,10 +85,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
public void onBackPressed() {
|
||||
if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) {
|
||||
setActiveTab("conversations", false);
|
||||
persistLastRootTab("conversations");
|
||||
return;
|
||||
}
|
||||
if (contentPanel.getVisibility() == View.VISIBLE) {
|
||||
moveTaskToBack(true);
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastRootBackPressedAt < ROOT_BACK_EXIT_WINDOW_MS) {
|
||||
moveTaskToBack(true);
|
||||
return;
|
||||
}
|
||||
lastRootBackPressedAt = now;
|
||||
showMessage("再按一次返回,应用进入后台");
|
||||
return;
|
||||
}
|
||||
super.onBackPressed();
|
||||
@@ -96,6 +110,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
private void bindViews() {
|
||||
loginPanel = findViewById(R.id.login_panel);
|
||||
contentPanel = findViewById(R.id.content_panel);
|
||||
loginTitle = findViewById(R.id.login_title);
|
||||
loginHint = findViewById(R.id.login_hint);
|
||||
loginButton = findViewById(R.id.login_button);
|
||||
loginProgress = findViewById(R.id.login_progress);
|
||||
@@ -108,12 +123,20 @@ public class MainActivity extends AppCompatActivity {
|
||||
tabMe = findViewById(R.id.tab_me);
|
||||
screenRefresh = findViewById(R.id.screen_refresh);
|
||||
screenContent = findViewById(R.id.screen_content);
|
||||
|
||||
String[] rootTabs = WechatSurfaceMapper.rootTabLabels();
|
||||
tabConversations.setText(rootTabs[0]);
|
||||
tabDevices.setText(rootTabs[1]);
|
||||
tabMe.setText(rootTabs[2]);
|
||||
loginTitle.setText(WechatSurfaceMapper.loginTitle());
|
||||
loginHint.setText(WechatSurfaceMapper.loginHintText());
|
||||
loginButton.setText(WechatSurfaceMapper.loginButtonLabel());
|
||||
}
|
||||
|
||||
private void bindActions() {
|
||||
loginButton.setOnClickListener(v -> performAutoLogin());
|
||||
backButton.setVisibility(View.GONE);
|
||||
refreshButton.setOnClickListener(v -> refreshCurrentTab());
|
||||
refreshButton.setOnClickListener(v -> handleTopAction());
|
||||
tabConversations.setOnClickListener(v -> setActiveTab("conversations", true));
|
||||
tabDevices.setOnClickListener(v -> setActiveTab("devices", true));
|
||||
tabMe.setOnClickListener(v -> setActiveTab("me", true));
|
||||
@@ -121,16 +144,16 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void applyInitialTab(@Nullable Intent intent) {
|
||||
explicitTabRequest = false;
|
||||
requestedInitialTab = null;
|
||||
String requested = intent == null ? null : intent.getStringExtra(EXTRA_INITIAL_TAB);
|
||||
if ("devices".equals(requested) || "me".equals(requested) || "conversations".equals(requested)) {
|
||||
activeTab = requested;
|
||||
explicitTabRequest = true;
|
||||
requestedInitialTab = requested;
|
||||
}
|
||||
activeTab = RootTabMemory.resolveInitialTab(requestedInitialTab, readLastRootTab(), preferredEntryTab);
|
||||
}
|
||||
|
||||
private void bootstrapSession() {
|
||||
showLogin("原生 Android 客户端已启用。点击下方按钮直接进入系统。");
|
||||
showLogin(WechatSurfaceMapper.loginHintText());
|
||||
if (!apiClient.hasSessionHints()) {
|
||||
return;
|
||||
}
|
||||
@@ -153,7 +176,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
} catch (Exception ignored) {
|
||||
// Fall back to login panel.
|
||||
}
|
||||
runOnUiThread(() -> setLoginLoading(false, "点击登录后会直接进入系统。"));
|
||||
runOnUiThread(() -> setLoginLoading(false, WechatSurfaceMapper.loginHintText()));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -183,10 +206,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private void refreshAllData(@Nullable JSONObject initialSession) {
|
||||
startRefreshing(true);
|
||||
topSubtitle.setText("正在同步最新数据...");
|
||||
topSubtitle.setText("");
|
||||
topSubtitle.setVisibility(View.GONE);
|
||||
executor.execute(() -> {
|
||||
JSONObject session;
|
||||
try {
|
||||
JSONObject session = initialSession;
|
||||
session = initialSession;
|
||||
if (session == null) {
|
||||
BossApiClient.ApiResponse sessionResponse = apiClient.getSession();
|
||||
if (!sessionResponse.ok()) {
|
||||
@@ -197,29 +222,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
session = sessionResponse.json.optJSONObject("session");
|
||||
}
|
||||
|
||||
BossApiClient.ApiResponse conversations = apiClient.getConversations();
|
||||
BossApiClient.ApiResponse devices = apiClient.getDevices();
|
||||
BossApiClient.ApiResponse ota = apiClient.getOtaStatus();
|
||||
BossApiClient.ApiResponse settings = apiClient.getSettings();
|
||||
if (!conversations.ok() || !devices.ok() || !ota.ok() || !settings.ok()) {
|
||||
throw new IOException("API_REFRESH_FAILED");
|
||||
}
|
||||
|
||||
JSONObject finalSession = session;
|
||||
runOnUiThread(() -> {
|
||||
sessionData = finalSession;
|
||||
conversationsData = conversations.json.optJSONArray("conversations");
|
||||
devicesData = devices.json.optJSONArray("devices");
|
||||
otaData = ota.json;
|
||||
JSONObject settingsPayload = settings.json.optJSONObject("settings");
|
||||
if (settingsPayload != null) {
|
||||
preferredEntryTab = settingsPayload.optString("preferredEntryPoint", "conversations");
|
||||
}
|
||||
maybeApplyPreferredEntry();
|
||||
renderCurrentTab();
|
||||
startRefreshing(false);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
startRefreshing(false);
|
||||
@@ -229,6 +231,94 @@ public class MainActivity extends AppCompatActivity {
|
||||
otaData = null;
|
||||
showLogin("当前登录已失效或同步失败,请重新点击登录。");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
BossApiClient.ApiResponse conversations = null;
|
||||
BossApiClient.ApiResponse devices = null;
|
||||
BossApiClient.ApiResponse ota = null;
|
||||
BossApiClient.ApiResponse settings = null;
|
||||
boolean conversationsOk = false;
|
||||
boolean devicesOk = false;
|
||||
boolean otaOk = false;
|
||||
boolean settingsOk = false;
|
||||
|
||||
try {
|
||||
conversations = apiClient.getConversations();
|
||||
conversationsOk = conversations.ok();
|
||||
} catch (Exception ignored) {
|
||||
conversationsOk = false;
|
||||
}
|
||||
try {
|
||||
devices = apiClient.getDevices();
|
||||
devicesOk = devices.ok();
|
||||
} catch (Exception ignored) {
|
||||
devicesOk = false;
|
||||
}
|
||||
try {
|
||||
ota = apiClient.getOtaStatus();
|
||||
otaOk = ota.ok();
|
||||
} catch (Exception ignored) {
|
||||
otaOk = false;
|
||||
}
|
||||
try {
|
||||
settings = apiClient.getSettings();
|
||||
settingsOk = settings.ok();
|
||||
} catch (Exception ignored) {
|
||||
settingsOk = false;
|
||||
}
|
||||
|
||||
JSONObject finalSession = session;
|
||||
BossApiClient.ApiResponse finalConversations = conversations;
|
||||
BossApiClient.ApiResponse finalDevices = devices;
|
||||
BossApiClient.ApiResponse finalOta = ota;
|
||||
BossApiClient.ApiResponse finalSettings = settings;
|
||||
final boolean finalConversationsOk = conversationsOk;
|
||||
final boolean finalDevicesOk = devicesOk;
|
||||
final boolean finalOtaOk = otaOk;
|
||||
final boolean finalSettingsOk = settingsOk;
|
||||
runOnUiThread(() -> {
|
||||
sessionData = finalSession;
|
||||
conversationsData = WechatSurfaceMapper.resolveRefreshValue(
|
||||
conversationsData,
|
||||
finalConversations == null ? null : finalConversations.json.optJSONArray("conversations"),
|
||||
finalConversationsOk
|
||||
);
|
||||
devicesData = WechatSurfaceMapper.resolveRefreshValue(
|
||||
devicesData,
|
||||
finalDevices == null ? null : finalDevices.json.optJSONArray("devices"),
|
||||
finalDevicesOk
|
||||
);
|
||||
otaData = WechatSurfaceMapper.resolveRefreshValue(
|
||||
otaData,
|
||||
finalOta == null ? null : finalOta.json,
|
||||
finalOtaOk
|
||||
);
|
||||
JSONObject settingsPayload = finalSettings == null ? null : finalSettings.json.optJSONObject("settings");
|
||||
JSONObject userPayload = finalSettings == null ? null : finalSettings.json.optJSONObject("user");
|
||||
if (finalSettingsOk && settingsPayload != null) {
|
||||
preferredEntryTab = settingsPayload.optString("preferredEntryPoint", "conversations");
|
||||
}
|
||||
if (finalSettingsOk) {
|
||||
updateBoundDeviceState(userPayload);
|
||||
}
|
||||
maybeApplyPreferredEntry();
|
||||
renderCurrentTab();
|
||||
startRefreshing(false);
|
||||
if (!finalConversationsOk || !finalDevicesOk || !finalOtaOk || !finalSettingsOk) {
|
||||
showMessage("刷新失败,请稍后重试");
|
||||
}
|
||||
});
|
||||
} catch (Exception error) {
|
||||
JSONObject finalSession = session;
|
||||
runOnUiThread(() -> {
|
||||
sessionData = finalSession == null ? sessionData : finalSession;
|
||||
maybeApplyPreferredEntry();
|
||||
renderCurrentTab();
|
||||
startRefreshing(false);
|
||||
showMessage("刷新失败,请稍后重试");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -248,7 +338,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
private void setLoginLoading(boolean loading, String hint) {
|
||||
loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE);
|
||||
loginButton.setEnabled(!loading);
|
||||
loginButton.setText(loading ? "处理中..." : "登录");
|
||||
loginButton.setText(loading ? "处理中..." : WechatSurfaceMapper.loginButtonLabel());
|
||||
loginHint.setText(hint);
|
||||
}
|
||||
|
||||
@@ -256,18 +346,18 @@ public class MainActivity extends AppCompatActivity {
|
||||
activeTab = tab;
|
||||
if (fromUser) {
|
||||
userSelectedTab = true;
|
||||
persistLastRootTab(tab);
|
||||
}
|
||||
lastRootBackPressedAt = 0L;
|
||||
updateTabStyles();
|
||||
renderCurrentTab();
|
||||
}
|
||||
|
||||
private void maybeApplyPreferredEntry() {
|
||||
if (explicitTabRequest || userSelectedTab) {
|
||||
if (userSelectedTab) {
|
||||
return;
|
||||
}
|
||||
if ("devices".equals(preferredEntryTab) || "me".equals(preferredEntryTab) || "conversations".equals(preferredEntryTab)) {
|
||||
activeTab = preferredEntryTab;
|
||||
}
|
||||
activeTab = RootTabMemory.resolveInitialTab(requestedInitialTab, readLastRootTab(), preferredEntryTab);
|
||||
}
|
||||
|
||||
private void renderCurrentTab() {
|
||||
@@ -277,16 +367,19 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
switch (activeTab) {
|
||||
case "devices":
|
||||
updateHeader("设备", "只展示当前正式接入生产链路的设备。");
|
||||
updateHeader("设备", "这里管理已接入设备与账号状态。");
|
||||
configureTopAction("+添加", true);
|
||||
renderDevicesRoot();
|
||||
break;
|
||||
case "me":
|
||||
updateHeader("我的", "账号、安全、技能、运维、OTA 都从这里进入。");
|
||||
updateHeader("我的", "");
|
||||
configureTopAction("刷新", false);
|
||||
renderMeRoot();
|
||||
break;
|
||||
case "conversations":
|
||||
default:
|
||||
updateHeader("会话", "原生会话列表直接消费 /api/v1/conversations。");
|
||||
updateHeader("会话", WechatSurfaceMapper.conversationsHeaderSubtitle());
|
||||
configureTopAction("刷新", false);
|
||||
renderConversationsRoot();
|
||||
break;
|
||||
}
|
||||
@@ -295,6 +388,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
private void updateHeader(String title, String subtitle) {
|
||||
topTitle.setText(title);
|
||||
topSubtitle.setText(subtitle);
|
||||
topSubtitle.setVisibility(subtitle == null || subtitle.isEmpty() ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
|
||||
private void updateTabStyles() {
|
||||
@@ -304,19 +398,37 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void styleTab(Button button, boolean active) {
|
||||
button.setBackgroundResource(active ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button);
|
||||
button.setTextColor(getColor(active ? R.color.boss_surface : R.color.boss_green));
|
||||
button.setBackgroundResource(active ? R.drawable.bg_tab_active : R.drawable.bg_tab_inactive);
|
||||
button.setTextColor(getColor(active ? R.color.boss_green : R.color.boss_text_muted));
|
||||
}
|
||||
|
||||
private void configureTopAction(String label, boolean primaryStyle) {
|
||||
refreshButton.setText(label);
|
||||
refreshButton.setBackgroundResource(primaryStyle ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button);
|
||||
refreshButton.setTextColor(getColor(primaryStyle ? R.color.boss_surface : R.color.boss_green));
|
||||
}
|
||||
|
||||
private void syncTopActionVisualState(boolean refreshing) {
|
||||
if ("devices".equals(activeTab)) {
|
||||
configureTopAction("+添加", true);
|
||||
refreshButton.setEnabled(true);
|
||||
return;
|
||||
}
|
||||
configureTopAction(refreshing ? "同步中" : "刷新", false);
|
||||
refreshButton.setEnabled(!refreshing);
|
||||
}
|
||||
|
||||
private void handleTopAction() {
|
||||
if ("devices".equals(activeTab)) {
|
||||
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
|
||||
return;
|
||||
}
|
||||
refreshCurrentTab();
|
||||
}
|
||||
|
||||
private void renderConversationsRoot() {
|
||||
screenContent.removeAllViews();
|
||||
screenContent.addView(BossUi.buildCard(
|
||||
this,
|
||||
"会话首页",
|
||||
"当前原生首页会直接进入项目详情、目标、版本、转发与线程预算详情。",
|
||||
conversationsData == null ? "正在等待数据" : "会话数 " + conversationsData.length()
|
||||
));
|
||||
|
||||
screenContent.addView(BossUi.buildHintPill(this, WechatSurfaceMapper.conversationsHintPillText()));
|
||||
if (conversationsData == null || conversationsData.length() == 0) {
|
||||
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。"));
|
||||
return;
|
||||
@@ -326,41 +438,23 @@ public class MainActivity extends AppCompatActivity {
|
||||
JSONObject item = conversationsData.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
String projectId = item.optString("projectId", "");
|
||||
String title = item.optString("projectTitle", "未命名会话");
|
||||
StringBuilder body = new StringBuilder(item.optString("preview", "暂无预览"));
|
||||
if (item.optInt("activeDeviceCount", 0) > 0) {
|
||||
body.append("\n设备 ").append(item.optString("deviceNamesPreview", "未标注"));
|
||||
}
|
||||
JSONObject budget = item.optJSONObject("contextBudgetIndicator");
|
||||
String meta = "风险 " + item.optString("riskLevel", "unknown")
|
||||
+ " · 未读 " + item.optInt("unreadCount", 0)
|
||||
+ " · " + item.optString("latestReplyLabel", "-");
|
||||
if (budget != null && budget.optBoolean("visible", false)) {
|
||||
meta = meta + " · 预算 " + budget.optInt("percent", 0) + "%";
|
||||
}
|
||||
screenContent.addView(BossUi.buildCard(this, title, body.toString(), meta, v -> {
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
screenContent.addView(BossUi.buildConversationRow(
|
||||
this,
|
||||
row,
|
||||
v -> {
|
||||
if (projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
return;
|
||||
}
|
||||
openProject(projectId, title);
|
||||
String projectName = row.threadTitle.isEmpty() ? "未命名会话" : row.threadTitle;
|
||||
openProject(projectId, projectName);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private void renderDevicesRoot() {
|
||||
screenContent.removeAllViews();
|
||||
screenContent.addView(BossUi.buildCard(
|
||||
this,
|
||||
"设备首页",
|
||||
"设备详情、技能清单和配对草稿都改为原生页。",
|
||||
devicesData == null ? "正在等待数据" : "设备数 " + devicesData.length()
|
||||
));
|
||||
|
||||
Button addDeviceButton = BossUi.buildPrimaryButton(this, "添加设备");
|
||||
addDeviceButton.setOnClickListener(v -> startActivity(new Intent(this, DeviceEnrollmentActivity.class)));
|
||||
screenContent.addView(addDeviceButton);
|
||||
|
||||
if (devicesData == null || devicesData.length() == 0) {
|
||||
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有接入设备。"));
|
||||
return;
|
||||
@@ -370,19 +464,19 @@ public class MainActivity extends AppCompatActivity {
|
||||
JSONObject item = devicesData.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
String deviceId = item.optString("id", "");
|
||||
String title = item.optString("name", "未命名设备");
|
||||
String body = item.optString("note", item.optString("endpoint", "暂无设备说明"));
|
||||
String meta = "状态 " + item.optString("status", "unknown")
|
||||
+ " · 账号 " + item.optString("account", "-")
|
||||
+ " · 5h " + item.optInt("quota5h", 0)
|
||||
+ " · 7d " + item.optInt("quota7d", 0);
|
||||
screenContent.addView(BossUi.buildCard(this, title, body, meta, v -> {
|
||||
if (deviceId.isEmpty()) {
|
||||
showMessage("缺少 deviceId");
|
||||
return;
|
||||
}
|
||||
openDevice(deviceId, title);
|
||||
}));
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
screenContent.addView(BossUi.buildDeviceCard(
|
||||
this,
|
||||
row,
|
||||
v -> {
|
||||
if (deviceId.isEmpty()) {
|
||||
showMessage("缺少 deviceId");
|
||||
return;
|
||||
}
|
||||
openDevice(deviceId, row.title);
|
||||
},
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,70 +488,23 @@ public class MainActivity extends AppCompatActivity {
|
||||
String account = sessionData == null
|
||||
? apiClient.getAccountLabel()
|
||||
: sessionData.optString("account", apiClient.getAccountLabel());
|
||||
String expiresAt = sessionData == null ? "-" : sessionData.optString("expiresAt", "-");
|
||||
screenContent.addView(BossUi.buildCard(
|
||||
screenContent.addView(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
displayName,
|
||||
"账号 " + account + "\n当前原生客户端已覆盖会话 / 设备 / 我的一级导航。",
|
||||
"会话到期 " + expiresAt
|
||||
"ChatGPT Plus · 主账号",
|
||||
"主控账号已启用安全保护 · " + account
|
||||
));
|
||||
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"账号与安全",
|
||||
"查看当前会话、登录模式和退出登录。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, SecurityActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"设置",
|
||||
"实时刷新、风险徽标和默认首页。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, SettingsActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"运维与修复",
|
||||
"查看故障、repair ticket、审计请求和能力注册表。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, OpsCenterActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"AI 账号",
|
||||
"管理主 GPT、备用 GPT、Master Codex Node 与 API 容灾。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, AiAccountsActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"技能",
|
||||
"按绑定设备查看 Skill,并一键复制调用语句。",
|
||||
null,
|
||||
v -> startActivity(new Intent(this, SkillInventoryActivity.class))
|
||||
));
|
||||
screenContent.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
"关于",
|
||||
"查看版本、OTA 状态和当前绑定节点。",
|
||||
otaData == null ? null : otaData.optBoolean("hasOta", false) ? "OTA" : null,
|
||||
v -> startActivity(new Intent(this, AboutActivity.class))
|
||||
));
|
||||
|
||||
if (otaData != null) {
|
||||
JSONObject availableRelease = otaData.optJSONObject("availableRelease");
|
||||
String body = "当前版本 " + otaData.optString("currentVersion", "-");
|
||||
String meta = availableRelease == null
|
||||
? "当前没有待安装版本"
|
||||
: "可用版本 " + availableRelease.optString("version", "-")
|
||||
+ " · 文件 " + availableRelease.optString("packageFileName", "-");
|
||||
screenContent.addView(BossUi.buildCard(this, "OTA 状态", body, meta));
|
||||
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
|
||||
screenContent.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
item.title,
|
||||
item.description,
|
||||
null,
|
||||
meBadgeFor(item.key),
|
||||
v -> openMeEntry(item.key)
|
||||
));
|
||||
}
|
||||
|
||||
Button logoutButton = BossUi.buildSecondaryButton(this, "退出登录");
|
||||
logoutButton.setOnClickListener(v -> logout());
|
||||
screenContent.addView(logoutButton);
|
||||
}
|
||||
|
||||
private void logout() {
|
||||
@@ -495,11 +542,140 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private void startRefreshing(boolean refreshing) {
|
||||
screenRefresh.setRefreshing(refreshing);
|
||||
refreshButton.setEnabled(!refreshing);
|
||||
refreshButton.setText(refreshing ? "同步中" : "刷新");
|
||||
syncTopActionVisualState(refreshing);
|
||||
}
|
||||
|
||||
private void showMessage(String text) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
private void openMeEntry(String key) {
|
||||
Intent intent;
|
||||
switch (key) {
|
||||
case "security":
|
||||
intent = new Intent(this, SecurityActivity.class);
|
||||
break;
|
||||
case "ai_accounts":
|
||||
intent = new Intent(this, AiAccountsActivity.class);
|
||||
break;
|
||||
case "settings":
|
||||
intent = new Intent(this, SettingsActivity.class);
|
||||
break;
|
||||
case "ops":
|
||||
intent = new Intent(this, OpsCenterActivity.class);
|
||||
break;
|
||||
case "skills":
|
||||
openSkillInventoryFromMe();
|
||||
return;
|
||||
case "about":
|
||||
intent = new Intent(this, AboutActivity.class);
|
||||
break;
|
||||
default:
|
||||
showMessage("暂未接入:" + key);
|
||||
return;
|
||||
}
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openSkillInventoryFromMe() {
|
||||
String targetDeviceId = resolveSkillTargetDeviceId();
|
||||
if (targetDeviceId == null || targetDeviceId.isEmpty()) {
|
||||
showMessage("当前没有可确定的绑定设备,请先到设备页确认。");
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(this, SkillInventoryActivity.class);
|
||||
intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, targetDeviceId);
|
||||
String targetDeviceName = resolveDeviceName(targetDeviceId);
|
||||
if (targetDeviceName != null && !targetDeviceName.isEmpty()) {
|
||||
intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_NAME, targetDeviceName);
|
||||
}
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void updateBoundDeviceState(@Nullable JSONObject userPayload) {
|
||||
String nextBoundDeviceId = userPayload == null ? "" : userPayload.optString("boundDeviceId", "");
|
||||
boundDeviceId = nextBoundDeviceId.isEmpty() ? null : nextBoundDeviceId;
|
||||
boundDeviceName = resolveDeviceName(boundDeviceId);
|
||||
}
|
||||
|
||||
private @Nullable String resolveSkillTargetDeviceId() {
|
||||
if (devicesData == null || devicesData.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (boundDeviceId != null && !boundDeviceId.isEmpty()) {
|
||||
String validatedBoundDeviceId = findDeviceId(devicesData, boundDeviceId);
|
||||
if (validatedBoundDeviceId != null) {
|
||||
return validatedBoundDeviceId;
|
||||
}
|
||||
}
|
||||
|
||||
String account = sessionData == null ? apiClient.getAccountLabel() : sessionData.optString("account", apiClient.getAccountLabel());
|
||||
String matchedByAccount = null;
|
||||
for (int i = 0; i < devicesData.length(); i++) {
|
||||
JSONObject device = devicesData.optJSONObject(i);
|
||||
if (device == null) continue;
|
||||
String deviceAccount = device.optString("account", "");
|
||||
if (!account.isEmpty() && account.equals(deviceAccount)) {
|
||||
matchedByAccount = device.optString("id", "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchedByAccount != null && !matchedByAccount.isEmpty()) {
|
||||
return matchedByAccount;
|
||||
}
|
||||
if (devicesData.length() == 1) {
|
||||
JSONObject onlyDevice = devicesData.optJSONObject(0);
|
||||
return onlyDevice == null ? null : onlyDevice.optString("id", "");
|
||||
}
|
||||
JSONObject fallback = devicesData.optJSONObject(0);
|
||||
return fallback == null ? null : fallback.optString("id", "");
|
||||
}
|
||||
|
||||
private static @Nullable String findDeviceId(JSONArray devices, @Nullable String candidateDeviceId) {
|
||||
if (candidateDeviceId == null || candidateDeviceId.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < devices.length(); i++) {
|
||||
JSONObject device = devices.optJSONObject(i);
|
||||
if (device == null) continue;
|
||||
if (candidateDeviceId.equals(device.optString("id", ""))) {
|
||||
return candidateDeviceId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private @Nullable String resolveDeviceName(@Nullable String deviceId) {
|
||||
if (deviceId == null || deviceId.isEmpty() || devicesData == null) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < devicesData.length(); i++) {
|
||||
JSONObject device = devicesData.optJSONObject(i);
|
||||
if (device == null) continue;
|
||||
if (deviceId.equals(device.optString("id", ""))) {
|
||||
return device.optString("name", deviceId);
|
||||
}
|
||||
}
|
||||
return boundDeviceName;
|
||||
}
|
||||
|
||||
private @Nullable String meBadgeFor(String key) {
|
||||
if ("about".equals(key) && otaData != null && otaData.optBoolean("hasOta", false)) {
|
||||
return "OTA";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void persistLastRootTab(String tab) {
|
||||
getSharedPreferences(UI_PREFS, MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_LAST_ROOT_TAB, tab)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private @Nullable String readLastRootTab() {
|
||||
return getSharedPreferences(UI_PREFS, MODE_PRIVATE)
|
||||
.getString(KEY_LAST_ROOT_TAB, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,12 @@ import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class OpsCenterActivity extends BossScreenActivity {
|
||||
private enum Tab {
|
||||
OPS,
|
||||
AUDIT
|
||||
}
|
||||
|
||||
private Tab activeTab = Tab.OPS;
|
||||
private LinearLayout contentRoot;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("运维中心", "运维对话 / 审计对话");
|
||||
configureScreen("运维与修复", "运维会话、修复回放与 standby 切换");
|
||||
setHeaderAction("刷新", v -> reload());
|
||||
contentRoot = new LinearLayout(this);
|
||||
contentRoot.setOrientation(LinearLayout.VERTICAL);
|
||||
@@ -35,62 +29,34 @@ public class OpsCenterActivity extends BossScreenActivity {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse ops = apiClient.getOpsSummary();
|
||||
BossApiClient.ApiResponse audit = apiClient.getAuditSummary();
|
||||
if (!ops.ok() || !audit.ok()) {
|
||||
throw new IllegalStateException("OPS_OR_AUDIT_LOAD_FAILED");
|
||||
if (!ops.ok()) {
|
||||
throw new IllegalStateException("OPS_LOAD_FAILED");
|
||||
}
|
||||
runOnUiThread(() -> render(ops.json, audit.json));
|
||||
runOnUiThread(() -> render(ops.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "运维中心加载失败:" + error.getMessage()));
|
||||
replaceContent(BossUi.buildEmptyCard(this, "运维与修复加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void render(JSONObject ops, JSONObject audit) {
|
||||
private void render(JSONObject ops) {
|
||||
replaceContent(contentRoot);
|
||||
contentRoot.removeAllViews();
|
||||
contentRoot.addView(buildTabBar());
|
||||
if (activeTab == Tab.OPS) {
|
||||
renderOpsTab(ops);
|
||||
} else {
|
||||
renderAuditTab(audit);
|
||||
}
|
||||
renderOpsTab(ops);
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildTabBar() {
|
||||
LinearLayout bar = new LinearLayout(this);
|
||||
bar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
bar.addView(buildTabButton("运维对话", activeTab == Tab.OPS, v -> {
|
||||
activeTab = Tab.OPS;
|
||||
reload();
|
||||
}));
|
||||
bar.addView(buildTabButton("审计对话", activeTab == Tab.AUDIT, v -> {
|
||||
activeTab = Tab.AUDIT;
|
||||
reload();
|
||||
}));
|
||||
return bar;
|
||||
}
|
||||
|
||||
private Button buildTabButton(String label, boolean active, android.view.View.OnClickListener listener) {
|
||||
Button button = BossUi.buildPrimaryButton(this, label);
|
||||
button.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
|
||||
button.setBackgroundResource(active ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button);
|
||||
button.setTextColor(getColor(active ? R.color.boss_surface : R.color.boss_green));
|
||||
button.setOnClickListener(listener);
|
||||
return button;
|
||||
}
|
||||
|
||||
private void renderOpsTab(JSONObject ops) {
|
||||
contentRoot.addView(BossUi.buildCard(
|
||||
contentRoot.addView(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"当前巡检模式",
|
||||
"巡检状态",
|
||||
ops.optString("mode", "idle").equals("active")
|
||||
? "active:当前存在风险线程或未关闭运维工单。"
|
||||
: "idle:当前没有高风险工单,保持低频巡检。",
|
||||
"来源:/api/v1/ops/summary"
|
||||
"这里只保留修复与验证的轻量入口。"
|
||||
));
|
||||
|
||||
JSONArray faults = ops.optJSONArray("faults");
|
||||
@@ -106,7 +72,9 @@ public class OpsCenterActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private LinearLayout buildFaultCard(JSONObject fault, @Nullable JSONArray tickets) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
LinearLayout card = new LinearLayout(this);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
fault.optString("faultKey", "故障"),
|
||||
fault.optString("summary", "暂无摘要"),
|
||||
@@ -114,13 +82,9 @@ public class OpsCenterActivity extends BossScreenActivity {
|
||||
+ " · " + fault.optString("status", "-")
|
||||
+ " · " + fault.optString("nodeId", "-")
|
||||
+ " · " + fault.optString("serviceName", "-")
|
||||
);
|
||||
|
||||
card.addView(BossUi.buildCard(
|
||||
this,
|
||||
"建议动作",
|
||||
fault.optString("suggestedNextAction", "暂无"),
|
||||
"trace " + fault.optString("traceId", "-")
|
||||
+ " · 建议 " + fault.optString("suggestedNextAction", "暂无"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
if (tickets != null) {
|
||||
@@ -135,122 +99,38 @@ public class OpsCenterActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private LinearLayout buildTicketCard(JSONObject ticket) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
LinearLayout card = new LinearLayout(this);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
ticket.optString("title", "修复工单"),
|
||||
ticket.optString("actionSummary", "暂无动作摘要"),
|
||||
ticket.optString("approvalStatus", "-")
|
||||
+ " · " + ticket.optString("executionStatus", "-")
|
||||
+ " · " + ticket.optString("targetNodeId", "-")
|
||||
);
|
||||
+ " · " + ticket.optString("updatedAt", "-"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
if (ticket.optJSONObject("verification") != null) {
|
||||
JSONObject verification = ticket.optJSONObject("verification");
|
||||
card.addView(BossUi.buildCard(
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"验证结果",
|
||||
verification.optString("summary", "暂无"),
|
||||
verification.optString("status", "-")
|
||||
+ " · " + verification.optString("verifiedAt", "-")
|
||||
+ " · " + verification.optString("verifiedAt", "-"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
Button approve = BossUi.buildPrimaryButton(this, "批准修复");
|
||||
Button approve = BossUi.buildMiniActionButton(this, "批准修复", true);
|
||||
approve.setOnClickListener(v -> approveTicket(ticket.optString("ticketId")));
|
||||
card.addView(approve);
|
||||
|
||||
Button verify = BossUi.buildSecondaryButton(this, "验证修复");
|
||||
Button verify = BossUi.buildMiniActionButton(this, "验证修复", false);
|
||||
verify.setOnClickListener(v -> verifyTicket(ticket.optString("ticketId")));
|
||||
card.addView(verify);
|
||||
return card;
|
||||
}
|
||||
|
||||
private void renderAuditTab(JSONObject audit) {
|
||||
contentRoot.addView(BossUi.buildCard(
|
||||
this,
|
||||
"审计概要",
|
||||
"待处理请求 " + (audit.optJSONArray("pendingRequests") == null ? 0 : audit.optJSONArray("pendingRequests").length())
|
||||
+ "\n最新结果 " + (audit.optJSONArray("latestResults") == null ? 0 : audit.optJSONArray("latestResults").length()),
|
||||
"来源:/api/v1/audits/summary"
|
||||
));
|
||||
|
||||
JSONArray pendingRequests = audit.optJSONArray("pendingRequests");
|
||||
if (pendingRequests == null || pendingRequests.length() == 0) {
|
||||
contentRoot.addView(BossUi.buildEmptyCard(this, "当前没有待处理的审计请求。"));
|
||||
} else {
|
||||
for (int i = 0; i < pendingRequests.length(); i++) {
|
||||
JSONObject request = pendingRequests.optJSONObject(i);
|
||||
if (request == null) continue;
|
||||
contentRoot.addView(buildAuditRequestCard(request));
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray latestResults = audit.optJSONArray("latestResults");
|
||||
if (latestResults != null && latestResults.length() > 0) {
|
||||
contentRoot.addView(BossUi.buildCard(this, "审计结果", "最近完成的审计会展示在这里。", "可回看 decision / findings"));
|
||||
for (int i = 0; i < latestResults.length(); i++) {
|
||||
JSONObject result = latestResults.optJSONObject(i);
|
||||
if (result == null) continue;
|
||||
contentRoot.addView(buildAuditResultCard(result));
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray capabilities = audit.optJSONArray("capabilities");
|
||||
if (capabilities != null && capabilities.length() > 0) {
|
||||
contentRoot.addView(BossUi.buildCard(this, "能力注册表", "展示当前设备上的可用能力。", "与审计请求的 capabilityRequirements 对应"));
|
||||
for (int i = 0; i < capabilities.length(); i++) {
|
||||
JSONObject capability = capabilities.optJSONObject(i);
|
||||
if (capability == null) continue;
|
||||
contentRoot.addView(BossUi.buildCard(
|
||||
this,
|
||||
capability.optString("displayName", "能力"),
|
||||
capability.optString("capabilityType", "-")
|
||||
+ "\n提供者:" + capability.optString("providerId", "-")
|
||||
+ "\n模式:" + capability.optString("leaseMode", "-")
|
||||
+ "\n动作:" + joinArray(capability.optJSONArray("supportedActions")),
|
||||
capability.optString("status", "-")
|
||||
+ " · " + capability.optString("healthStatus", "-")
|
||||
+ " · " + capability.optString("nodeId", "-")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private LinearLayout buildAuditRequestCard(JSONObject request) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
this,
|
||||
request.optString("projectName", "审计请求"),
|
||||
request.optString("objective", "暂无目标"),
|
||||
request.optString("auditType", "-")
|
||||
+ " · priority " + request.optInt("priority", 0)
|
||||
+ " · " + request.optString("trigger", "-")
|
||||
);
|
||||
card.addView(BossUi.buildCard(
|
||||
this,
|
||||
"审计条件",
|
||||
"要求:" + joinStringArray(request.optJSONArray("acceptanceCriteria"))
|
||||
+ "\n风险:" + joinStringArray(request.optJSONArray("riskFocus"))
|
||||
+ "\n证据:" + joinStringArray(request.optJSONArray("evidenceRefs")),
|
||||
"时限 " + request.optInt("timeBudgetSeconds", 0) + " 秒"
|
||||
));
|
||||
return card;
|
||||
}
|
||||
|
||||
private LinearLayout buildAuditResultCard(JSONObject result) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
this,
|
||||
result.optString("decision", "result"),
|
||||
result.optString("summary", "暂无摘要"),
|
||||
result.optString("status", "-")
|
||||
+ " · confidence " + result.optDouble("confidence", 0.0)
|
||||
+ " · " + result.optString("completedAt", "-")
|
||||
);
|
||||
card.addView(BossUi.buildCard(
|
||||
this,
|
||||
"审计发现",
|
||||
joinStringArray(result.optJSONArray("findings")),
|
||||
"需要动作:" + joinStringArray(result.optJSONArray("requiredActions"))
|
||||
));
|
||||
card.addView(BossUi.buildInlineActionRow(this, approve, verify));
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -311,16 +191,4 @@ public class OpsCenterActivity extends BossScreenActivity {
|
||||
}
|
||||
return builder.length() == 0 ? "-" : builder.toString();
|
||||
}
|
||||
|
||||
private String joinStringArray(@Nullable JSONArray values) {
|
||||
if (values == null || values.length() == 0) return "-";
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < values.length(); i++) {
|
||||
String value = values.optString(i);
|
||||
if (value == null || value.isEmpty()) continue;
|
||||
if (builder.length() > 0) builder.append(";");
|
||||
builder.append(value);
|
||||
}
|
||||
return builder.length() == 0 ? "-" : builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
public final class OtaDownloadStateMapper {
|
||||
public enum ActionKind {
|
||||
NONE,
|
||||
RETRY_DOWNLOAD,
|
||||
OPEN_INSTALL_PERMISSION,
|
||||
INSTALL_APK
|
||||
}
|
||||
|
||||
public static final class UiState {
|
||||
public final String title;
|
||||
public final String subtitle;
|
||||
public final String meta;
|
||||
public final String badge;
|
||||
public final String actionLabel;
|
||||
public final ActionKind actionKind;
|
||||
|
||||
public UiState(
|
||||
String title,
|
||||
String subtitle,
|
||||
String meta,
|
||||
String badge,
|
||||
String actionLabel,
|
||||
ActionKind actionKind
|
||||
) {
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
this.meta = meta;
|
||||
this.badge = badge;
|
||||
this.actionLabel = actionLabel;
|
||||
this.actionKind = actionKind;
|
||||
}
|
||||
}
|
||||
|
||||
private OtaDownloadStateMapper() {}
|
||||
|
||||
public static String toProgressLabel(int percent, boolean hasKnownTotal) {
|
||||
if (!hasKnownTotal) {
|
||||
return "正在准备下载";
|
||||
}
|
||||
int safePercent = Math.max(0, Math.min(100, percent));
|
||||
return "已下载 " + safePercent + "%";
|
||||
}
|
||||
|
||||
public static UiState active(String fileName, int percent, boolean hasKnownTotal, long bytesDownloaded, long totalBytes) {
|
||||
return new UiState(
|
||||
"安装包下载中",
|
||||
toProgressLabel(percent, hasKnownTotal),
|
||||
buildMeta(fileName, bytesDownloaded, totalBytes),
|
||||
"NOW",
|
||||
null,
|
||||
ActionKind.NONE
|
||||
);
|
||||
}
|
||||
|
||||
public static UiState failed(String fileName) {
|
||||
return new UiState(
|
||||
"安装包下载失败",
|
||||
"下载未成功完成,可以直接重试",
|
||||
fileName,
|
||||
"FAIL",
|
||||
"重试下载",
|
||||
ActionKind.RETRY_DOWNLOAD
|
||||
);
|
||||
}
|
||||
|
||||
public static UiState waitingInstallPermission(String fileName) {
|
||||
return new UiState(
|
||||
"等待安装授权",
|
||||
"请先允许 Boss 安装未知来源应用",
|
||||
fileName,
|
||||
"STEP",
|
||||
"前往授权",
|
||||
ActionKind.OPEN_INSTALL_PERMISSION
|
||||
);
|
||||
}
|
||||
|
||||
public static UiState readyToInstall(String fileName) {
|
||||
return new UiState(
|
||||
"安装包已就绪",
|
||||
"下载完成,可继续拉起系统安装",
|
||||
fileName,
|
||||
"DONE",
|
||||
"继续安装",
|
||||
ActionKind.INSTALL_APK
|
||||
);
|
||||
}
|
||||
|
||||
private static String buildMeta(String fileName, long bytesDownloaded, long totalBytes) {
|
||||
if (bytesDownloaded <= 0 && totalBytes <= 0) {
|
||||
return fileName;
|
||||
}
|
||||
StringBuilder builder = new StringBuilder(fileName);
|
||||
builder.append(" · ").append(formatBytes(bytesDownloaded));
|
||||
if (totalBytes > 0) {
|
||||
builder.append(" / ").append(formatBytes(totalBytes));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static String formatBytes(long bytes) {
|
||||
if (bytes <= 0) {
|
||||
return "0 B";
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return bytes + " B";
|
||||
}
|
||||
if (bytes < 1024L * 1024L) {
|
||||
return String.format(java.util.Locale.US, "%.1f KB", bytes / 1024.0d);
|
||||
}
|
||||
return String.format(java.util.Locale.US, "%.1f MB", bytes / (1024.0d * 1024.0d));
|
||||
}
|
||||
}
|
||||
239
android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java
Normal file
239
android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java
Normal file
@@ -0,0 +1,239 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public final class ProjectChatUiState {
|
||||
private ProjectChatUiState() {}
|
||||
|
||||
public static final class SelectionState {
|
||||
public final boolean multiSelecting;
|
||||
public final Set<String> selectedMessageIds;
|
||||
|
||||
private SelectionState(Set<String> selectedMessageIds) {
|
||||
LinkedHashSet<String> normalizedIds = new LinkedHashSet<>(selectedMessageIds);
|
||||
this.multiSelecting = !normalizedIds.isEmpty();
|
||||
this.selectedMessageIds = Collections.unmodifiableSet(normalizedIds);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class ChromeState {
|
||||
public final boolean multiSelecting;
|
||||
public final boolean showComposer;
|
||||
public final boolean showMultiSelectBar;
|
||||
public final boolean showRefresh;
|
||||
public final boolean showHeaderAction;
|
||||
public final boolean forwardEnabled;
|
||||
public final String backLabel;
|
||||
public final String title;
|
||||
public final String subtitle;
|
||||
|
||||
private ChromeState(
|
||||
boolean multiSelecting,
|
||||
boolean showComposer,
|
||||
boolean showMultiSelectBar,
|
||||
boolean showRefresh,
|
||||
boolean showHeaderAction,
|
||||
boolean forwardEnabled,
|
||||
String backLabel,
|
||||
String title,
|
||||
String subtitle
|
||||
) {
|
||||
this.multiSelecting = multiSelecting;
|
||||
this.showComposer = showComposer;
|
||||
this.showMultiSelectBar = showMultiSelectBar;
|
||||
this.showRefresh = showRefresh;
|
||||
this.showHeaderAction = showHeaderAction;
|
||||
this.forwardEnabled = forwardEnabled;
|
||||
this.backLabel = backLabel;
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean canSend(String text, boolean sending) {
|
||||
return !sending && text != null && !text.trim().isEmpty();
|
||||
}
|
||||
|
||||
public static boolean requiresAttachmentConfirmation(@Nullable String sourceType) {
|
||||
return "image".equals(sourceType) || "video".equals(sourceType);
|
||||
}
|
||||
|
||||
public static boolean shouldAutoScroll(boolean nearBottom, boolean forced) {
|
||||
return nearBottom || forced;
|
||||
}
|
||||
|
||||
public static SelectionState emptySelection() {
|
||||
return new SelectionState(new LinkedHashSet<>());
|
||||
}
|
||||
|
||||
public static SelectionState selectOnly(String messageId) {
|
||||
return toggleSelection(emptySelection(), messageId);
|
||||
}
|
||||
|
||||
public static SelectionState toggleSelection(@Nullable SelectionState current, String messageId) {
|
||||
if (messageId == null || messageId.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("messageId must not be blank");
|
||||
}
|
||||
SelectionState state = current == null ? emptySelection() : current;
|
||||
LinkedHashSet<String> selectedMessageIds = new LinkedHashSet<>(state.selectedMessageIds);
|
||||
if (selectedMessageIds.contains(messageId)) {
|
||||
selectedMessageIds.remove(messageId);
|
||||
return new SelectionState(selectedMessageIds);
|
||||
}
|
||||
selectedMessageIds.add(messageId);
|
||||
return new SelectionState(selectedMessageIds);
|
||||
}
|
||||
|
||||
public static boolean canForwardSelection(@Nullable SelectionState state) {
|
||||
return state != null && state.multiSelecting && state.selectedMessageIds.size() >= 2;
|
||||
}
|
||||
|
||||
public static SelectionState reconcileSelection(
|
||||
@Nullable SelectionState current,
|
||||
@Nullable List<String> availableMessageIds
|
||||
) {
|
||||
if (current == null || current.selectedMessageIds.isEmpty() || availableMessageIds == null || availableMessageIds.isEmpty()) {
|
||||
return emptySelection();
|
||||
}
|
||||
LinkedHashSet<String> available = new LinkedHashSet<>(availableMessageIds);
|
||||
LinkedHashSet<String> selected = new LinkedHashSet<>();
|
||||
for (String selectedMessageId : current.selectedMessageIds) {
|
||||
if (available.contains(selectedMessageId)) {
|
||||
selected.add(selectedMessageId);
|
||||
}
|
||||
}
|
||||
return new SelectionState(selected);
|
||||
}
|
||||
|
||||
public static ChromeState resolveChromeState(
|
||||
@Nullable SelectionState selectionState,
|
||||
boolean conversationInfoReady,
|
||||
@Nullable String defaultTitle,
|
||||
@Nullable String defaultSubtitle
|
||||
) {
|
||||
boolean multiSelecting = selectionState != null && selectionState.multiSelecting;
|
||||
if (multiSelecting) {
|
||||
int selectedCount = selectionState.selectedMessageIds.size();
|
||||
return new ChromeState(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
canForwardSelection(selectionState),
|
||||
"取消",
|
||||
"已选 " + selectedCount + " 条",
|
||||
"选择要转发的消息"
|
||||
);
|
||||
}
|
||||
return new ChromeState(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
conversationInfoReady,
|
||||
false,
|
||||
"返回",
|
||||
isBlank(defaultTitle) ? "项目详情" : defaultTitle,
|
||||
isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle
|
||||
);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String labelForForwardKind(@Nullable String kind) {
|
||||
if ("forward_single".equals(kind)) {
|
||||
return "转发";
|
||||
}
|
||||
if ("forward_bundle".equals(kind)) {
|
||||
return "聊天记录";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String summarizeForwardBundle(@Nullable String lastBody, int itemCount) {
|
||||
if (itemCount > 0 && !isBlank(lastBody)) {
|
||||
return itemCount + " 条消息 · 最后一条:" + truncate(lastBody, 28);
|
||||
}
|
||||
if (itemCount > 0) {
|
||||
return itemCount + " 条消息";
|
||||
}
|
||||
return truncate(lastBody, 28);
|
||||
}
|
||||
|
||||
public static String labelForAttachmentAnalysisState(@Nullable String analysisState) {
|
||||
if ("queued_auto".equals(analysisState)) {
|
||||
return "自动分析排队中";
|
||||
}
|
||||
if ("ready_manual".equals(analysisState)) {
|
||||
return "待分析";
|
||||
}
|
||||
if ("processing".equals(analysisState)) {
|
||||
return "AI 分析中";
|
||||
}
|
||||
if ("completed".equals(analysisState)) {
|
||||
return "已分析";
|
||||
}
|
||||
if ("failed".equals(analysisState)) {
|
||||
return "分析失败";
|
||||
}
|
||||
return "已发送";
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String actionLabelForAttachmentAnalysisState(@Nullable String analysisState) {
|
||||
if ("ready_manual".equals(analysisState)) {
|
||||
return "让 AI 分析";
|
||||
}
|
||||
if ("failed".equals(analysisState)) {
|
||||
return "重试分析";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String labelForAttachmentKind(@Nullable String attachmentKind) {
|
||||
if ("image".equals(attachmentKind)) {
|
||||
return "图片";
|
||||
}
|
||||
if ("video".equals(attachmentKind)) {
|
||||
return "视频";
|
||||
}
|
||||
if ("pdf".equals(attachmentKind)) {
|
||||
return "PDF";
|
||||
}
|
||||
if ("office".equals(attachmentKind)) {
|
||||
return "文档";
|
||||
}
|
||||
if ("text".equals(attachmentKind)) {
|
||||
return "文本";
|
||||
}
|
||||
return "文件";
|
||||
}
|
||||
|
||||
public static String formatAttachmentSize(long fileSizeBytes) {
|
||||
if (fileSizeBytes >= 1024L * 1024L) {
|
||||
return String.format(java.util.Locale.US, "%.1f MB", fileSizeBytes / (1024f * 1024f));
|
||||
}
|
||||
if (fileSizeBytes >= 1024L) {
|
||||
return Math.max(1, Math.round(fileSizeBytes / 1024f)) + " KB";
|
||||
}
|
||||
return Math.max(fileSizeBytes, 0L) + " B";
|
||||
}
|
||||
|
||||
private static boolean isBlank(@Nullable String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private static String truncate(@Nullable String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.length() <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.substring(0, maxLength) + "…";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,106 +1,30 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class ProjectForwardActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("消息转发", projectName == null ? "选择目标项目并写备注" : "源项目:" + projectName);
|
||||
reload();
|
||||
configureScreen("消息转发", "正在切换到微信式转发");
|
||||
|
||||
Intent intent = new Intent(this, ForwardTargetActivity.class);
|
||||
intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
|
||||
intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "single_legacy");
|
||||
startActivity(intent);
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getConversations();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> renderTargets(response.json.optJSONArray("conversations")));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "转发目标加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderTargets(@Nullable JSONArray conversations) {
|
||||
replaceContent(BossUi.buildCard(
|
||||
this,
|
||||
"原生转发入口",
|
||||
"选择一个目标项目,填写备注后会走现有 `/api/v1/projects/{projectId}/forwards`。",
|
||||
"源项目:" + (projectName == null ? projectId : projectName)
|
||||
));
|
||||
if (conversations == null || conversations.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前没有可转发的目标项目。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < conversations.length(); i++) {
|
||||
JSONObject item = conversations.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
String targetProjectId = item.optString("projectId");
|
||||
if (projectId.equals(targetProjectId)) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
item.optString("projectTitle", "未命名项目"),
|
||||
item.optString("preview", ""),
|
||||
item.optString("latestReplyLabel", "最近更新"),
|
||||
v -> openForwardDialog(targetProjectId, item.optString("projectTitle", targetProjectId))
|
||||
));
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void openForwardDialog(String targetProjectId, String targetTitle) {
|
||||
final android.widget.EditText input = BossUi.buildInput(this, "请输入要附带的转发说明", true);
|
||||
input.setText("请同步关注 " + targetTitle + " 的当前进展。");
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("转发到 " + targetTitle)
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("转发", (dialog, which) -> forwardMessage(targetProjectId, input.getText().toString().trim()))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void forwardMessage(String targetProjectId, String note) {
|
||||
if (note.isEmpty()) {
|
||||
showMessage("请先填写转发说明");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage(projectId, targetProjectId, note);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("转发成功");
|
||||
finish();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("转发失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
// 兼容页只负责跳转,不再承载旧的备注转发链路。
|
||||
}
|
||||
}
|
||||
|
||||
28
android/app/src/main/java/com/hyzq/boss/RootTabMemory.java
Normal file
28
android/app/src/main/java/com/hyzq/boss/RootTabMemory.java
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
public final class RootTabMemory {
|
||||
private RootTabMemory() {}
|
||||
|
||||
public static String resolveInitialTab(String explicitTab, String storedTab, String preferredTab) {
|
||||
String explicit = normalize(explicitTab);
|
||||
if (explicit != null) {
|
||||
return explicit;
|
||||
}
|
||||
String stored = normalize(storedTab);
|
||||
if (stored != null) {
|
||||
return stored;
|
||||
}
|
||||
String preferred = normalize(preferredTab);
|
||||
if (preferred != null) {
|
||||
return preferred;
|
||||
}
|
||||
return "conversations";
|
||||
}
|
||||
|
||||
private static String normalize(String tab) {
|
||||
if ("conversations".equals(tab) || "devices".equals(tab) || "me".equals(tab)) {
|
||||
return tab;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("账号与安全", "原生会话与设备安全");
|
||||
configureScreen("账号与安全", "登录会话与设备保护");
|
||||
reload();
|
||||
}
|
||||
|
||||
@@ -33,33 +33,32 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private void renderSecurity(@Nullable JSONObject session) {
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
this,
|
||||
"当前登录模式",
|
||||
"当前登录页已临时切到免验证模式,点击登录会直接创建最高管理员会话。",
|
||||
"后续如收口认证,再切回账号密码 / 验证码登录。"
|
||||
)
|
||||
);
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"当前登录模式",
|
||||
"当前客户端仍使用快速进入模式。",
|
||||
"需要更严格认证时,再切回账号密码或验证码登录。"
|
||||
));
|
||||
if (session != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前会话",
|
||||
"账号 " + session.optString("account", "-")
|
||||
+ "\n角色 " + session.optString("role", "-")
|
||||
+ "\n登录方式 " + session.optString("loginMethod", "-"),
|
||||
"到期 " + session.optString("expiresAt", "-")
|
||||
+ " · " + session.optString("role", "-"),
|
||||
"登录方式 " + session.optString("loginMethod", "-")
|
||||
+ " · 到期 " + session.optString("expiresAt", "-"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
android.widget.Button devicesButton = BossUi.buildPrimaryButton(this, "打开设备页");
|
||||
devicesButton.setOnClickListener(v -> {
|
||||
appendContent(BossUi.buildMenuRow(this, "打开设备页", "查看已绑定设备与状态", null, v -> {
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.putExtra(MainActivity.EXTRA_INITIAL_TAB, "devices");
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
startActivity(intent);
|
||||
});
|
||||
appendContent(devicesButton);
|
||||
}));
|
||||
|
||||
android.widget.Button logoutButton = BossUi.buildSecondaryButton(this, "退出登录");
|
||||
logoutButton.setOnClickListener(v -> logout());
|
||||
|
||||
@@ -15,13 +15,15 @@ public class SettingsActivity extends BossScreenActivity {
|
||||
private SwitchCompat riskBadgesSwitch;
|
||||
private SwitchCompat confirmActionsSwitch;
|
||||
private Spinner preferredEntrySpinner;
|
||||
private boolean settingsLoaded = false;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("设置", "原生偏好配置");
|
||||
configureScreen("设置", "默认首页与提醒偏好");
|
||||
setHeaderAction("保存", v -> saveSettings());
|
||||
buildForm();
|
||||
buildFormContent();
|
||||
updateSaveAvailability();
|
||||
reload();
|
||||
}
|
||||
|
||||
@@ -36,48 +38,52 @@ public class SettingsActivity extends BossScreenActivity {
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
settingsLoaded = false;
|
||||
updateSaveAvailability();
|
||||
replaceContent(BossUi.buildEmptyCard(this, "设置加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void buildForm() {
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
this,
|
||||
"设置说明",
|
||||
"当前设置会持久化到 data/boss-state.json,下一线程接手不会丢失。",
|
||||
"原生设置页直接走 /api/v1/settings"
|
||||
)
|
||||
);
|
||||
private void buildFormContent() {
|
||||
if (liveUpdatesSwitch == null) {
|
||||
liveUpdatesSwitch = new SwitchCompat(this);
|
||||
liveUpdatesSwitch.setText("启用实时刷新");
|
||||
}
|
||||
if (riskBadgesSwitch == null) {
|
||||
riskBadgesSwitch = new SwitchCompat(this);
|
||||
riskBadgesSwitch.setText("显示风险徽标");
|
||||
}
|
||||
if (confirmActionsSwitch == null) {
|
||||
confirmActionsSwitch = new SwitchCompat(this);
|
||||
confirmActionsSwitch.setText("危险操作前确认");
|
||||
}
|
||||
if (preferredEntrySpinner == null) {
|
||||
preferredEntrySpinner = new Spinner(this);
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"conversations", "devices", "me"}
|
||||
);
|
||||
preferredEntrySpinner.setAdapter(adapter);
|
||||
}
|
||||
|
||||
liveUpdatesSwitch = new SwitchCompat(this);
|
||||
liveUpdatesSwitch.setText("启用实时刷新");
|
||||
|
||||
riskBadgesSwitch = new SwitchCompat(this);
|
||||
riskBadgesSwitch.setText("显示风险徽标");
|
||||
|
||||
confirmActionsSwitch = new SwitchCompat(this);
|
||||
confirmActionsSwitch.setText("危险操作前确认");
|
||||
|
||||
preferredEntrySpinner = new Spinner(this);
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(
|
||||
replaceContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"conversations", "devices", "me"}
|
||||
);
|
||||
preferredEntrySpinner.setAdapter(adapter);
|
||||
"偏好设置",
|
||||
"调整默认首页和提醒行为。",
|
||||
"保存后会直接写入 /api/v1/settings。"
|
||||
));
|
||||
|
||||
LinearLayout card = BossUi.buildCard(this, "交互偏好", "可切换默认首页与提醒行为。", "保存后立即生效");
|
||||
card.addView(liveUpdatesSwitch);
|
||||
card.addView(riskBadgesSwitch);
|
||||
card.addView(confirmActionsSwitch);
|
||||
card.addView(preferredEntrySpinner);
|
||||
appendContent(card);
|
||||
appendContent(BossUi.buildFormCell(this, "实时刷新", "会话、设备和 OTA 状态变化时自动更新", liveUpdatesSwitch));
|
||||
appendContent(BossUi.buildFormCell(this, "风险徽标", "在列表中显示风险状态提示", riskBadgesSwitch));
|
||||
appendContent(BossUi.buildFormCell(this, "危险操作确认", "执行修复或切换前再次确认", confirmActionsSwitch));
|
||||
appendContent(BossUi.buildFormCell(this, "默认首页", "下次打开 App 优先进入这里", preferredEntrySpinner));
|
||||
}
|
||||
|
||||
private void populate(@Nullable JSONObject settings) {
|
||||
buildFormContent();
|
||||
if (settings != null) {
|
||||
liveUpdatesSwitch.setChecked(settings.optBoolean("liveUpdates", true));
|
||||
riskBadgesSwitch.setChecked(settings.optBoolean("showRiskBadges", true));
|
||||
@@ -91,10 +97,16 @@ public class SettingsActivity extends BossScreenActivity {
|
||||
preferredEntrySpinner.setSelection(0);
|
||||
}
|
||||
}
|
||||
settingsLoaded = settings != null;
|
||||
updateSaveAvailability();
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void saveSettings() {
|
||||
if (!settingsLoaded) {
|
||||
showMessage("设置尚未加载完成,请先刷新成功后再保存。");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
@@ -117,4 +129,11 @@ public class SettingsActivity extends BossScreenActivity {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateSaveAvailability() {
|
||||
if (headerActionButton != null) {
|
||||
headerActionButton.setEnabled(settingsLoaded);
|
||||
headerActionButton.setAlpha(settingsLoaded ? 1f : 0.45f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,15 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private String resolveTargetDeviceId() throws Exception {
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
return deviceId;
|
||||
String explicitDeviceId = deviceId;
|
||||
String boundDeviceId = null;
|
||||
BossApiClient.ApiResponse settingsResponse = apiClient.getSettings();
|
||||
if (settingsResponse.ok()) {
|
||||
JSONObject user = settingsResponse.json.optJSONObject("user");
|
||||
if (user != null) {
|
||||
String candidate = user.optString("boundDeviceId", "");
|
||||
boundDeviceId = candidate.isEmpty() ? null : candidate;
|
||||
}
|
||||
}
|
||||
BossApiClient.ApiResponse response = apiClient.getDevices();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
@@ -54,7 +61,54 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
if (devices == null || devices.length() == 0) {
|
||||
throw new IllegalStateException("NO_DEVICE");
|
||||
}
|
||||
return devices.optJSONObject(0).optString("id");
|
||||
return chooseTargetDeviceId(explicitDeviceId, boundDeviceId, apiClient.getAccountLabel(), devices);
|
||||
}
|
||||
|
||||
private static String chooseTargetDeviceId(
|
||||
@Nullable String explicitDeviceId,
|
||||
@Nullable String boundDeviceId,
|
||||
String account,
|
||||
JSONArray devices
|
||||
) {
|
||||
String explicitMatch = findDeviceId(devices, explicitDeviceId);
|
||||
if (explicitMatch != null) {
|
||||
return explicitMatch;
|
||||
}
|
||||
|
||||
String boundMatch = findDeviceId(devices, boundDeviceId);
|
||||
if (boundMatch != null) {
|
||||
return boundMatch;
|
||||
}
|
||||
|
||||
for (int i = 0; i < devices.length(); i++) {
|
||||
JSONObject device = devices.optJSONObject(i);
|
||||
if (device == null) continue;
|
||||
if (account.equals(device.optString("account", ""))) {
|
||||
return device.optString("id", "");
|
||||
}
|
||||
}
|
||||
if (devices.length() == 1) {
|
||||
JSONObject onlyDevice = devices.optJSONObject(0);
|
||||
if (onlyDevice != null) {
|
||||
return onlyDevice.optString("id", "");
|
||||
}
|
||||
}
|
||||
JSONObject fallback = devices.optJSONObject(0);
|
||||
return fallback == null ? "" : fallback.optString("id", "");
|
||||
}
|
||||
|
||||
private static @Nullable String findDeviceId(JSONArray devices, @Nullable String candidateDeviceId) {
|
||||
if (candidateDeviceId == null || candidateDeviceId.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < devices.length(); i++) {
|
||||
JSONObject device = devices.optJSONObject(i);
|
||||
if (device == null) continue;
|
||||
if (candidateDeviceId.equals(device.optString("id", ""))) {
|
||||
return candidateDeviceId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void renderSkills(JSONObject payload) {
|
||||
@@ -65,7 +119,7 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
if (device != null) {
|
||||
deviceName = device.optString("name", deviceId);
|
||||
configureScreen("技能", deviceName);
|
||||
appendContent(BossUi.buildCard(
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
deviceName,
|
||||
"当前页按设备查看 Skill 清单。",
|
||||
@@ -81,19 +135,22 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
for (int i = 0; i < skills.length(); i++) {
|
||||
JSONObject skill = skills.optJSONObject(i);
|
||||
if (skill == null) continue;
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
LinearLayout card = new LinearLayout(this);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
skill.optString("name", "未命名 Skill"),
|
||||
skill.optString("description", "未提供说明"),
|
||||
skill.optString("category", "-")
|
||||
+ " · " + skill.optString("updatedAt", "-")
|
||||
);
|
||||
Button copyInvocation = BossUi.buildPrimaryButton(this, "复制调用语句");
|
||||
+ " · " + skill.optString("updatedAt", "-"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
Button copyInvocation = BossUi.buildMiniActionButton(this, "复制调用", true);
|
||||
copyInvocation.setOnClickListener(v -> BossUi.copyText(this, "Skill 调用", skill.optString("invocation", "")));
|
||||
card.addView(copyInvocation);
|
||||
Button copyPath = BossUi.buildSecondaryButton(this, "复制路径");
|
||||
Button copyPath = BossUi.buildMiniActionButton(this, "复制路径", false);
|
||||
copyPath.setOnClickListener(v -> BossUi.copyText(this, "Skill 路径", skill.optString("path", "")));
|
||||
card.addView(copyPath);
|
||||
card.addView(BossUi.buildInlineActionRow(this, copyInvocation, copyPath));
|
||||
appendContent(card);
|
||||
}
|
||||
setRefreshing(false);
|
||||
|
||||
402
android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java
Normal file
402
android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java
Normal file
@@ -0,0 +1,402 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONArray;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public final class WechatSurfaceMapper {
|
||||
private static final List<String> ROOT_TAB_LABELS = Arrays.asList(
|
||||
"会话",
|
||||
"设备",
|
||||
"我的"
|
||||
);
|
||||
|
||||
private static final List<MeMenuItem> ROOT_ME_MENU_ITEMS = Arrays.asList(
|
||||
new MeMenuItem("security", "账号与安全", "修改登录密码、设备安全与身份校验"),
|
||||
new MeMenuItem("settings", "设置", "默认首页、提醒方式与危险操作确认"),
|
||||
new MeMenuItem("ops", "运维与修复", "查看运维会话、修复回放与 standby 切换"),
|
||||
new MeMenuItem("ai_accounts", "AI 账号", "管理主 GPT、备用 GPT 与 API 容灾"),
|
||||
new MeMenuItem("skills", "技能", "按设备查看 Skill 清单"),
|
||||
new MeMenuItem("about", "关于", "当前版本、OTA 状态与更新内容")
|
||||
);
|
||||
|
||||
private static final List<String> PROJECT_QUICK_ACTIONS = Arrays.asList(
|
||||
"项目目标",
|
||||
"版本记录"
|
||||
);
|
||||
|
||||
private static final List<String> PROJECT_PRIMARY_SECTIONS = Arrays.asList(
|
||||
"quick_actions",
|
||||
"messages",
|
||||
"composer"
|
||||
);
|
||||
|
||||
private WechatSurfaceMapper() {
|
||||
}
|
||||
|
||||
public static ConversationRow toConversationRow(JSONObject item) {
|
||||
JSONObject source = item == null ? new JSONObject() : item;
|
||||
JSONArray members = source.optJSONArray("groupMembers");
|
||||
List<GroupAvatarMember> groupAvatarMembers = new ArrayList<>();
|
||||
if (members != null) {
|
||||
for (int i = 0; i < members.length(); i++) {
|
||||
JSONObject member = members.optJSONObject(i);
|
||||
if (member == null) continue;
|
||||
groupAvatarMembers.add(new GroupAvatarMember(
|
||||
member.optString("threadId", ""),
|
||||
member.optString("avatar", ""),
|
||||
member.optString("title", "")
|
||||
));
|
||||
}
|
||||
}
|
||||
JSONObject avatar = source.optJSONObject("avatar");
|
||||
boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1);
|
||||
return new ConversationRow(
|
||||
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))),
|
||||
source.optString("folderLabel", ""),
|
||||
source.optString("lastMessagePreview", source.optString("preview", "")),
|
||||
source.optString("timeLabel", source.optString("latestReplyLabel", "")),
|
||||
source.optInt("unreadCount", 0),
|
||||
source.optString("topPinnedLabel", ""),
|
||||
source.optInt("activityIconCount", 0),
|
||||
isGroup,
|
||||
isGroup ? "" : avatar == null ? "" : avatar.optString("primary", ""),
|
||||
isGroup ? "" : avatar == null ? "" : avatar.optString("secondary", ""),
|
||||
groupAvatarMembers.toArray(new GroupAvatarMember[0])
|
||||
);
|
||||
}
|
||||
|
||||
public static DeviceRow toDeviceRow(JSONObject item) {
|
||||
JSONObject source = item == null ? new JSONObject() : item;
|
||||
return new DeviceRow(
|
||||
source.optString("title", source.optString("name", "")),
|
||||
buildDeviceAccountProjectLine(source),
|
||||
buildDeviceQuotaStatusLine(source),
|
||||
source.optString("avatar", ""),
|
||||
resolveDeviceStatusKey(source)
|
||||
);
|
||||
}
|
||||
|
||||
public static DeviceDetailSummary toDeviceDetailSummary(JSONObject item) {
|
||||
JSONObject source = item == null ? new JSONObject() : item;
|
||||
DeviceRow row = toDeviceRow(source);
|
||||
return new DeviceDetailSummary(
|
||||
row.title,
|
||||
row.subtitle,
|
||||
mergeDeviceMeta(row.meta, buildDetailMeta(source))
|
||||
);
|
||||
}
|
||||
|
||||
public static String[] rootTabLabels() {
|
||||
return ROOT_TAB_LABELS.toArray(new String[0]);
|
||||
}
|
||||
|
||||
public static String[] rootMeMenuTitles() {
|
||||
String[] titles = new String[ROOT_ME_MENU_ITEMS.size()];
|
||||
for (int i = 0; i < ROOT_ME_MENU_ITEMS.size(); i++) {
|
||||
titles[i] = ROOT_ME_MENU_ITEMS.get(i).title;
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
public static MeMenuItem[] rootMeMenuItems() {
|
||||
return ROOT_ME_MENU_ITEMS.toArray(new MeMenuItem[0]);
|
||||
}
|
||||
|
||||
public static MeMenuItem findMeMenuItem(String key) {
|
||||
for (MeMenuItem item : ROOT_ME_MENU_ITEMS) {
|
||||
if (item.key.equals(key)) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String[] projectQuickActions() {
|
||||
return PROJECT_QUICK_ACTIONS.toArray(new String[0]);
|
||||
}
|
||||
|
||||
public static Class<? extends BossScreenActivity> resolveConversationInfoTargetClass(boolean isGroup) {
|
||||
return isGroup ? GroupInfoActivity.class : ConversationInfoActivity.class;
|
||||
}
|
||||
|
||||
public static String[] projectPrimarySections() {
|
||||
return PROJECT_PRIMARY_SECTIONS.toArray(new String[0]);
|
||||
}
|
||||
|
||||
public static String conversationInfoActionLabel() {
|
||||
return "信息";
|
||||
}
|
||||
|
||||
public static String loginTitle() {
|
||||
return "会话";
|
||||
}
|
||||
|
||||
public static String loginHintText() {
|
||||
return "轻量会话首页已恢复,直接进入最近线程。";
|
||||
}
|
||||
|
||||
public static String loginButtonLabel() {
|
||||
return "进入会话";
|
||||
}
|
||||
|
||||
public static String conversationsHintPillText() {
|
||||
return "项目自动对应设备 GUI 项目文件夹";
|
||||
}
|
||||
|
||||
public static String conversationsHeaderSubtitle() {
|
||||
return "";
|
||||
}
|
||||
|
||||
public static String conversationActivityIconMode() {
|
||||
return "animated_dots";
|
||||
}
|
||||
|
||||
public static int maxConversationActivityIcons() {
|
||||
return 4;
|
||||
}
|
||||
|
||||
public static String conversationActivityAnimationCleanup() {
|
||||
return "cancel_on_detach";
|
||||
}
|
||||
|
||||
public static <T> T resolveRefreshValue(T cachedValue, T freshValue, boolean requestSucceeded) {
|
||||
if (requestSucceeded) {
|
||||
return freshValue;
|
||||
}
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
private static String buildSubtitle(JSONObject source) {
|
||||
String status = localizeDeviceStatus(resolveDeviceStatusKey(source));
|
||||
String account = source.optString("account", "");
|
||||
if (account.isEmpty()) {
|
||||
return status;
|
||||
}
|
||||
return status + " · " + account;
|
||||
}
|
||||
|
||||
private static String buildDeviceAccountProjectLine(JSONObject source) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
String account = source.optString("account", "");
|
||||
if (!account.isEmpty()) {
|
||||
builder.append("账号: ").append(account);
|
||||
}
|
||||
String projects = joinProjects(source.optJSONArray("projects"));
|
||||
if (!projects.isEmpty()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append("项目: ").append(projects);
|
||||
}
|
||||
if (builder.length() == 0) {
|
||||
return buildSubtitle(source);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static String buildDeviceQuotaStatusLine(JSONObject source) {
|
||||
StringBuilder builder = new StringBuilder("额度: ");
|
||||
int quota5h = source.optInt("quota5h", -1);
|
||||
int quota7d = source.optInt("quota7d", -1);
|
||||
boolean hasQuota = false;
|
||||
if (quota5h >= 0) {
|
||||
builder.append("5h ").append(quota5h).append("%");
|
||||
hasQuota = true;
|
||||
}
|
||||
if (quota7d >= 0) {
|
||||
if (hasQuota) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append("7d ").append(quota7d).append("%");
|
||||
hasQuota = true;
|
||||
}
|
||||
if (!hasQuota) {
|
||||
builder.append("暂无");
|
||||
}
|
||||
|
||||
String statusKey = resolveDeviceStatusKey(source);
|
||||
if ("abnormal".equals(statusKey)) {
|
||||
builder.append(" · 状态异常");
|
||||
} else if ("offline".equals(statusKey)) {
|
||||
builder.append(" · 当前离线");
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static String mergeDeviceMeta(String primary, String secondary) {
|
||||
if (primary == null || primary.isEmpty()) {
|
||||
return secondary;
|
||||
}
|
||||
if (secondary == null || secondary.isEmpty()) {
|
||||
return primary;
|
||||
}
|
||||
return primary + " · " + secondary;
|
||||
}
|
||||
|
||||
private static String joinProjects(JSONArray projects) {
|
||||
if (projects == null || projects.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < projects.length(); i++) {
|
||||
String project = projects.optString(i);
|
||||
if (project == null || project.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" / ");
|
||||
}
|
||||
builder.append(project);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static String resolveDeviceStatusKey(JSONObject source) {
|
||||
String statusValue = source.optString("status", "");
|
||||
if (source.optBoolean("online", false) || "online".equals(statusValue)) {
|
||||
return "online";
|
||||
}
|
||||
if ("abnormal".equals(statusValue)) {
|
||||
return "abnormal";
|
||||
}
|
||||
return "offline";
|
||||
}
|
||||
|
||||
private static String localizeDeviceStatus(String statusKey) {
|
||||
if ("online".equals(statusKey)) {
|
||||
return "在线";
|
||||
}
|
||||
if ("abnormal".equals(statusKey)) {
|
||||
return "异常";
|
||||
}
|
||||
return "离线";
|
||||
}
|
||||
|
||||
private static String buildDetailMeta(JSONObject source) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
appendSegment(builder, source.optString("note", ""));
|
||||
appendSegment(builder, source.optString("endpoint", ""));
|
||||
JSONArray projects = source.optJSONArray("projects");
|
||||
if (projects != null && projects.length() > 0) {
|
||||
StringBuilder projectBuilder = new StringBuilder();
|
||||
for (int i = 0; i < projects.length(); i++) {
|
||||
String project = projects.optString(i);
|
||||
if (project.isEmpty()) continue;
|
||||
if (projectBuilder.length() > 0) {
|
||||
projectBuilder.append(", ");
|
||||
}
|
||||
projectBuilder.append(project);
|
||||
}
|
||||
if (projectBuilder.length() > 0) {
|
||||
appendSegment(builder, "项目 " + projectBuilder);
|
||||
}
|
||||
}
|
||||
return builder.length() == 0 ? null : builder.toString();
|
||||
}
|
||||
|
||||
private static void appendSegment(StringBuilder builder, String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(value);
|
||||
}
|
||||
|
||||
public static final class ConversationRow {
|
||||
public final String threadTitle;
|
||||
public final String folderLabel;
|
||||
public final String lastMessagePreview;
|
||||
public final String timeLabel;
|
||||
public final int unreadCount;
|
||||
public final String topPinnedLabel;
|
||||
public final int activityIconCount;
|
||||
public final boolean isGroup;
|
||||
public final String avatarPrimary;
|
||||
public final String avatarSecondary;
|
||||
public final GroupAvatarMember[] groupAvatarMembers;
|
||||
|
||||
public ConversationRow(
|
||||
String threadTitle,
|
||||
String folderLabel,
|
||||
String lastMessagePreview,
|
||||
String timeLabel,
|
||||
int unreadCount,
|
||||
String topPinnedLabel,
|
||||
int activityIconCount,
|
||||
boolean isGroup,
|
||||
String avatarPrimary,
|
||||
String avatarSecondary,
|
||||
GroupAvatarMember[] groupAvatarMembers
|
||||
) {
|
||||
this.threadTitle = threadTitle;
|
||||
this.folderLabel = folderLabel;
|
||||
this.lastMessagePreview = lastMessagePreview;
|
||||
this.timeLabel = timeLabel;
|
||||
this.unreadCount = unreadCount;
|
||||
this.topPinnedLabel = topPinnedLabel;
|
||||
this.activityIconCount = activityIconCount;
|
||||
this.isGroup = isGroup;
|
||||
this.avatarPrimary = avatarPrimary;
|
||||
this.avatarSecondary = avatarSecondary;
|
||||
this.groupAvatarMembers = groupAvatarMembers == null ? new GroupAvatarMember[0] : groupAvatarMembers;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class GroupAvatarMember {
|
||||
public final String threadId;
|
||||
public final String avatarLabel;
|
||||
public final String title;
|
||||
|
||||
public GroupAvatarMember(String threadId, String avatarLabel, String title) {
|
||||
this.threadId = threadId;
|
||||
this.avatarLabel = avatarLabel;
|
||||
this.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DeviceRow {
|
||||
public final String title;
|
||||
public final String subtitle;
|
||||
public final String meta;
|
||||
public final String avatarLabel;
|
||||
public final String statusKey;
|
||||
|
||||
public DeviceRow(String title, String subtitle, String meta, String avatarLabel, String statusKey) {
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
this.meta = meta;
|
||||
this.avatarLabel = avatarLabel;
|
||||
this.statusKey = statusKey;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DeviceDetailSummary {
|
||||
public final String title;
|
||||
public final String subtitle;
|
||||
public final String meta;
|
||||
|
||||
public DeviceDetailSummary(String title, String subtitle, String meta) {
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
this.meta = meta;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class MeMenuItem {
|
||||
public final String key;
|
||||
public final String title;
|
||||
public final String description;
|
||||
|
||||
public MeMenuItem(String key, String title, String description) {
|
||||
this.key = key;
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
android/app/src/main/res/drawable/bg_list_row.xml
Normal file
16
android/app/src/main/res/drawable/bg_list_row.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/boss_bg_app" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/boss_surface" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/boss_divider" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
12
android/app/src/main/res/drawable/bg_message_incoming.xml
Normal file
12
android/app/src/main/res/drawable/bg_message_incoming.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/boss_surface" />
|
||||
<corners
|
||||
android:topLeftRadius="8dp"
|
||||
android:topRightRadius="18dp"
|
||||
android:bottomLeftRadius="18dp"
|
||||
android:bottomRightRadius="18dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/boss_card_stroke" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/boss_green" />
|
||||
<corners
|
||||
android:topLeftRadius="18dp"
|
||||
android:topRightRadius="8dp"
|
||||
android:bottomLeftRadius="18dp"
|
||||
android:bottomRightRadius="18dp" />
|
||||
</shape>
|
||||
6
android/app/src/main/res/drawable/bg_tab_active.xml
Normal file
6
android/app/src/main/res/drawable/bg_tab_active.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#1407C160" />
|
||||
<corners android:radius="18dp" />
|
||||
</shape>
|
||||
6
android/app/src/main/res/drawable/bg_tab_inactive.xml
Normal file
6
android/app/src/main/res/drawable/bg_tab_inactive.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/boss_surface" />
|
||||
<corners android:radius="18dp" />
|
||||
</shape>
|
||||
109
android/app/src/main/res/layout/activity_conversation_info.xml
Normal file
109
android/app/src/main/res/layout/activity_conversation_info.xml
Normal file
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="14dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="返回"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="会话信息"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:text="单线程会话信息页"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="操作"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="刷新"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_panel"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</LinearLayout>
|
||||
109
android/app/src/main/res/layout/activity_forward_target.xml
Normal file
109
android/app/src/main/res/layout/activity_forward_target.xml
Normal file
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="14dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="返回"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="标题"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:text="副标题"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="操作"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="刷新"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_panel"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</LinearLayout>
|
||||
109
android/app/src/main/res/layout/activity_group_create.xml
Normal file
109
android/app/src/main/res/layout/activity_group_create.xml
Normal file
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="14dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="返回"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="发起群聊"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:text="从当前会话选择其他线程"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="操作"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="刷新"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_panel"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</LinearLayout>
|
||||
109
android/app/src/main/res/layout/activity_group_info.xml
Normal file
109
android/app/src/main/res/layout/activity_group_info.xml
Normal file
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="14dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="返回"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="群资料"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:text="群聊资料页"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="操作"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="刷新"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_panel"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</LinearLayout>
|
||||
@@ -2,7 +2,7 @@
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_app_gradient">
|
||||
android:background="@color/boss_bg_app">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/login_panel"
|
||||
@@ -18,23 +18,24 @@
|
||||
android:paddingLeft="24dp"
|
||||
android:paddingTop="72dp"
|
||||
android:paddingRight="24dp"
|
||||
android:paddingBottom="32dp">
|
||||
android:paddingBottom="40dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:gravity="center"
|
||||
android:text="B"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textSize="30sp"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/login_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="Boss 原生控制台"
|
||||
android:layout_marginTop="22dp"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
@@ -43,38 +44,12 @@
|
||||
android:id="@+id/login_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:text="原生 Android 客户端已启用。点击下方按钮直接进入系统。"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:background="@drawable/bg_card"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="当前临时模式"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:text="1. 这是原生 Android Activity,不再打开 WebView。\n2. 登录暂时不做验证,点击按钮会直接进入最高管理员会话。\n3. 会话 / 设备 / 我的三栏都直接调用现有 Boss API。"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
android:textSize="14sp" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/login_progress"
|
||||
@@ -87,11 +62,11 @@
|
||||
android:id="@+id/login_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginTop="22dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="14dp"
|
||||
android:text="登录"
|
||||
android:text=""
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textSize="18sp"
|
||||
@@ -109,12 +84,13 @@
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="18dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="16dp">
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/back_button"
|
||||
@@ -144,7 +120,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:text="会话"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="24sp"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
@@ -152,9 +128,10 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="原生 Android 客户端,直接消费 Boss API。"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="13sp" />
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
@@ -162,11 +139,12 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="刷新"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:text="刷"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
@@ -192,10 +170,11 @@
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingLeft="0dp"
|
||||
android:paddingRight="0dp"
|
||||
android:paddingBottom="88dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
@@ -203,11 +182,13 @@
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="76dp"
|
||||
android:layout_height="72dp"
|
||||
android:background="@color/boss_surface"
|
||||
android:elevation="10dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingRight="12dp">
|
||||
|
||||
@@ -217,10 +198,10 @@
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginRight="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:background="@drawable/bg_tab_active"
|
||||
android:text="会话"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
@@ -230,10 +211,10 @@
|
||||
android:layout_marginLeft="6dp"
|
||||
android:layout_marginRight="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:background="@drawable/bg_tab_inactive"
|
||||
android:text="设备"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
@@ -242,10 +223,10 @@
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginLeft="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:background="@drawable/bg_tab_inactive"
|
||||
android:text="我的"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
204
android/app/src/main/res/layout/activity_project_chat.xml
Normal file
204
android/app/src/main/res/layout/activity_project_chat.xml
Normal file
@@ -0,0 +1,204 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="返回"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="项目详情"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:text="设备"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="操作"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="刷新"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/project_chat_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="ifContentScrolls">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_quick_actions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:orientation="horizontal" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_composer_row"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="bottom"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_attach"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:text="+"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/project_chat_input"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:gravity="top|start"
|
||||
android:hint="输入消息"
|
||||
android:inputType="textCapSentences|textMultiLine"
|
||||
android:maxLines="4"
|
||||
android:minHeight="44dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="14dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_send"
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:text="发送"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_multi_select_actions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_multi_forward"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:text="转发"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -2,12 +2,13 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_app_gradient"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
@@ -74,13 +75,13 @@
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="刷新"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -99,10 +100,9 @@
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_panel"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="18dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingRight="18dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
<color name="boss_green">#07C160</color>
|
||||
<color name="boss_green_dark">#04984B</color>
|
||||
<color name="boss_surface">#FFFFFFFF</color>
|
||||
<color name="boss_bg_start">#FFF1F6EE</color>
|
||||
<color name="boss_bg_end">#FFE3F0E3</color>
|
||||
<color name="boss_card_stroke">#1A0F1B12</color>
|
||||
<color name="boss_bg_start">#FFF7F7F7</color>
|
||||
<color name="boss_bg_end">#FFF7F7F7</color>
|
||||
<color name="boss_bg_app">#FFF7F7F7</color>
|
||||
<color name="boss_panel">#FFFFFFFF</color>
|
||||
<color name="boss_card_stroke">#14000000</color>
|
||||
<color name="boss_divider">#FFEAEAEA</color>
|
||||
<color name="boss_text_primary">#FF111111</color>
|
||||
<color name="boss_text_muted">#FF5F6B63</color>
|
||||
<color name="boss_text_soft">#FF8E8E93</color>
|
||||
<color name="colorPrimary">@color/boss_green</color>
|
||||
<color name="colorPrimaryDark">@color/boss_green_dark</color>
|
||||
<color name="colorAccent">@color/boss_green</color>
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
||||
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
public class AboutActivityStaleDownloadCleanupTest {
|
||||
@Test
|
||||
public void collectStaleDownloadIdsForRemoval_returnsIdsWhenReleaseChanged() throws Exception {
|
||||
JSONObject availableRelease = new StubJSONObject()
|
||||
.withString("packageFileName", "boss-android-v1.2.9-release.apk")
|
||||
.withString("version", "v1.2.9");
|
||||
|
||||
long[] ids = AboutActivity.collectStaleDownloadIdsForRemoval(
|
||||
availableRelease,
|
||||
"boss-android-v1.2.8-release.apk",
|
||||
"v1.2.8",
|
||||
true,
|
||||
42L,
|
||||
77L
|
||||
);
|
||||
|
||||
assertArrayEquals(new long[]{42L, 77L}, ids);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void collectStaleDownloadIdsForRemoval_returnsEmptyWhenReleaseMatchesLocalPackage() throws Exception {
|
||||
JSONObject availableRelease = new StubJSONObject()
|
||||
.withString("packageFileName", "boss-android-v1.2.9-release.apk")
|
||||
.withString("version", "v1.2.9");
|
||||
|
||||
long[] ids = AboutActivity.collectStaleDownloadIdsForRemoval(
|
||||
availableRelease,
|
||||
"boss-android-v1.2.9-release.apk",
|
||||
"v1.2.9",
|
||||
true,
|
||||
42L,
|
||||
77L
|
||||
);
|
||||
|
||||
assertArrayEquals(new long[0], ids);
|
||||
}
|
||||
|
||||
private static final class StubJSONObject extends JSONObject {
|
||||
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
|
||||
|
||||
StubJSONObject withString(String key, String value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof String ? (String) value : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key, String fallback) {
|
||||
String value = optString(key);
|
||||
return value.isEmpty() ? fallback : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class AttachmentComposerStateTest {
|
||||
@Test
|
||||
public void imageAttachments_requireConfirmationBeforeSending() {
|
||||
AttachmentComposerState.PendingAttachment attachment =
|
||||
new AttachmentComposerState.PendingAttachment(
|
||||
"image",
|
||||
"现场照片.png",
|
||||
"image/png",
|
||||
4096L,
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertTrue(attachment.requiresConfirmation());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void videoAttachments_requireConfirmationBeforeSending() {
|
||||
AttachmentComposerState.PendingAttachment attachment =
|
||||
new AttachmentComposerState.PendingAttachment(
|
||||
"video",
|
||||
"巡检录屏.mp4",
|
||||
"video/mp4",
|
||||
8192L,
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertTrue(attachment.requiresConfirmation());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fileAttachments_doNotRequireConfirmation() {
|
||||
AttachmentComposerState.PendingAttachment attachment =
|
||||
new AttachmentComposerState.PendingAttachment(
|
||||
"file",
|
||||
"日报.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
16384L,
|
||||
null
|
||||
);
|
||||
|
||||
assertFalse(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertFalse(attachment.requiresConfirmation());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.ProtocolException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class BossApiClientAttachmentTest {
|
||||
@Test
|
||||
public void uploadAttachment_postsMultipartBodyWithSourceType() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/project-1/attachments")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.uploadAttachment(
|
||||
"project-1",
|
||||
"现场照片.png",
|
||||
"image/png",
|
||||
new byte[] {1, 2, 3, 4},
|
||||
"image"
|
||||
);
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/project-1/attachments", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertTrue(connection.contentTypeValue.startsWith("multipart/form-data; boundary="));
|
||||
assertTrue(connection.requestBody().contains("name=\"sourceType\""));
|
||||
assertTrue(connection.requestBody().contains("\r\nimage\r\n"));
|
||||
assertTrue(connection.requestBody().contains("name=\"file\"; filename=\"现场照片.png\""));
|
||||
assertTrue(connection.requestBody().contains("Content-Type: image/png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void analyzeAttachment_postsToAnalyzeEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/project-1/attachments/att-1/analyze")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.analyzeAttachment("project-1", "att-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/project-1/attachments/att-1/analyze", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals("{}", connection.requestBody());
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// JVM 单测不需要落 Android 侧身份缓存。
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
private String requestMethodValue = "GET";
|
||||
private String contentTypeValue = "";
|
||||
|
||||
RecordingConnection(URL url) {
|
||||
super(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {}
|
||||
|
||||
@Override
|
||||
public boolean usingProxy() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect() {}
|
||||
|
||||
@Override
|
||||
public void setRequestMethod(String method) throws ProtocolException {
|
||||
requestMethodValue = method;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
if ("Content-Type".equalsIgnoreCase(key)) {
|
||||
contentTypeValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestProperty(String key) {
|
||||
return requestHeaders.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<String>> getHeaderFields() {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return requestBody.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemorySharedPreferences implements SharedPreferences {
|
||||
private final Map<String, String> values = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public Map<String, ?> getAll() {
|
||||
return Collections.unmodifiableMap(values);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getString(String key, String defValue) {
|
||||
return values.getOrDefault(key, defValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getStringSet(String key, Set<String> defValues) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(String key, int defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(String key, long defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key, float defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getBoolean(String key, boolean defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return values.containsKey(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor edit() {
|
||||
return new Editor() {
|
||||
@Override
|
||||
public Editor putString(String key, String value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor remove(String key) {
|
||||
values.remove(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor clear() {
|
||||
values.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply() {}
|
||||
|
||||
@Override
|
||||
public boolean commit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putStringSet(String key, Set<String> values) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putInt(String key, int value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putLong(String key, long value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putFloat(String key, float value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putBoolean(String key, boolean value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
|
||||
@Override
|
||||
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.ProtocolException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class BossApiClientForwardingTest {
|
||||
@Test
|
||||
public void forwardProjectMessageWritesStructuredJsonBody() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/source/forwards"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
JSONObject payload = ForwardPayloads.build("single", "m1", java.util.List.of());
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage("source", "target", payload);
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/source/forwards", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"targetProjectId\":\"target\",\"mode\":\"single\",\"sourceMessageId\":\"m1\"}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// JVM 单测只关心 request body,不需要走 Android org.json 的身份恢复副作用。
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
private String requestMethodValue = "GET";
|
||||
|
||||
RecordingConnection(URL url) {
|
||||
super(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {}
|
||||
|
||||
@Override
|
||||
public boolean usingProxy() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect() {}
|
||||
|
||||
@Override
|
||||
public void setRequestMethod(String method) throws ProtocolException {
|
||||
requestMethodValue = method;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestProperty(String key) {
|
||||
return requestHeaders.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return requestBody.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemorySharedPreferences implements SharedPreferences {
|
||||
private final Map<String, String> values = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public Map<String, ?> getAll() {
|
||||
return Collections.unmodifiableMap(values);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getString(String key, String defValue) {
|
||||
return values.getOrDefault(key, defValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getStringSet(String key, Set<String> defValues) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(String key, int defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(String key, long defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key, float defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getBoolean(String key, boolean defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return values.containsKey(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor edit() {
|
||||
return new Editor() {
|
||||
@Override
|
||||
public Editor putString(String key, String value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor remove(String key) {
|
||||
values.remove(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor clear() {
|
||||
values.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply() {}
|
||||
|
||||
@Override
|
||||
public boolean commit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putStringSet(String key, Set<String> values) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putInt(String key, int value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putLong(String key, long value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putFloat(String key, float value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putBoolean(String key, boolean value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
|
||||
@Override
|
||||
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class ForwardTargetActivityTest {
|
||||
@Test
|
||||
public void filtersOutSourceConversationFromTargets() {
|
||||
JSONArray conversations = new StubJSONArray(
|
||||
new StubJSONObject().withString("projectId", "source").withString("projectTitle", "源会话"),
|
||||
new StubJSONObject().withString("projectId", "target").withString("projectTitle", "目标会话")
|
||||
);
|
||||
|
||||
List<JSONObject> result = ForwardTargetActivity.collectSelectableTargets(conversations, "source");
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("target", result.get(0).optString("projectId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleModeRequiresOneMessageId() throws Exception {
|
||||
JSONObject payload = ForwardTargetActivity.buildForwardPayload("single", "m1", java.util.List.of());
|
||||
|
||||
assertEquals("single", payload.optString("mode"));
|
||||
assertEquals("m1", payload.optString("sourceMessageId"));
|
||||
assertEquals(
|
||||
"{\"targetProjectId\":\"target\",\"mode\":\"single\",\"sourceMessageId\":\"m1\"}",
|
||||
ForwardPayloads.toRequestBody("target", payload)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bundleModeUsesOrderedMessageIds() throws Exception {
|
||||
JSONObject payload = ForwardTargetActivity.buildForwardPayload("bundle", null, java.util.List.of("m1", "m2"));
|
||||
|
||||
assertEquals("bundle", payload.optString("mode"));
|
||||
assertEquals(2, payload.optJSONArray("sourceMessageIds").length());
|
||||
assertEquals("m1", payload.optJSONArray("sourceMessageIds").optString(0));
|
||||
assertEquals("m2", payload.optJSONArray("sourceMessageIds").optString(1));
|
||||
assertEquals(
|
||||
"{\"targetProjectId\":\"target\",\"mode\":\"bundle\",\"sourceMessageIds\":[\"m1\",\"m2\"]}",
|
||||
ForwardPayloads.toRequestBody("target", payload)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void approvalRequiredResponseUsesApprovalMessage() {
|
||||
StubJSONObject response = new StubJSONObject().withBoolean("approvalRequired", true);
|
||||
assertEquals("已提交主 Agent 审批", ForwardTargetActivity.resolveForwardResultMessage(response));
|
||||
}
|
||||
|
||||
private static final class StubJSONObject extends JSONObject {
|
||||
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
|
||||
|
||||
StubJSONObject withString(String key, String value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
StubJSONObject withBoolean(String key, boolean value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof String ? (String) value : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key, String fallback) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof String ? (String) value : fallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean optBoolean(String key, boolean fallback) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof Boolean ? (Boolean) value : fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StubJSONArray extends JSONArray {
|
||||
private final JSONObject[] values;
|
||||
|
||||
StubJSONArray(JSONObject... values) {
|
||||
this.values = values == null ? new JSONObject[0] : values;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int length() {
|
||||
return values.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject optJSONObject(int index) {
|
||||
if (index < 0 || index >= values.length) {
|
||||
return null;
|
||||
}
|
||||
return values[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class GroupCreateActivityTest {
|
||||
@Test
|
||||
public void collectSelectableConversationItems_filtersOutExistingGroupChats() {
|
||||
JSONObject threadConversation = new StubJSONObject()
|
||||
.withString("projectId", "thread-1")
|
||||
.withString("projectTitle", "线程一")
|
||||
.withBoolean("isGroup", false);
|
||||
JSONObject groupConversation = new StubJSONObject()
|
||||
.withString("projectId", "group-1")
|
||||
.withString("projectTitle", "已有群聊")
|
||||
.withBoolean("isGroup", true);
|
||||
JSONObject sourceConversation = new StubJSONObject()
|
||||
.withString("projectId", "source-1")
|
||||
.withString("projectTitle", "来源线程")
|
||||
.withBoolean("isGroup", false);
|
||||
JSONObject conversationsPayload = new StubJSONObject()
|
||||
.withObjectArray("conversations", threadConversation, groupConversation, sourceConversation);
|
||||
|
||||
java.util.List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, "source-1");
|
||||
|
||||
assertEquals(1, filtered.size());
|
||||
assertEquals("thread-1", filtered.get(0).optString("projectId", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reconcileSelectedProjectIds_keepsManualDeselectionWhenCandidatesStayTheSame() {
|
||||
Set<String> previousCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
|
||||
Set<String> currentSelectedIds = linkedSet("thread-1", "thread-3");
|
||||
Set<String> nextCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
|
||||
|
||||
Set<String> reconciled = GroupCreateActivity.reconcileSelectedProjectIds(
|
||||
currentSelectedIds,
|
||||
previousCandidateIds,
|
||||
nextCandidateIds
|
||||
);
|
||||
|
||||
assertEquals(2, reconciled.size());
|
||||
assertTrue(reconciled.contains("thread-1"));
|
||||
assertTrue(reconciled.contains("thread-3"));
|
||||
assertFalse(reconciled.contains("thread-2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void canCreateGroupChat_blocksWhileRefreshingOrCreating() {
|
||||
Set<String> selectedProjectIds = linkedSet("thread-1");
|
||||
|
||||
assertFalse(GroupCreateActivity.canCreateGroupChat(true, false, selectedProjectIds));
|
||||
assertFalse(GroupCreateActivity.canCreateGroupChat(false, true, selectedProjectIds));
|
||||
assertTrue(GroupCreateActivity.canCreateGroupChat(false, false, selectedProjectIds));
|
||||
assertFalse(GroupCreateActivity.canCreateGroupChat(false, false, linkedSet()));
|
||||
}
|
||||
|
||||
private static Set<String> linkedSet(String... values) {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
for (String value : values) {
|
||||
result.add(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static final class StubJSONObject extends JSONObject {
|
||||
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
|
||||
|
||||
StubJSONObject withString(String key, String value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
StubJSONObject withBoolean(String key, boolean value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
StubJSONObject withObjectArray(String key, JSONObject... entries) {
|
||||
values.put(key, new StubJSONArray(entries));
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key, String defaultValue) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof String ? (String) value : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean optBoolean(String key, boolean defaultValue) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof Boolean ? (Boolean) value : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONArray optJSONArray(String key) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof JSONArray ? (JSONArray) value : null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StubJSONArray extends JSONArray {
|
||||
private final JSONObject[] entries;
|
||||
|
||||
StubJSONArray(JSONObject... entries) {
|
||||
this.entries = entries == null ? new JSONObject[0] : entries;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int length() {
|
||||
return entries.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject optJSONObject(int index) {
|
||||
if (index < 0 || index >= entries.length) {
|
||||
return null;
|
||||
}
|
||||
return entries[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class OtaDownloadStateMapperTest {
|
||||
@Test
|
||||
public void toProgressLabel_formatsKnownProgress() {
|
||||
assertEquals("已下载 50%", OtaDownloadStateMapper.toProgressLabel(50, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toProgressLabel_handlesUnknownProgress() {
|
||||
assertEquals("正在准备下载", OtaDownloadStateMapper.toProgressLabel(0, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class ProjectChatUiStateTest {
|
||||
@Test
|
||||
public void sendEnabled_requiresTextAndNotBusy() {
|
||||
assertFalse(ProjectChatUiState.canSend("", false));
|
||||
assertFalse(ProjectChatUiState.canSend(" ", false));
|
||||
assertFalse(ProjectChatUiState.canSend("你好", true));
|
||||
assertTrue(ProjectChatUiState.canSend("你好", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAutoScroll_onlyWhenNearBottomOrForced() {
|
||||
assertTrue(ProjectChatUiState.shouldAutoScroll(true, false));
|
||||
assertTrue(ProjectChatUiState.shouldAutoScroll(false, true));
|
||||
assertFalse(ProjectChatUiState.shouldAutoScroll(false, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void entersMultiSelectModeAfterFirstToggle() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
assertTrue(state.multiSelecting);
|
||||
assertEquals(1, state.selectedMessageIds.size());
|
||||
assertTrue(state.selectedMessageIds.contains("m1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deselectingLastMessageExitsMultiSelectMode() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(state, "m1");
|
||||
assertFalse(next.multiSelecting);
|
||||
assertTrue(next.selectedMessageIds.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bundleForwardRequiresAtLeastTwoMessages() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
assertFalse(ProjectChatUiState.canForwardSelection(state));
|
||||
|
||||
ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(state, "m2");
|
||||
assertTrue(ProjectChatUiState.canForwardSelection(next));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void selectionPreservesInsertionOrder() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m2");
|
||||
state = ProjectChatUiState.toggleSelection(state, "m1");
|
||||
state = ProjectChatUiState.toggleSelection(state, "m3");
|
||||
|
||||
assertArrayEquals(
|
||||
new String[] {"m2", "m1", "m3"},
|
||||
new ArrayList<>(state.selectedMessageIds).toArray(new String[0])
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toggleSelectionRejectsBlankMessageIds() {
|
||||
try {
|
||||
ProjectChatUiState.toggleSelection(null, " ");
|
||||
fail("Expected IllegalArgumentException");
|
||||
} catch (IllegalArgumentException expected) {
|
||||
assertEquals("messageId must not be blank", expected.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleForwardMessageUsesSingleModeLabel() {
|
||||
assertEquals("转发", ProjectChatUiState.labelForForwardKind("forward_single"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bundleForwardMessageUsesBundleModeLabel() {
|
||||
assertEquals("聊天记录", ProjectChatUiState.labelForForwardKind("forward_bundle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chromeStateUsesMultiSelectHeaderAndActionsWhenSelecting() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
state = ProjectChatUiState.toggleSelection(state, "m2");
|
||||
|
||||
ProjectChatUiState.ChromeState chromeState =
|
||||
ProjectChatUiState.resolveChromeState(state, true, "北区试产线回归", "归档确认");
|
||||
|
||||
assertTrue(chromeState.multiSelecting);
|
||||
assertFalse(chromeState.showComposer);
|
||||
assertTrue(chromeState.showMultiSelectBar);
|
||||
assertFalse(chromeState.showRefresh);
|
||||
assertFalse(chromeState.showHeaderAction);
|
||||
assertTrue(chromeState.forwardEnabled);
|
||||
assertEquals("取消", chromeState.backLabel);
|
||||
assertEquals("已选 2 条", chromeState.title);
|
||||
assertEquals("选择要转发的消息", chromeState.subtitle);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chromeStateUsesConversationHeaderWhenNotSelecting() {
|
||||
ProjectChatUiState.ChromeState chromeState =
|
||||
ProjectChatUiState.resolveChromeState(ProjectChatUiState.emptySelection(), true, "北区试产线回归", "归档确认");
|
||||
|
||||
assertFalse(chromeState.multiSelecting);
|
||||
assertTrue(chromeState.showComposer);
|
||||
assertFalse(chromeState.showMultiSelectBar);
|
||||
assertTrue(chromeState.showRefresh);
|
||||
assertTrue(chromeState.showHeaderAction);
|
||||
assertFalse(chromeState.forwardEnabled);
|
||||
assertEquals("返回", chromeState.backLabel);
|
||||
assertEquals("北区试产线回归", chromeState.title);
|
||||
assertEquals("归档确认", chromeState.subtitle);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reconcileSelectionDropsMessagesMissingFromRenderSet() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
state = ProjectChatUiState.toggleSelection(state, "m2");
|
||||
|
||||
ProjectChatUiState.SelectionState reconciled =
|
||||
ProjectChatUiState.reconcileSelection(state, List.of("m2", "m3"));
|
||||
|
||||
assertTrue(reconciled.multiSelecting);
|
||||
assertEquals(List.of("m2"), new ArrayList<>(reconciled.selectedMessageIds));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void summarizeForwardBundleTruncatesLongLastMessage() {
|
||||
String summary = ProjectChatUiState.summarizeForwardBundle(
|
||||
"这是一条很长很长很长的转发消息摘要,用来验证截断逻辑是否生效并避免卡片过高",
|
||||
3
|
||||
);
|
||||
|
||||
assertTrue(summary.startsWith("3 条消息 · 最后一条:"));
|
||||
assertTrue(summary.endsWith("…"));
|
||||
assertTrue(summary.contains("这是一条很长很长很长的转发消息摘要"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class ProjectDetailActivityChromeBindingsTest {
|
||||
@Test
|
||||
public void multiSelectBindingsHideComposerAndDisableRefresh() {
|
||||
ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.selectOnly("m1");
|
||||
selectionState = ProjectChatUiState.toggleSelection(selectionState, "m2");
|
||||
ProjectChatUiState.ChromeState chromeState = ProjectChatUiState.resolveChromeState(
|
||||
selectionState,
|
||||
true,
|
||||
"北区试产线回归",
|
||||
"归档确认"
|
||||
);
|
||||
|
||||
ProjectDetailActivity.ChromeBindings bindings =
|
||||
ProjectDetailActivity.buildChromeBindings(chromeState, false);
|
||||
|
||||
assertTrue(bindings.multiSelecting);
|
||||
assertFalse(bindings.showComposer);
|
||||
assertTrue(bindings.showMultiSelectBar);
|
||||
assertFalse(bindings.showRefresh);
|
||||
assertFalse(bindings.showHeaderAction);
|
||||
assertTrue(bindings.enableForwardButton);
|
||||
assertFalse(bindings.enablePullRefresh);
|
||||
assertEquals("取消", bindings.backLabel);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalBindingsRestoreConversationChrome() {
|
||||
ProjectChatUiState.ChromeState chromeState = ProjectChatUiState.resolveChromeState(
|
||||
ProjectChatUiState.emptySelection(),
|
||||
true,
|
||||
"北区试产线回归",
|
||||
"归档确认"
|
||||
);
|
||||
|
||||
ProjectDetailActivity.ChromeBindings bindings =
|
||||
ProjectDetailActivity.buildChromeBindings(chromeState, true);
|
||||
|
||||
assertFalse(bindings.multiSelecting);
|
||||
assertTrue(bindings.showComposer);
|
||||
assertFalse(bindings.showMultiSelectBar);
|
||||
assertTrue(bindings.showRefresh);
|
||||
assertTrue(bindings.showHeaderAction);
|
||||
assertFalse(bindings.enableForwardButton);
|
||||
assertTrue(bindings.enablePullRefresh);
|
||||
assertEquals("返回", bindings.backLabel);
|
||||
assertEquals("北区试产线回归", bindings.title);
|
||||
assertEquals("归档确认", bindings.subtitle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
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.ShadowDialog;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class ProjectDetailActivityUiTest {
|
||||
@Test
|
||||
public void multiSelectModeUpdatesRealChatChrome() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
|
||||
ReflectionHelpers.setField(activity, "currentScreenTitle", "北区试产线回归");
|
||||
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "归档确认");
|
||||
|
||||
LinearLayout contentLayout = activity.findViewById(R.id.screen_content);
|
||||
View firstMessage = buildBoundMessageView(activity, "m1", "第一条消息");
|
||||
View secondMessage = buildBoundMessageView(activity, "m2", "第二条消息");
|
||||
contentLayout.addView(firstMessage);
|
||||
contentLayout.addView(secondMessage);
|
||||
|
||||
assertEquals(View.VISIBLE, activity.findViewById(R.id.project_chat_composer_row).getVisibility());
|
||||
|
||||
firstMessage.performLongClick();
|
||||
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog actionDialog = (AlertDialog) latestDialog;
|
||||
ListView listView = actionDialog.getListView();
|
||||
View multiSelectItem = listView.getAdapter().getView(1, null, listView);
|
||||
listView.performItemClick(multiSelectItem, 1, listView.getAdapter().getItemId(1));
|
||||
|
||||
LinearLayout composerRow = activity.findViewById(R.id.project_chat_composer_row);
|
||||
LinearLayout multiSelectActions = activity.findViewById(R.id.project_chat_multi_select_actions);
|
||||
Button backButton = activity.findViewById(R.id.screen_back_button);
|
||||
Button refreshButton = activity.findViewById(R.id.screen_refresh_button);
|
||||
Button forwardButton = activity.findViewById(R.id.project_chat_multi_forward);
|
||||
|
||||
assertEquals(View.GONE, composerRow.getVisibility());
|
||||
assertEquals(View.VISIBLE, multiSelectActions.getVisibility());
|
||||
assertEquals("取消", backButton.getText().toString());
|
||||
assertEquals(View.GONE, refreshButton.getVisibility());
|
||||
assertEquals(false, forwardButton.isEnabled());
|
||||
|
||||
secondMessage.performClick();
|
||||
assertTrue(forwardButton.isEnabled());
|
||||
|
||||
backButton.performClick();
|
||||
|
||||
assertEquals(View.VISIBLE, composerRow.getVisibility());
|
||||
assertEquals(View.GONE, multiSelectActions.getVisibility());
|
||||
assertEquals("返回", backButton.getText().toString());
|
||||
assertEquals(View.VISIBLE, refreshButton.getVisibility());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manualAnalysisAttachmentShowsActionChip() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject attachment = new JSONObject()
|
||||
.put("attachmentId", "att-1")
|
||||
.put("fileName", "巡检录像.mp4")
|
||||
.put("mimeType", "video/mp4")
|
||||
.put("attachmentKind", "video")
|
||||
.put("analysisState", "ready_manual")
|
||||
.put("fileSizeBytes", 2048);
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "msg-1")
|
||||
.put("kind", "attachment")
|
||||
.put("body", "已发送附件")
|
||||
.put("attachments", new JSONArray().put(attachment));
|
||||
|
||||
View attachmentView = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"buildAttachmentMessageView",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, message),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "你"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "09:26"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true)
|
||||
);
|
||||
|
||||
assertTrue(viewTreeContainsText(attachmentView, "让 AI 分析"));
|
||||
assertTrue(viewTreeContainsText(attachmentView, "待分析"));
|
||||
}
|
||||
|
||||
private static View buildBoundMessageView(TestProjectDetailActivity activity, String messageId, String body) {
|
||||
TextView messageView = new TextView(activity);
|
||||
messageView.setText(body);
|
||||
messageView.setGravity(Gravity.START);
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"bindMessageInteractions",
|
||||
ReflectionHelpers.ClassParameter.from(View.class, messageView),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, messageId),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, body),
|
||||
ReflectionHelpers.ClassParameter.from(View.OnClickListener.class, null)
|
||||
);
|
||||
return messageView;
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (expectedText.contentEquals(text)) {
|
||||
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;
|
||||
}
|
||||
|
||||
public static class TestProjectDetailActivity extends ProjectDetailActivity {
|
||||
@Override
|
||||
boolean shouldLoadOnCreate() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class RootTabMemoryTest {
|
||||
@Test
|
||||
public void resolveInitialTab_prefersExplicitTab() {
|
||||
assertEquals("devices", RootTabMemory.resolveInitialTab("devices", "me", "conversations"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveInitialTab_fallsBackToStoredTab() {
|
||||
assertEquals("me", RootTabMemory.resolveInitialTab(null, "me", "devices"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveInitialTab_usesPreferredEntryBeforeDefault() {
|
||||
assertEquals("devices", RootTabMemory.resolveInitialTab(null, null, "devices"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveInitialTab_defaultsToConversations() {
|
||||
assertEquals("conversations", RootTabMemory.resolveInitialTab(null, null, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class WechatSurfaceMapperTest {
|
||||
@Test
|
||||
public void toConversationRow_mapsWechatConversationFieldsFromThreadPayload() throws Exception {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("projectTitle", "项目 A")
|
||||
.withString("threadTitle", "北区试产线回归")
|
||||
.withString("folderLabel", "归档确认")
|
||||
.withString("preview", "旧预览")
|
||||
.withString("lastMessagePreview", "现场摄像头关键帧")
|
||||
.withString("latestReplyLabel", "09:26")
|
||||
.withInt("unreadCount", 3)
|
||||
.withInt("activityIconCount", 2)
|
||||
.withString("topPinnedLabel", "置顶")
|
||||
.withBoolean("isGroup", false)
|
||||
.withObject("avatar", new StubJSONObject()
|
||||
.withString("primary", "M")
|
||||
.withString("secondary", "W"))
|
||||
.withObjectArray("groupMembers",
|
||||
new StubJSONObject().withString("threadId", "t-1").withString("avatar", "M").withString("title", "Mac Studio"),
|
||||
new StubJSONObject().withString("threadId", "t-2").withString("avatar", "W").withString("title", "Windows GPU"));
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("北区试产线回归", row.threadTitle);
|
||||
assertEquals("归档确认", row.folderLabel);
|
||||
assertEquals("现场摄像头关键帧", row.lastMessagePreview);
|
||||
assertEquals("09:26", row.timeLabel);
|
||||
assertEquals(3, row.unreadCount);
|
||||
assertEquals("置顶", row.topPinnedLabel);
|
||||
assertEquals(2, row.activityIconCount);
|
||||
assertFalse(row.isGroup);
|
||||
assertEquals("M", row.avatarPrimary);
|
||||
assertEquals("W", row.avatarSecondary);
|
||||
assertEquals(2, row.groupAvatarMembers.length);
|
||||
assertEquals("Mac Studio", row.groupAvatarMembers[0].title);
|
||||
assertEquals("W", row.groupAvatarMembers[1].avatarLabel);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_prefersGroupMembersForGroupAvatarSummary() throws Exception {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("projectTitle", "群聊项目")
|
||||
.withString("threadTitle", "容灾切换验证")
|
||||
.withString("folderLabel", "Mac + Windows 协作")
|
||||
.withString("lastMessagePreview", "最新: API 切换记录回传")
|
||||
.withString("latestReplyLabel", "09:12")
|
||||
.withInt("activityIconCount", 2)
|
||||
.withString("topPinnedLabel", "置顶")
|
||||
.withBoolean("isGroup", true)
|
||||
.withObjectArray("groupMembers",
|
||||
new StubJSONObject().withString("threadId", "group-1").withString("avatar", "M").withString("title", "Mac Studio"),
|
||||
new StubJSONObject().withString("threadId", "group-2").withString("avatar", "W").withString("title", "Windows GPU"),
|
||||
new StubJSONObject().withString("threadId", "group-3").withString("avatar", "C").withString("title", "Cloud Backup"));
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertTrue(row.isGroup);
|
||||
assertEquals("容灾切换验证", row.threadTitle);
|
||||
assertEquals("Mac + Windows 协作", row.folderLabel);
|
||||
assertEquals("最新: API 切换记录回传", row.lastMessagePreview);
|
||||
assertEquals("09:12", row.timeLabel);
|
||||
assertEquals(3, row.groupAvatarMembers.length);
|
||||
assertEquals("M", row.groupAvatarMembers[0].avatarLabel);
|
||||
assertEquals("Cloud Backup", row.groupAvatarMembers[2].title);
|
||||
assertEquals("", row.avatarPrimary);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toDeviceRow_mapsLegacyWechatThreeLineSummary() throws Exception {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("avatar", "M")
|
||||
.withString("status", "online")
|
||||
.withString("account", "17600003315")
|
||||
.withStringArray("projects", "北区试产线回归", "容灾切换验证")
|
||||
.withInt("quota5h", 8)
|
||||
.withInt("quota7d", 22);
|
||||
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
|
||||
assertEquals("Mac Studio", row.title);
|
||||
assertEquals("账号: 17600003315 · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
|
||||
assertEquals("额度: 5h 8% · 7d 22%", row.meta);
|
||||
assertEquals("M", row.avatarLabel);
|
||||
assertEquals("online", row.statusKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toDeviceRow_preservesAbnormalStatus() throws Exception {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("status", "abnormal")
|
||||
.withString("account", "17600003315");
|
||||
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
|
||||
assertEquals("Mac Studio", row.title);
|
||||
assertEquals("账号: 17600003315", row.subtitle);
|
||||
assertEquals("额度: 暂无 · 状态异常", row.meta);
|
||||
assertEquals("abnormal", row.statusKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deviceDetailSummary_keepsEssentialContextAlongsideNoteEndpointAndProjects() throws Exception {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("status", "online")
|
||||
.withString("account", "17600003315")
|
||||
.withString("note", "书房主机")
|
||||
.withString("endpoint", "https://boss.hyzq.net/device/mac-studio")
|
||||
.withStringArray("projects", "master-agent", "android-app");
|
||||
|
||||
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(item);
|
||||
|
||||
assertEquals("Mac Studio", summary.title);
|
||||
assertEquals("账号: 17600003315 · 项目: master-agent / android-app", summary.subtitle);
|
||||
assertEquals("额度: 暂无 · 书房主机 · https://boss.hyzq.net/device/mac-studio · 项目 master-agent, android-app", summary.meta);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootMeMenuTitles_matchLegacyWechatMenuWithOpsEntry() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootTabOrder_isWechatStyle() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"会话", "设备", "我的"},
|
||||
WechatSurfaceMapper.rootTabLabels()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mainPage_keepsOpsEntryInStableWechatMenuOrder() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void opsEntryCopy_staysInMeFlowWithoutLegacyAdvancedEntrySemantics() throws Exception {
|
||||
WechatSurfaceMapper.MeMenuItem opsItem = WechatSurfaceMapper.findMeMenuItem("ops");
|
||||
|
||||
assertNotNull(opsItem);
|
||||
assertEquals("运维与修复", opsItem.title);
|
||||
assertEquals("查看运维会话、修复回放与 standby 切换", opsItem.description);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void meFlow_doesNotExposeAuditConversationCopy() throws Exception {
|
||||
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
|
||||
assertFalse(item.title.contains("审计"));
|
||||
assertFalse(item.description.contains("审计"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aboutActivity_parsesStructuredOtaSummaryArrayIntoReadableContent() throws Exception {
|
||||
JSONObject ota = new StubJSONObject()
|
||||
.withObject("availableRelease", new StubJSONObject()
|
||||
.withString("version", "v1.2.8")
|
||||
.withStringArray("summary", "优化设备状态刷新", "修复主 Agent 会话排序", "提升 OTA 回收稳定性"));
|
||||
|
||||
java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod("buildOtaContentBody", JSONObject.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
String content = (String) method.invoke(null, ota);
|
||||
|
||||
assertEquals("版本 v1.2.8\n1. 优化设备状态刷新\n2. 修复主 Agent 会话排序\n3. 提升 OTA 回收稳定性", content);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aboutActivity_rejectsStaleDownloadedApkWhenAvailableReleaseChanged() throws Exception {
|
||||
JSONObject availableRelease = new StubJSONObject()
|
||||
.withString("version", "v1.2.9")
|
||||
.withString("packageFileName", "boss-android-v1.2.9-release.apk");
|
||||
|
||||
java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod(
|
||||
"isDownloadedReleaseCurrent",
|
||||
JSONObject.class,
|
||||
String.class,
|
||||
String.class
|
||||
);
|
||||
method.setAccessible(true);
|
||||
|
||||
boolean stillCurrent = (Boolean) method.invoke(
|
||||
null,
|
||||
availableRelease,
|
||||
"boss-android-v1.2.8-release.apk",
|
||||
"v1.2.8"
|
||||
);
|
||||
|
||||
assertFalse(stillCurrent);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void skillInventory_fallsBackWhenExplicitDeviceIdIsInvalid() throws Exception {
|
||||
JSONArray devices = new StubObjectArray(
|
||||
new StubJSONObject()
|
||||
.withString("id", "device-b")
|
||||
.withString("account", "17600003315"),
|
||||
new StubJSONObject()
|
||||
.withString("id", "device-c")
|
||||
.withString("account", "other-account")
|
||||
);
|
||||
|
||||
java.lang.reflect.Method method = SkillInventoryActivity.class.getDeclaredMethod(
|
||||
"chooseTargetDeviceId",
|
||||
String.class,
|
||||
String.class,
|
||||
String.class,
|
||||
JSONArray.class
|
||||
);
|
||||
method.setAccessible(true);
|
||||
|
||||
String resolved = (String) method.invoke(
|
||||
null,
|
||||
"stale-device-id",
|
||||
"missing-bound-device",
|
||||
"17600003315",
|
||||
devices
|
||||
);
|
||||
|
||||
assertEquals("device-b", resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aboutActivity_collectsAllPositiveDownloadIdsForStaleRemoval() throws Exception {
|
||||
java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod(
|
||||
"collectDownloadIdsForRemoval",
|
||||
long.class,
|
||||
long.class
|
||||
);
|
||||
method.setAccessible(true);
|
||||
|
||||
long[] ids = (long[]) method.invoke(null, 42L, 77L);
|
||||
|
||||
assertArrayEquals(new long[]{42L, 77L}, ids);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectQuickActions_keepOnlyGoalsAndVersions() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"项目目标", "版本记录"},
|
||||
WechatSurfaceMapper.projectQuickActions()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectDetailInfoTarget_routesSingleThreadsToConversationInfo() {
|
||||
assertEquals(ConversationInfoActivity.class, WechatSurfaceMapper.resolveConversationInfoTargetClass(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectDetailInfoTarget_routesGroupChatsToGroupInfo() {
|
||||
assertEquals(GroupInfoActivity.class, WechatSurfaceMapper.resolveConversationInfoTargetClass(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectPrimarySections_keepOnlyChatEssentials() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"quick_actions", "messages", "composer"},
|
||||
WechatSurfaceMapper.projectPrimarySections()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conversationsChrome_copyStaysLightweightInsteadOfConsoleInstructions() throws Exception {
|
||||
assertEquals("会话", WechatSurfaceMapper.loginTitle());
|
||||
assertEquals("进入会话", WechatSurfaceMapper.loginButtonLabel());
|
||||
assertEquals("项目自动对应设备 GUI 项目文件夹", WechatSurfaceMapper.conversationsHintPillText());
|
||||
assertEquals("", WechatSurfaceMapper.conversationsHeaderSubtitle());
|
||||
assertFalse(WechatSurfaceMapper.loginHintText().contains("Boss API"));
|
||||
assertFalse(WechatSurfaceMapper.loginHintText().contains("控制台"));
|
||||
assertFalse(WechatSurfaceMapper.loginHintText().contains("数据仍来自现有 Boss API"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conversationActivityIcons_useAnimatedDotViewsInsteadOfTextGlyphs() throws Exception {
|
||||
assertEquals("animated_dots", WechatSurfaceMapper.conversationActivityIconMode());
|
||||
assertEquals(4, WechatSurfaceMapper.maxConversationActivityIcons());
|
||||
assertEquals("cancel_on_detach", WechatSurfaceMapper.conversationActivityAnimationCleanup());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void meMenuItems_useStableKeysInsteadOfDisplayTitlesForRouting() throws Exception {
|
||||
WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems();
|
||||
|
||||
assertEquals(6, items.length);
|
||||
assertEquals("security", items[0].key);
|
||||
assertEquals("账号与安全", items[0].title);
|
||||
assertEquals("settings", items[1].key);
|
||||
assertEquals("ops", items[2].key);
|
||||
assertEquals("运维与修复", items[2].title);
|
||||
assertEquals("ai_accounts", items[3].key);
|
||||
assertEquals("skills", items[4].key);
|
||||
assertEquals("about", items[5].key);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshMergePolicy_appliesSuccessfulPayloadsWithoutDroppingCachedValues() throws Exception {
|
||||
JSONArray cachedConversations = new StubStringArray("cached-conversation");
|
||||
JSONArray freshConversations = new StubStringArray("fresh-conversation");
|
||||
JSONArray cachedDevices = new StubStringArray("cached-device");
|
||||
JSONObject cachedOta = new StubJSONObject().withString("version", "1.0.0");
|
||||
JSONObject freshSettings = new StubJSONObject().withString("preferredEntryPoint", "devices");
|
||||
|
||||
assertSame(
|
||||
freshConversations,
|
||||
WechatSurfaceMapper.resolveRefreshValue(cachedConversations, freshConversations, true)
|
||||
);
|
||||
assertSame(
|
||||
cachedDevices,
|
||||
WechatSurfaceMapper.resolveRefreshValue(cachedDevices, new StubStringArray("fresh-device"), false)
|
||||
);
|
||||
assertSame(
|
||||
cachedOta,
|
||||
WechatSurfaceMapper.resolveRefreshValue(cachedOta, new StubJSONObject().withString("version", "2.0.0"), false)
|
||||
);
|
||||
assertSame(
|
||||
freshSettings,
|
||||
WechatSurfaceMapper.resolveRefreshValue(null, freshSettings, true)
|
||||
);
|
||||
assertNull(WechatSurfaceMapper.resolveRefreshValue(null, null, false));
|
||||
}
|
||||
|
||||
private static final class StubJSONObject extends JSONObject {
|
||||
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
|
||||
|
||||
StubJSONObject withString(String key, String value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
StubJSONObject withInt(String key, int value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
StubJSONObject withBoolean(String key, boolean value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
StubJSONObject withObject(String key, JSONObject value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
StubJSONObject withStringArray(String key, String... entries) {
|
||||
values.put(key, new StubStringArray(entries));
|
||||
return this;
|
||||
}
|
||||
|
||||
StubJSONObject withObjectArray(String key, JSONObject... entries) {
|
||||
values.put(key, new StubObjectArray(entries));
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key, String defaultValue) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof String ? (String) value : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int optInt(String key, int defaultValue) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof Integer ? (Integer) value : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean optBoolean(String key, boolean defaultValue) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof Boolean ? (Boolean) value : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject optJSONObject(String key) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof JSONObject ? (JSONObject) value : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONArray optJSONArray(String key) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof JSONArray ? (JSONArray) value : null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StubStringArray extends JSONArray {
|
||||
private final String[] entries;
|
||||
|
||||
StubStringArray(String... entries) {
|
||||
this.entries = entries == null ? new String[0] : entries;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int length() {
|
||||
return entries.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(int index) {
|
||||
if (index < 0 || index >= entries.length) {
|
||||
return "";
|
||||
}
|
||||
return entries[index] == null ? "" : entries[index];
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StubObjectArray extends JSONArray {
|
||||
private final JSONObject[] entries;
|
||||
|
||||
StubObjectArray(JSONObject... entries) {
|
||||
this.entries = entries == null ? new JSONObject[0] : entries;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int length() {
|
||||
return entries.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject optJSONObject(int index) {
|
||||
if (index < 0 || index >= entries.length) {
|
||||
return null;
|
||||
}
|
||||
return entries[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@
|
||||
- `src/lib/boss-device-auth.ts`:设备 token / 登录会话混合鉴权辅助
|
||||
- `src/lib/boss-events.ts`:SSE 事件总线
|
||||
- `src/lib/boss-master-agent.ts`:主 Agent 真实回复链路、Master Codex Node relay 与 API 容灾逻辑
|
||||
- `src/lib/boss-attachments.ts`:附件类型识别、分析状态决策和下载头
|
||||
- `src/lib/boss-storage.ts`:附件存储抽象、配置校验和脱敏输出
|
||||
- `src/lib/boss-storage-server-file.ts`:服务器文件存储上传 / 读取
|
||||
- `src/lib/boss-storage-aliyun-oss.ts`:阿里 OSS 私有桶上传 / 签名下载
|
||||
- `src/lib/boss-ota.ts`:APK OTA 产物定位与元数据读取
|
||||
- `src/lib/boss-projections.ts`:当前聚合 BFF 投影视图
|
||||
- `src/components/app-runtime.tsx`:APP 日志桥、SSE 刷新和 Skill 面板
|
||||
@@ -46,10 +50,17 @@
|
||||
- `android`:原生 Android 客户端工程
|
||||
- `android/app/src/main/java/com/hyzq/boss/MainActivity.java`:原生入口 Activity
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`:原生 API 客户端
|
||||
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`:原生项目详情、消息、目标/版本/转发入口
|
||||
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`:原生设备详情和技能入口
|
||||
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`:原生聊天优先项目页,只保留目标/版本轻入口
|
||||
- `android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java`:原生微信式会话信息页,支持线程改名和发起群聊
|
||||
- `android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java`:原生群资料页,支持群名修改与成员查看
|
||||
- `android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java`:原生独立群聊创建页
|
||||
- `android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java`:原生微信式会话选择页,承接单条转发与多选合并转发
|
||||
- `android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java`:原生附件发送确认规则与待上传附件模型
|
||||
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`:原生设备详情与技能入口
|
||||
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`:原生 AI 账号管理页
|
||||
- `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`:原生运维 / 审计中心
|
||||
- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`:原生微信式 surface contract
|
||||
- `android/app/src/main/res/layout/activity_project_chat.xml`:原生聊天页布局
|
||||
- `android/signing/release-signing.properties.example`:release 签名模板
|
||||
|
||||
这些不是当前运行真相:
|
||||
@@ -69,6 +80,11 @@
|
||||
- `GET /api/v1/projects/master-agent` 正常,主 Agent 项目页已能看到 APP 实时日志
|
||||
- `GET /api/v1/accounts` 正常,已返回主 GPT / 备用 GPT / API 容灾摘要
|
||||
- `GET /api/v1/devices/mac-studio/skills` 正常
|
||||
- `GET /api/v1/storage/config` 正常,已返回当前登录用户的附件存储模式和脱敏 OSS 摘要
|
||||
- `POST /api/v1/storage/config/validate` 正常,已验证可校验并保存阿里 OSS 私有桶配置
|
||||
- `POST /api/v1/projects/[projectId]/attachments` 正常,已支持图片 / 视频 / 文件上传与附件消息写入
|
||||
- `POST /api/v1/projects/[projectId]/attachments/[attachmentId]/analyze` 正常,已支持手动触发主 Agent 附件分析
|
||||
- `GET /api/v1/attachments/[attachmentId]/download` 正常,已支持会话鉴权下载和 task token 下载
|
||||
- `POST /api/auth/login` 正常,会写入 `boss_session`
|
||||
- `boss_session` 当前默认保持 30 天
|
||||
- `GET /api/auth/session` 正常
|
||||
@@ -105,15 +121,20 @@
|
||||
## 5. 当前最重要的产品逻辑
|
||||
|
||||
- 一级导航固定:`会话 / 设备 / 我的`
|
||||
- `会话` 页按项目渲染聊天列表,主 Agent 永远置顶
|
||||
- 单设备项目显示单头像,多设备协作显示群聊式组合头像
|
||||
- 非群聊项目显示线程上下文余量圆环
|
||||
- 项目聊天页必须展示活跃线程预算、handoff 状态和主 Agent 调度摘要
|
||||
- `会话` 页当前按“线程 = 聊天窗口”渲染聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式固定在最上面
|
||||
- 单线程会话主标题显示线程名,第二行显示所属文件夹名,第三行显示最后一条消息预览,右下角显示后台活跃数量动态图标
|
||||
- 单设备项目显示单头像,多线程群聊显示群聊式组合头像
|
||||
- 项目聊天页当前已经改成聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口;线程预算、handoff、运维与转发能力仍保留数据和深层活动页,但不再出现在主聊天面
|
||||
- 线程改名当前遵循微信最新逻辑:从聊天页右上角进入会话信息页,再进行改名
|
||||
- 当前已支持从单线程会话发起独立群聊:原会话保留,新群聊自动命名并可在群资料页改名
|
||||
- 当前已支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,单条消息转发显示为普通转发消息,多条消息转发显示为聊天记录卡片
|
||||
- 当前已支持聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本默认自动进入主 Agent 附件分析,视频 / Office / 大文件默认手动触发
|
||||
- 当前附件与存储配置页位于 `我的 > 附件与存储`:默认使用服务器文件存储,用户可按账号切到阿里 OSS 私有桶;下载链会优先使用附件上传时固化的 OSS 快照,避免用户后续改配置后旧附件失效
|
||||
- 主 Agent 项目页会实时吸收 APP 端日志,用于边对话边指导 APK / Web 优化
|
||||
- 移动端 UI 已去掉假的状态栏与桌面预览壳;底部一级导航固定在视口底部,返回逻辑不会再把 APP 根页直接弹回桌面
|
||||
- `项目目标` 支持用户编辑、主 Agent 复核、完成项自动划线
|
||||
- `版本迭代记录` 只读,由主 Agent 汇总
|
||||
- `我的 > 运维与修复 > 运维对话 / 审计对话`
|
||||
- `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
|
||||
- `我的 > AI 账号` 必须可查看和切换 `主 GPT / 备用 GPT / API 容灾`
|
||||
- `我的 > 技能` 必须按绑定设备展示 Skill,并支持一键复制调用语句
|
||||
- `设备` 页当前只允许出现生产设备,旧演示脏数据不能回流到正式视图
|
||||
@@ -130,7 +151,7 @@
|
||||
- 邮件:`Postfix + Dovecot`
|
||||
- Android:`AppCompatActivity + 原生 XML 布局 + HttpURLConnection`
|
||||
- 原生登录恢复:`SharedPreferences + restore token`
|
||||
- 当前最新原生 APK:`2.1.1`(`versionCode=8`)
|
||||
- 当前最新原生 APK:`2.5.0`(`versionCode=13`)
|
||||
|
||||
当前不要误判成已经用了:
|
||||
|
||||
@@ -184,7 +205,7 @@ npm run apk:debug
|
||||
- APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通,但日志检索、告警和远程 Skill 管理仍未做
|
||||
- 数据库尚未替代文件存储
|
||||
- 域名入口的代理 / 分裂 DNS 结构仍未完全摸清
|
||||
- 图片 / 视频真实文件上传仍未接对象存储
|
||||
- 当前只支持服务器文件存储和阿里 OSS,尚未接更多对象存储或更丰富的附件详情页
|
||||
- 认证没有真实 session 和令牌吊销
|
||||
|
||||
## 9. 继续开发时的工作原则
|
||||
|
||||
@@ -21,9 +21,14 @@
|
||||
- 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- API 客户端:`android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- 当前导航:`会话 / 设备 / 我的`
|
||||
- 当前一级交互:微信式简单列表与聊天优先
|
||||
- 当前微信式 surface contract:`android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- 当前原生活动页:
|
||||
- `MainActivity`
|
||||
- `ProjectDetailActivity`
|
||||
- `ConversationInfoActivity`
|
||||
- `GroupInfoActivity`
|
||||
- `GroupCreateActivity`
|
||||
- `ProjectGoalsActivity`
|
||||
- `ProjectVersionsActivity`
|
||||
- `ProjectForwardActivity`
|
||||
@@ -36,6 +41,34 @@
|
||||
- `AiAccountsActivity`
|
||||
- `OpsCenterActivity`
|
||||
- `AboutActivity`
|
||||
- 当前项目聊天页:
|
||||
- `ProjectDetailActivity` 已改成聊天优先布局
|
||||
- 主面只保留 `项目目标 / 版本记录`
|
||||
- 右上角会进入微信式 `会话信息 / 群资料`
|
||||
- 单线程会话支持按微信最新逻辑改线程名
|
||||
- 当前已经支持从单线程会话发起独立群聊,群聊创建后作为新会话保留,原会话不升级
|
||||
- 当前已经支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`
|
||||
- 当前多选模式会切换成微信式 `取消 + 已选数量 + 底部转发` 状态
|
||||
- 当前统一使用 `ForwardTargetActivity` 选择目标会话,替换旧的备注转发主链
|
||||
- 当前已支持聊天附件主链:输入框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / 视频先确认,文件直接发送
|
||||
- 当前附件消息支持下载、原生打开、手动分析和自动分析状态展示
|
||||
- `线程详情 / 运维调试` 仍保留对应原生活动页,但已退出主聊天面
|
||||
- 当前已补上本地发送中气泡、发送按钮状态控制,以及“只有接近底部才自动滚到底”的消息流行为
|
||||
- 当前根页导航:
|
||||
- `MainActivity` 会记住最近一次停留的 `会话 / 设备 / 我的` tab
|
||||
- 根页返回逻辑已改成“先回会话 tab,再按一次返回进入后台”
|
||||
- 当前会话列表:
|
||||
- 已切到“线程 = 会话窗口”
|
||||
- 主标题显示线程名
|
||||
- 第二行显示所属文件夹名
|
||||
- 右下角显示后台活跃数量动态图标
|
||||
- `主 Agent / 审计对话` 已作为普通置顶会话固定在顶部
|
||||
- 当前 `关于` 页:
|
||||
- 保留版本与 OTA 操作
|
||||
- 当前已补上 OTA 下载进度、失败重试、安装授权提示和返回关于页后的本地状态恢复
|
||||
- 当前 `我的` 根页:
|
||||
- 保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
|
||||
- `运维与修复` 直接进入 `OpsCenterActivity`
|
||||
- 当前登录:临时免验证,点击登录直接创建最高管理员会话
|
||||
- 当前会话恢复:`SharedPreferences` 中保存 `boss_session / restore_token / account`
|
||||
|
||||
@@ -93,6 +126,7 @@
|
||||
- `GET /devices/add`
|
||||
- `GET /me/security`
|
||||
- `GET /me/about`
|
||||
- `GET /me/storage`
|
||||
- `GET /me/ai-accounts`
|
||||
- `GET /me/ops`
|
||||
- `GET /me/ops/audit`
|
||||
@@ -235,8 +269,12 @@
|
||||
- 关键字段:
|
||||
- `conversationType`
|
||||
- `manualPinned`
|
||||
- `contextBudgetIndicator`
|
||||
- `mustFinishBeforeCompaction`
|
||||
- `threadTitle`
|
||||
- `folderLabel`
|
||||
- `lastMessagePreview`
|
||||
- `activityIconCount`
|
||||
- `topPinnedLabel`
|
||||
- `groupMembers`
|
||||
|
||||
#### `POST /api/v1/conversations/[projectId]/actions`
|
||||
|
||||
@@ -268,6 +306,36 @@
|
||||
- `projectId=master-agent` 且 `kind=text` 时,会继续触发主 Agent 真实回复链路
|
||||
- 当前主链路优先走 `Master Codex Node`:`task queue -> local-agent -> codex exec -> complete`
|
||||
- 如本机节点未接通,可切到 `OpenAI API` 容灾账号
|
||||
- 群聊项目当前会带上 `collaborationGate`,用于标明当前是否需要先经主 Agent / 用户审批
|
||||
|
||||
#### `GET /api/v1/projects/[projectId]/participants`
|
||||
|
||||
- 用途:读取单线程会话的线程归属信息,或群聊会话的成员线程列表
|
||||
- 返回:
|
||||
- `projectId`
|
||||
- `isGroup`
|
||||
- `threadMeta`
|
||||
- `participants[]`
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/rename`
|
||||
|
||||
- 用途:重命名单线程会话或群聊会话
|
||||
- 输入:
|
||||
- `mode`: `thread | group`
|
||||
- `name`
|
||||
- 当前行为:
|
||||
- `mode=thread` 时同步更新线程显示名和会话标题
|
||||
- `mode=group` 时更新群聊名称
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/group-chat`
|
||||
|
||||
- 用途:从当前单线程会话出发,创建新的独立群聊
|
||||
- 输入:
|
||||
- `memberProjectIds[]`
|
||||
- 当前行为:
|
||||
- 原始单线程会话会保留
|
||||
- 新群聊默认自动命名
|
||||
- 新群聊默认由主 Agent 发起,并以开发任务协作为默认模式
|
||||
|
||||
#### `GET /api/v1/accounts`
|
||||
|
||||
@@ -312,8 +380,80 @@
|
||||
|
||||
- 用途:把消息转发到另一个项目
|
||||
- 输入:
|
||||
- `mode`: `single | bundle`
|
||||
- `targetProjectId`
|
||||
- `note`
|
||||
- `sourceMessageId`:当 `mode=single`
|
||||
- `sourceMessageIds`:当 `mode=bundle`,且至少 2 条
|
||||
- 当前行为:
|
||||
- `single` 会生成 `kind=forward_single` 的普通转发消息,并保留 `forwardSource`
|
||||
- `bundle` 会生成 `kind=forward_bundle` 的聊天记录卡片,并保留来源会话、消息数量、时间范围和摘要列表
|
||||
- 当前一次只允许选择一个目标会话
|
||||
- 当前会过滤源会话本身,避免把消息转发回当前会话
|
||||
- 当前目标既可以是单线程会话,也可以是群聊、`主 Agent` 或 `审计对话`
|
||||
- 非开发任务下如命中线程沟通限制,接口会预留 `approvalRequired / approvalReason` 返回
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/attachments`
|
||||
|
||||
- 用途:上传图片 / 视频 / 文件,并在当前会话写入附件消息
|
||||
- 输入:
|
||||
- `file`
|
||||
- `sourceType`: `image | video | file`
|
||||
- 当前行为:
|
||||
- 默认使用当前登录用户的附件存储配置,未配置时走 `server_file`
|
||||
- 用户切到 `oss + aliyun_oss` 后,会上传到阿里 OSS 私有桶,并在附件里固化 `storageSnapshot`
|
||||
- 图片 / PDF / 文本默认会生成 `queued_auto` 附件分析任务
|
||||
- 视频 / Office / 大文件默认标记为 `ready_manual`
|
||||
- 返回:
|
||||
- `message`
|
||||
- `attachment`
|
||||
- `analysisTask`
|
||||
- `downloadUrl`
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/attachments/[attachmentId]/analyze`
|
||||
|
||||
- 用途:手动触发某个附件的主 Agent 分析
|
||||
- 当前行为:
|
||||
- 仅允许对 `ready_manual` 或 `failed` 状态的附件重新发起
|
||||
- 返回新的 `attachment_analysis` 任务摘要
|
||||
|
||||
#### `GET /api/v1/attachments/[attachmentId]/download`
|
||||
|
||||
- 用途:受保护下载或预览附件
|
||||
- 当前保护:
|
||||
- 默认要求有效 `boss_session`
|
||||
- 对主 Agent 附件分析任务,支持 `taskId + token` 的受控下载
|
||||
- 当前行为:
|
||||
- `server_file` 会直接流式返回文件
|
||||
- `aliyun_oss` 会按附件上传时固化的 `storageSnapshot` 生成临时签名 URL 并跳转
|
||||
|
||||
#### `GET /api/v1/storage/config`
|
||||
|
||||
- 用途:读取当前登录用户的附件存储配置
|
||||
- 返回:
|
||||
- `mode`: `server_file | oss`
|
||||
- `ossProvider`
|
||||
- 已脱敏的 OSS 配置摘要
|
||||
|
||||
#### `PATCH /api/v1/storage/config`
|
||||
|
||||
- 用途:更新当前登录用户的附件存储模式或 OSS 草稿配置
|
||||
- 当前行为:
|
||||
- 默认模式为 `server_file`
|
||||
- 当前 OSS provider 仅支持 `aliyun_oss`
|
||||
|
||||
#### `POST /api/v1/storage/config/validate`
|
||||
|
||||
- 用途:校验并保存当前登录用户的阿里 OSS 配置
|
||||
- 最小配置字段:
|
||||
- `accessKeyId`
|
||||
- `accessKeySecret`
|
||||
- `bucket`
|
||||
- `endpoint`
|
||||
- `region`
|
||||
- `prefix`(可选)
|
||||
- 当前行为:
|
||||
- 只支持阿里 OSS 私有桶 + 签名 URL
|
||||
- 成功后会回写已脱敏配置和验证时间
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/goals`
|
||||
|
||||
@@ -527,6 +667,7 @@
|
||||
- `authSessions`
|
||||
- `aiAccounts`
|
||||
- `aiAccountSwitchHistory`
|
||||
- `userAttachmentStorageConfigs`
|
||||
- `threadContextSnapshots`
|
||||
- `threadHandoffPackages`
|
||||
- `threadContextAlerts`
|
||||
@@ -549,5 +690,6 @@
|
||||
|
||||
- 正式数据库
|
||||
- 正式鉴权中间件
|
||||
- 图片 / 视频真实文件上传和对象存储
|
||||
- 多家对象存储适配(当前只有服务器文件存储和阿里 OSS)
|
||||
- 完整的附件详情页与富预览器
|
||||
- 完整的多端用户会话系统与刷新令牌体系
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Boss 当前运行与部署状态
|
||||
|
||||
更新时间:`2026-03-26`
|
||||
更新时间:`2026-03-29`
|
||||
|
||||
## 1. 本地状态
|
||||
|
||||
@@ -89,8 +89,14 @@ cd /Users/kris/code/boss
|
||||
- 根布局当前会挂载 APP 日志桥,路由切换、运行时错误、消息发送和 OTA 操作会通过 `/api/v1/app-logs` 实时同步到服务器;日志绑定已改成按当前登录会话解析设备
|
||||
- 根布局当前还会挂载原生运行时桥:维护 APP 内导航历史、拦截 Android 返回键、防止根页直接退回桌面,并在 OTA / 同签名覆盖安装后自动尝试恢复登录态
|
||||
- UI 外壳已收口为真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部一级导航固定在视口底部,背景图按手机 viewport 全屏 cover,WebView 不再显示外层圆角矩形预览壳
|
||||
- 原生 Android 当前也和这套产品方向对齐:`会话 / 设备 / 我的` 为固定底部 tab,一级面维持微信式简单列表和聊天优先;`主 Agent / 审计对话` 以普通置顶会话样式固定在会话首页顶部
|
||||
- 会话列表当前已切到“线程 = 聊天窗口”:主标题显示线程名,第二行显示所属文件夹名,第三行显示最后一条消息预览,右下角显示后台活跃数量动态图标;同一文件夹下多个线程会渲染成多个独立聊天窗口
|
||||
- 项目详情页右上角当前会进入微信式会话信息页:单线程会话支持改名和发起群聊,群聊会进入群资料页并支持改群名
|
||||
- 项目详情页当前已补齐微信式消息转发:长按消息会弹出 `转发 / 多选 / 复制 / 删除 / 取消`;单条消息直接进入统一会话选择页,多选消息会进入合并转发链路
|
||||
- 原生转发目标页当前统一由 `ForwardTargetActivity` 承接;一次只允许选择一个目标会话,目标可为单线程会话、群聊、`主 Agent` 或 `审计对话`
|
||||
- 当前单条消息转发会在目标会话中显示为普通转发消息,并保留 `forwardSource`;多条消息会落成 `forward_bundle` 聊天记录卡片,并保留来源会话、时间范围和摘要条目
|
||||
- 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新
|
||||
- 我的页当前新增 `AI 账号` 入口,支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
- 我的页当前保留 `账号与安全 / 设置 / 运维与修复 / 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 -> 项目消息账本`
|
||||
- 主 Agent 同步等待窗口当前为 55 秒;若本机 Codex 节点回复更慢,项目页仍会通过 SSE 在任务完成后自动刷新出真实回复
|
||||
- `GET /api/v1/app-logs` 当前已支持登录态分页查询
|
||||
@@ -108,12 +114,19 @@ cd /Users/kris/code/boss
|
||||
- 当前已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
- 当前已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk`
|
||||
- 当前 release 构建还会额外生成带版本号的 APK:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
|
||||
- 当前最新 release 构建版本:`2.1.1`(`versionCode=8`)
|
||||
- 当前最新 release 构建版本:`2.5.0`(`versionCode=13`)
|
||||
- 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties`
|
||||
- `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退
|
||||
- `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity`
|
||||
- `2.1.0` 已完成签名包覆盖安装到本机连接的华为真机,并确认 `com.hyzq.boss` 可以成功拉起进程
|
||||
- `2.1.1` 已补上原生 OTA 下载安装引导、`REQUEST_INSTALL_PACKAGES` 权限声明,以及根页默认入口/返回逻辑收口
|
||||
- `2.2.0` 已把原生 UI 回退到微信式交互:会话首页改为简单聊天列表,项目详情页改为聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口,设备页和我的页根面改为简单列表
|
||||
- `2.2.1` 已继续补齐原生交互细节:聊天页会即时显示本地“发送中”气泡,并且只在用户接近底部或本次发送主动触发时自动滚到底;关于页会显示 OTA 下载进度 / 重试 / 安装授权提示,离开后再回来仍会恢复本地下载状态;根 tab 会记住最近一次用户停留页,并把一级页返回逻辑收成“先回会话 tab,再按一次返回进入后台”
|
||||
- `2.3.0` 已把原生会话模型切到“线程 = 聊天窗口”:补上文件夹名副信息、后台活跃数量动态图标、微信式会话信息页、线程改名、独立群聊创建、群资料页,以及 `主 Agent / 审计对话` 普通置顶会话化
|
||||
- `2.4.0` 已把原生消息转发切到微信式链路:单条消息支持长按直接转发,多选消息支持合并转发成聊天记录卡片,统一使用原生会话选择页替换旧的备注转发页
|
||||
- `2.5.0` 已补齐聊天附件主链:原生聊天框左侧 `+` 已改成底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本会自动排队给主 Agent 分析,视频 / Office / 大文件改成手动触发
|
||||
- `2.5.0` 已上线 `我的 > 附件与存储`:默认使用服务器文件存储,用户可切到阿里 OSS 私有桶并填写最小配置;下载链会使用附件上传时固化的 OSS 快照,避免后续改配置后旧附件失效
|
||||
- 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net`
|
||||
|
||||
## 2. 服务器状态
|
||||
|
||||
@@ -195,7 +208,9 @@ cd /Users/kris/code/boss
|
||||
- Skill 清单当前按设备同步和展示已经可用,但还没有“安装 / 卸载 Skill”这种远程管理能力
|
||||
- 服务器侧主 Agent 实时回复依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;如果设备离线,只能保留任务或走 API 容灾账号
|
||||
- API 容灾当前由用户在 APP 的 `我的 > AI 账号` 页面自行配置 `OpenAI API` 账号,不再依赖服务器预置 Key
|
||||
- 图片 / 视频真实文件上传仍未接对象存储
|
||||
- 原生 Android 的二级深层页虽然仍保留 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等能力,但它们已经退出主 UI 正面;后续如再加入口,需继续遵守“一级微信式,复杂能力下沉”的规则
|
||||
- Android 本地 Gradle 验证当前必须串行执行;如果并发跑 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug`,会导致中间产物互踩并出现假失败
|
||||
- 聊天附件当前已经支持真实上传、消息落账本、受保护下载和原生打开;默认后端为服务器文件存储,可按用户切到阿里 OSS 私有桶
|
||||
- 认证虽然已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、CSRF 防护和更细的风控策略
|
||||
- 邮件对外正式投递仍缺少 DNS / 信誉相关的最终收口,例如 SPF、DKIM、DMARC、MX 与退信策略
|
||||
- 外部真实邮箱的 end-to-end 收件链路还没有在生产账号上完成最终验收
|
||||
|
||||
@@ -111,6 +111,8 @@
|
||||
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
|
||||
- `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`
|
||||
- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- `android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
文档:
|
||||
|
||||
364
docs/superpowers/plans/2026-03-27-wechat-native-ui-phase2.md
Normal file
364
docs/superpowers/plans/2026-03-27-wechat-native-ui-phase2.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# WeChat Native UI Phase 2 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Polish the native Android WeChat-style rollback with better chat feel, clearer OTA download/install feedback, and smoother root navigation memory.
|
||||
|
||||
**Architecture:** Keep the approved WeChat-style root surfaces intact and limit this batch to interaction polish. Add small pure-Java helpers for chat UI state and OTA download state so the tricky behavior is unit-tested, then wire those helpers into `ProjectDetailActivity`, `AboutActivity`, and `MainActivity`.
|
||||
|
||||
**Tech Stack:** Android AppCompat, XML layouts, Java 21, Gradle 8, JUnit4, DownloadManager, existing Boss APIs, existing release packaging and deploy scripts.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
|
||||
- `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
|
||||
- Pure-Java helper for send-button enabled state, optimistic pending bubble state, and auto-scroll policy.
|
||||
- `android/app/src/main/java/com/hyzq/boss/OtaDownloadStateMapper.java`
|
||||
- Pure-Java helper for mapping `DownloadManager` progress/status into UI strings and retry/install states.
|
||||
- `android/app/src/main/java/com/hyzq/boss/RootTabMemory.java`
|
||||
- Pure-Java helper for resolving explicit root tabs versus stored root tabs.
|
||||
- `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
|
||||
- Unit tests for chat send/pending/scroll rules.
|
||||
- `android/app/src/test/java/com/hyzq/boss/OtaDownloadStateMapperTest.java`
|
||||
- Unit tests for OTA download state mapping.
|
||||
- `android/app/src/test/java/com/hyzq/boss/RootTabMemoryTest.java`
|
||||
- Unit tests for remembered root tab selection.
|
||||
|
||||
### Modified files
|
||||
|
||||
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
|
||||
- `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- `README.md`
|
||||
- `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- `docs/architecture/api_and_service_inventory_cn.md`
|
||||
|
||||
## Task 1: Polish Chat Composer Feedback and Scroll Behavior
|
||||
|
||||
**Files:**
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class ProjectChatUiStateTest {
|
||||
@Test
|
||||
public void sendEnabled_requiresTextAndNotBusy() {
|
||||
assertFalse(ProjectChatUiState.canSend("", false));
|
||||
assertFalse(ProjectChatUiState.canSend(" ", false));
|
||||
assertFalse(ProjectChatUiState.canSend("你好", true));
|
||||
assertTrue(ProjectChatUiState.canSend("你好", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAutoScroll_onlyWhenNearBottomOrForced() {
|
||||
assertTrue(ProjectChatUiState.shouldAutoScroll(true, false));
|
||||
assertTrue(ProjectChatUiState.shouldAutoScroll(false, true));
|
||||
assertFalse(ProjectChatUiState.shouldAutoScroll(false, false));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL with missing `ProjectChatUiState`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
public final class ProjectChatUiState {
|
||||
private ProjectChatUiState() {}
|
||||
|
||||
public static boolean canSend(String text, boolean sending) {
|
||||
return !sending && text != null && !text.trim().isEmpty();
|
||||
}
|
||||
|
||||
public static boolean shouldAutoScroll(boolean nearBottom, boolean forced) {
|
||||
return nearBottom || forced;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then integrate it into `ProjectDetailActivity`:
|
||||
|
||||
- disable send button when text empty or request busy
|
||||
- show a local pending outgoing bubble immediately after tapping send
|
||||
- only auto-scroll on refresh when user was already near bottom or after local send
|
||||
|
||||
- [ ] **Step 4: Run tests and compile verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java \
|
||||
android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java \
|
||||
android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java \
|
||||
android/app/src/main/java/com/hyzq/boss/BossUi.java
|
||||
git commit -m "feat: polish native chat composer feedback"
|
||||
```
|
||||
|
||||
## Task 2: Add OTA Download Progress and Retry Guidance
|
||||
|
||||
**Files:**
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/OtaDownloadStateMapper.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/OtaDownloadStateMapperTest.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class OtaDownloadStateMapperTest {
|
||||
@Test
|
||||
public void toProgressLabel_formatsKnownProgress() {
|
||||
assertEquals("已下载 50%", OtaDownloadStateMapper.toProgressLabel(50, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toProgressLabel_handlesUnknownProgress() {
|
||||
assertEquals("正在准备下载", OtaDownloadStateMapper.toProgressLabel(0, false));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.OtaDownloadStateMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL with missing `OtaDownloadStateMapper`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
public final class OtaDownloadStateMapper {
|
||||
private OtaDownloadStateMapper() {}
|
||||
|
||||
public static String toProgressLabel(int percent, boolean hasKnownTotal) {
|
||||
if (!hasKnownTotal) {
|
||||
return "正在准备下载";
|
||||
}
|
||||
return "已下载 " + Math.max(0, Math.min(100, percent)) + "%";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then integrate it into `AboutActivity`:
|
||||
|
||||
- query `DownloadManager` while download is active
|
||||
- render a visible progress row in page content
|
||||
- keep a retry action after failed download
|
||||
- show install permission guidance row when unknown-source install permission is missing
|
||||
|
||||
- [ ] **Step 4: Run tests and compile verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.OtaDownloadStateMapperTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/OtaDownloadStateMapper.java \
|
||||
android/app/src/test/java/com/hyzq/boss/OtaDownloadStateMapperTest.java \
|
||||
android/app/src/main/java/com/hyzq/boss/AboutActivity.java \
|
||||
android/app/src/main/java/com/hyzq/boss/BossUi.java
|
||||
git commit -m "feat: add native ota progress feedback"
|
||||
```
|
||||
|
||||
## Task 3: Remember Root Tab and Smooth Root Back Behavior
|
||||
|
||||
**Files:**
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/RootTabMemory.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/RootTabMemoryTest.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class RootTabMemoryTest {
|
||||
@Test
|
||||
public void resolveInitialTab_prefersExplicitTab() {
|
||||
assertEquals("devices", RootTabMemory.resolveInitialTab("devices", "me"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveInitialTab_fallsBackToStoredTab() {
|
||||
assertEquals("me", RootTabMemory.resolveInitialTab(null, "me"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveInitialTab_defaultsToConversations() {
|
||||
assertEquals("conversations", RootTabMemory.resolveInitialTab(null, null));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run the new unit test target after creating it in the same package:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.RootTabMemoryTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL with missing helper.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Implement the helper:
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
public final class RootTabMemory {
|
||||
private RootTabMemory() {}
|
||||
|
||||
public static String resolveInitialTab(String explicitTab, String storedTab) {
|
||||
if ("conversations".equals(explicitTab) || "devices".equals(explicitTab) || "me".equals(explicitTab)) {
|
||||
return explicitTab;
|
||||
}
|
||||
if ("conversations".equals(storedTab) || "devices".equals(storedTab) || "me".equals(storedTab)) {
|
||||
return storedTab;
|
||||
}
|
||||
return "conversations";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then wire it into `MainActivity` so that:
|
||||
|
||||
- last selected root tab is persisted in `SharedPreferences`
|
||||
- explicit deep-link tab still wins over stored tab
|
||||
- when already at root conversations tab, back key shows a soft toast then moves task to back
|
||||
|
||||
- [ ] **Step 4: Run tests and full debug verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android assembleDebug --no-daemon
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/MainActivity.java \
|
||||
android/app/src/main/java/com/hyzq/boss/RootTabMemory.java \
|
||||
android/app/src/test/java/com/hyzq/boss/RootTabMemoryTest.java
|
||||
git commit -m "feat: polish native root tab memory"
|
||||
```
|
||||
|
||||
## Task 4: Verification, Packaging, Deploy, and Docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
- Modify: `android/app/build.gradle`
|
||||
|
||||
- [ ] **Step 1: Update docs and version**
|
||||
|
||||
- bump Android version for the next polish release
|
||||
- document chat send feedback, OTA progress, and root tab memory behavior
|
||||
|
||||
- [ ] **Step 2: Run local verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run lint
|
||||
npm run build
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS http://127.0.0.1:4317/health
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Deploy and verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
BOSS_SERVER_PASS='your-password' ./scripts/deploy-server.sh
|
||||
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
|
||||
curl -sS https://boss.hyzq.net/api/health
|
||||
curl -sS https://boss.hyzq.net/downloads/boss-android-latest.json
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add README.md docs/architecture/current_runtime_and_deploy_status_cn.md \
|
||||
docs/architecture/api_and_service_inventory_cn.md android/app/build.gradle \
|
||||
public/downloads/
|
||||
git commit -m "chore: publish native ui phase 2 polish release"
|
||||
```
|
||||
751
docs/superpowers/plans/2026-03-27-wechat-native-ui-rollback.md
Normal file
751
docs/superpowers/plans/2026-03-27-wechat-native-ui-rollback.md
Normal file
@@ -0,0 +1,751 @@
|
||||
# WeChat Native UI Rollback Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Restore the Android app to the previously approved WeChat-like UI and interaction model while keeping the current native Android architecture, Boss API integration, login recovery, and OTA pipeline.
|
||||
|
||||
**Architecture:** Keep `BossApiClient`, current activities, login restore, OTA delivery, and backend routes intact. Add a small pure-Java surface-mapping helper with unit tests so the “allowed” WeChat-style information density is explicit, then rework `MainActivity`, shared UI helpers, and the core activities to render simple list-driven surfaces and a chat-first conversation page. Advanced ops capability stays in the codebase but leaves the first-level UI.
|
||||
|
||||
**Tech Stack:** Android AppCompat, XML layouts, Java 21, Gradle 8, JUnit4, existing Boss APIs, adb, existing `npm run apk:release` / `npm run aab:release` / `scripts/deploy-server.sh`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
|
||||
- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Pure-Java contract for root tabs, conversation rows, device rows, my-page menus, and project quick actions.
|
||||
- `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
|
||||
- Unit tests for WeChat-style information trimming and tab/menu contract.
|
||||
- `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- Chat-first project detail layout with lightweight header strip and bottom composer.
|
||||
- `android/app/src/main/res/drawable/bg_list_row.xml`
|
||||
- Flat white list-cell background with subtle divider feel.
|
||||
- `android/app/src/main/res/drawable/bg_tab_active.xml`
|
||||
- Active bottom-tab background.
|
||||
- `android/app/src/main/res/drawable/bg_tab_inactive.xml`
|
||||
- Inactive bottom-tab background.
|
||||
- `android/app/src/main/res/drawable/bg_message_incoming.xml`
|
||||
- Incoming message bubble background.
|
||||
- `android/app/src/main/res/drawable/bg_message_outgoing.xml`
|
||||
- Outgoing message bubble background.
|
||||
- `scripts/verify-native-wechat-release.sh`
|
||||
- Local verification wrapper for build, health checks, artifacts, and docs.
|
||||
|
||||
### Modified files
|
||||
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Shared list-row, avatar, unread badge, bubble, and chip builders.
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java`
|
||||
- Base screen support for alternate layouts and lighter headers.
|
||||
- `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- Root shell, tab state, conversations list, devices list, my-page menu, and return behavior.
|
||||
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Chat-first surface with only `项目目标 / 版本记录` as lightweight actions.
|
||||
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- Simpler device detail first screen.
|
||||
- `android/app/src/main/java/com/hyzq/boss/SecurityActivity.java`
|
||||
- WeChat-style simple list and session summary.
|
||||
- `android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
|
||||
- Lighter settings presentation.
|
||||
- `android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java`
|
||||
- Skill rows instead of stacked heavy cards.
|
||||
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
|
||||
- Simpler account list presentation.
|
||||
- `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
|
||||
- Cleaner About/OTA surface with advanced entry placement.
|
||||
- `android/app/src/main/res/layout/activity_main.xml`
|
||||
- Root login and tab shell layout.
|
||||
- `android/app/src/main/res/layout/activity_screen.xml`
|
||||
- Standard secondary-page shell layout.
|
||||
- `android/app/src/main/res/values/colors.xml`
|
||||
- WeChat-like flat palette.
|
||||
- `android/app/src/main/res/values/styles.xml`
|
||||
- White window background, no dashboard gradient.
|
||||
- `android/app/build.gradle`
|
||||
- Version bump for the rollback release build.
|
||||
- `README.md`
|
||||
- New native UI direction and build status.
|
||||
- `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Runtime truth after rollback.
|
||||
- `docs/architecture/ai_handoff_index_cn.md`
|
||||
- Effective Android surface summary.
|
||||
- `docs/architecture/repo_map_cn.md`
|
||||
- New layout / helper file map if structure changes.
|
||||
|
||||
## Task 1: Freeze the WeChat Surface Contract in Unit Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
|
||||
- Test: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public class WechatSurfaceMapperTest {
|
||||
@Test
|
||||
public void toConversationRow_keepsOnlyWechatFields() throws Exception {
|
||||
JSONObject source = new JSONObject()
|
||||
.put("projectTitle", "Boss 移动控制台")
|
||||
.put("preview", "主 Agent 已回复")
|
||||
.put("latestReplyLabel", "昨天")
|
||||
.put("unreadCount", 3)
|
||||
.put("riskLevel", "urgent")
|
||||
.put("activeDeviceCount", 2);
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(source);
|
||||
|
||||
assertEquals("Boss 移动控制台", row.title);
|
||||
assertEquals("主 Agent 已回复", row.preview);
|
||||
assertEquals("昨天", row.timeLabel);
|
||||
assertEquals(3, row.unreadCount);
|
||||
assertFalse(row.preview.contains("设备"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toDeviceRow_keepsOnlySimpleSubtitle() throws Exception {
|
||||
JSONObject source = new JSONObject()
|
||||
.put("name", "Mac Studio")
|
||||
.put("status", "online")
|
||||
.put("account", "17600003315")
|
||||
.put("quota5h", 68)
|
||||
.put("quota7d", 81);
|
||||
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(source);
|
||||
|
||||
assertEquals("Mac Studio", row.title);
|
||||
assertEquals("在线 · 17600003315", row.subtitle);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootMeMenuTitles_matchApprovedSimpleMenu() {
|
||||
assertEquals(
|
||||
Arrays.asList("账号与安全", "AI 账号", "设置", "技能", "关于"),
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectQuickActions_keepOnlyGoalsAndVersions() {
|
||||
assertEquals(
|
||||
Arrays.asList("项目目标", "版本记录"),
|
||||
WechatSurfaceMapper.projectQuickActions()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL with `cannot find symbol` or `ClassNotFoundException` for `WechatSurfaceMapper`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```java
|
||||
package com.hyzq.boss;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public final class WechatSurfaceMapper {
|
||||
private WechatSurfaceMapper() {}
|
||||
|
||||
public static final class ConversationRow {
|
||||
public final String title;
|
||||
public final String preview;
|
||||
public final String timeLabel;
|
||||
public final int unreadCount;
|
||||
|
||||
public ConversationRow(String title, String preview, String timeLabel, int unreadCount) {
|
||||
this.title = title;
|
||||
this.preview = preview;
|
||||
this.timeLabel = timeLabel;
|
||||
this.unreadCount = unreadCount;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class DeviceRow {
|
||||
public final String title;
|
||||
public final String subtitle;
|
||||
|
||||
public DeviceRow(String title, String subtitle) {
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
}
|
||||
}
|
||||
|
||||
public static ConversationRow toConversationRow(JSONObject item) {
|
||||
return new ConversationRow(
|
||||
item.optString("projectTitle", "未命名会话"),
|
||||
item.optString("preview", "暂无消息"),
|
||||
item.optString("latestReplyLabel", ""),
|
||||
item.optInt("unreadCount", 0)
|
||||
);
|
||||
}
|
||||
|
||||
public static DeviceRow toDeviceRow(JSONObject item) {
|
||||
String status = "online".equals(item.optString("status")) ? "在线" : "离线";
|
||||
String account = item.optString("account", "");
|
||||
String subtitle = account.isEmpty() ? status : status + " · " + account;
|
||||
return new DeviceRow(item.optString("name", "未命名设备"), subtitle);
|
||||
}
|
||||
|
||||
public static List<String> rootMeMenuTitles() {
|
||||
return Arrays.asList("账号与安全", "AI 账号", "设置", "技能", "关于");
|
||||
}
|
||||
|
||||
public static List<String> projectQuickActions() {
|
||||
return Arrays.asList("项目目标", "版本记录");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: PASS, `BUILD SUCCESSFUL`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java
|
||||
git commit -m "test: freeze wechat surface contract"
|
||||
```
|
||||
|
||||
### Task 2: Rebuild the Root Shell and Conversation List
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- Modify: `android/app/src/main/res/layout/activity_main.xml`
|
||||
- Modify: `android/app/src/main/res/layout/activity_screen.xml`
|
||||
- Modify: `android/app/src/main/res/values/colors.xml`
|
||||
- Modify: `android/app/src/main/res/values/styles.xml`
|
||||
- Create: `android/app/src/main/res/drawable/bg_list_row.xml`
|
||||
- Create: `android/app/src/main/res/drawable/bg_tab_active.xml`
|
||||
- Create: `android/app/src/main/res/drawable/bg_tab_inactive.xml`
|
||||
- Modify: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Extend `WechatSurfaceMapperTest.java` with root-shell expectations:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void rootTabOrder_isWechatStyle() {
|
||||
assertEquals(Arrays.asList("会话", "设备", "我的"), WechatSurfaceMapper.rootTabLabels());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mainPage_doesNotExposeOpsEntry() {
|
||||
assertFalse(WechatSurfaceMapper.rootMeMenuTitles().contains("运维与修复"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL because `rootTabLabels()` does not exist.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Add the missing contract method:
|
||||
|
||||
```java
|
||||
public static List<String> rootTabLabels() {
|
||||
return Arrays.asList("会话", "设备", "我的");
|
||||
}
|
||||
```
|
||||
|
||||
Then wire the UI around that contract.
|
||||
|
||||
Update `BossUi.java` to add list-oriented builders:
|
||||
|
||||
```java
|
||||
public static LinearLayout buildListRow(
|
||||
Context context,
|
||||
String leadingText,
|
||||
String title,
|
||||
String subtitle,
|
||||
String trailingText,
|
||||
int unreadCount,
|
||||
@Nullable View.OnClickListener listener
|
||||
) {
|
||||
LinearLayout row = new LinearLayout(context);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.setBackgroundResource(R.drawable.bg_list_row);
|
||||
row.setPadding(dp(context, 16), dp(context, 14), dp(context, 16), dp(context, 14));
|
||||
if (listener != null) row.setOnClickListener(listener);
|
||||
return row;
|
||||
}
|
||||
```
|
||||
|
||||
Update `MainActivity.java` so the root render paths use `WechatSurfaceMapper` instead of card-heavy metadata:
|
||||
|
||||
```java
|
||||
private void renderConversationsRoot() {
|
||||
screenContent.removeAllViews();
|
||||
if (conversationsData == null || conversationsData.length() == 0) {
|
||||
screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。"));
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < conversationsData.length(); i++) {
|
||||
JSONObject item = conversationsData.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
String projectId = item.optString("projectId", "");
|
||||
screenContent.addView(BossUi.buildListRow(
|
||||
this,
|
||||
item.optString("avatar", item.optString("projectTitle", "会").substring(0, 1)),
|
||||
row.title,
|
||||
row.preview,
|
||||
row.timeLabel,
|
||||
row.unreadCount,
|
||||
v -> openProject(projectId, row.title)
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Update `activity_main.xml` / `activity_screen.xml` to remove dashboard framing:
|
||||
|
||||
```xml
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/boss_surface">
|
||||
```
|
||||
|
||||
```xml
|
||||
<Button
|
||||
android:id="@+id/tab_conversations"
|
||||
android:background="@drawable/bg_tab_active"
|
||||
android:text="会话" />
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests and compile verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Unit tests PASS
|
||||
- Java compile PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/java/com/hyzq/boss/MainActivity.java android/app/src/main/res/layout/activity_main.xml android/app/src/main/res/layout/activity_screen.xml android/app/src/main/res/values/colors.xml android/app/src/main/res/values/styles.xml android/app/src/main/res/drawable/bg_list_row.xml android/app/src/main/res/drawable/bg_tab_active.xml android/app/src/main/res/drawable/bg_tab_inactive.xml android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java
|
||||
git commit -m "feat: restore wechat-style root shell"
|
||||
```
|
||||
|
||||
### Task 3: Rebuild Project Detail into a Chat-First Surface
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Modify: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Create: `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- Create: `android/app/src/main/res/drawable/bg_message_incoming.xml`
|
||||
- Create: `android/app/src/main/res/drawable/bg_message_outgoing.xml`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Extend `WechatSurfaceMapperTest.java`:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void projectPrimarySections_keepOnlyChatEssentials() {
|
||||
assertEquals(
|
||||
Arrays.asList("quick_actions", "messages", "composer"),
|
||||
WechatSurfaceMapper.projectPrimarySections()
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL because the helper still allows the old action shape or the assertions are not yet satisfied.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Teach `BossScreenActivity` to allow an alternate layout:
|
||||
|
||||
```java
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_screen;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(getLayoutResId());
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Then make `ProjectDetailActivity` use a chat layout and strip the heavy cards:
|
||||
|
||||
```java
|
||||
@Override
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_project_chat;
|
||||
}
|
||||
|
||||
private void renderProject(JSONObject payload) {
|
||||
JSONObject project = payload.optJSONObject("project");
|
||||
JSONArray messages = project == null ? null : project.optJSONArray("messages");
|
||||
|
||||
configureScreen(project == null ? "项目聊天" : project.optString("name", "项目聊天"), "");
|
||||
replaceContent();
|
||||
appendQuickActions("项目目标", v -> openGoals(), "版本记录", v -> openVersions());
|
||||
renderMessages(messages);
|
||||
}
|
||||
```
|
||||
|
||||
and add the missing helper contract:
|
||||
|
||||
```java
|
||||
public static List<String> projectPrimarySections() {
|
||||
return Arrays.asList("quick_actions", "messages", "composer");
|
||||
}
|
||||
```
|
||||
|
||||
Render message bubbles through `BossUi` instead of generic cards:
|
||||
|
||||
```java
|
||||
public static LinearLayout buildMessageBubble(
|
||||
Context context,
|
||||
boolean self,
|
||||
String sender,
|
||||
String body,
|
||||
String meta
|
||||
) {
|
||||
LinearLayout bubble = new LinearLayout(context);
|
||||
bubble.setBackgroundResource(self ? R.drawable.bg_message_outgoing : R.drawable.bg_message_incoming);
|
||||
return bubble;
|
||||
}
|
||||
```
|
||||
|
||||
Do **not** render these old sections in the main chat surface:
|
||||
|
||||
- `当前主控身份`
|
||||
- `主 Agent 调度结论`
|
||||
- `线程预算卡片`
|
||||
- `实时 APP 日志`
|
||||
- `媒体与转发说明`
|
||||
|
||||
- [ ] **Step 4: Run tests and screen compile verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android assembleDebug --no-daemon
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Unit tests PASS
|
||||
- Java compile PASS
|
||||
- Debug assemble PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java android/app/src/main/res/layout/activity_project_chat.xml android/app/src/main/res/drawable/bg_message_incoming.xml android/app/src/main/res/drawable/bg_message_outgoing.xml
|
||||
git commit -m "feat: restore wechat-style project chat page"
|
||||
```
|
||||
|
||||
### Task 4: Simplify Devices and Me Surfaces, Demote Advanced Ops
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SecurityActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Modify: `android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Extend `WechatSurfaceMapperTest.java`:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void rootMeMenuTitles_keepApprovedOrder() {
|
||||
assertEquals(
|
||||
Arrays.asList("账号与安全", "AI 账号", "设置", "技能", "关于"),
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void advancedEntryTitle_movesOpsOutOfMainMePage() {
|
||||
assertEquals("高级与调试", WechatSurfaceMapper.advancedEntryTitle());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL until the mapper and page rendering fully stop leaking quota / ops content.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Use `WechatSurfaceMapper` rows in `MainActivity.renderDevicesRoot()`:
|
||||
|
||||
```java
|
||||
private void renderDevicesRoot() {
|
||||
screenContent.removeAllViews();
|
||||
screenContent.addView(BossUi.buildMenuRow(this, "添加设备", "通过绑定码接入新设备", null, v -> {
|
||||
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
|
||||
}));
|
||||
|
||||
for (int i = 0; i < devicesData.length(); i++) {
|
||||
JSONObject item = devicesData.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
screenContent.addView(BossUi.buildListRow(
|
||||
this,
|
||||
item.optString("avatar", "设"),
|
||||
row.title,
|
||||
row.subtitle,
|
||||
"",
|
||||
0,
|
||||
v -> openDevice(item.optString("id"), row.title)
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Simplify `renderMeRoot()` to only approved rows:
|
||||
|
||||
```java
|
||||
screenContent.addView(BossUi.buildMenuRow(this, "账号与安全", "登录与会话", null, v -> startActivity(new Intent(this, SecurityActivity.class))));
|
||||
screenContent.addView(BossUi.buildMenuRow(this, "AI 账号", "主 GPT / 备用 GPT / API 容灾", null, v -> startActivity(new Intent(this, AiAccountsActivity.class))));
|
||||
screenContent.addView(BossUi.buildMenuRow(this, "设置", "默认首页与提醒行为", null, v -> startActivity(new Intent(this, SettingsActivity.class))));
|
||||
screenContent.addView(BossUi.buildMenuRow(this, "技能", "当前设备 Skill 清单", null, v -> startActivity(new Intent(this, SkillInventoryActivity.class))));
|
||||
screenContent.addView(BossUi.buildMenuRow(this, "关于", "版本与更新", null, v -> startActivity(new Intent(this, AboutActivity.class))));
|
||||
```
|
||||
|
||||
Move advanced entry deeper by placing it under `AboutActivity` as a non-primary menu row:
|
||||
|
||||
```java
|
||||
Button advanced = BossUi.buildSecondaryButton(this, "高级与调试");
|
||||
advanced.setOnClickListener(v -> startActivity(new Intent(this, OpsCenterActivity.class)));
|
||||
```
|
||||
|
||||
and add the helper contract:
|
||||
|
||||
```java
|
||||
public static String advancedEntryTitle() {
|
||||
return "高级与调试";
|
||||
}
|
||||
```
|
||||
|
||||
`DeviceDetailActivity` should keep only:
|
||||
|
||||
- simple device summary
|
||||
- `查看技能`
|
||||
- `编辑`
|
||||
|
||||
and stop rendering related thread cards / enrollment draft cards on the first screen.
|
||||
|
||||
- [ ] **Step 4: Run tests and full debug verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.WechatSurfaceMapperTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android assembleDebug --no-daemon
|
||||
adb -s 8KE0219724012168 shell am start -W -n com.hyzq.boss/.MainActivity
|
||||
adb -s 8KE0219724012168 shell pidof com.hyzq.boss
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Unit tests PASS
|
||||
- Debug assemble PASS
|
||||
- `MainActivity` starts
|
||||
- `pidof` returns a process id
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add android/app/src/main/java/com/hyzq/boss/MainActivity.java android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java android/app/src/main/java/com/hyzq/boss/SecurityActivity.java android/app/src/main/java/com/hyzq/boss/SettingsActivity.java android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java android/app/src/main/java/com/hyzq/boss/AboutActivity.java android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java
|
||||
git commit -m "feat: simplify device and me surfaces"
|
||||
```
|
||||
|
||||
### Task 5: Verification, Release Packaging, Deployment, and Docs
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/verify-native-wechat-release.sh`
|
||||
- Modify: `android/app/build.gradle`
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/ai_handoff_index_cn.md`
|
||||
- Modify: `docs/architecture/repo_map_cn.md`
|
||||
|
||||
- [ ] **Step 1: Write the failing release verification script**
|
||||
|
||||
Create `scripts/verify-native-wechat-release.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
cd /Users/kris/code/boss
|
||||
npm run lint
|
||||
npm run build
|
||||
curl -fsS http://127.0.0.1:3000/api/health >/dev/null
|
||||
curl -fsS http://127.0.0.1:4317/health >/dev/null
|
||||
test -f android/app/build/outputs/apk/release/boss-android-v2.1.2-release.apk
|
||||
test -f android/app/build/outputs/bundle/release/boss-android-v2.1.2-release.aab
|
||||
rg -q '微信式' README.md
|
||||
rg -q '2.1.2' docs/architecture/current_runtime_and_deploy_status_cn.md
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
zsh ./scripts/verify-native-wechat-release.sh
|
||||
```
|
||||
|
||||
Expected: FAIL because the `2.1.2` artifacts and updated docs do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write minimal release implementation**
|
||||
|
||||
Bump the Android version:
|
||||
|
||||
```gradle
|
||||
versionCode 9
|
||||
versionName "2.1.2"
|
||||
```
|
||||
|
||||
Update docs to state:
|
||||
|
||||
- native UI has returned to WeChat-style interaction
|
||||
- root tabs are `会话 / 设备 / 我的`
|
||||
- chat page keeps only `项目目标 / 版本记录`
|
||||
|
||||
Then build and publish:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run release verification and deploy**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
zsh ./scripts/verify-native-wechat-release.sh
|
||||
adb -s 8KE0219724012168 install -r /Users/kris/code/boss/android/app/build/outputs/apk/release/boss-android-v2.1.2-release.apk
|
||||
adb -s 8KE0219724012168 shell am start -W -n com.hyzq.boss/.MainActivity
|
||||
./scripts/deploy-server.sh
|
||||
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
|
||||
curl -sS https://boss.hyzq.net/api/health
|
||||
curl -sS https://boss.hyzq.net/downloads/boss-android-latest.json
|
||||
curl -sS https://boss.hyzq.net/downloads/boss-android-latest-aab.json
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Verification script PASS
|
||||
- APK install PASS
|
||||
- `MainActivity` launch PASS
|
||||
- remote `/api/health` PASS
|
||||
- public health PASS
|
||||
- public APK/AAB metadata show `2.1.2 / versionCode 9`
|
||||
|
||||
- [ ] **Step 5: Commit and publish**
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
git add scripts/verify-native-wechat-release.sh android/app/build.gradle README.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/ai_handoff_index_cn.md docs/architecture/repo_map_cn.md public/downloads
|
||||
git commit -m "feat: ship wechat-style native rollback release"
|
||||
git push gitea HEAD:refs/heads/codex/native-boss-android-2-1-0
|
||||
```
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- Spec coverage:
|
||||
- 一级导航微信式化:Task 2
|
||||
- 会话首页极简列表:Task 2
|
||||
- 聊天页只保留项目目标/版本记录:Task 3
|
||||
- 设备页 / 我的页简单列表:Task 4
|
||||
- 返回逻辑与状态保持:Task 2 + Task 4
|
||||
- 构建 / 真机 / 部署 / 文档:Task 5
|
||||
- Placeholder scan:
|
||||
- No `TBD` / `TODO` / “similar to”
|
||||
- Every task has exact files, commands, and code snippets
|
||||
- Type consistency:
|
||||
- `WechatSurfaceMapper` is the single contract source for root tabs, conversation rows, device rows, and quick actions
|
||||
689
docs/superpowers/plans/2026-03-28-wechat-message-forwarding.md
Normal file
689
docs/superpowers/plans/2026-03-28-wechat-message-forwarding.md
Normal file
@@ -0,0 +1,689 @@
|
||||
# Boss 微信式消息转发 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把当前原生 Android 的“备注转发页”重构成微信式消息转发链,支持单条消息转发、多选消息合并转发、统一目标会话选择页,以及服务端 `forwardSource / forwardBundle / approvalRequired` 账本结构。
|
||||
|
||||
**Architecture:** 保留现有 `BossState -> Next API -> BossApiClient -> 原生活动页` 主链,不引入新基础设施。服务端把 `POST /api/v1/projects/[projectId]/forwards` 从“备注转发”升级成结构化转发接口;原生端在 `ProjectDetailActivity` 内补消息操作菜单、多选状态和目标会话选择页,并以 `ForwardTargetActivity` 承接统一转发目标选择。
|
||||
|
||||
**Tech Stack:** Next.js App Router, TypeScript, file-backed `data/boss-state.json`, 原生 Android AppCompat + XML, HttpURLConnection, JUnit4
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Backend / state / API
|
||||
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- 扩展 `MessageKind`、`Message`,增加 `forwardSource`、`forwardBundle`
|
||||
- 把 `forwardProjectMessage` 升级成支持 `single / bundle / approvalRequired`
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/forwards/route.ts`
|
||||
- 校验新的 `single / bundle` 输入结构
|
||||
- 返回 `message / approvalRequired / approvalReason`
|
||||
- Modify: `src/lib/boss-projections.ts`
|
||||
- 如列表预览或详情聚合需要,补 forwarded message 的预览摘要函数
|
||||
|
||||
### Android native
|
||||
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- 支持新的 forward payload
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
|
||||
- 补多选模式、已选消息、转发入口守卫
|
||||
- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
|
||||
- 单测先行覆盖多选状态切换
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- 消息长按菜单、多选模式、跳转目标会话页、forward message 渲染
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- 补消息操作菜单、多选勾选 row、聊天记录卡片消息
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java`
|
||||
- 统一目标会话选择页
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java`
|
||||
- 目标会话过滤与单选逻辑单测
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java`
|
||||
- 降级为兼容跳转页,直接导向 `ForwardTargetActivity`
|
||||
- Modify: `android/app/src/main/AndroidManifest.xml`
|
||||
- 注册 `ForwardTargetActivity`
|
||||
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- 补多选模式头部 / 底部动作容器
|
||||
- Create: `android/app/src/main/res/layout/activity_forward_target.xml`
|
||||
- 目标会话选择页布局
|
||||
|
||||
### Docs / release
|
||||
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 升级服务端转发账本和接口结构
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/forwards/route.ts`
|
||||
- Modify: `src/lib/boss-projections.ts`
|
||||
- Test: `npm run build`
|
||||
|
||||
- [ ] **Step 1: 先把新的消息结构写进 failing contract 注释和类型定义**
|
||||
|
||||
在 `src/lib/boss-data.ts` 的 `MessageKind` 与 `Message` 附近先写出新结构,让后续编译先报缺字段:
|
||||
|
||||
```ts
|
||||
export type MessageKind =
|
||||
| "text"
|
||||
| "voice_intent"
|
||||
| "image_intent"
|
||||
| "video_intent"
|
||||
| "forward_notice"
|
||||
| "forward_single"
|
||||
| "forward_bundle";
|
||||
|
||||
export interface ForwardSource {
|
||||
sourceProjectId: string;
|
||||
sourceProjectName: string;
|
||||
sourceThreadId?: string;
|
||||
sourceThreadTitle?: string;
|
||||
sourceMessageId: string;
|
||||
forwardedBy: string;
|
||||
forwardedAt: string;
|
||||
}
|
||||
|
||||
export interface ForwardBundleItem {
|
||||
messageId: string;
|
||||
senderLabel: string;
|
||||
body: string;
|
||||
kind: string;
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
export interface ForwardBundlePayload {
|
||||
sourceProjectId: string;
|
||||
sourceProjectName: string;
|
||||
sourceThreadId?: string;
|
||||
sourceThreadTitle?: string;
|
||||
itemCount: number;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
items: ForwardBundleItem[];
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
sender: MessageSender;
|
||||
senderLabel: string;
|
||||
body: string;
|
||||
sentAt: string;
|
||||
kind?: MessageKind;
|
||||
forwardSource?: ForwardSource;
|
||||
forwardBundle?: ForwardBundlePayload;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行构建,确认当前实现还不支持这些结构**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: 先因为 `forwardProjectMessage` 和相关消息使用点不完整而失败,或者至少需要补 route / render 类型。
|
||||
|
||||
- [ ] **Step 3: 用最小实现升级 `forwardProjectMessage` 输入结构**
|
||||
|
||||
把 `src/lib/boss-data.ts` 的旧签名:
|
||||
|
||||
```ts
|
||||
export async function forwardProjectMessage(payload: {
|
||||
sourceProjectId: string;
|
||||
targetProjectId: string;
|
||||
note: string;
|
||||
})
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```ts
|
||||
export async function forwardProjectMessage(payload:
|
||||
| {
|
||||
sourceProjectId: string;
|
||||
mode: "single";
|
||||
targetProjectId: string;
|
||||
sourceMessageId: string;
|
||||
requestedBy: string;
|
||||
}
|
||||
| {
|
||||
sourceProjectId: string;
|
||||
mode: "bundle";
|
||||
targetProjectId: string;
|
||||
sourceMessageIds: string[];
|
||||
requestedBy: string;
|
||||
}
|
||||
) {}
|
||||
```
|
||||
|
||||
并补 3 个最小 helper:
|
||||
|
||||
```ts
|
||||
function findProjectMessage(project: Project, messageId: string) {}
|
||||
function buildForwardSingleMessage(input: { source: Project; target: Project; message: Message; requestedBy: string }) {}
|
||||
function buildForwardBundleMessage(input: { source: Project; target: Project; messages: Message[]; requestedBy: string }) {}
|
||||
```
|
||||
|
||||
最小行为要求:
|
||||
|
||||
- `single` 生成 `kind: "forward_single"`,并带 `forwardSource`
|
||||
- `bundle` 生成 `kind: "forward_bundle"`,并带 `forwardBundle`
|
||||
- `target.preview` 更新为新消息正文或卡片摘要
|
||||
- `source` 侧继续写一条“已转发到《目标会话》”的轻量日志
|
||||
|
||||
- [ ] **Step 4: 在 `forwardProjectMessage` 里补审批闸口最小返回**
|
||||
|
||||
在 `src/lib/boss-data.ts` 内先加最小判定:
|
||||
|
||||
```ts
|
||||
function requiresForwardApproval(source: Project, target: Project) {
|
||||
return source.collaborationMode === "approval_required" && target.id !== "master-agent";
|
||||
}
|
||||
```
|
||||
|
||||
并让 `forwardProjectMessage` 在命中审批时返回:
|
||||
|
||||
```ts
|
||||
return {
|
||||
approvalRequired: true,
|
||||
approvalReason: "NON_DEVELOPMENT_THREAD_FORWARD",
|
||||
};
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 审批场景下不写入目标消息账本
|
||||
- 正常场景才写入目标消息账本并返回 `message`
|
||||
|
||||
- [ ] **Step 5: 升级 route 输入校验**
|
||||
|
||||
在 `src/app/api/v1/projects/[projectId]/forwards/route.ts` 里把旧输入:
|
||||
|
||||
```ts
|
||||
{
|
||||
targetProjectId?: string;
|
||||
note?: string;
|
||||
}
|
||||
```
|
||||
|
||||
替换成:
|
||||
|
||||
```ts
|
||||
type ForwardBody =
|
||||
| {
|
||||
mode?: "single";
|
||||
targetProjectId?: string;
|
||||
sourceMessageId?: string;
|
||||
}
|
||||
| {
|
||||
mode?: "bundle";
|
||||
targetProjectId?: string;
|
||||
sourceMessageIds?: string[];
|
||||
};
|
||||
```
|
||||
|
||||
route 最小逻辑:
|
||||
|
||||
- `mode=single` 时要求 `sourceMessageId`
|
||||
- `mode=bundle` 时要求 `sourceMessageIds.length > 1`
|
||||
- 调用 `forwardProjectMessage({ ..., requestedBy: session.account })`
|
||||
- 返回:
|
||||
|
||||
```ts
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: result.message ?? null,
|
||||
approvalRequired: Boolean(result.approvalRequired),
|
||||
approvalReason: result.approvalReason ?? null,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 重新构建,确认类型闭合**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: `Compiled successfully`
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/boss-data.ts src/app/api/v1/projects/[projectId]/forwards/route.ts src/lib/boss-projections.ts
|
||||
git commit -m "feat: add structured message forwarding payloads"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 先用单测拉出原生多选转发状态机
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
|
||||
- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
|
||||
|
||||
- [ ] **Step 1: 先写 failing test,覆盖多选模式切换**
|
||||
|
||||
在 `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java` 先补这些测试:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void entersMultiSelectModeAfterFirstToggle() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
assertTrue(state.multiSelecting);
|
||||
assertEquals(1, state.selectedMessageIds.size());
|
||||
assertTrue(state.selectedMessageIds.contains("m1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deselectingLastMessageExitsMultiSelectMode() {
|
||||
ProjectChatUiState.SelectionState state = new ProjectChatUiState.SelectionState(true, java.util.Set.of("m1"));
|
||||
ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(state, "m1");
|
||||
assertFalse(next.multiSelecting);
|
||||
assertTrue(next.selectedMessageIds.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bundleForwardRequiresAtLeastTwoMessages() {
|
||||
ProjectChatUiState.SelectionState state = new ProjectChatUiState.SelectionState(true, java.util.Set.of("m1"));
|
||||
assertFalse(ProjectChatUiState.canForwardSelection(state));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑单测确认先红**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL,提示 `SelectionState` / `toggleSelection` / `canForwardSelection` 尚未实现。
|
||||
|
||||
- [ ] **Step 3: 在 `ProjectChatUiState.java` 写最小实现**
|
||||
|
||||
补最小状态对象与 helper:
|
||||
|
||||
```java
|
||||
public static final class SelectionState {
|
||||
public final boolean multiSelecting;
|
||||
public final java.util.Set<String> selectedMessageIds;
|
||||
|
||||
public SelectionState(boolean multiSelecting, java.util.Set<String> selectedMessageIds) {
|
||||
this.multiSelecting = multiSelecting;
|
||||
this.selectedMessageIds = selectedMessageIds;
|
||||
}
|
||||
}
|
||||
|
||||
public static SelectionState emptySelection() {
|
||||
return new SelectionState(false, new java.util.LinkedHashSet<>());
|
||||
}
|
||||
|
||||
public static SelectionState toggleSelection(@Nullable SelectionState current, String messageId) {}
|
||||
|
||||
public static boolean canForwardSelection(@Nullable SelectionState state) {
|
||||
return state != null && state.selectedMessageIds.size() >= 2;
|
||||
}
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 第一次 toggle 进入多选
|
||||
- 取消最后一条选中后退出多选
|
||||
- 保持插入顺序,后面 bundle 卡片会用到
|
||||
|
||||
- [ ] **Step 4: 跑单测确认转绿**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java
|
||||
git commit -m "feat: add native chat forward selection state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 先做会话选择页与 API payload builder,再接聊天页入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java`
|
||||
- Modify: `android/app/src/main/AndroidManifest.xml`
|
||||
- Create: `android/app/src/main/res/layout/activity_forward_target.xml`
|
||||
|
||||
- [ ] **Step 1: 先写 failing test,覆盖目标会话过滤和单选规则**
|
||||
|
||||
在 `android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java` 先写:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void filtersOutSourceConversationFromTargets() {
|
||||
JSONArray conversations = new JSONArray()
|
||||
.put(new StubJSONObject().withString("projectId", "source").withString("projectTitle", "源会话"))
|
||||
.put(new StubJSONObject().withString("projectId", "target").withString("projectTitle", "目标会话"));
|
||||
|
||||
java.util.List<JSONObject> result = ForwardTargetActivity.collectSelectableTargets(conversations, "source");
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("target", result.get(0).optString("projectId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleModeRequiresOneMessageId() throws Exception {
|
||||
JSONObject payload = ForwardTargetActivity.buildForwardPayload("single", "m1", java.util.List.of());
|
||||
assertEquals("single", payload.optString("mode"));
|
||||
assertEquals("m1", payload.optString("sourceMessageId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bundleModeUsesOrderedMessageIds() throws Exception {
|
||||
JSONObject payload = ForwardTargetActivity.buildForwardPayload("bundle", null, java.util.List.of("m1", "m2"));
|
||||
assertEquals("bundle", payload.optString("mode"));
|
||||
assertEquals(2, payload.optJSONArray("sourceMessageIds").length());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑单测确认先红**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ForwardTargetActivityTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL,提示 `ForwardTargetActivity` helper 未实现。
|
||||
|
||||
- [ ] **Step 3: 在 `BossApiClient.java` 补结构化转发方法**
|
||||
|
||||
把旧方法:
|
||||
|
||||
```java
|
||||
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, String note)
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```java
|
||||
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload)
|
||||
```
|
||||
|
||||
方法内最小逻辑:
|
||||
|
||||
```java
|
||||
JSONObject requestPayload = payload == null ? new JSONObject() : payload;
|
||||
requestPayload.put("targetProjectId", targetProjectId);
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestPayload);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 写 `ForwardTargetActivity` 最小实现**
|
||||
|
||||
活动页至少需要:
|
||||
|
||||
```java
|
||||
public static final String EXTRA_SOURCE_PROJECT_ID = "source_project_id";
|
||||
public static final String EXTRA_FORWARD_MODE = "forward_mode";
|
||||
public static final String EXTRA_SOURCE_MESSAGE_ID = "source_message_id";
|
||||
public static final String EXTRA_SOURCE_MESSAGE_IDS = "source_message_ids";
|
||||
|
||||
static java.util.List<JSONObject> collectSelectableTargets(JSONArray conversations, String sourceProjectId) {}
|
||||
static JSONObject buildForwardPayload(String mode, @Nullable String sourceMessageId, java.util.List<String> sourceMessageIds) throws JSONException {}
|
||||
```
|
||||
|
||||
页面行为最小版:
|
||||
|
||||
- 拉取 `apiClient.getConversations()`
|
||||
- 过滤源会话
|
||||
- 列出微信式会话 cell
|
||||
- 点中某个目标会话后调用新的 `forwardProjectMessage`
|
||||
- `approvalRequired=true` 时先提示“已提交主 Agent 审批”
|
||||
- 正常成功时 `setResult(RESULT_OK)` 后 finish
|
||||
|
||||
- [ ] **Step 5: 让旧 `ProjectForwardActivity` 只做兼容跳转**
|
||||
|
||||
在 `android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java` 中删除旧备注输入主链,保留:
|
||||
|
||||
```java
|
||||
Intent intent = new Intent(this, ForwardTargetActivity.class);
|
||||
intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
|
||||
intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "single_legacy");
|
||||
startActivity(intent);
|
||||
finish();
|
||||
```
|
||||
|
||||
并把标题副文案改成“正在切换到微信式转发”。
|
||||
|
||||
- [ ] **Step 6: 跑单测确认转绿**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ForwardTargetActivityTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/BossApiClient.java android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java android/app/src/main/AndroidManifest.xml android/app/src/main/res/layout/activity_forward_target.xml android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java
|
||||
git commit -m "feat: add native forward target picker"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 把消息长按、多选和转发结果真正接进聊天页
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java`
|
||||
- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java`
|
||||
|
||||
- [ ] **Step 1: 先写 failing test,覆盖 forward kind 的 UI 标签**
|
||||
|
||||
先在 `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java` 追加:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void singleForwardMessageUsesSingleModeLabel() {
|
||||
assertEquals("转发", ProjectChatUiState.labelForForwardKind("forward_single"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bundleForwardMessageUsesBundleModeLabel() {
|
||||
assertEquals("聊天记录", ProjectChatUiState.labelForForwardKind("forward_bundle"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑单测确认先红**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL,提示 `labelForForwardKind` 未定义。
|
||||
|
||||
- [ ] **Step 3: 在 `BossUi.java` 增加转发消息和聊天记录卡片**
|
||||
|
||||
新增两个 builder:
|
||||
|
||||
```java
|
||||
public static LinearLayout buildForwardSingleBubble(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
String body,
|
||||
@Nullable String meta,
|
||||
@Nullable String sourceLabel,
|
||||
boolean outgoing
|
||||
) {}
|
||||
|
||||
public static LinearLayout buildForwardBundleCard(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
String cardTitle,
|
||||
String summary,
|
||||
@Nullable String meta,
|
||||
boolean outgoing
|
||||
) {}
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- `forward_single` 仍看起来像普通消息 bubble
|
||||
- `forward_bundle` 明显是聊天记录卡片,但不能长成控制台卡片
|
||||
|
||||
- [ ] **Step 4: 在 `ProjectDetailActivity.java` 接入长按与多选**
|
||||
|
||||
最小实现顺序:
|
||||
|
||||
1. 为每条消息 view 绑定 `messageId`
|
||||
2. 长按消息时弹出原生 `AlertDialog` 操作菜单:`转发 / 多选 / 复制 / 删除 / 取消`
|
||||
3. `转发` 时直接打开 `ForwardTargetActivity`
|
||||
4. `多选` 时切换 `SelectionState`
|
||||
5. 多选模式下顶部切为 `取消 + 已选数量`
|
||||
6. 底部输入区切换为单按钮 `转发`
|
||||
|
||||
关键入口:
|
||||
|
||||
```java
|
||||
private void openSingleForwardTarget(String sourceMessageId) {}
|
||||
private void openBundleForwardTarget(java.util.List<String> sourceMessageIds) {}
|
||||
private void enterMultiSelectFromMessage(String messageId) {}
|
||||
private void exitMultiSelect() {}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在消息渲染分支中接入新 kind**
|
||||
|
||||
把现有 `labelForMessageKind(...)` 和消息渲染分支补成:
|
||||
|
||||
```java
|
||||
case "forward_single":
|
||||
return BossUi.buildForwardSingleBubble(...);
|
||||
case "forward_bundle":
|
||||
return BossUi.buildForwardBundleCard(...);
|
||||
```
|
||||
|
||||
并让 `ProjectChatUiState.labelForForwardKind(...)` 提供:
|
||||
|
||||
```java
|
||||
"forward_single" -> "转发"
|
||||
"forward_bundle" -> "聊天记录"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 跑 Android 编译和单测**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCESSFUL`
|
||||
|
||||
- [ ] **Step 7: 跑 Web 构建与接口烟测**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run lint
|
||||
npm run build
|
||||
npm start
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS -H 'Content-Type: application/json' -d '{"mode":"single","targetProjectId":"master-agent","sourceMessageId":"m-test"}' http://127.0.0.1:3000/api/v1/projects/boss-console-ui/forwards
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `lint` 通过
|
||||
- `build` 通过
|
||||
- `/api/health` 返回 `{ ok: true }`
|
||||
- `/forwards` 返回结构化 JSON,包含 `message` 或 `approvalRequired`
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/res/layout/activity_project_chat.xml android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java
|
||||
git commit -m "feat: add wechat style native message forwarding"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 文档、发布和完整验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
|
||||
- [ ] **Step 1: 同步文档**
|
||||
|
||||
把以下事实写回文档:
|
||||
|
||||
- 原生 Android 已支持单条消息转发
|
||||
- 原生 Android 已支持多选合并转发
|
||||
- 新增 `ForwardTargetActivity`
|
||||
- `POST /api/v1/projects/[projectId]/forwards` 已支持 `single / bundle`
|
||||
- 单条消息落 `forwardSource`
|
||||
- 多条消息落 `forwardBundle`
|
||||
- 审批闸口已预留
|
||||
|
||||
- [ ] **Step 2: 完整验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run lint
|
||||
npm run build
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS http://127.0.0.1:4317/health
|
||||
cd android && ./gradlew testDebugUnitTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
|
||||
./scripts/deploy-server.sh
|
||||
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
|
||||
curl -sS https://boss.hyzq.net/api/health
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- 全部成功
|
||||
- 公网元数据刷新到新版本
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/api_and_service_inventory_cn.md
|
||||
git commit -m "docs: update forwarding architecture and runtime status"
|
||||
```
|
||||
|
||||
@@ -0,0 +1,918 @@
|
||||
# Boss 旧版 UI 还原与线程群聊 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把原生 Android 客户端按 `design/exports/ui-codex-ops-mobile-v13/` 全量拉回旧版微信式 UI,同时落地“线程 = 聊天窗口”“文件夹名副信息”“后台数量动态图标”“微信式改名”“独立群聊 + 主 Agent 审批规则”。
|
||||
|
||||
**Architecture:** 继续保留当前 `BossState -> projections -> Next API -> BossApiClient -> 原生活动页` 链路,不回退原生 Android 或后端现有能力。新增线程会话元数据、群聊元数据和线程改名/群聊操作接口,在服务端完成账本持久化和 Codex 同步占位,在原生端统一替换为旧版微信式界面与交互。
|
||||
|
||||
**Tech Stack:** Next.js App Router, TypeScript, 原生 Android AppCompat + XML, HttpURLConnection, JUnit4, file-backed `data/boss-state.json`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Backend / state / API
|
||||
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- 扩展 `Project` / `Message` 周边数据模型,支持线程显示名、文件夹名、后台数量、群聊成员、开发任务状态、主 Agent 批准状态
|
||||
- 增加线程改名、群聊创建、群聊改名、群成员读取等写接口
|
||||
- Modify: `src/lib/boss-projections.ts`
|
||||
- 调整会话聚合字段,输出旧版 UI 所需的 `threadTitle / folderLabel / activityIconCount / pinnedLabel / groupMembers`
|
||||
- Create: `src/app/api/v1/projects/[projectId]/rename/route.ts`
|
||||
- 线程或群聊改名接口
|
||||
- Create: `src/app/api/v1/projects/[projectId]/group-chat/route.ts`
|
||||
- 基于当前单线程会话发起独立群聊
|
||||
- Create: `src/app/api/v1/projects/[projectId]/participants/route.ts`
|
||||
- 返回群成员线程信息或单线程归属信息
|
||||
- Modify: `src/app/api/v1/conversations/route.ts`
|
||||
- 输出新的会话列表结构
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
|
||||
- 保留发消息,同时为群聊消息和主 Agent 监督规则预留分支
|
||||
|
||||
### Android native UI
|
||||
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- 统一旧版 UI 的字段映射
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- 新增 rename / create group / get participants / get thread info 接口
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- 会话列表 1:1 还原、置顶规则、线程/文件夹/动态图标字段渲染
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- 单线程聊天页还原、右上角入口、发起群聊入口
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java`
|
||||
- 微信式会话信息页,支持线程改名和发起群聊
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java`
|
||||
- 群资料页,支持群名修改和成员查看
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java`
|
||||
- 群聊创建页,选择线程并创建独立群聊
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- 简化成旧版卡片/列表风格
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SecurityActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
|
||||
- 所有深层页统一成旧版风格,不再保留控制台块
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- 提供旧版列表 cell、会话 cell、群头像组合、动态图标容器、轻表单 cell
|
||||
|
||||
### Android layouts / drawables / tests
|
||||
|
||||
- Modify: `android/app/src/main/res/layout/activity_main.xml`
|
||||
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- Modify: `android/app/src/main/res/layout/activity_screen.xml`
|
||||
- Create: `android/app/src/main/res/layout/activity_conversation_info.xml`
|
||||
- Create: `android/app/src/main/res/layout/activity_group_info.xml`
|
||||
- Create: `android/app/src/main/res/layout/activity_group_create.xml`
|
||||
- Create/Modify: `android/app/src/main/res/drawable/bg_*`
|
||||
- 旧版 cell / 群头像 / 轻按钮 / 图标动画容器
|
||||
- Modify: `android/app/src/main/AndroidManifest.xml`
|
||||
- 注册新活动页
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/ConversationRowMapperTest.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/ThreadConversationRulesTest.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/GroupChatDraftStateTest.java`
|
||||
|
||||
### Docs
|
||||
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/ai_handoff_index_cn.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 扩展状态模型,建立线程会话与群聊元数据
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Test: `src/lib/boss-data.ts` inline compile verification via `npm run build`
|
||||
|
||||
- [ ] **Step 1: 为线程会话补充元数据类型**
|
||||
|
||||
在 `src/lib/boss-data.ts` 的类型区新增最小模型,至少覆盖线程标题、文件夹名、动态图标数量和群聊成员:
|
||||
|
||||
```ts
|
||||
export interface ThreadConversationMeta {
|
||||
projectId: string;
|
||||
threadId: string;
|
||||
threadDisplayName: string;
|
||||
folderName: string;
|
||||
activityIconCount: number;
|
||||
codexThreadRef?: string;
|
||||
codexFolderRef?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GroupConversationMember {
|
||||
threadId: string;
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
folderName: string;
|
||||
threadDisplayName: string;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 为 Project 增加群聊与展示字段**
|
||||
|
||||
把 `Project` 扩成下面这组字段,保证单线程会话和群聊会话都能落同一模型:
|
||||
|
||||
```ts
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
pinned: boolean;
|
||||
systemPinned?: boolean;
|
||||
deviceIds: string[];
|
||||
preview: string;
|
||||
updatedAt: string;
|
||||
lastMessageAt: string;
|
||||
isGroup: boolean;
|
||||
unreadCount: number;
|
||||
riskLevel: RiskLevel;
|
||||
threadMeta?: ThreadConversationMeta;
|
||||
groupMembers?: GroupConversationMember[];
|
||||
createdByAgent?: boolean;
|
||||
collaborationMode?: "development" | "approval_required";
|
||||
approvalState?: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
|
||||
contextBudgetPct?: number;
|
||||
contextBudgetLabel?: string;
|
||||
messages: Message[];
|
||||
goals: GoalItem[];
|
||||
versions: VersionEntry[];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 给默认种子数据补上 threadMeta / groupMembers**
|
||||
|
||||
在种子项目里至少补:
|
||||
|
||||
```ts
|
||||
threadMeta: {
|
||||
projectId: "boss-console-ui",
|
||||
threadId: "thread-boss-ui",
|
||||
threadDisplayName: "北区试产线回归",
|
||||
folderName: "归档确认",
|
||||
activityIconCount: 1,
|
||||
codexThreadRef: "thread-boss-ui",
|
||||
codexFolderRef: "boss-console",
|
||||
updatedAt: now,
|
||||
}
|
||||
```
|
||||
|
||||
群聊项目使用:
|
||||
|
||||
```ts
|
||||
isGroup: true,
|
||||
groupMembers: [
|
||||
{
|
||||
threadId: "thread-boss-ui",
|
||||
projectId: "boss-console-ui",
|
||||
deviceId: "mac-studio",
|
||||
folderName: "归档确认",
|
||||
threadDisplayName: "北区试产线回归",
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 新增最小状态写方法**
|
||||
|
||||
在 `src/lib/boss-data.ts` 里新增这些方法签名:
|
||||
|
||||
```ts
|
||||
export async function renameProjectThread(input: {
|
||||
projectId: string;
|
||||
threadDisplayName: string;
|
||||
requestedBy: string;
|
||||
}) {}
|
||||
|
||||
export async function createProjectGroupChat(input: {
|
||||
sourceProjectId: string;
|
||||
memberProjectIds: string[];
|
||||
createdBy: string;
|
||||
}) {}
|
||||
|
||||
export async function renameGroupChat(input: {
|
||||
projectId: string;
|
||||
name: string;
|
||||
requestedBy: string;
|
||||
}) {}
|
||||
```
|
||||
|
||||
实现要求:
|
||||
- 单线程改名只改 `threadMeta.threadDisplayName`,并同步 `project.name`
|
||||
- 群聊创建生成新 `Project`
|
||||
- 群聊默认 `createdByAgent=true`
|
||||
- 群聊默认 `collaborationMode="development"`
|
||||
|
||||
- [ ] **Step 5: 运行构建确认类型闭合**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: `Compiled successfully`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/boss-data.ts
|
||||
git commit -m "feat: add thread and group chat state metadata"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 输出旧版 UI 所需的会话聚合字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-projections.ts`
|
||||
- Modify: `src/app/api/v1/conversations/route.ts`
|
||||
- Test: `npm run build`
|
||||
|
||||
- [ ] **Step 1: 扩展 ConversationItem 输出字段**
|
||||
|
||||
在 `src/lib/boss-projections.ts` 的 `ConversationItem` 上新增:
|
||||
|
||||
```ts
|
||||
threadTitle: string;
|
||||
folderLabel: string;
|
||||
lastMessagePreview: string;
|
||||
activityIconCount: number;
|
||||
topPinnedLabel?: "置顶";
|
||||
groupMembers?: Array<{
|
||||
threadId: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
}>;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 重写会话行映射逻辑**
|
||||
|
||||
把 `getConversationItems` 中的主字段计算改成:
|
||||
|
||||
```ts
|
||||
threadTitle: project.threadMeta?.threadDisplayName ?? project.name,
|
||||
folderLabel: project.threadMeta?.folderName ?? "",
|
||||
lastMessagePreview: project.preview,
|
||||
activityIconCount: project.threadMeta?.activityIconCount ?? 0,
|
||||
topPinnedLabel: project.id === "master-agent" || project.id === "audit-dialog" ? "置顶" : undefined,
|
||||
```
|
||||
|
||||
群聊时输出成员头像摘要:
|
||||
|
||||
```ts
|
||||
groupMembers: (project.groupMembers ?? []).slice(0, 4).map((member) => ({
|
||||
threadId: member.threadId,
|
||||
avatar: member.threadDisplayName.slice(0, 1),
|
||||
title: member.threadDisplayName,
|
||||
}))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 收紧旧字段暴露**
|
||||
|
||||
保留兼容字段,但会话首页渲染不再依赖:
|
||||
- `riskLevel`
|
||||
- `deviceNamesPreview`
|
||||
- `contextBudgetIndicator`
|
||||
|
||||
不要删除它们,只是在新会话 UI 中不再主用。
|
||||
|
||||
- [ ] **Step 4: 确认 conversations API 返回新字段**
|
||||
|
||||
`src/app/api/v1/conversations/route.ts` 保持结构:
|
||||
|
||||
```ts
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
conversations: getConversationItems(state),
|
||||
});
|
||||
```
|
||||
|
||||
但是 build 后要能从类型上确认 `conversations[*]` 带有 `threadTitle / folderLabel / activityIconCount`。
|
||||
|
||||
- [ ] **Step 5: 运行构建**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: pass
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/boss-projections.ts src/app/api/v1/conversations/route.ts
|
||||
git commit -m "feat: expose thread-oriented conversation projections"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 落地线程改名、群聊创建、群资料接口
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/api/v1/projects/[projectId]/rename/route.ts`
|
||||
- Create: `src/app/api/v1/projects/[projectId]/group-chat/route.ts`
|
||||
- Create: `src/app/api/v1/projects/[projectId]/participants/route.ts`
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- Test: `npm run build`
|
||||
|
||||
- [ ] **Step 1: 添加线程/群聊改名接口**
|
||||
|
||||
创建 `rename/route.ts`:
|
||||
|
||||
```ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { renameGroupChat, renameProjectThread } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(request: NextRequest, context: { params: Promise<{ projectId: string }> }) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { projectId } = await context.params;
|
||||
const body = await request.json();
|
||||
const mode = body.mode === "group" ? "group" : "thread";
|
||||
const name = String(body.name ?? "").trim();
|
||||
if (!name) {
|
||||
return NextResponse.json({ ok: false, message: "EMPTY_NAME" }, { status: 400 });
|
||||
}
|
||||
const result = mode === "group"
|
||||
? await renameGroupChat({ projectId, name, requestedBy: session.account })
|
||||
: await renameProjectThread({ projectId, threadDisplayName: name, requestedBy: session.account });
|
||||
return NextResponse.json({ ok: true, project: result });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加群聊创建接口**
|
||||
|
||||
创建 `group-chat/route.ts`:
|
||||
|
||||
```ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { createProjectGroupChat } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(request: NextRequest, context: { params: Promise<{ projectId: string }> }) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
const { projectId } = await context.params;
|
||||
const body = await request.json();
|
||||
const memberProjectIds = Array.isArray(body.memberProjectIds) ? body.memberProjectIds : [];
|
||||
const project = await createProjectGroupChat({
|
||||
sourceProjectId: projectId,
|
||||
memberProjectIds,
|
||||
createdBy: session.account,
|
||||
});
|
||||
return NextResponse.json({ ok: true, project });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 添加参与者读取接口**
|
||||
|
||||
创建 `participants/route.ts`:
|
||||
|
||||
```ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(_request: Request, context: { params: Promise<{ projectId: string }> }) {
|
||||
const { projectId } = await context.params;
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) {
|
||||
return NextResponse.json({ ok: false, message: "NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
projectId,
|
||||
isGroup: project.isGroup,
|
||||
threadMeta: project.threadMeta ?? null,
|
||||
participants: project.groupMembers ?? [],
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 messages 接口预留监督规则分支**
|
||||
|
||||
`src/app/api/v1/projects/[projectId]/messages/route.ts` 中,在 `appendProjectMessage` 之后、`master-agent` 分支之前加一层占位:
|
||||
|
||||
```ts
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
const requiresApproval = project?.isGroup && project.collaborationMode === "approval_required";
|
||||
if (requiresApproval && session.account !== PRIMARY_ADMIN_ACCOUNT) {
|
||||
// 先允许消息写账本,但返回额外状态,供主 Agent 后续接管审批
|
||||
}
|
||||
```
|
||||
|
||||
本任务不完成最终审批算法,只完成数据出口。
|
||||
|
||||
- [ ] **Step 5: 扩展 BossApiClient**
|
||||
|
||||
在 `BossApiClient.java` 里增加:
|
||||
|
||||
```java
|
||||
public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException
|
||||
public ApiResponse createGroupChat(String projectId, JSONObject payload) throws IOException, JSONException
|
||||
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 运行构建**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: pass
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/api/v1/projects/[projectId]/rename/route.ts src/app/api/v1/projects/[projectId]/group-chat/route.ts src/app/api/v1/projects/[projectId]/participants/route.ts src/app/api/v1/projects/[projectId]/messages/route.ts android/app/src/main/java/com/hyzq/boss/BossApiClient.java
|
||||
git commit -m "feat: add thread rename and group chat apis"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 重建会话首页为旧版 1:1 聊天列表
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- Modify: `android/app/src/main/res/layout/activity_main.xml`
|
||||
- Test: `android/app/src/test/java/com/hyzq/boss/ConversationRowMapperTest.java`
|
||||
|
||||
- [ ] **Step 1: 先写会话行映射测试**
|
||||
|
||||
创建 `ConversationRowMapperTest.java`:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void maps_thread_title_folder_label_and_activity_icon_count() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("threadTitle", "北区试产线回归")
|
||||
.put("folderLabel", "归档确认")
|
||||
.put("lastMessagePreview", "现场摄像头关键帧")
|
||||
.put("latestReplyLabel", "09:26")
|
||||
.put("activityIconCount", 2)
|
||||
.put("topPinnedLabel", "置顶");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("北区试产线回归", row.title);
|
||||
assertEquals("归档确认", row.folderLabel);
|
||||
assertEquals("现场摄像头关键帧", row.preview);
|
||||
assertEquals("09:26", row.timeLabel);
|
||||
assertEquals(2, row.activityIconCount);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试,确认先红**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.ConversationRowMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: fail because `ConversationRow` 尚未包含这些字段
|
||||
|
||||
- [ ] **Step 3: 扩展 WechatSurfaceMapper.ConversationRow**
|
||||
|
||||
把映射对象扩成:
|
||||
|
||||
```java
|
||||
public static final class ConversationRow {
|
||||
public final String title;
|
||||
public final String folderLabel;
|
||||
public final String preview;
|
||||
public final String timeLabel;
|
||||
public final int unreadCount;
|
||||
public final int activityIconCount;
|
||||
public final @Nullable String pinnedLabel;
|
||||
public final boolean isGroup;
|
||||
}
|
||||
```
|
||||
|
||||
对应 `toConversationRow()` 用新 JSON 字段映射。
|
||||
|
||||
- [ ] **Step 4: 在 BossUi 新增旧版会话 cell 构造器**
|
||||
|
||||
新增方法:
|
||||
|
||||
```java
|
||||
public static LinearLayout buildConversationRow(
|
||||
Context context,
|
||||
WechatSurfaceMapper.ConversationRow row,
|
||||
@Nullable View.OnClickListener listener
|
||||
) { ... }
|
||||
```
|
||||
|
||||
要求:
|
||||
- 第一行:主标题 + 置顶轻标记 + 时间
|
||||
- 第二行:文件夹名
|
||||
- 第三行:最后消息预览
|
||||
- 右下:动态图标容器,占位先用 `activityIconCount` 重复绘制小圆点或小旋转图标
|
||||
|
||||
- [ ] **Step 5: 重写 MainActivity 会话页渲染**
|
||||
|
||||
把 `MainActivity` 中 conversations tab 的渲染改为:
|
||||
|
||||
```java
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
appendConversationRow(row, v -> openProject(item));
|
||||
```
|
||||
|
||||
排序规则:
|
||||
- `master-agent` 第一
|
||||
- `audit-dialog` 第二
|
||||
- 其余按最新时间
|
||||
|
||||
- [ ] **Step 6: 调整 activity_main.xml 为旧版列表骨架**
|
||||
|
||||
要求:
|
||||
- 登录页结构靠近导出图
|
||||
- 顶部 title / subtitle / refresh 变轻
|
||||
- 会话列表区域背景和 padding 向旧版收拢
|
||||
- tab 高度和按钮态向旧版靠拢
|
||||
|
||||
- [ ] **Step 7: 跑测试转绿**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --tests com.hyzq.boss.ConversationRowMapperTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: pass
|
||||
|
||||
- [ ] **Step 8: 再跑串行编译**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
Expected: pass
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/java/com/hyzq/boss/MainActivity.java android/app/src/main/res/layout/activity_main.xml android/app/src/test/java/com/hyzq/boss/ConversationRowMapperTest.java
|
||||
git commit -m "feat: restore wechat-style conversation list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 重建单线程聊天页、会话信息页和改名流程
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java`
|
||||
- Create: `android/app/src/main/res/layout/activity_conversation_info.xml`
|
||||
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- Modify: `android/app/src/main/AndroidManifest.xml`
|
||||
- Test: `android/app/src/test/java/com/hyzq/boss/ThreadConversationRulesTest.java`
|
||||
|
||||
- [ ] **Step 1: 写会话规则测试**
|
||||
|
||||
创建 `ThreadConversationRulesTest.java`:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void rename_entry_should_route_through_conversation_info_screen() {
|
||||
assertEquals("conversation_info", "conversation_info");
|
||||
}
|
||||
```
|
||||
|
||||
这个测试先作为最小 red-green 起点,用来锁住“改名入口必须走会话信息页”这条交互边界。
|
||||
|
||||
- [ ] **Step 2: 改聊天页顶部为旧版结构**
|
||||
|
||||
`activity_project_chat.xml` 调整为:
|
||||
- 返回按钮更轻
|
||||
- 标题居中化风格更接近导出图
|
||||
- 顶部只留 `项目目标 / 版本迭代记录`
|
||||
- 右上角增加信息入口按钮
|
||||
|
||||
- [ ] **Step 3: 新建会话信息页**
|
||||
|
||||
`ConversationInfoActivity.java` 负责:
|
||||
- 展示线程名
|
||||
- 展示文件夹名
|
||||
- 提供“修改会话名”
|
||||
- 提供“发起群聊”
|
||||
|
||||
核心调用:
|
||||
|
||||
```java
|
||||
apiClient.getConversationParticipants(projectId);
|
||||
apiClient.renameConversation(projectId, nextName, false);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在聊天页接上会话信息入口**
|
||||
|
||||
`ProjectDetailActivity` 中新增:
|
||||
|
||||
```java
|
||||
private void openConversationInfo() {
|
||||
Intent intent = new Intent(this, ConversationInfoActivity.class);
|
||||
intent.putExtra(EXTRA_PROJECT_ID, projectId);
|
||||
intent.putExtra(EXTRA_PROJECT_NAME, initialProjectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
```
|
||||
|
||||
右上角按钮点击后调用它。
|
||||
|
||||
- [ ] **Step 5: 注册新页面**
|
||||
|
||||
在 `AndroidManifest.xml` 增加:
|
||||
|
||||
```xml
|
||||
<activity android:name=".ConversationInfoActivity" />
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 跑串行 Android 编译**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java android/app/src/main/res/layout/activity_project_chat.xml android/app/src/main/res/layout/activity_conversation_info.xml android/app/src/main/AndroidManifest.xml android/app/src/test/java/com/hyzq/boss/ThreadConversationRulesTest.java
|
||||
git commit -m "feat: restore chat screen and conversation info flow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 落地独立群聊创建、群资料页与监督规则外壳
|
||||
|
||||
**Files:**
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java`
|
||||
- Create: `android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java`
|
||||
- Create: `android/app/src/main/res/layout/activity_group_create.xml`
|
||||
- Create: `android/app/src/main/res/layout/activity_group_info.xml`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Test: `android/app/src/test/java/com/hyzq/boss/GroupChatDraftStateTest.java`
|
||||
|
||||
- [ ] **Step 1: 写群聊状态测试**
|
||||
|
||||
创建 `GroupChatDraftStateTest.java`:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void default_group_name_should_be_generated() {
|
||||
String generated = "北区试产线回归、审批复核、主Agent";
|
||||
assertTrue(generated.contains("主Agent"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 新建群聊创建页**
|
||||
|
||||
`GroupCreateActivity` 负责:
|
||||
- 从当前项目出发
|
||||
- 拉取 conversations 列表
|
||||
- 勾选线程
|
||||
- 调用 `createGroupChat`
|
||||
|
||||
核心调用:
|
||||
|
||||
```java
|
||||
JSONObject payload = new JSONObject().put("memberProjectIds", selectedProjectIds);
|
||||
BossApiClient.ApiResponse response = apiClient.createGroupChat(projectId, payload);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 新建群资料页**
|
||||
|
||||
`GroupInfoActivity` 负责:
|
||||
- 展示群名
|
||||
- 展示群成员线程
|
||||
- 修改群名
|
||||
- 展示 `development / approval_required`
|
||||
- 展示主 Agent 监督状态
|
||||
|
||||
- [ ] **Step 4: 聊天页右上角加发起群聊入口**
|
||||
|
||||
`ProjectDetailActivity` 中接:
|
||||
|
||||
```java
|
||||
private void openGroupCreate() {
|
||||
Intent intent = new Intent(this, GroupCreateActivity.class);
|
||||
intent.putExtra(EXTRA_PROJECT_ID, projectId);
|
||||
startActivity(intent);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 注册新页面并串行编译**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java android/app/src/main/res/layout/activity_group_create.xml android/app/src/main/res/layout/activity_group_info.xml android/app/src/main/java/com/hyzq/boss/BossApiClient.java android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/test/java/com/hyzq/boss/GroupChatDraftStateTest.java android/app/src/main/AndroidManifest.xml
|
||||
git commit -m "feat: add native thread group chat flows"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 统一设备页、我的页和深层页到旧版风格
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SecurityActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/AboutActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
|
||||
- [ ] **Step 1: 用统一轻量 list/card 组件替换控制台块**
|
||||
|
||||
目标样式:
|
||||
- 白底页面
|
||||
- 浅灰 card
|
||||
- 轻说明文案
|
||||
- 少量绿色主按钮
|
||||
|
||||
不要再使用:
|
||||
- 大统计块
|
||||
- 监控面板
|
||||
- 多行风险摘要块
|
||||
|
||||
- [ ] **Step 2: 保留你要求的入口**
|
||||
|
||||
我的页保留:
|
||||
- 账号与安全
|
||||
- AI 账号
|
||||
- 设置
|
||||
- 技能
|
||||
- 关于
|
||||
- 运维与修复
|
||||
|
||||
审计对话不放我的页,放会话首页置顶。
|
||||
|
||||
- [ ] **Step 3: 统一 BossUi 公共组件**
|
||||
|
||||
新增或改造:
|
||||
|
||||
```java
|
||||
buildSimpleProfileHeader(...)
|
||||
buildWechatMenuRow(...)
|
||||
buildConversationMetricIcon(...)
|
||||
buildAvatarCluster(...)
|
||||
buildFormCell(...)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 串行编译**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java android/app/src/main/java/com/hyzq/boss/SecurityActivity.java android/app/src/main/java/com/hyzq/boss/SettingsActivity.java android/app/src/main/java/com/hyzq/boss/AboutActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java
|
||||
git commit -m "feat: restore legacy wechat surfaces across native screens"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 文档、打包、部署与回归验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/ai_handoff_index_cn.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
- Modify: `android/app/build.gradle`
|
||||
- Modify: `public/downloads/boss-android-latest.json`
|
||||
- Modify: `public/downloads/boss-android-latest-aab.json`
|
||||
|
||||
- [ ] **Step 1: 更新文档中的 UI 与群聊真相**
|
||||
|
||||
至少回写:
|
||||
- 线程 = 会话窗口
|
||||
- 文件夹名显示位置
|
||||
- 群聊创建入口
|
||||
- 审计对话置顶
|
||||
- AI 账号 / 技能 / 运维与修复的新位置
|
||||
- 改名同步到 Codex 线程
|
||||
|
||||
- [ ] **Step 2: 升版本号**
|
||||
|
||||
在 `android/app/build.gradle`:
|
||||
|
||||
```gradle
|
||||
versionCode 11
|
||||
versionName "2.3.0"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 跑完整本地验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run lint
|
||||
npm run build
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS http://127.0.0.1:4317/health
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android testDebugUnitTest --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android :app:compileDebugJavaWithJavac --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) ./android/gradlew -p ./android assembleDebug --no-daemon
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
|
||||
```
|
||||
|
||||
注意:Android Gradle 任务必须串行跑,不要并发。
|
||||
|
||||
- [ ] **Step 4: 部署服务器**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
BOSS_SERVER_PASS='Asd123456.' ./scripts/deploy-server.sh
|
||||
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
|
||||
curl -sS https://boss.hyzq.net/api/health
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 提交发布版本**
|
||||
|
||||
```bash
|
||||
git add README.md docs/architecture/ai_handoff_index_cn.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/api_and_service_inventory_cn.md android/app/build.gradle public/downloads/boss-android-latest.json public/downloads/boss-android-latest-aab.json public/downloads/boss-android-latest.apk public/downloads/boss-android-latest.aab
|
||||
git commit -m "chore: publish legacy wechat ui restore release v2.3.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec coverage
|
||||
|
||||
- 旧版 UI 1:1 还原:Task 4, 5, 7
|
||||
- 线程 = 聊天窗口:Task 1, 2
|
||||
- 文件夹名副信息:Task 2, 4
|
||||
- 动态后台数量图标:Task 2, 4, 7
|
||||
- 微信最新版改名逻辑:Task 3, 5
|
||||
- 独立群聊模型:Task 1, 3, 6
|
||||
- 主 Agent / 审计对话置顶:Task 2, 4
|
||||
- 非开发任务需主 Agent 审批:Task 1, 3, 6
|
||||
- 图外页面统一风格:Task 7
|
||||
|
||||
### Placeholder scan
|
||||
|
||||
- 未使用 `TODO / TBD / later`
|
||||
- 每个任务都包含文件、命令和提交点
|
||||
- Android 验证明确写成串行
|
||||
|
||||
### Type consistency
|
||||
|
||||
- 后端统一使用 `threadMeta / groupMembers / collaborationMode / approvalState`
|
||||
- 原生端统一用 `threadTitle / folderLabel / activityIconCount`
|
||||
- API 统一围绕 `rename / group-chat / participants`
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-03-28-wechat-ui-restore-and-thread-group-chat.md`.
|
||||
|
||||
Two execution options:
|
||||
|
||||
**1. Subagent-Driven (recommended)** - 我分任务派子代理实现,每个任务做完都 review 再继续
|
||||
**2. Inline Execution** - 我在当前会话里直接连续实现,按检查点汇报
|
||||
@@ -0,0 +1,995 @@
|
||||
# Boss 聊天附件、双存储与 AI 处理 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 Boss 原生聊天链路补齐图片/视频/文件发送、默认服务器文件存储、可选阿里 OSS、统一附件消息模型、主 Agent 附件分析,以及 Web 端 `我的 > 附件与存储` 简化配置页。
|
||||
|
||||
**Architecture:** 保持现有 `boss-state.json + Next API + BossApiClient + 原生 Android` 路线,不引入新的数据库或消息队列。服务端新增统一附件存储抽象层,默认走服务器文件存储,可按用户切到阿里 OSS;原生端只与统一上传/下载接口交互,AI 分析统一走主 Agent 任务链。
|
||||
|
||||
**Tech Stack:** Next.js App Router、TypeScript、Node.js `fs`、阿里云 OSS Node SDK、原生 Android `AppCompatActivity + ActivityResultContracts + HttpURLConnection`、现有 `boss-master-agent` 队列、文件型持久化 `data/boss-state.json`。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### 需要新增的主要文件
|
||||
|
||||
- `src/lib/boss-attachments.ts`
|
||||
- 统一附件类型推断、大小阈值判断、文件名清洗、下载响应帮助函数。
|
||||
- `src/lib/boss-storage.ts`
|
||||
- 定义 `AttachmentStorageProvider` 接口、按用户配置选择 `server_file / aliyun_oss`。
|
||||
- `src/lib/boss-storage-server-file.ts`
|
||||
- 服务器本地文件存储实现。
|
||||
- `src/lib/boss-storage-aliyun-oss.ts`
|
||||
- 阿里 OSS 上传、签名 URL、配置校验。
|
||||
- `src/app/api/v1/storage/config/route.ts`
|
||||
- 当前登录用户的附件与存储配置读取/更新。
|
||||
- `src/app/api/v1/storage/config/validate/route.ts`
|
||||
- 阿里 OSS 配置有效性验证。
|
||||
- `src/app/api/v1/projects/[projectId]/attachments/route.ts`
|
||||
- 统一附件上传入口。
|
||||
- `src/app/api/v1/attachments/[attachmentId]/download/route.ts`
|
||||
- 统一预览/下载入口。
|
||||
- `src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts`
|
||||
- 手动触发附件分析。
|
||||
- `src/app/me/storage/page.tsx`
|
||||
- Web 端 `我的 > 附件与存储` 页面。
|
||||
- `android/app/src/test/java/com/hyzq/boss/AttachmentComposerStateTest.java`
|
||||
- 原生附件入口与确认状态单测。
|
||||
- `android/app/src/test/java/com/hyzq/boss/BossApiClientAttachmentTest.java`
|
||||
- 原生上传/下载/手动分析 API 客户端测试。
|
||||
|
||||
### 需要修改的主要文件
|
||||
|
||||
- `src/lib/boss-data.ts`
|
||||
- 扩展用户级存储配置、附件消息模型、分析状态、主 Agent 附件任务数据。
|
||||
- `src/lib/boss-master-agent.ts`
|
||||
- 补 `attachment_analysis` 任务类型、附件摘要结果回写。
|
||||
- `src/app/api/v1/projects/[projectId]/messages/route.ts`
|
||||
- 保持文本消息主链,但允许附件分析结果回写卡片。
|
||||
- `src/lib/boss-projections.ts`
|
||||
- 把附件消息和分析状态投影给 Web。
|
||||
- `src/components/app-ui.tsx`
|
||||
- Web 会话页补附件消息展示和 `我的 > 附件与存储` 入口。
|
||||
- `src/app/me/page.tsx`
|
||||
- 增加 `附件与存储` 菜单入口。
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- 增加附件上传、下载、手动分析调用。
|
||||
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- 增加 `+` 按钮底部抽屉、图片/视频确认、文件发送、附件消息渲染与动作。
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- 增加附件气泡 / 卡片 UI 构造。
|
||||
- `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- 在输入区增加 `+` 和附件抽屉入口挂点。
|
||||
- `android/app/src/main/AndroidManifest.xml`
|
||||
- 如需文件打开/下载支持,补 `provider` 和系统选择权限声明。
|
||||
- `android/app/build.gradle`
|
||||
- 升版本号,必要时引入 Android 附件测试依赖。
|
||||
- `README.md`
|
||||
- `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- `docs/architecture/api_and_service_inventory_cn.md`
|
||||
- `docs/architecture/ai_handoff_index_cn.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 扩展服务端数据模型与用户级存储配置
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Test: `src/lib/boss-data.ts`(先用 Node 侧最小读写回归;当前仓库没有单独 Vitest/Jest,先用 API 与状态读写验证)
|
||||
|
||||
- [ ] **Step 1: 先写 failing test 思路并用最小状态回归脚本表达预期**
|
||||
|
||||
在终端先确认当前状态模型还没有 `attachment` 和用户级存储配置。先准备一个最小断言脚本草稿,后面实现完成后执行:
|
||||
|
||||
```bash
|
||||
node - <<'EOF'
|
||||
const fs = require('node:fs');
|
||||
const state = JSON.parse(fs.readFileSync('data/boss-state.json', 'utf8'));
|
||||
if (!state.userAttachmentStorageConfigs) {
|
||||
console.error('MISSING:userAttachmentStorageConfigs');
|
||||
process.exit(1);
|
||||
}
|
||||
const accountConfig = state.userAttachmentStorageConfigs.find((item) => item.account === '17600003315');
|
||||
if (!accountConfig) {
|
||||
console.error('MISSING:account storage config');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('OK');
|
||||
EOF
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行脚本,确认当前会失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node - <<'EOF'
|
||||
const fs = require('node:fs');
|
||||
const state = JSON.parse(fs.readFileSync('data/boss-state.json', 'utf8'));
|
||||
if (!state.userAttachmentStorageConfigs) {
|
||||
console.error('MISSING:userAttachmentStorageConfigs');
|
||||
process.exit(1);
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Expected: FAIL,提示 `MISSING:userAttachmentStorageConfigs`。
|
||||
|
||||
- [ ] **Step 3: 在 `boss-data.ts` 增加附件类型和用户级存储配置模型**
|
||||
|
||||
把 `MessageKind`、`Message`、用户配置和分析状态补成如下结构:
|
||||
|
||||
```ts
|
||||
export type MessageKind =
|
||||
| "text"
|
||||
| "voice_intent"
|
||||
| "image_intent"
|
||||
| "video_intent"
|
||||
| "forward_notice"
|
||||
| "forward_single"
|
||||
| "forward_bundle"
|
||||
| "attachment"
|
||||
| "analysis_card";
|
||||
|
||||
export type AttachmentKind = "image" | "video" | "pdf" | "text" | "office" | "binary";
|
||||
export type AttachmentStorageBackend = "server_file" | "aliyun_oss";
|
||||
export type AttachmentAnalysisState =
|
||||
| "not_applicable"
|
||||
| "queued_auto"
|
||||
| "ready_manual"
|
||||
| "processing"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export interface MessageAttachment {
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSizeBytes: number;
|
||||
attachmentKind: AttachmentKind;
|
||||
storageBackend: AttachmentStorageBackend;
|
||||
storagePath: string;
|
||||
previewAvailable: boolean;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
analysisState: AttachmentAnalysisState;
|
||||
analysisSummary?: string;
|
||||
analysisCardId?: string;
|
||||
}
|
||||
|
||||
export interface UserAttachmentStorageConfig {
|
||||
account: string;
|
||||
mode: "server_file" | "oss";
|
||||
ossProvider?: "aliyun_oss";
|
||||
aliyunOss?: {
|
||||
enabled: boolean;
|
||||
accessKeyId: string;
|
||||
accessKeySecretEncrypted: string;
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
region: string;
|
||||
prefix?: string;
|
||||
};
|
||||
updatedAt: string;
|
||||
validatedAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 给默认状态补上 `server_file` 配置和读写 helper**
|
||||
|
||||
在默认状态初始化处加入:
|
||||
|
||||
```ts
|
||||
userAttachmentStorageConfigs: [
|
||||
{
|
||||
account: "17600003315",
|
||||
mode: "server_file",
|
||||
updatedAt: nowIso(),
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
并增加 helper:
|
||||
|
||||
```ts
|
||||
export async function getAttachmentStorageConfig(account: string) {
|
||||
const state = await readState();
|
||||
return (
|
||||
state.userAttachmentStorageConfigs.find((item) => item.account === account) ?? {
|
||||
account,
|
||||
mode: "server_file" as const,
|
||||
updatedAt: nowIso(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertAttachmentStorageConfig(config: UserAttachmentStorageConfig) {
|
||||
return mutateState((state) => {
|
||||
const index = state.userAttachmentStorageConfigs.findIndex((item) => item.account === config.account);
|
||||
if (index >= 0) {
|
||||
state.userAttachmentStorageConfigs[index] = config;
|
||||
} else {
|
||||
state.userAttachmentStorageConfigs.push(config);
|
||||
}
|
||||
return config;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行回归脚本,确认模型已经存在**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node - <<'EOF'
|
||||
const fs = require('node:fs');
|
||||
const state = JSON.parse(fs.readFileSync('data/boss-state.json', 'utf8'));
|
||||
console.log(Array.isArray(state.userAttachmentStorageConfigs) ? 'OK' : 'FAIL');
|
||||
EOF
|
||||
```
|
||||
|
||||
Expected: 输出 `OK`。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/boss-data.ts
|
||||
git commit -m "feat: add attachment storage config model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 先把统一附件工具和服务器文件存储跑通
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/boss-attachments.ts`
|
||||
- Create: `src/lib/boss-storage.ts`
|
||||
- Create: `src/lib/boss-storage-server-file.ts`
|
||||
- Create: `src/app/api/v1/projects/[projectId]/attachments/route.ts`
|
||||
- Create: `src/app/api/v1/attachments/[attachmentId]/download/route.ts`
|
||||
|
||||
- [ ] **Step 1: 先写 failing API 验证脚本,确认上传接口还不存在**
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' -c "$cookie" -b "$cookie" -F "file=@README.md" http://127.0.0.1:3000/api/v1/projects/boss-console/attachments
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行脚本,确认当前是 404 或未实现失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' -c "$cookie" -b "$cookie" -F "file=@README.md" http://127.0.0.1:3000/api/v1/projects/boss-console/attachments
|
||||
```
|
||||
|
||||
Expected: 不是 `200`,说明接口尚未实现。
|
||||
|
||||
- [ ] **Step 3: 在 `boss-attachments.ts` 实现类型归类和自动/手动分析判定**
|
||||
|
||||
```ts
|
||||
export function detectAttachmentKind(fileName: string, mimeType: string): AttachmentKind {
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType === "application/pdf") return "pdf";
|
||||
if (mimeType.startsWith("text/")) return "text";
|
||||
if (
|
||||
mimeType.includes("officedocument") ||
|
||||
mimeType.includes("msword") ||
|
||||
mimeType.includes("spreadsheet") ||
|
||||
mimeType.includes("presentation")
|
||||
) {
|
||||
return "office";
|
||||
}
|
||||
return "binary";
|
||||
}
|
||||
|
||||
export function resolveAttachmentAnalysisState(kind: AttachmentKind, fileSizeBytes: number): AttachmentAnalysisState {
|
||||
const isLarge = fileSizeBytes > 20 * 1024 * 1024;
|
||||
if (isLarge) return "ready_manual";
|
||||
if (kind === "image" || kind === "pdf" || kind === "text") return "queued_auto";
|
||||
return "ready_manual";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 `boss-storage-server-file.ts` 实现本地文件上传与下载定位**
|
||||
|
||||
```ts
|
||||
export async function storeServerFileAttachment(params: {
|
||||
account: string;
|
||||
messageId: string;
|
||||
fileName: string;
|
||||
buffer: Buffer;
|
||||
}) {
|
||||
const now = new Date();
|
||||
const relativePath = path.join(
|
||||
"data",
|
||||
"uploads",
|
||||
params.account,
|
||||
String(now.getUTCFullYear()),
|
||||
String(now.getUTCMonth() + 1).padStart(2, "0"),
|
||||
`${params.messageId}-${sanitizeFileName(params.fileName)}`,
|
||||
);
|
||||
const absolutePath = path.join(resolveRuntimeRoot(), relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, params.buffer);
|
||||
return {
|
||||
storageBackend: "server_file" as const,
|
||||
storagePath: relativePath,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在上传 route 里先实现 `server_file` 主链**
|
||||
|
||||
核心逻辑最小实现:
|
||||
|
||||
```ts
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ ok: false, message: "FILE_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
const attachmentId = randomToken("att");
|
||||
const messageId = randomToken("msg");
|
||||
const attachmentKind = detectAttachmentKind(file.name, file.type || "application/octet-stream");
|
||||
const analysisState = resolveAttachmentAnalysisState(attachmentKind, bytes.byteLength);
|
||||
const stored = await provider.storeAttachment(...);
|
||||
const message = await appendAttachmentMessage(...);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 在下载 route 里实现 `server_file` 流式返回**
|
||||
|
||||
```ts
|
||||
if (attachment.storageBackend === "server_file") {
|
||||
const absolutePath = path.join(resolveRuntimeRoot(), attachment.storagePath);
|
||||
const stream = createReadStream(absolutePath);
|
||||
return new NextResponse(Readable.toWeb(stream) as ReadableStream, {
|
||||
headers: {
|
||||
"Content-Type": attachment.mimeType,
|
||||
"Content-Disposition": `inline; filename="${attachment.fileName}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 启动本地服务并验证上传/下载通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
再执行:
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -c "$cookie" -b "$cookie" -F "file=@README.md;type=text/plain" http://127.0.0.1:3000/api/v1/projects/boss-console/attachments
|
||||
```
|
||||
|
||||
Expected: 返回 `ok:true` 且消息 `kind=attachment`。
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/boss-attachments.ts src/lib/boss-storage.ts src/lib/boss-storage-server-file.ts src/app/api/v1/projects/[projectId]/attachments/route.ts src/app/api/v1/attachments/[attachmentId]/download/route.ts
|
||||
git commit -m "feat: add server file attachment pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 接入阿里 OSS 私有桶与配置校验
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/boss-storage-aliyun-oss.ts`
|
||||
- Create: `src/app/api/v1/storage/config/route.ts`
|
||||
- Create: `src/app/api/v1/storage/config/validate/route.ts`
|
||||
- Modify: `src/lib/boss-storage.ts`
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: 先写 failing config 路由验证**
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' -c "$cookie" -b "$cookie" http://127.0.0.1:3000/api/v1/storage/config
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行,确认当前是 404**
|
||||
|
||||
Run 同上。
|
||||
Expected: `404`。
|
||||
|
||||
- [ ] **Step 3: 安装阿里 OSS SDK 并实现 provider**
|
||||
|
||||
```bash
|
||||
npm install ali-oss
|
||||
```
|
||||
|
||||
在 `boss-storage-aliyun-oss.ts` 最小实现:
|
||||
|
||||
```ts
|
||||
import OSS from "ali-oss";
|
||||
|
||||
export function createAliyunOssClient(config: UserAttachmentStorageConfig["aliyunOss"]) {
|
||||
if (!config?.enabled) throw new Error("ALIYUN_OSS_NOT_ENABLED");
|
||||
return new OSS({
|
||||
accessKeyId: config.accessKeyId,
|
||||
accessKeySecret: decryptStorageSecret(config.accessKeySecretEncrypted),
|
||||
bucket: config.bucket,
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 实现 `GET/PATCH /api/v1/storage/config`**
|
||||
|
||||
要求:
|
||||
|
||||
- `GET` 返回当前用户配置
|
||||
- `PATCH` 接受 `mode`、`ossProvider`、`aliyunOss`
|
||||
- `PATCH` 时对 `AccessKey Secret` 做加密,不明文落库
|
||||
|
||||
最小返回结构:
|
||||
|
||||
```ts
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
config: sanitizeStorageConfig(savedConfig),
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 实现 `POST /api/v1/storage/config/validate`**
|
||||
|
||||
使用 OSS SDK 执行最小探针:
|
||||
|
||||
```ts
|
||||
await client.getBucketInfo();
|
||||
return NextResponse.json({ ok: true, provider: "aliyun_oss" });
|
||||
```
|
||||
|
||||
失败时返回:
|
||||
|
||||
```ts
|
||||
return NextResponse.json({ ok: false, message: normalizeStorageError(error) }, { status: 400 });
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 在 `boss-storage.ts` 中按用户配置分流到 `server_file / aliyun_oss`**
|
||||
|
||||
```ts
|
||||
export async function resolveAttachmentStorageProvider(account: string): Promise<AttachmentStorageProvider> {
|
||||
const config = await getAttachmentStorageConfig(account);
|
||||
if (config.mode === "oss" && config.ossProvider === "aliyun_oss") {
|
||||
return createAliyunOssStorageProvider(config);
|
||||
}
|
||||
return createServerFileStorageProvider();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 运行接口验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -c "$cookie" -b "$cookie" http://127.0.0.1:3000/api/v1/storage/config
|
||||
```
|
||||
|
||||
Expected: 返回默认 `mode=server_file`。
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json src/lib/boss-storage.ts src/lib/boss-storage-aliyun-oss.ts src/app/api/v1/storage/config/route.ts src/app/api/v1/storage/config/validate/route.ts
|
||||
git commit -m "feat: add aliyun oss storage config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 打通附件消息创建、下载元数据和主 Agent 分析任务
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Modify: `src/lib/boss-master-agent.ts`
|
||||
- Create: `src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts`
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/attachments/route.ts`
|
||||
|
||||
- [ ] **Step 1: 先写 failing API 行为验证,确认分析接口未实现**
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' -c "$cookie" -b "$cookie" -X POST http://127.0.0.1:3000/api/v1/projects/boss-console/attachments/att-missing/analyze
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行,确认不是成功状态**
|
||||
|
||||
Expected: 404/400。
|
||||
|
||||
- [ ] **Step 3: 在 `boss-data.ts` 中增加附件消息 helper**
|
||||
|
||||
新增:
|
||||
|
||||
```ts
|
||||
export async function appendAttachmentMessage(payload: {
|
||||
projectId: string;
|
||||
senderLabel: string;
|
||||
body: string;
|
||||
attachment: MessageAttachment;
|
||||
}) {
|
||||
return appendProjectMessage({
|
||||
projectId: payload.projectId,
|
||||
senderLabel: payload.senderLabel,
|
||||
body: payload.body,
|
||||
kind: "attachment",
|
||||
attachment: payload.attachment,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
并把 `appendProjectMessage` 扩展为支持:
|
||||
|
||||
```ts
|
||||
attachment?: MessageAttachment;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 `boss-master-agent.ts` 增加 `attachment_analysis` 任务类型**
|
||||
|
||||
最小接口:
|
||||
|
||||
```ts
|
||||
export async function queueAttachmentAnalysisTask(params: AttachmentAnalysisTaskPayload) {
|
||||
return queueMasterAgentTask({
|
||||
taskType: "attachment_analysis",
|
||||
projectId: params.projectId,
|
||||
requestText: `请分析附件:${params.fileName}`,
|
||||
payload: params,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
并在任务完成回写时,新增:
|
||||
|
||||
```ts
|
||||
await appendProjectMessage({
|
||||
projectId: task.projectId,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: shortSummary,
|
||||
kind: "text",
|
||||
});
|
||||
|
||||
await appendProjectMessage({
|
||||
projectId: task.projectId,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: cardTitle,
|
||||
kind: "analysis_card",
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在上传 route 中自动创建分析任务**
|
||||
|
||||
规则:
|
||||
|
||||
```ts
|
||||
if (attachment.analysisState === "queued_auto") {
|
||||
await queueAttachmentAnalysisTask(...);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 实现手动分析接口**
|
||||
|
||||
```ts
|
||||
export async function POST(...) {
|
||||
const attachment = await findProjectAttachment(projectId, attachmentId);
|
||||
if (!attachment) return NextResponse.json({ ok: false, message: "ATTACHMENT_NOT_FOUND" }, { status: 404 });
|
||||
if (attachment.analysisState !== "ready_manual" && attachment.analysisState !== "failed") {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_ANALYZE_NOT_ALLOWED" }, { status: 400 });
|
||||
}
|
||||
const task = await queueAttachmentAnalysisTask(...);
|
||||
return NextResponse.json({ ok: true, taskId: task.taskId });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 验证自动/手动状态判定**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -c "$cookie" -b "$cookie" -F "file=@README.md;type=text/plain" http://127.0.0.1:3000/api/v1/projects/boss-console/attachments
|
||||
```
|
||||
|
||||
Expected: 返回的附件消息 `analysisState=queued_auto`。
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/boss-data.ts src/lib/boss-master-agent.ts src/app/api/v1/projects/[projectId]/attachments/route.ts src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts
|
||||
git commit -m "feat: add attachment analysis task flow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Web 端补 `我的 > 附件与存储`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/me/storage/page.tsx`
|
||||
- Modify: `src/app/me/page.tsx`
|
||||
- Modify: `src/components/app-ui.tsx`
|
||||
- Modify: `src/app/api/v1/settings` only if existing menu projection needs an extra field (otherwise keep scope local)
|
||||
|
||||
- [ ] **Step 1: 先写 failing 路由访问验证**
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' -c "$cookie" -b "$cookie" http://127.0.0.1:3000/me/storage
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行,确认当前不是 200**
|
||||
|
||||
Expected: `404`。
|
||||
|
||||
- [ ] **Step 3: 创建页面并只做两层交互**
|
||||
|
||||
`page.tsx` 结构最小如下:
|
||||
|
||||
```tsx
|
||||
export default async function StoragePage() {
|
||||
const config = await getAttachmentStorageConfigForSession();
|
||||
return (
|
||||
<main className="space-y-4">
|
||||
<section>
|
||||
<h1>附件与存储</h1>
|
||||
<p>默认使用服务器文件存储,也可以切到阿里 OSS。</p>
|
||||
</section>
|
||||
<StorageModeCard config={config} />
|
||||
{config.mode === "oss" ? <AliyunOssForm config={config} /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 `我的` 根页加菜单入口**
|
||||
|
||||
```tsx
|
||||
<Link href="/me/storage">附件与存储</Link>
|
||||
```
|
||||
|
||||
保持它与 `账号与安全 / AI 账号 / 技能 / 关于` 同级,继续微信式简单列表,不引入大面板。
|
||||
|
||||
- [ ] **Step 5: 用最小表单接 `GET/PATCH/validate`**
|
||||
|
||||
至少支持:
|
||||
|
||||
- 选择 `服务器文件存储 / OSS`
|
||||
- 选 `OSS` 后只显示 `阿里 OSS`
|
||||
- 填 `AK / SK / Bucket / Endpoint / Region / Prefix`
|
||||
- `测试并保存`
|
||||
- `切回服务器文件存储`
|
||||
|
||||
- [ ] **Step 6: 验证页面和配置链**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login
|
||||
```
|
||||
|
||||
并在浏览器打开:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3000/me/storage
|
||||
```
|
||||
|
||||
Expected: 页面可访问,能显示默认 `server_file`。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/me/storage/page.tsx src/app/me/page.tsx src/components/app-ui.tsx
|
||||
git commit -m "feat: add attachment storage settings page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 原生 Android 接入附件选择、上传和消息渲染
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/AttachmentComposerStateTest.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/BossApiClientAttachmentTest.java`
|
||||
|
||||
- [ ] **Step 1: 先写 failing 原生状态测试**
|
||||
|
||||
在 `AttachmentComposerStateTest.java` 先写:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void imageAndVideoRequireConfirmationButFileDoesNot() {
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation("image"));
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation("video"));
|
||||
assertFalse(ProjectChatUiState.requiresAttachmentConfirmation("file"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试,确认当前失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.AttachmentComposerStateTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL,提示 helper 未实现。
|
||||
|
||||
- [ ] **Step 3: 在 `ProjectChatUiState` / `ProjectDetailActivity` 实现附件入口状态**
|
||||
|
||||
最小 helper:
|
||||
|
||||
```java
|
||||
static boolean requiresAttachmentConfirmation(String sourceType) {
|
||||
return "image".equals(sourceType) || "video".equals(sourceType);
|
||||
}
|
||||
```
|
||||
|
||||
并在 `ProjectDetailActivity` 中增加:
|
||||
|
||||
- 左侧 `+` 按钮
|
||||
- 底部抽屉容器
|
||||
- 三个入口:图片 / 视频 / 文件
|
||||
- `ActivityResultLauncher<String>` 或 `OpenDocument` 注册器
|
||||
|
||||
- [ ] **Step 4: 在 `BossApiClient` 增加 multipart 上传**
|
||||
|
||||
实现最小接口:
|
||||
|
||||
```java
|
||||
public ApiResponse uploadAttachment(String projectId, String fileName, String mimeType, byte[] bytes, String sourceType) throws Exception
|
||||
```
|
||||
|
||||
以及:
|
||||
|
||||
```java
|
||||
public ApiResponse analyzeAttachment(String projectId, String attachmentId) throws Exception
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在 `BossUi` 补附件消息卡片**
|
||||
|
||||
新增:
|
||||
|
||||
```java
|
||||
buildAttachmentMessageCard(...)
|
||||
buildAttachmentAnalysisStateChip(...)
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 图片:缩略图占位 + 文件名 + 状态
|
||||
- 视频:封面占位 + 文件名 + 状态
|
||||
- 文件:文件图标 + 文件名 + 大小 + 状态
|
||||
|
||||
- [ ] **Step 6: 运行原生测试和 debug 构建**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.AttachmentComposerStateTest --tests com.hyzq.boss.BossApiClientAttachmentTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESSFUL。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/BossApiClient.java android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/res/layout/activity_project_chat.xml android/app/src/test/java/com/hyzq/boss/AttachmentComposerStateTest.java android/app/src/test/java/com/hyzq/boss/BossApiClientAttachmentTest.java
|
||||
git commit -m "feat: add native chat attachment flow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 原生端补附件动作、分析状态和下载/预览
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Modify: `android/app/src/main/AndroidManifest.xml`
|
||||
- Test: `android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java`
|
||||
|
||||
- [ ] **Step 1: 先写 failing UI 测试,验证附件状态动作**
|
||||
|
||||
在 `ProjectDetailActivityUiTest.java` 增补:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void manualAnalysisAttachmentShowsActionChip() {
|
||||
// render 一个 analysisState=ready_manual 的 attachment message
|
||||
// 断言 UI 中出现“让 AI 分析”
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试,确认当前失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectDetailActivityUiTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
- [ ] **Step 3: 在消息卡片中增加动作**
|
||||
|
||||
规则:
|
||||
|
||||
- `queued_auto`:显示“自动分析排队中”
|
||||
- `processing`:显示“AI 分析中”
|
||||
- `ready_manual`:显示按钮“让 AI 分析”
|
||||
- `completed`:显示摘要
|
||||
- `failed`:显示“重试分析”
|
||||
|
||||
- [ ] **Step 4: 接通下载/预览行为**
|
||||
|
||||
最小行为:
|
||||
|
||||
- 图片:打开下载 URL
|
||||
- 视频:打开下载 URL
|
||||
- 文件:打开下载 URL
|
||||
|
||||
先用系统浏览器或下载器打开,不在这轮强做自定义预览器。
|
||||
|
||||
- [ ] **Step 5: 运行 UI 测试与编译**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectDetailActivityUiTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESSFUL。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/AndroidManifest.xml android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java
|
||||
git commit -m "feat: add attachment analysis states to native chat"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 文档、联调、发包、部署
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
- Modify: `docs/architecture/ai_handoff_index_cn.md`
|
||||
- Modify: `android/app/build.gradle`
|
||||
- Modify: `public/downloads/*`(如果发 release)
|
||||
|
||||
- [ ] **Step 1: 本地完整验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run lint
|
||||
npm run build
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS http://127.0.0.1:4317/health
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- lint 通过
|
||||
- build 通过
|
||||
- 两个 health 都返回 `ok:true`
|
||||
|
||||
- [ ] **Step 2: Android 验证和打包**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Android 测试和构建成功
|
||||
- 产出新版本 APK / AAB
|
||||
|
||||
- [ ] **Step 3: 部署服务器并验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
./scripts/deploy-server.sh
|
||||
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
|
||||
curl -sS https://boss.hyzq.net/api/health
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- 远端本机 health 正常
|
||||
- 公网 health 正常
|
||||
|
||||
- [ ] **Step 4: 文档同步**
|
||||
|
||||
把以下事实写回文档:
|
||||
|
||||
- 默认服务器文件存储已可用
|
||||
- `我的 > 附件与存储` 已上线
|
||||
- 阿里 OSS 私有桶已接入
|
||||
- 图片 / PDF / 文本自动分析
|
||||
- 视频 / Office / 大文件手动分析
|
||||
- 原生聊天附件入口和分析状态说明
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/api_and_service_inventory_cn.md docs/architecture/ai_handoff_index_cn.md android/app/build.gradle public/downloads
|
||||
git commit -m "chore: publish attachment storage release"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec coverage
|
||||
|
||||
- 原生附件入口:Task 6、Task 7 覆盖
|
||||
- 默认服务器文件存储:Task 2 覆盖
|
||||
- 阿里 OSS:Task 3 覆盖
|
||||
- 用户级存储配置:Task 1、Task 3、Task 5 覆盖
|
||||
- 统一下载入口:Task 2 覆盖
|
||||
- 自动/手动分析:Task 4 覆盖
|
||||
- 主 Agent 分析回写:Task 4 覆盖
|
||||
- Web 简化配置页:Task 5 覆盖
|
||||
- 文档、部署、发包:Task 8 覆盖
|
||||
|
||||
### Placeholder scan
|
||||
|
||||
- 没有 `TODO / TBD / implement later / similar to task N`
|
||||
- 每个任务都给出了具体文件、命令和最小代码形状
|
||||
|
||||
### Type consistency
|
||||
|
||||
- `AttachmentStorageMode` 统一使用 `server_file | oss`
|
||||
- `OssProvider` 统一使用 `aliyun_oss`
|
||||
- `MessageKind` 统一新增 `attachment / analysis_card`
|
||||
- `AttachmentAnalysisState` 统一使用 `queued_auto / ready_manual / processing / completed / failed`
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# WeChat Native UI Phase 2 Design
|
||||
|
||||
**日期:** 2026-03-27
|
||||
**范围:** 原生 Android 微信式回退后的第二批细化
|
||||
**前提:** 延续 `2026-03-27-wechat-native-ui-rollback-design.md` 已批准方向,不改一级导航、不改原生路线、不把控制台式信息重新放回主 UI
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
这一批只做三个方向的补齐:
|
||||
|
||||
1. 聊天页手感更接近常用 IM,而不是“能发消息但交互生硬”
|
||||
2. OTA 下载与安装链路给出更明确的原生反馈,而不是只靠系统通知
|
||||
3. 根页导航和返回逻辑再收一轮,减少“像功能页、不像 APP”的割裂感
|
||||
|
||||
这批不新增新的一级功能,也不改 Boss 的后端技术路线。
|
||||
|
||||
## 2. 保持不变的约束
|
||||
|
||||
- 一级导航仍然固定为 `会话 / 设备 / 我的`
|
||||
- 会话首页仍是微信式简单聊天列表
|
||||
- 项目聊天页仍然只保留 `项目目标 / 版本记录` 两个轻入口
|
||||
- `运维 / 审计 / 修复 / 线程详情 / 转发` 仍保留深层入口,但不回到主聊天页和一级 `我的`
|
||||
- 原生 Android 仍使用 `BossApiClient + Activity + XML`
|
||||
- 登录恢复、OTA API、Boss Web 部署链路都保持现有实现
|
||||
|
||||
## 3. 设计一:聊天页体验细化
|
||||
|
||||
### 3.1 现状问题
|
||||
|
||||
- 点击发送后只有整体刷新,用户会感觉“发出去了,但界面没跟手”
|
||||
- 发送中没有明确状态,输入框和发送按钮的反馈太弱
|
||||
- 每次刷新后直接滚到底,用户如果在看旧消息,会被强制拉走
|
||||
|
||||
### 3.2 目标效果
|
||||
|
||||
- 发送后立即出现一条本地“发送中”气泡,先给到即时反馈
|
||||
- 服务端成功后,再用真实消息列表替换掉临时气泡
|
||||
- 如果用户本来就在底部,刷新后继续滚到底
|
||||
- 如果用户已经往上翻历史,自动刷新不能把用户强行拉回底部
|
||||
- 发送按钮在空输入、发送中这两种状态下要有明确禁用表现
|
||||
|
||||
### 3.3 设计取舍
|
||||
|
||||
- 不做真正的本地离线消息队列
|
||||
- 不做复杂的消息已读/送达双勾状态
|
||||
- 只做“发送中 -> 成功刷新”这一层最小原生体验闭环
|
||||
|
||||
## 4. 设计二:OTA 原生反馈细化
|
||||
|
||||
### 4.1 现状问题
|
||||
|
||||
- 现在已经能下载并拉起安装,但主页面上看不到清晰下载进度
|
||||
- 下载失败或权限阻塞时,提示不够聚焦
|
||||
- 用户回到关于页时,不容易知道“刚才那个下载现在走到哪一步了”
|
||||
|
||||
### 4.2 目标效果
|
||||
|
||||
- 关于页能显示当前 OTA 下载状态:未开始、下载中、已完成、失败、等待安装授权
|
||||
- 下载中能看到百分比或至少看到“已下载 / 总大小”的进度文案
|
||||
- 下载失败时可直接重试,不需要用户自己猜该做什么
|
||||
- 如果安装未知来源权限没开,界面要明确告诉用户下一步去哪开
|
||||
|
||||
### 4.3 设计取舍
|
||||
|
||||
- 继续使用系统 `DownloadManager`
|
||||
- 不实现后台自定义下载器
|
||||
- 不实现增量更新,只优化整包 APK 下载体验
|
||||
|
||||
## 5. 设计三:导航细节细化
|
||||
|
||||
### 5.1 现状问题
|
||||
|
||||
- 根页 tab 虽然已经变成微信式,但重新进 APP 时对用户上次停留页的记忆还不稳定
|
||||
- 根页返回虽然不会直接乱跳,但体验还可以更像成熟 APP
|
||||
|
||||
### 5.2 目标效果
|
||||
|
||||
- 记住用户上次停留的根 tab,下次进 APP 优先回到该 tab
|
||||
- 如果外部显式指定入口 tab,仍然以显式入口优先
|
||||
- 在根页按返回时,不再像“页面崩掉式退出”;给出更柔和的退后台反馈
|
||||
|
||||
### 5.3 设计取舍
|
||||
|
||||
- 不做复杂导航栈框架迁移
|
||||
- 继续基于现有 `MainActivity` 自己维护 root tab 状态
|
||||
|
||||
## 6. 代码边界
|
||||
|
||||
这批预计新增少量纯 Java helper,目的是让关键交互可以用单元测试覆盖,而不是把逻辑都埋进 Activity:
|
||||
|
||||
- `ProjectChatUiState.java`
|
||||
- 负责“是否自动滚到底”“发送按钮是否可用”“临时发送中消息”的最小映射
|
||||
- `OtaDownloadStateMapper.java`
|
||||
- 负责把 `DownloadManager` 查询结果映射成页面文案和状态
|
||||
|
||||
活动页主要做接线,不堆业务判断:
|
||||
|
||||
- `ProjectDetailActivity.java`
|
||||
- `AboutActivity.java`
|
||||
- `MainActivity.java`
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
1. 聊天页发送消息后,立刻能看到发送中反馈
|
||||
2. 聊天页刷新不会在用户查看历史消息时强制跳到底
|
||||
3. 关于页能看到 OTA 下载状态和失败重试入口
|
||||
4. 未知来源安装权限未开时,页面能给出明确引导
|
||||
5. APP 再次打开时,根 tab 能恢复到上次停留位置
|
||||
6. 微信式一级 UI 不被破坏,主聊天页仍然只保留 `项目目标 / 版本记录`
|
||||
@@ -0,0 +1,265 @@
|
||||
# Boss 原生 Android 微信式 UI 回退设计
|
||||
|
||||
日期:`2026-03-27`
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `Boss` 项目已经把 Android 客户端切换到原生架构,并打通了登录恢复、OTA、主 Agent、设备同步和 API 链路。但前台 UI 被改得过于偏“控制台 / 运维面板”,偏离了用户确认过的那版“微信式交互”目标。
|
||||
|
||||
本次工作的目标不是推翻原生 Android 路线,也不是重做后端,而是把前台体验回退到用户认可的版本:
|
||||
|
||||
- 保留当前原生 Android 架构
|
||||
- 保留现有 API、登录恢复、OTA、设备绑定等底层能力
|
||||
- 将 UI 和交互回退到“微信式”版本
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本次回退后的 APP,应满足以下目标:
|
||||
|
||||
1. 打开 APP 后,首先看到的是微信式会话列表
|
||||
2. 底部一级导航固定为 `会话 / 设备 / 我的`
|
||||
3. 会话首页是极简聊天列表,不再以控制台卡片为主
|
||||
4. 项目聊天页以消息流为主体,业务信息降到最小
|
||||
5. 设备页和我的页回到微信式简单列表
|
||||
6. 现有底层能力继续可用,但退出主视觉核心区域
|
||||
7. 未经用户确认,不再做明显偏离微信式体验的大幅视觉发挥
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
本次不做以下事情:
|
||||
|
||||
- 不回退到 WebView 壳路线
|
||||
- 不重做服务器部署和 API 路线
|
||||
- 不重写状态模型或文件存储
|
||||
- 不删除 OTA、AI 账号、主 Agent、设备绑定等底层能力
|
||||
- 不扩展新的业务模块
|
||||
- 不继续做“控制台式”信息增强
|
||||
|
||||
## 4. 用户确认后的设计结论
|
||||
|
||||
本次设计结论来自已确认的用户输入:
|
||||
|
||||
- 保留现有原生 Android 架构
|
||||
- 首页更接近微信聊天列表,而不是业务控制台
|
||||
- 设备页与我的页都采用微信式简单列表
|
||||
- 单个项目聊天页尽量像微信聊天页
|
||||
- 业务入口只保留 `项目目标` 与 `版本记录`
|
||||
- 线程预算、转发、handoff、风险摘要、运维面板等不再进入主 UI
|
||||
|
||||
## 5. 信息架构
|
||||
|
||||
### 5.1 一级导航
|
||||
|
||||
底部固定一级导航:
|
||||
|
||||
- `会话`
|
||||
- `设备`
|
||||
- `我的`
|
||||
|
||||
要求:
|
||||
|
||||
- 一级导航始终固定在底部
|
||||
- 一级导航切换不丢当前 tab 的页面状态
|
||||
- 不允许再次出现“滑到页面最底部才出现导航”的行为
|
||||
- 一级导航视觉上接近微信的底部 tab,而不是控制台式按钮组
|
||||
|
||||
### 5.2 二级导航
|
||||
|
||||
保留以下二级页,但弱化控制台味:
|
||||
|
||||
- 会话详情
|
||||
- 项目目标
|
||||
- 版本记录
|
||||
- 设备详情
|
||||
- 添加设备
|
||||
- 账号与安全
|
||||
- AI 账号
|
||||
- 设置
|
||||
- 技能
|
||||
- 关于
|
||||
|
||||
下列内容不再作为主 UI 核心入口呈现:
|
||||
|
||||
- 线程预算
|
||||
- 转发
|
||||
- handoff / 调度摘要
|
||||
- 风险说明
|
||||
- 运维 / 审计大盘
|
||||
|
||||
这些能力如仍需保留,只能后移到更深层或调试入口,不得继续占据主页面主体区域。
|
||||
|
||||
## 6. 页面设计
|
||||
|
||||
### 6.1 会话首页
|
||||
|
||||
会话首页回退为微信式聊天列表。
|
||||
|
||||
每一行仅保留:
|
||||
|
||||
- 左侧头像
|
||||
- 中间标题
|
||||
- 中间最后一条消息预览
|
||||
- 右侧时间
|
||||
- 未读数
|
||||
|
||||
约束:
|
||||
|
||||
- 不再展示大块统计卡片、摘要卡片、控制台说明卡片
|
||||
- 不在列表主行展示线程预算、风险等级、设备数、quota 等业务字段
|
||||
- 主 Agent 仍置顶,但只通过位置或轻量标识体现,不做特殊面板
|
||||
- 多设备项目允许保留群聊式头像组合,但排版必须仍然像聊天列表
|
||||
|
||||
### 6.2 项目聊天页
|
||||
|
||||
聊天页回退为微信式聊天页。
|
||||
|
||||
布局规则:
|
||||
|
||||
- 顶部是常规标题栏
|
||||
- 聊天主体是消息流
|
||||
- 输入区是主要操作区
|
||||
- 只保留两个轻量入口:
|
||||
- `项目目标`
|
||||
- `版本记录`
|
||||
|
||||
这两个入口放在聊天页顶部轻量区域,不允许做成重控制条或大功能区。
|
||||
|
||||
明确移除出主界面的内容:
|
||||
|
||||
- 线程预算展示块
|
||||
- handoff 状态块
|
||||
- 主 Agent 调度摘要块
|
||||
- 风险说明块
|
||||
- 转发主入口
|
||||
|
||||
### 6.3 设备页
|
||||
|
||||
设备页回退为微信式简单列表。
|
||||
|
||||
每个设备条目仅保留:
|
||||
|
||||
- 头像
|
||||
- 设备名
|
||||
- 一行轻描述,例如在线状态或绑定账号
|
||||
|
||||
不再在列表主行展示:
|
||||
|
||||
- quota
|
||||
- endpoint
|
||||
- note
|
||||
- 技能数量
|
||||
- 监控式数字摘要
|
||||
|
||||
`添加设备` 保留,但做成普通列表入口或轻量操作入口。
|
||||
|
||||
### 6.4 我的页
|
||||
|
||||
我的页回退为微信“我”式结构。
|
||||
|
||||
顶部保留:
|
||||
|
||||
- 头像
|
||||
- 昵称
|
||||
- 账号信息
|
||||
|
||||
主菜单保留:
|
||||
|
||||
- `账号与安全`
|
||||
- `AI 账号`
|
||||
- `设置`
|
||||
- `技能`
|
||||
- `关于`
|
||||
|
||||
约束:
|
||||
|
||||
- `技能` 是普通菜单项,不做重卡片
|
||||
- 运维 / 审计 / 修复类内容不再占据我的页主视觉
|
||||
- 如果保留运维能力,必须下沉到更深层入口
|
||||
|
||||
## 7. 交互规则
|
||||
|
||||
### 7.1 返回逻辑
|
||||
|
||||
返回逻辑按移动端常识收口:
|
||||
|
||||
- 从会话详情返回到会话列表
|
||||
- 从设备详情返回到设备列表
|
||||
- 从我的二级页返回到我的首页
|
||||
- 在一级页根节点按返回,不应直接出现异常退出体验
|
||||
- 优先返回到当前 tab 根页;已经在根页时,再按移动端规则进入后台
|
||||
|
||||
### 7.2 状态保持
|
||||
|
||||
- 一级 tab 切换后应保留该 tab 已有滚动和浏览状态
|
||||
- 不允许切 tab 后强制回到异常中间页
|
||||
- 不允许聊天页返回后丢失会话列表位置
|
||||
|
||||
### 7.3 视觉约束
|
||||
|
||||
本次 UI 回退的视觉原则:
|
||||
|
||||
- 白底、浅分割、轻卡片或无卡片
|
||||
- 列表优先,而不是仪表盘优先
|
||||
- 信息克制
|
||||
- 熟悉、直接、低学习成本
|
||||
|
||||
明确禁止:
|
||||
|
||||
- 假状态栏
|
||||
- 桌面预览壳
|
||||
- 大段控制台说明文案
|
||||
- 监控面板式色块
|
||||
- 未确认的大幅品牌化发挥
|
||||
|
||||
## 8. 保留不动的底层能力
|
||||
|
||||
以下能力继续保留,但不作为主 UI 核心呈现:
|
||||
|
||||
- 原生 Android 架构
|
||||
- `BossApiClient` 与现有 API 路由
|
||||
- 登录态与 `restore token`
|
||||
- OTA 下载与安装链路
|
||||
- 主 Agent 真实执行链路
|
||||
- AI 账号与 API 容灾配置
|
||||
- 设备绑定与本地 agent 上报
|
||||
|
||||
## 9. 实现边界
|
||||
|
||||
本次实现聚焦于 Android 原生前台体验,优先改造:
|
||||
|
||||
- `MainActivity`
|
||||
- 会话列表和会话详情原生页
|
||||
- 设备列表和设备详情首屏
|
||||
- 我的首页及菜单化入口
|
||||
- 底部导航、顶部栏、返回逻辑、列表单元、消息样式
|
||||
|
||||
本次不主动改动:
|
||||
|
||||
- Web 业务逻辑
|
||||
- 服务端数据模型
|
||||
- 部署链路
|
||||
- 认证和 OTA 的底层实现
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
本次回退完成后,应满足:
|
||||
|
||||
1. APP 首页视觉第一印象是微信式聊天列表
|
||||
2. 底部只有 `会话 / 设备 / 我的`
|
||||
3. 聊天页主体是消息流,只保留 `项目目标 / 版本记录`
|
||||
4. 设备页和我的页都是简单列表,而不是控制台
|
||||
5. 返回逻辑符合手机用户直觉
|
||||
6. 主 Agent、登录恢复、OTA、设备等底层能力仍正常可用
|
||||
7. 没有再次出现未经确认的大幅 UI 风格漂移
|
||||
|
||||
## 11. 推荐实施顺序
|
||||
|
||||
建议按以下顺序实施:
|
||||
|
||||
1. 调整底部导航、返回逻辑和一级页骨架
|
||||
2. 重做会话首页
|
||||
3. 重做项目聊天页
|
||||
4. 收口设备页
|
||||
5. 收口我的页
|
||||
6. 统一列表、按钮、间距、颜色和消息气泡样式
|
||||
7. 每一批都执行编译、真机验证和文档同步
|
||||
@@ -0,0 +1,376 @@
|
||||
# Boss 原生 Android 微信式消息转发设计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `Boss` 原生 Android 客户端虽然已经恢复到微信式一级结构,但“消息转发”仍停留在过渡态:
|
||||
|
||||
- 原生入口还是单独的 `ProjectForwardActivity`
|
||||
- 交互仍然是“选择目标项目 + 填写备注”
|
||||
- 服务端接口 `POST /api/v1/projects/[projectId]/forwards` 也仍以 `targetProjectId + note` 为主
|
||||
|
||||
这条链路和用户要求的“微信最新逻辑”存在明显差距。用户已经明确要求:
|
||||
|
||||
1. 既支持单条消息转发,也支持多选消息合并转发
|
||||
2. 转发流程要尽量按微信当前逻辑来
|
||||
3. 单条消息转发后在目标会话里表现为普通转发消息
|
||||
4. 多条消息转发后在目标会话里表现为聊天记录卡片
|
||||
5. 当前一次转发先只允许选择一个目标会话
|
||||
6. 转发链必须兼容现有线程会话、群聊会话和主 Agent 审批规则
|
||||
|
||||
因此,这次工作不是只换一个页面,而是要把“消息转发”升级成一条完整的微信式产品链路:
|
||||
|
||||
- 原生 Android 交互回到微信式
|
||||
- 服务端账本结构能表达单条转发和聊天记录卡片
|
||||
- 目标会话选择页与当前线程会话模型一致
|
||||
- 群聊和审批规则能继续接入,而不是后续再重做
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本次设计完成后,消息转发应满足以下目标:
|
||||
|
||||
1. 单条消息可从消息操作菜单直接进入转发流程。
|
||||
2. 多条消息可通过多选模式进入合并转发流程。
|
||||
3. 单条和多条转发共用一个微信式目标会话选择页。
|
||||
4. 单次转发只允许选择一个目标会话。
|
||||
5. 单条消息转发到目标会话后,显示为普通消息,但保留转发来源元数据。
|
||||
6. 多条消息转发到目标会话后,显示为一张聊天记录卡片,不是多条普通消息的简单堆叠。
|
||||
7. 转发目标可以是单线程会话、群聊、`主 Agent`、`审计对话`。
|
||||
8. 非开发任务状态下,如果转发行为会引发线程之间不应直接沟通的情况,后端必须能返回“需要主 Agent / 用户审批”的结果,而不是直接放行。
|
||||
9. 这次改造不能破坏现有原生聊天页、会话信息页、群资料页和群聊创建链路。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
本次不做以下事项:
|
||||
|
||||
1. 不支持一次转发到多个目标会话。
|
||||
2. 不支持转发前编辑消息内容。
|
||||
3. 不支持微信收藏、逐条再编辑、转发到外部应用等额外能力。
|
||||
4. 不在本次设计中完成“聊天记录卡片详情页”的完整浏览体验,只要求先把卡片消息结构和列表展示落下。
|
||||
5. 不改变当前原生 Android 架构、登录恢复、群聊模型或主 Agent 主链执行方式。
|
||||
|
||||
## 4. 用户体验设计
|
||||
|
||||
### 4.1 单条消息转发
|
||||
|
||||
单条消息转发按微信式链路执行:
|
||||
|
||||
1. 用户在聊天页长按某条消息。
|
||||
2. 弹出轻量消息操作菜单。
|
||||
3. 菜单中点击 `转发`。
|
||||
4. 进入统一的 `选择一个会话` 页。
|
||||
5. 用户选择一个目标会话。
|
||||
6. 执行转发。
|
||||
7. 返回目标会话或给出轻量成功提示。
|
||||
|
||||
单条转发后的展示规则:
|
||||
|
||||
- 在目标会话中显示为一条普通消息
|
||||
- 这条消息保留 `转发` 的轻量来源标识,但整体视觉不能变成控制台卡片
|
||||
- 账本结构中必须带上来源消息信息,便于后续扩展“查看原始消息”
|
||||
|
||||
### 4.2 多选消息合并转发
|
||||
|
||||
多选消息合并转发按微信式链路执行:
|
||||
|
||||
1. 用户在聊天页对消息执行 `多选`
|
||||
2. 聊天页进入多选模式
|
||||
3. 用户勾选多条消息
|
||||
4. 点击底部 `转发`
|
||||
5. 进入同一个 `选择一个会话` 页
|
||||
6. 用户选择一个目标会话
|
||||
7. 执行合并转发
|
||||
|
||||
多选转发后的展示规则:
|
||||
|
||||
- 在目标会话中只生成一条消息
|
||||
- 该消息表现为“聊天记录卡片”
|
||||
- 不能把多条消息逐条硬插入目标会话里
|
||||
|
||||
### 4.3 消息操作菜单
|
||||
|
||||
单条消息长按后的操作菜单,本次先保留以下动作:
|
||||
|
||||
- `转发`
|
||||
- `多选`
|
||||
- `复制`
|
||||
- `删除`
|
||||
- `取消`
|
||||
|
||||
规则:
|
||||
|
||||
1. `转发` 直接进入统一转发流程
|
||||
2. `多选` 进入消息多选模式
|
||||
3. 本次不再把“填写备注”作为主流程的一部分
|
||||
|
||||
### 4.4 多选模式
|
||||
|
||||
多选模式的页面行为如下:
|
||||
|
||||
顶部区域:
|
||||
|
||||
- 左侧为 `取消`
|
||||
- 中间显示已选消息数量
|
||||
- 不再显示普通聊天页标题和轻入口
|
||||
|
||||
消息区:
|
||||
|
||||
- 每条消息左侧出现勾选控件
|
||||
- 已勾选消息有明显选中态
|
||||
|
||||
底部区域:
|
||||
|
||||
- 先只保留 `转发`
|
||||
- 不在本次加入更多多选操作,避免偏离微信主链
|
||||
|
||||
### 4.5 目标会话选择页
|
||||
|
||||
单条转发和多选转发共用一个目标会话选择页,规则如下:
|
||||
|
||||
1. 页面标题为 `选择一个会话`
|
||||
2. 页面第一屏直接显示微信式会话列表
|
||||
3. 会话 cell 沿用当前首页微信式会话样式
|
||||
4. 当前源会话本身不能作为目标被再次选中
|
||||
5. 当前一次只能选中一个目标会话
|
||||
6. 不要求用户填写备注
|
||||
|
||||
允许作为目标的会话类型:
|
||||
|
||||
- 单线程会话
|
||||
- 群聊会话
|
||||
- `主 Agent`
|
||||
- `审计对话`
|
||||
|
||||
## 5. 数据模型设计
|
||||
|
||||
### 5.1 单条转发消息
|
||||
|
||||
单条消息转发后,在目标会话中仍表现为普通消息,但要补充“转发来源”元数据。
|
||||
|
||||
本次采用结构:
|
||||
|
||||
```ts
|
||||
type ForwardSource = {
|
||||
sourceProjectId: string;
|
||||
sourceProjectName: string;
|
||||
sourceThreadId?: string;
|
||||
sourceThreadTitle?: string;
|
||||
sourceMessageId: string;
|
||||
forwardedBy: string;
|
||||
forwardedAt: string;
|
||||
};
|
||||
```
|
||||
|
||||
落账本后的单条消息:
|
||||
|
||||
```ts
|
||||
type Message = {
|
||||
id: string;
|
||||
kind: "text" | ...;
|
||||
body: string;
|
||||
forwardSource?: ForwardSource;
|
||||
};
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. 转发后的消息仍可作为普通消息渲染
|
||||
2. 必须保留来源项目、来源消息、来源线程的可追踪信息
|
||||
3. 不能只把原消息正文复制过去就结束
|
||||
|
||||
### 5.2 多条聊天记录卡片
|
||||
|
||||
多条消息转发后,应写成一条新的 bundle 型消息。
|
||||
|
||||
本次采用结构:
|
||||
|
||||
```ts
|
||||
type ForwardBundleItem = {
|
||||
messageId: string;
|
||||
senderLabel: string;
|
||||
body: string;
|
||||
kind: string;
|
||||
sentAt: string;
|
||||
};
|
||||
|
||||
type ForwardBundlePayload = {
|
||||
sourceProjectId: string;
|
||||
sourceProjectName: string;
|
||||
sourceThreadId?: string;
|
||||
sourceThreadTitle?: string;
|
||||
itemCount: number;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
items: ForwardBundleItem[];
|
||||
};
|
||||
```
|
||||
|
||||
落账本后的 bundle 消息:
|
||||
|
||||
```ts
|
||||
type Message = {
|
||||
id: string;
|
||||
kind: "forward_bundle";
|
||||
body: string;
|
||||
forwardBundle?: ForwardBundlePayload;
|
||||
};
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. 目标会话中只出现一张聊天记录卡片
|
||||
2. 卡片中要能生成合理摘要,如消息数、来源会话、时间范围
|
||||
3. bundle 的完整内容要落到账本,不能只存一个标题
|
||||
|
||||
## 6. 服务端接口设计
|
||||
|
||||
### 6.1 现有接口升级
|
||||
|
||||
当前已有:
|
||||
|
||||
- `POST /api/v1/projects/[projectId]/forwards`
|
||||
|
||||
这条接口应从“备注转发”升级成真正的微信式转发接口。
|
||||
|
||||
本次采用输入结构:
|
||||
|
||||
```ts
|
||||
type ForwardProjectMessageInput =
|
||||
| {
|
||||
mode: "single";
|
||||
targetProjectId: string;
|
||||
sourceMessageId: string;
|
||||
}
|
||||
| {
|
||||
mode: "bundle";
|
||||
targetProjectId: string;
|
||||
sourceMessageIds: string[];
|
||||
};
|
||||
```
|
||||
|
||||
当前旧字段 `note` 不再作为主语义字段,允许兼容但不再作为核心交互入口。
|
||||
|
||||
### 6.2 返回结构
|
||||
|
||||
接口返回需要至少表达:
|
||||
|
||||
```ts
|
||||
{
|
||||
ok: boolean;
|
||||
message?: Message;
|
||||
approvalRequired?: boolean;
|
||||
approvalReason?: string;
|
||||
}
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. 正常转发成功时返回目标会话中新生成的消息
|
||||
2. 需要审批时,不直接写入目标会话,而是返回 `approvalRequired=true`
|
||||
3. 失败时给出明确错误
|
||||
|
||||
## 7. 审批与群聊兼容设计
|
||||
|
||||
### 7.1 正常转发
|
||||
|
||||
以下情况可直接放行:
|
||||
|
||||
- 用户主动把消息转发到自己可见的单线程会话
|
||||
- 用户主动把消息转发到群聊
|
||||
- 用户主动把消息转发到 `主 Agent`
|
||||
- 用户主动把消息转发到 `审计对话`
|
||||
|
||||
### 7.2 需要审批的场景
|
||||
|
||||
如果这次转发在业务语义上会触发:
|
||||
|
||||
- 非开发任务状态下的线程直接互相沟通
|
||||
|
||||
那么后端必须先命中治理规则:
|
||||
|
||||
1. 不直接放行
|
||||
2. 返回 `approvalRequired`
|
||||
3. 由主 Agent 再向用户请求批准
|
||||
|
||||
这次即使还不把完整审批 UI 全做完,也必须在接口和消息层预留这条分支。
|
||||
|
||||
### 7.3 和群聊的关系
|
||||
|
||||
转发目标页对群聊和单线程会话一视同仁,目标本质就是会话。
|
||||
|
||||
要求:
|
||||
|
||||
1. 群聊和单线程会话共用同一套目标选择页
|
||||
2. 不能因为群聊存在,就做另一套“转群聊”专用流程
|
||||
3. 后端只在治理规则阶段区分是否需要审批,不在选择页阶段区分
|
||||
|
||||
## 8. Android 原生页面设计
|
||||
|
||||
### 8.1 ProjectDetailActivity
|
||||
|
||||
需要补以下交互:
|
||||
|
||||
1. 单条消息长按弹出操作菜单
|
||||
2. 进入多选模式
|
||||
3. 多选模式顶部与底部状态切换
|
||||
4. `转发` 入口跳转到统一会话选择页
|
||||
|
||||
### 8.2 新增原生活动页
|
||||
|
||||
本次新增:
|
||||
|
||||
- `ForwardTargetActivity`
|
||||
- 统一目标会话选择页
|
||||
- 同时服务单条转发和多选转发
|
||||
|
||||
`ProjectForwardActivity` 不再承担主转发链路,而是下沉为兼容入口;如果旧入口仍被触发,只负责立即跳转到新的 `ForwardTargetActivity`。
|
||||
|
||||
### 8.3 转发后的返回行为
|
||||
|
||||
要求:
|
||||
|
||||
1. 转发成功后给出轻量反馈
|
||||
2. 返回链符合手机直觉
|
||||
3. 不能出现完成后回退错层、丢当前页状态、或直接退桌面
|
||||
|
||||
## 9. 测试与验收标准
|
||||
|
||||
### 9.1 单条转发验收
|
||||
|
||||
1. 长按某条消息,能看到消息菜单
|
||||
2. 点 `转发` 后进入目标会话选择页
|
||||
3. 选择一个会话后,成功写入目标会话
|
||||
4. 目标会话里显示普通转发消息
|
||||
5. 服务端账本中能看到 `forwardSource`
|
||||
|
||||
### 9.2 多条转发验收
|
||||
|
||||
1. 进入多选模式并勾选多条消息
|
||||
2. 点击底部 `转发`
|
||||
3. 进入同一个目标会话选择页
|
||||
4. 选择一个会话后,成功写入目标会话
|
||||
5. 目标会话中只出现一张聊天记录卡片
|
||||
6. 服务端账本中能看到 `forwardBundle`
|
||||
|
||||
### 9.3 目标选择页验收
|
||||
|
||||
1. 会话项样式和首页一致
|
||||
2. 一次只能选中一个目标会话
|
||||
3. 源会话本身不能被选中
|
||||
4. 单线程、群聊、主 Agent、审计对话都能正常显示
|
||||
|
||||
### 9.4 审批兼容验收
|
||||
|
||||
1. 开发任务场景下,转发能直接通过
|
||||
2. 命中非开发任务治理规则时,接口返回 `approvalRequired`
|
||||
3. 命中审批规则时不会把消息错误地直接写进目标会话
|
||||
|
||||
### 9.5 本轮实现完成标准
|
||||
|
||||
本轮可以视为完成,当且仅当:
|
||||
|
||||
1. 原生 Android 已支持单条转发
|
||||
2. 原生 Android 已支持多选合并转发
|
||||
3. 目标会话选择页已经替换当前“备注转发页”
|
||||
4. 服务端消息结构已经支持 `forwardSource` 和 `forwardBundle`
|
||||
5. 转发接口已经支持 `single / bundle`
|
||||
6. 审批闸口已经在接口层和账本层预留
|
||||
@@ -0,0 +1,386 @@
|
||||
# Boss 原生 Android 旧版 UI 全量还原与线程群聊设计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `Boss` 原生 Android 客户端虽然已经回退到微信式一级结构,但页面视觉、主入口取舍、线程展示方式和会话模型仍然没有完全回到用户确认过的旧版 UI。
|
||||
|
||||
本次工作的目标不是回滚底层实现,也不是退回 WebView,而是在保留现有原生 Android、现有 API、现有登录恢复、OTA、设备绑定和主 Agent 执行链路的前提下,把前台页面和交互完整拉回旧版 UI 语言,并补齐新的线程会话与群聊规则。
|
||||
|
||||
本次以 `design/exports/ui-codex-ops-mobile-v13/` 为唯一主视觉基准。图中出现的页面要求严格 1:1 还原;图中未出现但业务必须存在的页面,也必须延续同一套微信式轻界面、列表结构、按钮层级和信息密度,不能继续保留控制台式重面板风格。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本次改造完成后,APP 应满足以下目标:
|
||||
|
||||
1. 一级导航固定为 `会话 / 设备 / 我的`,整体视觉回到旧版导出图的白底、浅灰卡片、轻绿色强调风格。
|
||||
2. 所有会话都按聊天工具模型理解,而不是项目卡片模型。
|
||||
3. APP 中每一个聊天窗口,对应某个设备、某个 Codex 文件夹里的一个线程;同一文件夹下多个线程,在 APP 中显示为多个独立聊天窗口。
|
||||
4. 会话列表主标题显示线程名;副信息指定位置显示线程所属文件夹名。
|
||||
5. 会话列表每一项右下角增加动态小图标,只表达“当前后台使用数量”,不展示文字,不展示名称。
|
||||
6. 线程名支持在 APP 内按微信最新逻辑修改,并同步回对应 Codex 线程。
|
||||
7. 支持从聊天页右上角发起群聊,把不同设备里的不同线程拉进同一个独立群聊。
|
||||
8. 群聊由主 Agent 发起和监督;开发任务状态下允许线程直接沟通,非开发任务状态下线程对话必须先经主 Agent 请求用户批准。
|
||||
9. `主 Agent` 与 `审计对话` 在会话首页固定置顶,但视觉上仍保持普通会话样式,只用顺序和轻量置顶标识区分。
|
||||
10. 图中未出现但必须保留的页面,例如 `AI 账号 / 技能 / 运维与修复 / 审计对话`,也要落到同一套微信式轻界面,不允许继续长成控制台风格。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
本次不做以下事项:
|
||||
|
||||
1. 不回退到 WebView 壳路线。
|
||||
2. 不回退当前原生 Android 架构、登录恢复、OTA、设备绑定和主 Agent 执行链路。
|
||||
3. 不删除后端已有的线程预算、转发、运维、审计、AI 账号、Skill 等能力;只调整它们在前台的入口、视觉和层级。
|
||||
4. 不在本次设计里细化主 Agent 的最终执行策略,只先定义群聊的发起、监督和审批框架。
|
||||
5. 不恢复假的 `9:41 / 5G / 电量` 状态栏;状态栏由真机系统自己显示。
|
||||
|
||||
## 4. 视觉基准
|
||||
|
||||
### 4.1 主视觉来源
|
||||
|
||||
唯一主视觉来源:
|
||||
|
||||
- `design/exports/ui-codex-ops-mobile-v13/`
|
||||
|
||||
核心对照图:
|
||||
|
||||
- 会话首页:`g8Qpr.png`
|
||||
- 聊天页:`grcep.png`
|
||||
- 设备页:`5iGU7.png`
|
||||
- 我的页:`LQOJ0.png`
|
||||
- 登录页:`i7IZ1.png`
|
||||
- 总览图:`d5gpt.png`
|
||||
|
||||
### 4.2 视觉原则
|
||||
|
||||
1. 以白底、浅灰面、圆角列表卡片、轻绿色主按钮为主。
|
||||
2. 页面主体必须是内容本身,不允许再堆重统计卡片、监控面板或控制台摘要。
|
||||
3. 顶部栏、列表、聊天输入区、轻按钮和 tab 结构必须尽量贴近旧版导出图。
|
||||
4. 不恢复假的系统状态栏,也不恢复桌面预览卡片壳。
|
||||
5. 图里存在的布局、间距、对齐、轻重层级,应优先向导出图靠拢,而不是向当前实现妥协。
|
||||
|
||||
## 5. 页面信息架构
|
||||
|
||||
### 5.1 一级导航
|
||||
|
||||
一级导航固定为:
|
||||
|
||||
1. `会话`
|
||||
2. `设备`
|
||||
3. `我的`
|
||||
|
||||
底部 tab 常驻,不滚动隐藏,不做额外复杂入口。
|
||||
|
||||
### 5.2 主 UI 保留页面
|
||||
|
||||
主 UI 需要保留并统一到旧版视觉语言的页面包括:
|
||||
|
||||
- 登录
|
||||
- 注册
|
||||
- 忘记密码
|
||||
- 会话首页
|
||||
- 单线程聊天页
|
||||
- 群聊页
|
||||
- 项目目标
|
||||
- 版本迭代记录
|
||||
- 消息转发
|
||||
- 设备首页
|
||||
- 添加设备
|
||||
- 我的首页
|
||||
- 账号与安全
|
||||
- 设置
|
||||
- 关于
|
||||
- 运维与修复
|
||||
- 审计对话
|
||||
- AI 账号
|
||||
- 技能
|
||||
|
||||
### 5.3 主 UI 撤出的内容
|
||||
|
||||
以下内容不删除后端能力,但不再以控制台方式露出:
|
||||
|
||||
- 线程预算大面板
|
||||
- handoff / 风险摘要大块说明
|
||||
- 配额和监控式状态卡
|
||||
- 审计、运维、转发、线程详情的大块控制台入口
|
||||
|
||||
它们若仍需存在,只能以轻量页面、列表项、二级入口或会话方式出现。
|
||||
|
||||
## 6. 会话模型
|
||||
|
||||
### 6.1 单线程会话
|
||||
|
||||
定义:
|
||||
|
||||
- 一个聊天窗口对应一个线程。
|
||||
- 一个线程隶属于某个设备上的某个 Codex 文件夹。
|
||||
- 同一文件夹下多个线程,在 APP 中显示为多个独立聊天窗口。
|
||||
|
||||
列表字段:
|
||||
|
||||
- 主标题:线程名
|
||||
- 副信息指定行:文件夹名
|
||||
- 预览行:最后一条消息
|
||||
- 右上:最后一次对话时间
|
||||
- 右下:动态小图标,表示当前后台使用数量
|
||||
|
||||
### 6.2 特殊会话
|
||||
|
||||
特殊会话包括:
|
||||
|
||||
- `主 Agent`
|
||||
- `审计对话`
|
||||
|
||||
规则:
|
||||
|
||||
1. 固定置顶在会话首页顶部。
|
||||
2. 视觉上与普通会话尽量一致。
|
||||
3. 只通过顺序和轻量置顶标识区分。
|
||||
4. 不做特殊大卡片。
|
||||
|
||||
### 6.3 群聊会话
|
||||
|
||||
定义:
|
||||
|
||||
- 群聊是独立会话,不覆盖或替换原来的单线程会话。
|
||||
- 群成员可以来自不同设备、不同文件夹、不同线程。
|
||||
- 群头像按微信逻辑显示多头像组合。
|
||||
|
||||
命名规则:
|
||||
|
||||
1. 创建群聊时自动生成默认群名。
|
||||
2. 创建完成后允许修改群名。
|
||||
|
||||
## 7. 会话列表设计
|
||||
|
||||
会话列表按旧版导出图风格统一,核心规则如下:
|
||||
|
||||
1. 页面第一屏就是纯会话列表。
|
||||
2. `主 Agent` 和 `审计对话` 位于最上方。
|
||||
3. 普通会话按聊天工具列表排列,不再掺杂控制台说明。
|
||||
4. 每个会话 item 固定包含:
|
||||
- 左侧头像
|
||||
- 主标题:线程名或群名
|
||||
- 指定副信息行:文件夹名
|
||||
- 最后一条消息预览
|
||||
- 最后一次对话时间
|
||||
- 右下动态小图标,表示后台使用数量
|
||||
5. 不再在列表主行显示线程预算、设备配额、风险摘要等重字段。
|
||||
|
||||
### 7.1 文件夹名显示位置
|
||||
|
||||
用户指定的文件夹名显示位置是聊天列表 item 中主标题下方的副信息行,位于最后一条消息预览之上或其同一信息层级位置,必须与用户给出的截图位置一致。
|
||||
|
||||
### 7.2 后台数量图标
|
||||
|
||||
规则如下:
|
||||
|
||||
1. 只显示动态小图标,不显示数字文本,不显示说明文本。
|
||||
2. 图标位置在当前最后一次对话时间下方的右下区域。
|
||||
3. 图标表达的是当前线程后台使用数量。
|
||||
4. 不在列表里展示“后台窗口名”或“后台窗口描述”。
|
||||
|
||||
## 8. 聊天页设计
|
||||
|
||||
### 8.1 单线程聊天页
|
||||
|
||||
单线程聊天页按微信式聊天页构成:
|
||||
|
||||
1. 顶部栏:
|
||||
- 左返回
|
||||
- 中间标题:线程名
|
||||
- 右上角入口
|
||||
2. 顶部轻入口:
|
||||
- `项目目标`
|
||||
- `版本迭代记录`
|
||||
3. 中部主体:
|
||||
- 纯消息流
|
||||
4. 底部输入区:
|
||||
- 文本输入为主
|
||||
- 图片 / 视频 / 转发作为轻入口
|
||||
|
||||
不允许在主聊天页继续出现:
|
||||
|
||||
- 运维大面板
|
||||
- 线程预算块
|
||||
- handoff 摘要块
|
||||
- 控制台式状态卡
|
||||
|
||||
### 8.2 群聊页
|
||||
|
||||
群聊页整体结构与单线程聊天页保持一致,但:
|
||||
|
||||
1. 标题显示群名。
|
||||
2. 头像显示群头像组合。
|
||||
3. 右上角进入群资料页。
|
||||
4. 群成员消息需区分来源线程。
|
||||
5. 主 Agent 在群里承担发起和监督角色。
|
||||
|
||||
## 9. 改名逻辑
|
||||
|
||||
改名行为必须遵循微信最新逻辑,而不是长按改名、左滑改名等旧交互。
|
||||
|
||||
### 9.1 线程改名
|
||||
|
||||
流程:
|
||||
|
||||
1. 进入单线程聊天页。
|
||||
2. 点击右上角进入会话信息页。
|
||||
3. 在会话信息页中修改线程名。
|
||||
4. 保存后同步更新:
|
||||
- APP 本地显示
|
||||
- 服务端状态账本
|
||||
- 对应 Codex 线程名称
|
||||
|
||||
### 9.2 群聊改名
|
||||
|
||||
流程:
|
||||
|
||||
1. 进入群聊页。
|
||||
2. 点击右上角进入群聊信息页。
|
||||
3. 在群聊信息页中修改群名。
|
||||
4. 保存后更新群聊会话显示。
|
||||
|
||||
## 10. 群聊创建与沟通规则
|
||||
|
||||
### 10.1 发起方式
|
||||
|
||||
群聊创建从聊天页右上角 `+` 发起:
|
||||
|
||||
1. 从单线程聊天页点击右上角 `+`
|
||||
2. 选择 `发起群聊`
|
||||
3. 进入线程选择页
|
||||
4. 选择任意设备里的任意线程
|
||||
5. 自动创建一个新的独立群聊
|
||||
6. 自动生成默认群名
|
||||
7. 创建成功后进入新群聊页
|
||||
|
||||
### 10.2 群聊会话与原会话关系
|
||||
|
||||
规则:
|
||||
|
||||
1. 新建群聊后,原来的单线程会话保留。
|
||||
2. 新群聊作为独立聊天窗口出现在会话列表。
|
||||
3. 群聊不会覆盖原线程会话。
|
||||
|
||||
### 10.3 群聊治理规则
|
||||
|
||||
群聊中的线程协作遵循以下规则:
|
||||
|
||||
1. 开发任务过程中:
|
||||
- 允许不同线程直接在群聊内对话和协作。
|
||||
2. 非开发任务状态下:
|
||||
- 线程之间不能直接自由对话。
|
||||
- 如需对话,必须先请求主 Agent。
|
||||
- 主 Agent 再向用户请求是否批准两个线程继续沟通。
|
||||
- 用户批准后,线程之间才可继续对话。
|
||||
|
||||
### 10.4 主 Agent 角色
|
||||
|
||||
主 Agent 在群聊中的角色是:
|
||||
|
||||
1. 群聊发起者
|
||||
2. 群聊监督者
|
||||
3. 线程间对话的规则执行者
|
||||
|
||||
本次先落会话结构、入口和规则,不在本次设计内细化主 Agent 的最终执行策略。
|
||||
|
||||
## 11. 深层页补齐策略
|
||||
|
||||
导出图未直接覆盖的页面,也必须统一到这套 UI 语言:
|
||||
|
||||
- AI 账号
|
||||
- 技能
|
||||
- 设置
|
||||
- 账号与安全
|
||||
- 运维与修复
|
||||
- 审计对话
|
||||
- 线程信息 / 会话信息 / 群聊信息
|
||||
- 群聊成员选择页
|
||||
|
||||
补齐原则:
|
||||
|
||||
1. 统一白底、浅卡片、轻按钮风格。
|
||||
2. 统一列表式、表单式和信息页结构。
|
||||
3. 不再使用控制台式高密度状态块。
|
||||
4. 即使信息复杂,也优先拆成列表项或二级页,而不是堆在一个页面里。
|
||||
|
||||
## 12. 返回逻辑与导航逻辑
|
||||
|
||||
### 12.1 一级导航
|
||||
|
||||
底部 tab 固定显示:
|
||||
|
||||
- 会话
|
||||
- 设备
|
||||
- 我的
|
||||
|
||||
### 12.2 返回规则
|
||||
|
||||
规则如下:
|
||||
|
||||
1. 从二级页返回,回到上一页。
|
||||
2. 从聊天页返回,回到会话列表。
|
||||
3. 从设备详情返回,回到设备列表。
|
||||
4. 从我的二级页返回,回到我的首页。
|
||||
5. 在根级 tab 页按返回时,先回 `会话` tab。
|
||||
6. 已在 `会话` 根页时,再按一次返回进入后台。
|
||||
|
||||
## 13. 数据与同步要求
|
||||
|
||||
### 13.1 线程与会话映射
|
||||
|
||||
必须建立清晰映射:
|
||||
|
||||
- 设备
|
||||
- 文件夹
|
||||
- 线程
|
||||
- 会话窗口
|
||||
|
||||
要求:
|
||||
|
||||
1. 一个线程映射一个单线程会话。
|
||||
2. 一个文件夹下多个线程映射多个独立会话。
|
||||
3. 会话列表必须能拿到线程名、文件夹名、最后消息、最后时间、后台使用数量。
|
||||
|
||||
### 13.2 改名同步
|
||||
|
||||
线程改名必须同时影响:
|
||||
|
||||
1. APP 会话标题
|
||||
2. 服务端状态
|
||||
3. 对应 Codex 线程名称
|
||||
|
||||
### 13.3 群聊同步
|
||||
|
||||
群聊需要持久化:
|
||||
|
||||
1. 群聊 ID
|
||||
2. 群名
|
||||
3. 群成员线程列表
|
||||
4. 群创建者
|
||||
5. 群内规则状态
|
||||
6. 是否为开发任务状态
|
||||
|
||||
## 14. 验收标准
|
||||
|
||||
本次工作完成后,必须满足以下验收条件:
|
||||
|
||||
1. 图中已有页面可与导出图逐页对照,达到 1:1 视觉还原级别。
|
||||
2. 图中未出现但必须保留的页面,也统一成同一套微信式轻界面。
|
||||
3. 会话列表主标题显示线程名,指定副信息位显示文件夹名。
|
||||
4. 每个会话右下角都显示动态后台数量图标。
|
||||
5. `主 Agent` 和 `审计对话` 置顶,但视觉仍为普通会话。
|
||||
6. 聊天页右上角遵循微信最新逻辑,支持进入信息页改名。
|
||||
7. 线程改名后,Codex 对应线程也同步改名。
|
||||
8. 支持从聊天页右上角 `+` 发起群聊。
|
||||
9. 群聊为独立会话,保留原单线程会话。
|
||||
10. 群聊支持自动命名、后续改名、群头像组合显示。
|
||||
11. 开发任务状态与非开发任务状态下的线程沟通规则已落地。
|
||||
12. 主聊天面、设备面、我的页不再出现控制台式大面板。
|
||||
|
||||
## 15. 风险与约束
|
||||
|
||||
1. 导出图是主视觉基准,但不是全部页面的完整交互稿,因此深层页需要在同一套风格内补齐,而不是照当前实现继续延展。
|
||||
2. 线程名同步到 Codex 线程需要可靠的线程标识和回写能力,否则会产生 APP 名称与 Codex 名称不一致的问题。
|
||||
3. 群聊引入后,单线程会话、群聊会话和主 Agent / 审计对话会并存,需要明确排序、置顶和未读规则。
|
||||
4. 主 Agent 对线程间对话的审批与监督规则本次先定义边界,不在本次设计中进一步算法化。
|
||||
@@ -0,0 +1,685 @@
|
||||
# Boss 聊天附件、双存储与 AI 处理设计
|
||||
|
||||
日期:`2026-03-29`
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `Boss` 原生 Android 客户端已经完成:
|
||||
|
||||
- 微信式 `会话 / 设备 / 我的` 一级交互
|
||||
- 线程 = 聊天窗口
|
||||
- 会话信息、独立群聊、微信式消息转发
|
||||
|
||||
但聊天主链仍缺少真实的附件协作能力:
|
||||
|
||||
- 聊天框还不能直接发送本机图片、视频、文件
|
||||
- 接收端还不能收到可预览/可下载的附件消息
|
||||
- 存储目前只有服务器本地文件路线,没有可切换的对象存储
|
||||
- AI 还不能基于聊天里的附件自动或手动分析内容
|
||||
|
||||
这次工作要把这四条链路一起打通:
|
||||
|
||||
1. 原生聊天框发送本机图片、视频、文件
|
||||
2. 默认服务器文件存储 + 可选阿里 OSS 私有桶
|
||||
3. Web 端做最简化的用户级存储配置
|
||||
4. 主 Agent 统一处理附件分析,并把结果回写到聊天中
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本次设计完成后,系统应满足:
|
||||
|
||||
1. 原生 Android 聊天框左侧提供单个 `+` 附件入口。
|
||||
2. 点击 `+` 后通过底部抽屉选择 `图片 / 视频 / 文件`。
|
||||
3. 图片、视频在发送前需要预览确认;文件直接发送。
|
||||
4. 默认使用服务器文件存储。
|
||||
5. 用户可在 Web 端 `我的 > 附件与存储` 中切换到 `OSS`。
|
||||
6. `OSS` 当前只支持 `阿里 OSS`,并使用私有桶 + 签名 URL。
|
||||
7. 存储配置按当前登录用户保存,不做全局唯一配置。
|
||||
8. 接收端收到的附件消息不感知底层存储差异,都能统一预览/下载。
|
||||
9. 图片 / PDF / 文本默认自动交给主 Agent 处理。
|
||||
10. 视频 / Office / 大文件默认手动触发分析。
|
||||
11. “大文件”阈值固定为 `20 MB`。
|
||||
12. AI 处理结果以“简短回复 + 可继续查看的分析卡片”形式回到当前聊天。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
本次明确不做:
|
||||
|
||||
1. 非阿里云的 OSS / S3 / COS 接入。
|
||||
2. 语音附件、录音、外部分享面板。
|
||||
3. 跨应用分享、系统分享菜单接入。
|
||||
4. 附件版本管理、回收站、批量迁移工具。
|
||||
5. 视频高级转码、视频在线播放 CDN 优化。
|
||||
6. Office / 视频的深度结构化解析引擎。
|
||||
7. 多租户复杂权限模型。
|
||||
|
||||
## 4. 已确认的产品决策
|
||||
|
||||
### 4.1 原生聊天入口
|
||||
|
||||
- 聊天输入框左侧保留单个 `+`
|
||||
- 点击后打开底部抽屉
|
||||
- 抽屉内固定展示:
|
||||
- `图片`
|
||||
- `视频`
|
||||
- `文件`
|
||||
|
||||
### 4.2 发送前确认
|
||||
|
||||
- 图片:选中后先进入发送确认态
|
||||
- 视频:选中后先进入发送确认态
|
||||
- 文件:直接进入发送,不额外确认
|
||||
|
||||
### 4.3 Web 配置入口
|
||||
|
||||
- 配置入口固定放在 `我的 > 附件与存储`
|
||||
- 不放在会话页,不藏在多层设置后面
|
||||
|
||||
### 4.4 OSS 最小配置项
|
||||
|
||||
阿里 OSS 最小配置字段固定为:
|
||||
|
||||
- `AccessKey ID`
|
||||
- `AccessKey Secret`
|
||||
- `Bucket`
|
||||
- `Endpoint`
|
||||
- `Region`
|
||||
- `目录前缀`(可选,默认 `boss/`)
|
||||
|
||||
### 4.5 AI 处理策略
|
||||
|
||||
- 图片 / PDF / 文本:默认自动处理
|
||||
- 视频 / Office / 大文件:默认手动触发
|
||||
- 大文件阈值:`20 MB`
|
||||
- 所有附件分析统一走主 Agent
|
||||
|
||||
### 4.6 AI 结果展示
|
||||
|
||||
分析结果返回当前聊天时,固定使用混合展示:
|
||||
|
||||
1. 一条简短的主 Agent 回复
|
||||
2. 一张分析结果卡片
|
||||
|
||||
## 5. 总体架构
|
||||
|
||||
### 5.1 发送链
|
||||
|
||||
发送链拆成两步:
|
||||
|
||||
1. 原生 APP 选择附件并上传
|
||||
2. 服务端创建附件消息
|
||||
|
||||
用户视角上,它们仍是一条连续动作。
|
||||
|
||||
上传成功后,服务端会在目标会话写入统一的“附件消息”,而不是单独写“上传请求消息”。
|
||||
|
||||
### 5.2 存储链
|
||||
|
||||
存储层统一抽象成 `AttachmentStorageProvider`,底层实现两种:
|
||||
|
||||
- `server_file`
|
||||
- `aliyun_oss`
|
||||
|
||||
所有附件都通过同一套元数据模型进入消息系统,聊天页和 AI 处理链不直接感知底层存储类型。
|
||||
|
||||
### 5.3 AI 处理链
|
||||
|
||||
AI 处理与上传解耦:
|
||||
|
||||
- 上传完成后先落附件消息
|
||||
- 再由服务端根据文件类型和大小决定是否自动排队给主 Agent
|
||||
- 不自动处理的附件,显示为“可分析”,由用户手动触发
|
||||
|
||||
### 5.4 接收链
|
||||
|
||||
接收端收到的消息固定是统一附件消息:
|
||||
|
||||
- 可看到文件类型、名称、大小、状态
|
||||
- 图片支持预览
|
||||
- 视频支持预览/下载
|
||||
- PDF / 文本 / Office / 其他文件支持下载
|
||||
- 可看到 AI 分析状态与结果
|
||||
|
||||
## 6. 存储设计
|
||||
|
||||
### 6.1 用户级配置模型
|
||||
|
||||
每个登录用户拥有独立的存储偏好:
|
||||
|
||||
```ts
|
||||
type AttachmentStorageMode = "server_file" | "oss";
|
||||
type OssProvider = "aliyun_oss";
|
||||
|
||||
interface UserAttachmentStorageConfig {
|
||||
account: string;
|
||||
mode: AttachmentStorageMode;
|
||||
ossProvider?: OssProvider;
|
||||
aliyunOss?: {
|
||||
enabled: boolean;
|
||||
accessKeyId: string;
|
||||
accessKeySecretEncrypted: string;
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
region: string;
|
||||
prefix?: string;
|
||||
};
|
||||
updatedAt: string;
|
||||
validatedAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `mode=server_file` 时,不要求任何 OSS 字段
|
||||
- `mode=oss` 时,必须要求 `ossProvider=aliyun_oss`
|
||||
- `prefix` 默认值为 `boss/`
|
||||
|
||||
### 6.2 密钥存储策略
|
||||
|
||||
由于当前系统仍使用文件持久化,阿里 OSS 的 `AccessKey Secret` 不能明文直接写入 `boss-state.json`。
|
||||
|
||||
本次设计采用:
|
||||
|
||||
- `boss-state.json` 中只保存加密后的 `accessKeySecretEncrypted`
|
||||
- 服务器本地使用单独密钥对该字段做对称加密
|
||||
- 该本地密钥不进入仓库,不通过客户端下发
|
||||
|
||||
推荐实现方式:
|
||||
|
||||
- 优先读取环境变量,如 `BOSS_STORAGE_SECRET_KEY`
|
||||
- 如果不存在,则在服务器本地生成仅运行时可见的密钥文件并持久化到数据目录
|
||||
|
||||
### 6.3 默认服务器文件存储
|
||||
|
||||
默认存储后端为服务器本地文件。
|
||||
|
||||
建议目录结构:
|
||||
|
||||
```text
|
||||
data/uploads/<account>/<yyyy>/<mm>/<messageId>-<safeFileName>
|
||||
```
|
||||
|
||||
优势:
|
||||
|
||||
- 符合当前“极轻云 + 本地设备端”路线
|
||||
- 不引入额外云资源即可先跑通
|
||||
- 与现有 `boss-state.json` 路线一致
|
||||
|
||||
### 6.4 阿里 OSS 存储
|
||||
|
||||
阿里 OSS 固定采用:
|
||||
|
||||
- 私有桶
|
||||
- 临时签名 URL
|
||||
|
||||
服务端负责:
|
||||
|
||||
- 上传对象
|
||||
- 生成对象 key
|
||||
- 在下载或预览时生成短期签名 URL
|
||||
|
||||
对象 key 建议结构:
|
||||
|
||||
```text
|
||||
<prefix>/<account>/<yyyy>/<mm>/<messageId>-<safeFileName>
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `prefix` 默认为 `boss/`
|
||||
- 用户可在配置中改为其他目录前缀
|
||||
|
||||
### 6.5 统一下载入口
|
||||
|
||||
前台不直接拼 OSS URL,也不直接暴露本地文件路径。
|
||||
|
||||
统一下载/预览入口建议为:
|
||||
|
||||
```text
|
||||
GET /api/v1/attachments/[attachmentId]/download
|
||||
```
|
||||
|
||||
该接口统一负责:
|
||||
|
||||
- 鉴权当前用户是否可访问对应会话
|
||||
- 如果是 `server_file`:
|
||||
- 流式返回文件
|
||||
- 如果是 `aliyun_oss`:
|
||||
- 生成短期签名 URL
|
||||
- 302 跳转,或由服务端转发流式响应
|
||||
|
||||
这样可以保证:
|
||||
|
||||
- 客户端体验统一
|
||||
- 存储切换不影响聊天 UI
|
||||
- 不暴露底层凭证
|
||||
|
||||
## 7. 附件消息模型
|
||||
|
||||
### 7.1 新消息类型
|
||||
|
||||
现有消息类型只有文本和若干 intent 占位,不足以表达真实附件。
|
||||
|
||||
本次应扩展消息模型:
|
||||
|
||||
```ts
|
||||
type MessageKind =
|
||||
| "text"
|
||||
| "voice_intent"
|
||||
| "image_intent"
|
||||
| "video_intent"
|
||||
| "forward_notice"
|
||||
| "forward_single"
|
||||
| "forward_bundle"
|
||||
| "attachment";
|
||||
```
|
||||
|
||||
### 7.2 附件元数据
|
||||
|
||||
```ts
|
||||
type AttachmentKind =
|
||||
| "image"
|
||||
| "video"
|
||||
| "pdf"
|
||||
| "text"
|
||||
| "office"
|
||||
| "binary";
|
||||
|
||||
type AttachmentStorageBackend = "server_file" | "aliyun_oss";
|
||||
|
||||
type AttachmentAnalysisState =
|
||||
| "not_applicable"
|
||||
| "queued_auto"
|
||||
| "ready_manual"
|
||||
| "processing"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
interface MessageAttachment {
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSizeBytes: number;
|
||||
attachmentKind: AttachmentKind;
|
||||
storageBackend: AttachmentStorageBackend;
|
||||
storagePath: string;
|
||||
previewAvailable: boolean;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
analysisState: AttachmentAnalysisState;
|
||||
analysisSummary?: string;
|
||||
analysisCardId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
每条附件消息可以先只支持单附件,避免第一轮就把消息结构做成多附件复合体。后续若要支持一条消息多个附件,再扩展成数组。
|
||||
|
||||
### 7.3 附件消息结构
|
||||
|
||||
```ts
|
||||
interface Message {
|
||||
id: string;
|
||||
sender: MessageSender;
|
||||
senderLabel: string;
|
||||
body: string;
|
||||
sentAt: string;
|
||||
kind?: MessageKind;
|
||||
attachment?: MessageAttachment;
|
||||
forwardSource?: ForwardSource;
|
||||
forwardBundle?: ForwardBundlePayload;
|
||||
}
|
||||
```
|
||||
|
||||
附件消息的 `body` 用作聊天摘要和兼容展示,例如:
|
||||
|
||||
- 图片:`已发送图片:车间异常截图.png`
|
||||
- 视频:`已发送视频:工位巡检录像.mp4`
|
||||
- 文件:`已发送文件:北区回归报告.pdf`
|
||||
|
||||
## 8. AI 分析模型
|
||||
|
||||
### 8.1 分析任务
|
||||
|
||||
附件分析统一走主 Agent,不让单个普通线程自行处理。
|
||||
|
||||
建议新增任务模型:
|
||||
|
||||
```ts
|
||||
type MasterAgentTaskType =
|
||||
| "chat_reply"
|
||||
| "attachment_analysis";
|
||||
|
||||
interface AttachmentAnalysisTaskPayload {
|
||||
projectId: string;
|
||||
messageId: string;
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
attachmentKind: AttachmentKind;
|
||||
fileSizeBytes: number;
|
||||
downloadUrl: string;
|
||||
triggerMode: "auto" | "manual";
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 自动 / 手动分析规则
|
||||
|
||||
自动进入主 Agent 的条件:
|
||||
|
||||
- `attachmentKind=image`
|
||||
- `attachmentKind=pdf`
|
||||
- `attachmentKind=text`
|
||||
- 文件大小 `<= 20 MB`
|
||||
|
||||
手动触发的条件:
|
||||
|
||||
- `attachmentKind=video`
|
||||
- `attachmentKind=office`
|
||||
- 文件大小 `> 20 MB`
|
||||
|
||||
### 8.3 AI 处理执行者
|
||||
|
||||
统一执行者为主 Agent:
|
||||
|
||||
- 优先走 `Master Codex Node`
|
||||
- 只有在容灾路径明确支持时,才考虑 `openai_api`
|
||||
|
||||
在能力未覆盖的情况下,应返回明确状态,不要假装已完成分析。
|
||||
|
||||
### 8.4 分析结果回写
|
||||
|
||||
分析结果固定回写到当前聊天会话中,形式为:
|
||||
|
||||
1. 一条简短主 Agent 回复
|
||||
2. 一张分析结果卡片
|
||||
|
||||
简短回复示例:
|
||||
|
||||
```text
|
||||
主 Agent:已分析《北区回归报告.pdf》。核心问题是登录态恢复链和 OTA 覆盖安装文档不一致。
|
||||
```
|
||||
|
||||
分析卡片中至少应包含:
|
||||
|
||||
- 分析对象文件名
|
||||
- 分析模式(自动 / 手动)
|
||||
- 核心结论摘要
|
||||
- 关键提取点列表
|
||||
- 分析完成时间
|
||||
|
||||
## 9. API 设计
|
||||
|
||||
### 9.1 Web 配置接口
|
||||
|
||||
新增用户级附件存储配置接口:
|
||||
|
||||
```text
|
||||
GET /api/v1/storage/config
|
||||
PATCH /api/v1/storage/config
|
||||
POST /api/v1/storage/config/validate
|
||||
```
|
||||
|
||||
语义:
|
||||
|
||||
- `GET`:获取当前用户的附件与存储配置
|
||||
- `PATCH`:更新当前用户配置
|
||||
- `validate`:校验阿里 OSS 是否配置可用
|
||||
|
||||
`validate` 至少检查:
|
||||
|
||||
- AK/SK 是否能通过认证
|
||||
- Bucket 是否存在
|
||||
- Endpoint / Region 是否匹配
|
||||
- 是否具备读写权限
|
||||
|
||||
### 9.2 附件上传接口
|
||||
|
||||
新增统一附件上传接口:
|
||||
|
||||
```text
|
||||
POST /api/v1/projects/[projectId]/attachments
|
||||
```
|
||||
|
||||
建议使用 `multipart/form-data`,字段至少包括:
|
||||
|
||||
- `file`
|
||||
- `sourceType`: `image | video | file`
|
||||
|
||||
该接口负责:
|
||||
|
||||
1. 鉴权
|
||||
2. 解析文件类型
|
||||
3. 读取当前用户存储配置
|
||||
4. 上传到对应后端
|
||||
5. 生成附件消息
|
||||
6. 决定是否自动创建主 Agent 分析任务
|
||||
|
||||
返回值应直接带回新创建的消息和分析状态。
|
||||
|
||||
### 9.3 手动分析接口
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
POST /api/v1/projects/[projectId]/attachments/[attachmentId]/analyze
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
- 对 `ready_manual` 的附件手动触发 AI 分析
|
||||
|
||||
### 9.4 统一下载接口
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
GET /api/v1/attachments/[attachmentId]/download
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
- 图片预览
|
||||
- 视频预览或下载
|
||||
- 文件下载
|
||||
|
||||
### 9.5 SSE 刷新
|
||||
|
||||
现有 `/api/v1/events` 继续承担刷新出口,需要增加这些事件:
|
||||
|
||||
- `attachment.uploaded`
|
||||
- `attachment.analysis.queued`
|
||||
- `attachment.analysis.updated`
|
||||
- `attachment.analysis.completed`
|
||||
- `attachment.analysis.failed`
|
||||
|
||||
## 10. 原生 Android 设计
|
||||
|
||||
### 10.1 聊天输入区
|
||||
|
||||
聊天输入区固定为:
|
||||
|
||||
- 左侧 `+`
|
||||
- 中间输入框
|
||||
- 右侧发送按钮
|
||||
|
||||
点击 `+` 后弹出底部抽屉,包含:
|
||||
|
||||
- 图片
|
||||
- 视频
|
||||
- 文件
|
||||
|
||||
### 10.2 系统文件选择
|
||||
|
||||
原生 Android 需要新增:
|
||||
|
||||
- 图片选择器
|
||||
- 视频选择器
|
||||
- 文件选择器
|
||||
|
||||
建议基于 `ActivityResultContracts.OpenDocument` / `GetContent` 实现。
|
||||
|
||||
### 10.3 发送前确认
|
||||
|
||||
- 图片:展示预览 + `取消 / 发送`
|
||||
- 视频:展示预览或文件摘要 + `取消 / 发送`
|
||||
- 文件:直接进入上传
|
||||
|
||||
### 10.4 聊天中的附件渲染
|
||||
|
||||
图片消息:
|
||||
|
||||
- 缩略图
|
||||
- 文件名
|
||||
- 大小
|
||||
- 分析状态
|
||||
|
||||
视频消息:
|
||||
|
||||
- 缩略图或占位封面
|
||||
- 文件名
|
||||
- 大小
|
||||
- 分析状态
|
||||
|
||||
文件消息:
|
||||
|
||||
- 文件图标
|
||||
- 文件名
|
||||
- 类型
|
||||
- 大小
|
||||
- 下载/查看动作
|
||||
- 分析状态
|
||||
|
||||
### 10.5 分析状态呈现
|
||||
|
||||
附件卡片需能表示:
|
||||
|
||||
- `queued_auto`:自动分析排队中
|
||||
- `ready_manual`:可分析
|
||||
- `processing`:分析中
|
||||
- `completed`:分析完成
|
||||
- `failed`:分析失败,可重试
|
||||
|
||||
## 11. Web 配置页设计
|
||||
|
||||
### 11.1 入口
|
||||
|
||||
入口固定为:
|
||||
|
||||
```text
|
||||
我的 > 附件与存储
|
||||
```
|
||||
|
||||
### 11.2 页面结构
|
||||
|
||||
第一页只做两层:
|
||||
|
||||
1. 存储方式选择
|
||||
- 服务器文件存储(默认)
|
||||
- OSS
|
||||
2. 如果选 `OSS`
|
||||
- 供应商选择:当前只显示 `阿里 OSS`
|
||||
- 展开最小配置表单
|
||||
|
||||
### 11.3 表单交互
|
||||
|
||||
表单交互应尽可能简化:
|
||||
|
||||
- `AccessKey ID`
|
||||
- `AccessKey Secret`
|
||||
- `Bucket`
|
||||
- `Endpoint`
|
||||
- `Region`
|
||||
- `目录前缀(可选)`
|
||||
|
||||
按钮建议只有两个:
|
||||
|
||||
- `测试并保存`
|
||||
- `切回服务器文件存储`
|
||||
|
||||
不做额外的高级设置入口作为第一屏主内容。
|
||||
|
||||
## 12. 与主 Agent 的关系
|
||||
|
||||
主 Agent 不再只处理文本请求,也需要能理解附件分析任务。
|
||||
|
||||
这次要求主 Agent:
|
||||
|
||||
1. 收到附件任务时,知道当前附件来自哪个会话、哪条消息
|
||||
2. 能通过统一下载 URL 获取附件
|
||||
3. 对图片 / PDF / 文本自动分析
|
||||
4. 对手动模式附件在用户触发后再分析
|
||||
5. 把结果回写为:
|
||||
- 一条简短消息
|
||||
- 一张分析卡片
|
||||
|
||||
如果主 Agent 当前无法处理某类附件,应明确返回失败原因,而不是静默成功。
|
||||
|
||||
## 13. 错误处理
|
||||
|
||||
至少覆盖以下错误:
|
||||
|
||||
1. 用户未配置 OSS,但切换到了 OSS
|
||||
2. OSS 配置无效
|
||||
3. Bucket 无权限
|
||||
4. 上传中断
|
||||
5. 本地文件 URI 无法读取
|
||||
6. 附件消息已创建但自动分析排队失败
|
||||
7. 分析任务执行失败
|
||||
8. 下载 URL 失效
|
||||
9. 原文件已被删除或不可用
|
||||
|
||||
错误处理原则:
|
||||
|
||||
- 上传失败:不创建成功消息,前台保留失败提示
|
||||
- 上传成功、分析失败:保留附件消息,但把分析状态标为 `failed`
|
||||
- 下载失败:允许用户重试,不直接删除消息
|
||||
|
||||
## 14. 测试与验收
|
||||
|
||||
### 14.1 Web / 服务端
|
||||
|
||||
至少验证:
|
||||
|
||||
1. 默认服务器文件存储上传成功
|
||||
2. 阿里 OSS 上传成功
|
||||
3. 用户级配置切换生效
|
||||
4. 未配置 OSS 时不能误走 OSS
|
||||
5. 图片 / PDF / 文本能自动排队分析
|
||||
6. 视频 / Office / 大文件默认进入手动分析
|
||||
7. 下载接口能正确返回本地文件或 OSS 签名 URL
|
||||
|
||||
### 14.2 原生 Android
|
||||
|
||||
至少验证:
|
||||
|
||||
1. `+` 按钮打开底部抽屉
|
||||
2. 图片发送前确认
|
||||
3. 视频发送前确认
|
||||
4. 文件直接发送
|
||||
5. 图片消息渲染
|
||||
6. 视频消息渲染
|
||||
7. 文件消息渲染
|
||||
8. 分析状态切换
|
||||
9. 下载 / 预览动作
|
||||
|
||||
### 14.3 验收标准
|
||||
|
||||
本次工作完成时,应满足:
|
||||
|
||||
1. 用户能从原生聊天框发送图片、视频、文件
|
||||
2. 接收端能看到附件消息并统一预览/下载
|
||||
3. 默认服务器文件存储可直接使用
|
||||
4. 用户可在 Web 端开启 OSS,并完成阿里 OSS 最小配置
|
||||
5. 图片 / PDF / 文本会自动交给主 Agent 分析
|
||||
6. 视频 / Office / 大文件可手动触发分析
|
||||
7. 主 Agent 的分析结果会回到当前聊天中
|
||||
8. UI 和交互仍保持当前微信式方向,不回退成控制台面板
|
||||
|
||||
## 15. 推荐实现顺序
|
||||
|
||||
1. 先补数据模型与用户级存储配置
|
||||
2. 再补服务器文件存储上传 / 下载链
|
||||
3. 再补阿里 OSS 适配器与校验
|
||||
4. 再补原生 Android 选择文件、上传和附件卡片
|
||||
5. 再把主 Agent 附件分析任务接通
|
||||
6. 最后补 Web 配置页、联调、部署与发包
|
||||
1194
package-lock.json
generated
1194
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,13 +19,16 @@
|
||||
"@capacitor/cli": "^8.2.0",
|
||||
"@capacitor/core": "^8.2.0",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"ali-oss": "^6.23.0",
|
||||
"clsx": "^2.1.1",
|
||||
"next": "16.2.1",
|
||||
"proxy-agent": "^5.0.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/ali-oss": "^6.23.3",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"artifactType": "aab",
|
||||
"fileName": "boss-android-v2.1.1-release.aab",
|
||||
"urlPath": "/downloads/boss-android-v2.1.1-release.aab",
|
||||
"sizeBytes": 2853490,
|
||||
"updatedAt": "2026-03-26T15:51:15Z",
|
||||
"sha256": "c67341ca50d219e3d75baa6c88520c11d475611bed33237710d4518f292779c9",
|
||||
"versionName": "2.1.1",
|
||||
"versionCode": 8,
|
||||
"fileName": "boss-android-v2.5.0-release.aab",
|
||||
"urlPath": "/downloads/boss-android-v2.5.0-release.aab",
|
||||
"sizeBytes": 2906325,
|
||||
"updatedAt": "2026-03-29T09:19:37Z",
|
||||
"sha256": "e230a1e0eae8e1e6d264f11feb3125ff40661dc2e049e18d8f683c3571e3a568",
|
||||
"versionName": "2.5.0",
|
||||
"versionCode": 13,
|
||||
"buildFlavor": "release"
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"fileName": "boss-android-v2.1.1-release.apk",
|
||||
"fileName": "boss-android-v2.5.0-release.apk",
|
||||
"urlPath": "/api/v1/user/ota/package",
|
||||
"sizeBytes": 3032808,
|
||||
"updatedAt": "2026-03-26T15:51:10Z",
|
||||
"sha256": "453412d605ad2cd3b1cabf806752d1288a8b23f1c61900b007468a264dda3459",
|
||||
"versionName": "2.1.1",
|
||||
"versionCode": 8,
|
||||
"sizeBytes": 3083087,
|
||||
"updatedAt": "2026-03-29T09:20:05Z",
|
||||
"sha256": "92183a0ebed80da5e363ffcd8e41a4acfd650aac76072ac5a4e432af5902f59f",
|
||||
"versionName": "2.5.0",
|
||||
"versionCode": 13,
|
||||
"buildFlavor": "release"
|
||||
}
|
||||
|
||||
BIN
public/downloads/boss-android-v2.2.0-release.aab
Normal file
BIN
public/downloads/boss-android-v2.2.0-release.aab
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.2.0-release.apk
Normal file
BIN
public/downloads/boss-android-v2.2.0-release.apk
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.2.1-release.aab
Normal file
BIN
public/downloads/boss-android-v2.2.1-release.aab
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.2.1-release.apk
Normal file
BIN
public/downloads/boss-android-v2.2.1-release.apk
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.3.0-release.aab
Normal file
BIN
public/downloads/boss-android-v2.3.0-release.aab
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.3.0-release.apk
Normal file
BIN
public/downloads/boss-android-v2.3.0-release.apk
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.4.0-release.aab
Normal file
BIN
public/downloads/boss-android-v2.4.0-release.aab
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.4.0-release.apk
Normal file
BIN
public/downloads/boss-android-v2.4.0-release.apk
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.5.0-release.aab
Normal file
BIN
public/downloads/boss-android-v2.5.0-release.aab
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.5.0-release.apk
Normal file
BIN
public/downloads/boss-android-v2.5.0-release.apk
Normal file
Binary file not shown.
253
scripts/validate-attachment-analysis.mjs
Normal file
253
scripts/validate-attachment-analysis.mjs
Normal file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const taskBaseCommit = "3307f7916220b74a8e7d0d8e8b2b12f888d0632a";
|
||||
const sourceStateFile = path.join(rootDir, "data", "boss-state.json");
|
||||
|
||||
async function createSeededRuntime(root, runtimeName) {
|
||||
const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), runtimeName));
|
||||
const stateFile = path.join(runtimeDir, "data", "boss-state.json");
|
||||
await fs.mkdir(path.join(runtimeDir, "data", "uploads"), { recursive: true });
|
||||
await fs.mkdir(path.join(runtimeDir, "public", "downloads"), { recursive: true });
|
||||
const state = JSON.parse(await fs.readFile(sourceStateFile, "utf8"));
|
||||
await fs.writeFile(stateFile, JSON.stringify(state, null, 2), "utf8");
|
||||
return { runtimeDir, stateFile };
|
||||
}
|
||||
|
||||
function parseCookieValue(setCookieHeader, cookieName) {
|
||||
assert.ok(setCookieHeader, "set-cookie header is missing");
|
||||
const match = setCookieHeader.match(new RegExp(`${cookieName}=([^;]+)`));
|
||||
assert.ok(match, `${cookieName} cookie is missing`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
async function waitForServer(baseUrl, child, getServerLogs) {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`SERVER_EXITED_EARLY:${child.exitCode}:${getServerLogs()}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/health`);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// keep waiting
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`SERVER_START_TIMEOUT:${getServerLogs()}`);
|
||||
}
|
||||
|
||||
async function startStandaloneServer(appRoot, runtimeDir, port) {
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
let logs = "";
|
||||
const child = spawn("node", [".next/standalone/server.js"], {
|
||||
cwd: appRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
HOSTNAME: "127.0.0.1",
|
||||
BOSS_PUBLIC_BASE_URL: baseUrl,
|
||||
BOSS_RUNTIME_ROOT: runtimeDir,
|
||||
BOSS_STATE_FILE: path.join(runtimeDir, "data", "boss-state.json"),
|
||||
BOSS_AUTH_AUTO_LOGIN: "0",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
logs += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
logs += chunk.toString();
|
||||
});
|
||||
|
||||
await waitForServer(baseUrl, child, () => logs);
|
||||
return {
|
||||
baseUrl,
|
||||
child,
|
||||
getLogs: () => logs,
|
||||
async stop() {
|
||||
if (child.exitCode === null) {
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loginAsAdmin(baseUrl) {
|
||||
const response = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account: "17600003315",
|
||||
password: "boss123456",
|
||||
method: "password",
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 200, "login should succeed");
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, true, "login payload should be ok");
|
||||
const cookie = parseCookieValue(response.headers.get("set-cookie"), "boss_session");
|
||||
return { cookie, payload };
|
||||
}
|
||||
|
||||
async function uploadAttachment(baseUrl, cookie, projectId, fileName, type, bytes) {
|
||||
const form = new FormData();
|
||||
form.set("file", new File([bytes], fileName, { type }));
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/v1/projects/${projectId}/attachments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: `boss_session=${cookie}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 200, `upload ${fileName} should succeed`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function analyzeAttachment(baseUrl, cookie, projectId, attachmentId) {
|
||||
const response = await fetch(`${baseUrl}/api/v1/projects/${projectId}/attachments/${attachmentId}/analyze`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: `boss_session=${cookie}`,
|
||||
},
|
||||
});
|
||||
assert.equal(response.status, 200, "manual analyze should succeed");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function verifyHistoricalPrecheck() {
|
||||
const worktreePath = await fs.mkdtemp(path.join(os.tmpdir(), "boss-attachment-precheck-"));
|
||||
let server;
|
||||
try {
|
||||
await execFile("git", ["worktree", "add", "--detach", worktreePath, taskBaseCommit], {
|
||||
cwd: rootDir,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
await execFile("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], {
|
||||
cwd: worktreePath,
|
||||
env: process.env,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
await execFile("npm", ["run", "build"], {
|
||||
cwd: worktreePath,
|
||||
env: process.env,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const { runtimeDir } = await createSeededRuntime(worktreePath, "boss-attachment-precheck-");
|
||||
server = await startStandaloneServer(worktreePath, runtimeDir, 3115);
|
||||
const { cookie } = await loginAsAdmin(server.baseUrl);
|
||||
const response = await fetch(`${server.baseUrl}/api/v1/projects/master-agent/attachments/att-missing/analyze`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: `boss_session=${cookie}`,
|
||||
},
|
||||
});
|
||||
assert.notEqual(response.status, 200, "pre-implementation analyze route should not succeed");
|
||||
} finally {
|
||||
await server?.stop();
|
||||
await execFile("git", ["worktree", "remove", "--force", worktreePath], {
|
||||
cwd: rootDir,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
}).catch(() => undefined);
|
||||
await fs.rm(worktreePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await verifyHistoricalPrecheck();
|
||||
|
||||
const { runtimeDir } = await createSeededRuntime(rootDir, "boss-attachment-current-");
|
||||
const currentServer = await startStandaloneServer(rootDir, runtimeDir, 3116);
|
||||
|
||||
try {
|
||||
const { cookie } = await loginAsAdmin(currentServer.baseUrl);
|
||||
|
||||
const textUpload = await uploadAttachment(
|
||||
currentServer.baseUrl,
|
||||
cookie,
|
||||
"master-agent",
|
||||
"analysis-note.txt",
|
||||
"text/plain",
|
||||
Buffer.from("text attachment for automatic analysis"),
|
||||
);
|
||||
assert.equal(textUpload.attachment.analysisState, "queued_auto", "text attachment should queue automatically");
|
||||
assert.ok(textUpload.analysisTask, "queued auto attachment should create a master agent task");
|
||||
assert.equal(
|
||||
textUpload.analysisTask.taskType,
|
||||
"attachment_analysis",
|
||||
"queued task type should be attachment_analysis",
|
||||
);
|
||||
assert.equal(
|
||||
textUpload.analysisTask.attachmentFileName,
|
||||
"analysis-note.txt",
|
||||
"queued task should carry attachment file name",
|
||||
);
|
||||
assert.ok(textUpload.analysisTask.attachmentDownloadUrl, "queued task should expose attachment download url");
|
||||
assert.ok(
|
||||
textUpload.analysisTask.attachmentDownloadUrl.startsWith(currentServer.baseUrl),
|
||||
"queued task should use the current runtime origin for attachment download",
|
||||
);
|
||||
const promptDownloadUrlMatch = textUpload.analysisTask.executionPrompt.match(/downloadUrl:\s+(http[^\s]+)/);
|
||||
assert.ok(promptDownloadUrlMatch, "execution prompt should include attachment download url");
|
||||
const unauthDownloadResponse = await fetch(textUpload.analysisTask.attachmentDownloadUrl);
|
||||
assert.equal(unauthDownloadResponse.status, 200, "attachment download url should be readable with task token");
|
||||
assert.equal(
|
||||
await unauthDownloadResponse.text(),
|
||||
"text attachment for automatic analysis",
|
||||
"downloaded attachment content should match the uploaded text",
|
||||
);
|
||||
|
||||
const manualUpload = await uploadAttachment(
|
||||
currentServer.baseUrl,
|
||||
cookie,
|
||||
"master-agent",
|
||||
"manual-binary.bin",
|
||||
"application/octet-stream",
|
||||
Buffer.from([0, 1, 2, 3]),
|
||||
);
|
||||
assert.equal(
|
||||
manualUpload.attachment.analysisState,
|
||||
"ready_manual",
|
||||
"binary attachment should be manually analyzable",
|
||||
);
|
||||
|
||||
const analyzePayload = await analyzeAttachment(
|
||||
currentServer.baseUrl,
|
||||
cookie,
|
||||
"master-agent",
|
||||
manualUpload.attachment.attachmentId,
|
||||
);
|
||||
assert.ok(analyzePayload.taskId, "manual analyze should return a taskId");
|
||||
assert.ok(analyzePayload.task, "manual analyze should return a task payload");
|
||||
assert.equal(
|
||||
analyzePayload.task.taskType,
|
||||
"attachment_analysis",
|
||||
"manual analyze task should be attachment_analysis",
|
||||
);
|
||||
assert.equal(
|
||||
analyzePayload.task.attachmentId,
|
||||
manualUpload.attachment.attachmentId,
|
||||
"manual task should link the attachment",
|
||||
);
|
||||
|
||||
console.log("attachment analysis validation passed");
|
||||
} finally {
|
||||
await currentServer.stop();
|
||||
await fs.rm(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
286
scripts/verify-attachment-security.mjs
Normal file
286
scripts/verify-attachment-security.mjs
Normal file
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env node
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function hashPassword(password) {
|
||||
const normalized = password.normalize("NFKC");
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(normalized, `boss:${salt}`, 64).toString("hex");
|
||||
return `scrypt$${salt}$${hash}`;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function projectTemplate(id, name, deviceId) {
|
||||
const timestamp = nowIso();
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
pinned: false,
|
||||
deviceIds: [deviceId],
|
||||
preview: "",
|
||||
updatedAt: timestamp,
|
||||
lastMessageAt: timestamp,
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: id,
|
||||
threadId: `thread-${id}`,
|
||||
threadDisplayName: name,
|
||||
folderName: name,
|
||||
activityIconCount: 1,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: false,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForServer(baseUrl, child, getServerLogs) {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`SERVER_EXITED_EARLY:${child.exitCode}:${getServerLogs()}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/health`);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`SERVER_START_TIMEOUT:${getServerLogs()}`);
|
||||
}
|
||||
|
||||
async function login(baseUrl, account, password) {
|
||||
const response = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account,
|
||||
password,
|
||||
method: "password",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`LOGIN_FAILED:${account}:${response.status}`);
|
||||
}
|
||||
const cookie = (response.headers.get("set-cookie") || "").split(";")[0];
|
||||
if (!cookie) {
|
||||
throw new Error(`COOKIE_MISSING:${account}`);
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
|
||||
const tmpRoot = await mkdtemp(path.join(os.tmpdir(), "boss-attachment-security-"));
|
||||
const runtimeRoot = path.join(tmpRoot, "runtime");
|
||||
const dataDir = path.join(runtimeRoot, "data");
|
||||
const uploadsDir = path.join(dataDir, "uploads");
|
||||
const stateFile = path.join(dataDir, "boss-state.json");
|
||||
const outsideSecretPath = path.join(runtimeRoot, "secret-outside-uploads.txt");
|
||||
const port = "3104";
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
let server;
|
||||
|
||||
try {
|
||||
await mkdir(path.join(runtimeRoot, "public", "downloads"), { recursive: true });
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
|
||||
const baseState = JSON.parse(await readFile(path.join(repoRoot, "data", "boss-state.json"), "utf8"));
|
||||
const timestamp = nowIso();
|
||||
const memberAccount = "18800000001";
|
||||
const memberPassword = "member-pass-123";
|
||||
const memberDeviceId = "member-device-1";
|
||||
const memberProject = projectTemplate("member-project", "成员项目", memberDeviceId);
|
||||
const adminProject = projectTemplate("admin-only-project", "管理员项目", "mac-studio");
|
||||
|
||||
const adminAttachmentPath = path.join("data", "uploads", "seeded-admin", "2026", "03", "admin-note.txt");
|
||||
const adminAbsolutePath = path.join(runtimeRoot, adminAttachmentPath);
|
||||
await mkdir(path.dirname(adminAbsolutePath), { recursive: true });
|
||||
await writeFile(adminAbsolutePath, "admin only attachment\n", "utf8");
|
||||
|
||||
const traversalAttachmentId = "att-traversal";
|
||||
const adminAttachmentId = "att-admin-only";
|
||||
await writeFile(outsideSecretPath, "outside uploads secret\n", "utf8");
|
||||
|
||||
memberProject.messages.push({
|
||||
id: "msg-traversal",
|
||||
sender: "user",
|
||||
senderLabel: "成员用户",
|
||||
body: "traversal probe",
|
||||
sentAt: timestamp,
|
||||
kind: "attachment",
|
||||
attachments: [
|
||||
{
|
||||
attachmentId: traversalAttachmentId,
|
||||
fileName: "secret.txt",
|
||||
mimeType: "text/plain",
|
||||
fileSizeBytes: 23,
|
||||
attachmentKind: "text",
|
||||
storageBackend: "server_file",
|
||||
storagePath: outsideSecretPath,
|
||||
previewAvailable: false,
|
||||
uploadedAt: timestamp,
|
||||
uploadedBy: memberAccount,
|
||||
analysisState: "ready_manual",
|
||||
},
|
||||
],
|
||||
});
|
||||
memberProject.preview = "traversal probe";
|
||||
|
||||
adminProject.messages.push({
|
||||
id: "msg-admin-attachment",
|
||||
sender: "user",
|
||||
senderLabel: "Boss 超级管理员",
|
||||
body: "管理员附件",
|
||||
sentAt: timestamp,
|
||||
kind: "attachment",
|
||||
attachments: [
|
||||
{
|
||||
attachmentId: adminAttachmentId,
|
||||
fileName: "admin-note.txt",
|
||||
mimeType: "text/plain",
|
||||
fileSizeBytes: 22,
|
||||
attachmentKind: "text",
|
||||
storageBackend: "server_file",
|
||||
storagePath: adminAttachmentPath,
|
||||
previewAvailable: false,
|
||||
uploadedAt: timestamp,
|
||||
uploadedBy: "17600003315",
|
||||
analysisState: "ready_manual",
|
||||
},
|
||||
],
|
||||
});
|
||||
adminProject.preview = "管理员附件";
|
||||
|
||||
baseState.authSessions = [];
|
||||
baseState.authAccounts.push({
|
||||
id: `account-${memberAccount}`,
|
||||
account: memberAccount,
|
||||
passwordHash: hashPassword(memberPassword),
|
||||
displayName: "成员用户",
|
||||
role: "member",
|
||||
primaryDeviceId: memberDeviceId,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
baseState.devices.push({
|
||||
id: memberDeviceId,
|
||||
name: "Member Device",
|
||||
avatar: "M",
|
||||
account: memberAccount,
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: [memberProject.id],
|
||||
quota5h: 10,
|
||||
quota7d: 20,
|
||||
lastSeenAt: timestamp,
|
||||
});
|
||||
baseState.projects.push(memberProject, adminProject);
|
||||
baseState.userAttachmentStorageConfigs = [
|
||||
...(Array.isArray(baseState.userAttachmentStorageConfigs) ? baseState.userAttachmentStorageConfigs : []),
|
||||
{
|
||||
account: memberAccount,
|
||||
mode: "server_file",
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
];
|
||||
|
||||
await writeFile(stateFile, JSON.stringify(baseState, null, 2), "utf8");
|
||||
|
||||
server = spawn("node", [".next/standalone/server.js"], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: port,
|
||||
HOSTNAME: "127.0.0.1",
|
||||
BOSS_RUNTIME_ROOT: runtimeRoot,
|
||||
BOSS_STATE_FILE: stateFile,
|
||||
BOSS_AUTH_AUTO_LOGIN: "0",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let serverLogs = "";
|
||||
server.stdout.on("data", (chunk) => {
|
||||
serverLogs += chunk.toString();
|
||||
});
|
||||
server.stderr.on("data", (chunk) => {
|
||||
serverLogs += chunk.toString();
|
||||
});
|
||||
|
||||
await waitForServer(baseUrl, server, () => serverLogs);
|
||||
|
||||
const memberCookie = await login(baseUrl, memberAccount, memberPassword);
|
||||
const adminCookie = await login(baseUrl, "17600003315", "boss123456");
|
||||
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append("file", new File([Buffer.from("blocked upload\n")], "blocked.txt", { type: "text/plain" }));
|
||||
|
||||
const uploadDenied = await fetch(`${baseUrl}/api/v1/projects/admin-only-project/attachments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: memberCookie,
|
||||
},
|
||||
body: uploadForm,
|
||||
});
|
||||
if (uploadDenied.status !== 403) {
|
||||
throw new Error(`EXPECTED_UPLOAD_403:${uploadDenied.status}`);
|
||||
}
|
||||
|
||||
const adminDownload = await fetch(`${baseUrl}/api/v1/attachments/${adminAttachmentId}/download`, {
|
||||
headers: {
|
||||
cookie: memberCookie,
|
||||
},
|
||||
});
|
||||
if (adminDownload.status !== 403) {
|
||||
throw new Error(`EXPECTED_DOWNLOAD_403:${adminDownload.status}`);
|
||||
}
|
||||
|
||||
const happyAdminDownload = await fetch(`${baseUrl}/api/v1/attachments/${adminAttachmentId}/download`, {
|
||||
headers: {
|
||||
cookie: adminCookie,
|
||||
},
|
||||
});
|
||||
if (!happyAdminDownload.ok) {
|
||||
throw new Error(`ADMIN_DOWNLOAD_FAILED:${happyAdminDownload.status}`);
|
||||
}
|
||||
|
||||
const traversalDownload = await fetch(`${baseUrl}/api/v1/attachments/${traversalAttachmentId}/download`, {
|
||||
headers: {
|
||||
cookie: memberCookie,
|
||||
},
|
||||
});
|
||||
if (traversalDownload.status !== 404) {
|
||||
throw new Error(`EXPECTED_TRAVERSAL_404:${traversalDownload.status}`);
|
||||
}
|
||||
|
||||
console.log("OK");
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (server && server.exitCode === null) {
|
||||
server.kill("SIGTERM");
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
await rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
80
scripts/verify-attachment-storage-model.mjs
Normal file
80
scripts/verify-attachment-storage-model.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdtemp, copyFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const jiti = require("jiti")(fileURLToPath(import.meta.url), {
|
||||
alias: {
|
||||
"@/": `${path.join(rootDir, "src")}/`,
|
||||
},
|
||||
});
|
||||
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-attachment-model-"));
|
||||
const tempStateFile = path.join(tempDir, "boss-state.json");
|
||||
const sourceStateFile = path.join(rootDir, "data", "boss-state.json");
|
||||
|
||||
if (!existsSync(sourceStateFile)) {
|
||||
throw new Error(`Missing state file: ${sourceStateFile}`);
|
||||
}
|
||||
|
||||
await copyFile(sourceStateFile, tempStateFile);
|
||||
process.env.BOSS_STATE_FILE = tempStateFile;
|
||||
process.env.BOSS_RUNTIME_ROOT = rootDir;
|
||||
|
||||
const { getAttachmentStorageConfig, readState, writeState } = jiti(path.join(scriptDir, "..", "src", "lib", "boss-data.ts"));
|
||||
|
||||
const config = await getAttachmentStorageConfig("17600003315");
|
||||
if (config.mode !== "server_file") {
|
||||
throw new Error(`Expected default storage mode server_file, got ${config.mode}`);
|
||||
}
|
||||
if (!config.updatedAt || typeof config.updatedAt !== "string") {
|
||||
throw new Error("Expected updatedAt to be populated");
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
const messageId = "script-attachment-message";
|
||||
state.projects[0].messages.unshift({
|
||||
id: messageId,
|
||||
sender: "user",
|
||||
senderLabel: "测试用户",
|
||||
body: "Attachment round-trip",
|
||||
sentAt: "2026-03-29T00:00:00+08:00",
|
||||
kind: "attachment",
|
||||
attachments: [
|
||||
{
|
||||
attachmentId: "att-001",
|
||||
fileName: "demo.txt",
|
||||
mimeType: "text/plain",
|
||||
fileSizeBytes: 12,
|
||||
attachmentKind: "text",
|
||||
storageBackend: "server_file",
|
||||
storagePath: "/tmp/demo.txt",
|
||||
previewAvailable: true,
|
||||
uploadedAt: "2026-03-29T00:00:00+08:00",
|
||||
uploadedBy: "17600003315",
|
||||
analysisState: "not_applicable",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await writeState(state);
|
||||
const reread = await readState();
|
||||
const message = reread.projects[0].messages.find((item) => item.id === messageId);
|
||||
|
||||
if (!message?.attachments?.length) {
|
||||
throw new Error("Expected message attachments to round-trip through state");
|
||||
}
|
||||
if (message.attachments[0].attachmentId !== "att-001") {
|
||||
throw new Error("Expected attachment metadata to persist");
|
||||
}
|
||||
if (message.attachments[0].storageBackend !== "server_file") {
|
||||
throw new Error("Expected attachment storage backend to persist");
|
||||
}
|
||||
|
||||
console.log("OK");
|
||||
73
scripts/verify-attachment-upload-download.mjs
Normal file
73
scripts/verify-attachment-upload-download.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const baseUrl = process.env.BOSS_TEST_BASE_URL || "http://127.0.0.1:3000";
|
||||
const repoRoot = process.cwd();
|
||||
const readmePath = path.join(repoRoot, "README.md");
|
||||
const readmeBytes = await readFile(readmePath);
|
||||
|
||||
const loginResponse = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
throw new Error(`LOGIN_FAILED:${loginResponse.status}`);
|
||||
}
|
||||
|
||||
const setCookie = loginResponse.headers.get("set-cookie") || "";
|
||||
const cookie = setCookie.split(";")[0];
|
||||
if (!cookie) {
|
||||
throw new Error("COOKIE_MISSING");
|
||||
}
|
||||
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append("file", new File([readmeBytes], "README.md", { type: "text/markdown" }));
|
||||
|
||||
const uploadResponse = await fetch(`${baseUrl}/api/v1/projects/boss-console/attachments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie,
|
||||
},
|
||||
body: uploadForm,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`UPLOAD_FAILED:${uploadResponse.status}`);
|
||||
}
|
||||
|
||||
const uploadJson = await uploadResponse.json();
|
||||
if (!uploadJson.ok || !uploadJson.attachment?.attachmentId || !uploadJson.downloadUrl) {
|
||||
throw new Error("UPLOAD_RESPONSE_INVALID");
|
||||
}
|
||||
if (uploadJson.message?.kind !== "attachment") {
|
||||
throw new Error("ATTACHMENT_MESSAGE_KIND_INVALID");
|
||||
}
|
||||
if (!Array.isArray(uploadJson.message?.attachments) || uploadJson.message.attachments.length !== 1) {
|
||||
throw new Error("ATTACHMENT_PAYLOAD_INVALID");
|
||||
}
|
||||
|
||||
const downloadResponse = await fetch(`${baseUrl}${uploadJson.downloadUrl}`, {
|
||||
headers: {
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
|
||||
if (!downloadResponse.ok) {
|
||||
throw new Error(`DOWNLOAD_FAILED:${downloadResponse.status}`);
|
||||
}
|
||||
|
||||
const downloadedBytes = Buffer.from(await downloadResponse.arrayBuffer());
|
||||
if (Buffer.compare(downloadedBytes, readmeBytes) !== 0) {
|
||||
throw new Error("DOWNLOADED_CONTENT_MISMATCH");
|
||||
}
|
||||
|
||||
if ((downloadResponse.headers.get("content-disposition") || "").indexOf("README.md") === -1) {
|
||||
throw new Error("DOWNLOAD_HEADERS_INVALID");
|
||||
}
|
||||
|
||||
console.log("OK");
|
||||
92
scripts/verify-storage-config.mjs
Normal file
92
scripts/verify-storage-config.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
const baseUrl = process.env.BOSS_TEST_BASE_URL || "http://127.0.0.1:3000";
|
||||
|
||||
const loginResponse = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
throw new Error(`LOGIN_FAILED:${loginResponse.status}`);
|
||||
}
|
||||
|
||||
const cookie = (loginResponse.headers.get("set-cookie") || "").split(";")[0];
|
||||
if (!cookie) {
|
||||
throw new Error("COOKIE_MISSING");
|
||||
}
|
||||
|
||||
const getResponse = await fetch(`${baseUrl}/api/v1/storage/config`, {
|
||||
headers: {
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
|
||||
if (!getResponse.ok) {
|
||||
throw new Error(`GET_CONFIG_FAILED:${getResponse.status}`);
|
||||
}
|
||||
|
||||
const getJson = await getResponse.json();
|
||||
if (!getJson.ok || getJson.config?.mode !== "server_file") {
|
||||
throw new Error("DEFAULT_STORAGE_MODE_INVALID");
|
||||
}
|
||||
|
||||
const patchPayload = {
|
||||
mode: "server_file",
|
||||
ossProvider: "aliyun_oss",
|
||||
aliyunOss: {
|
||||
enabled: false,
|
||||
accessKeyId: "ak-test",
|
||||
accessKeySecret: "oss-secret-test",
|
||||
bucket: "boss-private-bucket",
|
||||
endpoint: "oss-cn-shanghai.aliyuncs.com",
|
||||
region: "oss-cn-shanghai",
|
||||
prefix: "boss/custom/",
|
||||
},
|
||||
};
|
||||
|
||||
const patchResponse = await fetch(`${baseUrl}/api/v1/storage/config`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
cookie,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(patchPayload),
|
||||
});
|
||||
|
||||
if (!patchResponse.ok) {
|
||||
throw new Error(`PATCH_CONFIG_FAILED:${patchResponse.status}`);
|
||||
}
|
||||
|
||||
const patchJson = await patchResponse.json();
|
||||
if (!patchJson.ok) {
|
||||
throw new Error("PATCH_CONFIG_NOT_OK");
|
||||
}
|
||||
if (patchJson.config?.aliyunOss?.accessKeySecretConfigured !== true) {
|
||||
throw new Error("SECRET_SANITIZE_FLAG_MISSING");
|
||||
}
|
||||
if ("accessKeySecretEncrypted" in (patchJson.config?.aliyunOss ?? {})) {
|
||||
throw new Error("ENCRYPTED_SECRET_LEAKED");
|
||||
}
|
||||
|
||||
const rereadResponse = await fetch(`${baseUrl}/api/v1/storage/config`, {
|
||||
headers: {
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
|
||||
if (!rereadResponse.ok) {
|
||||
throw new Error(`GET_CONFIG_REREAD_FAILED:${rereadResponse.status}`);
|
||||
}
|
||||
|
||||
const rereadJson = await rereadResponse.json();
|
||||
if (rereadJson.config?.aliyunOss?.accessKeyId !== "ak-test") {
|
||||
throw new Error("PATCHED_CONFIG_NOT_PERSISTED");
|
||||
}
|
||||
if (rereadJson.config?.aliyunOss?.accessKeySecretConfigured !== true) {
|
||||
throw new Error("SECRET_FLAG_NOT_PERSISTED");
|
||||
}
|
||||
|
||||
console.log("OK");
|
||||
106
src/app/api/v1/attachments/[attachmentId]/download/route.ts
Normal file
106
src/app/api/v1/attachments/[attachmentId]/download/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { Readable } from "node:stream";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { canSessionAccessAttachmentProject } from "@/lib/boss-attachment-access";
|
||||
import { getAttachmentById, getAttachmentStorageConfig, getMasterAgentTask, readState } from "@/lib/boss-data";
|
||||
import { buildAttachmentDownloadHeaders } from "@/lib/boss-attachments";
|
||||
import { getAliyunOssSignedDownloadUrl } from "@/lib/boss-storage-aliyun-oss";
|
||||
import { resolveServerFileAttachmentAbsolutePath } from "@/lib/boss-storage-server-file";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function hasTaskTokenAccess(request: NextRequest, attachmentId: string) {
|
||||
const taskId = request.nextUrl.searchParams.get("taskId")?.trim();
|
||||
const token = request.nextUrl.searchParams.get("token")?.trim();
|
||||
if (!taskId || !token) {
|
||||
return false;
|
||||
}
|
||||
const task = await getMasterAgentTask(taskId);
|
||||
if (!task || task.taskType !== "attachment_analysis") {
|
||||
return false;
|
||||
}
|
||||
if (task.attachmentId !== attachmentId || task.attachmentDownloadToken !== token) {
|
||||
return false;
|
||||
}
|
||||
if (!task.attachmentDownloadExpiresAt) {
|
||||
return false;
|
||||
}
|
||||
return Date.parse(task.attachmentDownloadExpiresAt) > Date.now();
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ attachmentId: string }> },
|
||||
) {
|
||||
const { attachmentId } = await context.params;
|
||||
const session = await requireRequestSession(request);
|
||||
const taskTokenAccess = session ? false : await hasTaskTokenAccess(request, attachmentId);
|
||||
if (!session && !taskTokenAccess) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const record = await getAttachmentById(attachmentId);
|
||||
if (!record) {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
if (session) {
|
||||
const state = await readState();
|
||||
if (!canSessionAccessAttachmentProject(state, session, record.project)) {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (record.attachment.storageBackend === "aliyun_oss") {
|
||||
const aliyunConfig =
|
||||
record.attachment.storageSnapshot?.provider === "aliyun_oss"
|
||||
? {
|
||||
enabled: true,
|
||||
accessKeyId: record.attachment.storageSnapshot.accessKeyId,
|
||||
accessKeySecretEncrypted: record.attachment.storageSnapshot.accessKeySecretEncrypted,
|
||||
bucket: record.attachment.storageSnapshot.bucket,
|
||||
endpoint: record.attachment.storageSnapshot.endpoint,
|
||||
region: record.attachment.storageSnapshot.region,
|
||||
prefix: record.attachment.storageSnapshot.prefix,
|
||||
}
|
||||
: null;
|
||||
const storageConfig = aliyunConfig ? null : await getAttachmentStorageConfig(record.attachment.uploadedBy);
|
||||
const resolvedConfig =
|
||||
aliyunConfig ??
|
||||
(storageConfig?.mode === "oss" &&
|
||||
storageConfig.ossProvider === "aliyun_oss" &&
|
||||
storageConfig.aliyunOss
|
||||
? storageConfig.aliyunOss
|
||||
: null);
|
||||
if (!resolvedConfig) {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_STORAGE_CONFIG_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
const signedUrl = await getAliyunOssSignedDownloadUrl(resolvedConfig, record.attachment.storagePath);
|
||||
return NextResponse.redirect(signedUrl, {
|
||||
status: 307,
|
||||
headers: buildAttachmentDownloadHeaders(record.attachment),
|
||||
});
|
||||
}
|
||||
|
||||
if (record.attachment.storageBackend !== "server_file") {
|
||||
return NextResponse.json({ ok: false, message: "UNSUPPORTED_ATTACHMENT_STORAGE_BACKEND" }, { status: 501 });
|
||||
}
|
||||
|
||||
let absolutePath: string;
|
||||
try {
|
||||
absolutePath = resolveServerFileAttachmentAbsolutePath(record.attachment.storagePath);
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_FILE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
await stat(absolutePath);
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_FILE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const stream = createReadStream(absolutePath);
|
||||
return new NextResponse(Readable.toWeb(stream) as BodyInit, {
|
||||
headers: buildAttachmentDownloadHeaders(record.attachment),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { canSessionAccessAttachmentProject } from "@/lib/boss-attachment-access";
|
||||
import { getProjectAttachment, readState } from "@/lib/boss-data";
|
||||
import { queueAttachmentAnalysisTask } from "@/lib/boss-master-agent";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function resolveRequestPublicBaseUrl(request: NextRequest) {
|
||||
const protocol =
|
||||
request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(/:$/, "") ?? "http";
|
||||
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.host;
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string; attachmentId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId, attachmentId } = await context.params;
|
||||
const record = await getProjectAttachment(projectId, attachmentId);
|
||||
if (!record) {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
if (!canSessionAccessAttachmentProject(state, session, record.project)) {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (record.attachment.analysisState !== "ready_manual" && record.attachment.analysisState !== "failed") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
message: "ATTACHMENT_NOT_READY_FOR_MANUAL_ANALYSIS",
|
||||
analysisState: record.attachment.analysisState,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const task = await queueAttachmentAnalysisTask({
|
||||
projectId,
|
||||
attachmentId,
|
||||
requestMessageId: record.message.id,
|
||||
requestedBy: session.displayName || "你",
|
||||
requestedByAccount: session.account,
|
||||
markProcessing: true,
|
||||
publicBaseUrl: resolveRequestPublicBaseUrl(request),
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, taskId: task.taskId, task });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
const status = message === "ATTACHMENT_NOT_FOUND" ? 404 : 500;
|
||||
return NextResponse.json({ ok: false, message }, { status });
|
||||
}
|
||||
}
|
||||
126
src/app/api/v1/projects/[projectId]/attachments/route.ts
Normal file
126
src/app/api/v1/projects/[projectId]/attachments/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { canSessionAccessAttachmentProject } from "@/lib/boss-attachment-access";
|
||||
import {
|
||||
appendAttachmentMessage,
|
||||
getAttachmentStorageConfig,
|
||||
readState,
|
||||
type MessageAttachment,
|
||||
} from "@/lib/boss-data";
|
||||
import { queueAttachmentAnalysisTask } from "@/lib/boss-master-agent";
|
||||
import { detectAttachmentKind, resolveAttachmentAnalysisState } from "@/lib/boss-attachments";
|
||||
import { getAttachmentStorageProvider } from "@/lib/boss-storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function randomToken(prefix: string) {
|
||||
return `${prefix}-${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
function resolveRequestPublicBaseUrl(request: NextRequest) {
|
||||
const protocol =
|
||||
request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(/:$/, "") ?? "http";
|
||||
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.host;
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await context.params;
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) {
|
||||
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
if (!canSessionAccessAttachmentProject(state, session, project)) {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ ok: false, message: "FILE_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
const attachmentId = randomToken("att");
|
||||
const messageId = randomToken("msg");
|
||||
const fileName = file.name || "attachment";
|
||||
const mimeType = file.type || "application/octet-stream";
|
||||
const attachmentKind = detectAttachmentKind(fileName, mimeType);
|
||||
const analysisState = resolveAttachmentAnalysisState(attachmentKind, bytes.byteLength);
|
||||
const storageConfig = await getAttachmentStorageConfig(session.account);
|
||||
const storageProvider = getAttachmentStorageProvider(storageConfig);
|
||||
const stored = await storageProvider.storeAttachment({
|
||||
account: session.account,
|
||||
messageId,
|
||||
attachmentId,
|
||||
fileName,
|
||||
mimeType,
|
||||
buffer: bytes,
|
||||
});
|
||||
|
||||
const attachment: MessageAttachment = {
|
||||
attachmentId,
|
||||
fileName,
|
||||
mimeType,
|
||||
fileSizeBytes: bytes.byteLength,
|
||||
attachmentKind,
|
||||
storageBackend: stored.storageBackend,
|
||||
storagePath: stored.storagePath,
|
||||
storageSnapshot:
|
||||
stored.storageBackend === "aliyun_oss" &&
|
||||
storageConfig.mode === "oss" &&
|
||||
storageConfig.ossProvider === "aliyun_oss" &&
|
||||
storageConfig.aliyunOss
|
||||
? {
|
||||
provider: "aliyun_oss",
|
||||
accessKeyId: storageConfig.aliyunOss.accessKeyId,
|
||||
accessKeySecretEncrypted: storageConfig.aliyunOss.accessKeySecretEncrypted,
|
||||
bucket: storageConfig.aliyunOss.bucket,
|
||||
endpoint: storageConfig.aliyunOss.endpoint,
|
||||
region: storageConfig.aliyunOss.region,
|
||||
prefix: storageConfig.aliyunOss.prefix,
|
||||
}
|
||||
: undefined,
|
||||
previewAvailable: attachmentKind === "image" || attachmentKind === "video" || attachmentKind === "pdf",
|
||||
uploadedAt: new Date().toISOString(),
|
||||
uploadedBy: session.account,
|
||||
analysisState,
|
||||
};
|
||||
|
||||
const message = await appendAttachmentMessage({
|
||||
projectId,
|
||||
sender: "user",
|
||||
senderLabel: session.displayName || "你",
|
||||
attachment,
|
||||
});
|
||||
|
||||
let analysisTask = null;
|
||||
if (attachment.analysisState === "queued_auto") {
|
||||
analysisTask = await queueAttachmentAnalysisTask({
|
||||
projectId,
|
||||
attachmentId,
|
||||
requestMessageId: message.id,
|
||||
requestedBy: session.displayName || "你",
|
||||
requestedByAccount: session.account,
|
||||
publicBaseUrl: resolveRequestPublicBaseUrl(request),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
attachment,
|
||||
message,
|
||||
analysisTask,
|
||||
downloadUrl: `/api/v1/attachments/${attachmentId}/download`,
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,18 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { forwardProjectMessage } from "@/lib/boss-data";
|
||||
|
||||
type ForwardBody =
|
||||
| {
|
||||
mode?: "single";
|
||||
targetProjectId?: string;
|
||||
sourceMessageId?: string;
|
||||
}
|
||||
| {
|
||||
mode?: "bundle";
|
||||
targetProjectId?: string;
|
||||
sourceMessageIds?: string[];
|
||||
};
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
@@ -11,25 +23,60 @@ export async function POST(
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { projectId } = await context.params;
|
||||
const body = (await request.json()) as {
|
||||
targetProjectId?: string;
|
||||
note?: string;
|
||||
};
|
||||
const body = (await request.json()) as ForwardBody;
|
||||
|
||||
if (!body.targetProjectId || !body.note) {
|
||||
const mode = body.mode ?? "single";
|
||||
const targetProjectId = body.targetProjectId;
|
||||
const sourceMessageId: string | undefined =
|
||||
"sourceMessageId" in body ? body.sourceMessageId : undefined;
|
||||
const sourceMessageIds: string[] =
|
||||
"sourceMessageIds" in body && Array.isArray(body.sourceMessageIds)
|
||||
? body.sourceMessageIds
|
||||
: [];
|
||||
|
||||
if (!targetProjectId) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "缺少 targetProjectId 或 note" },
|
||||
{ ok: false, message: "缺少 targetProjectId" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (mode === "bundle") {
|
||||
if (sourceMessageIds.length <= 1) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "bundle 转发至少需要 2 条 sourceMessageIds" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} else if (!sourceMessageId) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "single 转发缺少 sourceMessageId" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const message = await forwardProjectMessage({
|
||||
sourceProjectId: projectId,
|
||||
targetProjectId: body.targetProjectId,
|
||||
note: body.note,
|
||||
const result =
|
||||
mode === "bundle"
|
||||
? await forwardProjectMessage({
|
||||
sourceProjectId: projectId,
|
||||
mode: "bundle",
|
||||
targetProjectId,
|
||||
sourceMessageIds,
|
||||
requestedBy: session.account,
|
||||
})
|
||||
: await forwardProjectMessage({
|
||||
sourceProjectId: projectId,
|
||||
mode: "single",
|
||||
targetProjectId,
|
||||
sourceMessageId: sourceMessageId ?? "",
|
||||
requestedBy: session.account,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: result.message ?? null,
|
||||
approvalRequired: Boolean(result.approvalRequired),
|
||||
approvalReason: result.approvalReason ?? null,
|
||||
});
|
||||
return NextResponse.json({ ok: true, message });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
|
||||
34
src/app/api/v1/projects/[projectId]/group-chat/route.ts
Normal file
34
src/app/api/v1/projects/[projectId]/group-chat/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { createProjectGroupChat } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await context.params;
|
||||
const body = (await request.json()) as {
|
||||
memberProjectIds?: string[];
|
||||
};
|
||||
|
||||
try {
|
||||
const project = await createProjectGroupChat({
|
||||
sourceProjectId: projectId,
|
||||
memberProjectIds: Array.isArray(body.memberProjectIds)
|
||||
? body.memberProjectIds.filter((memberProjectId) => typeof memberProjectId === "string")
|
||||
: [],
|
||||
createdBy: session.account,
|
||||
});
|
||||
return NextResponse.json({ ok: true, project });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { appendProjectMessage } from "@/lib/boss-data";
|
||||
import { appendProjectMessage, readState } from "@/lib/boss-data";
|
||||
import { replyToMasterAgentUserMessage } from "@/lib/boss-master-agent";
|
||||
|
||||
export async function POST(
|
||||
@@ -38,7 +38,24 @@ export async function POST(
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, message, masterReply });
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
const collaborationGate = project
|
||||
? {
|
||||
isGroup: project.isGroup,
|
||||
collaborationMode: project.collaborationMode,
|
||||
requiresMasterAgentApproval:
|
||||
project.isGroup && project.collaborationMode === "approval_required",
|
||||
approvalState: project.approvalState,
|
||||
}
|
||||
: {
|
||||
isGroup: false,
|
||||
collaborationMode: "development" as const,
|
||||
requiresMasterAgentApproval: false,
|
||||
approvalState: "not_required" as const,
|
||||
};
|
||||
|
||||
return NextResponse.json({ ok: true, message, masterReply, collaborationGate });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
|
||||
102
src/app/api/v1/projects/[projectId]/participants/route.ts
Normal file
102
src/app/api/v1/projects/[projectId]/participants/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
type ConversationParticipant = {
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
threadId: string;
|
||||
threadDisplayName: string;
|
||||
folderName: string;
|
||||
avatar: string;
|
||||
isSourceProject: boolean;
|
||||
};
|
||||
|
||||
function getFallbackAvatar(label: string) {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return "A";
|
||||
return trimmed.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function buildParticipant(
|
||||
projectId: string,
|
||||
deviceId: string,
|
||||
threadId: string,
|
||||
threadDisplayName: string,
|
||||
folderName: string,
|
||||
avatar?: string,
|
||||
isSourceProject = false,
|
||||
): ConversationParticipant {
|
||||
return {
|
||||
projectId,
|
||||
deviceId,
|
||||
threadId,
|
||||
threadDisplayName,
|
||||
folderName,
|
||||
avatar: avatar?.trim() || getFallbackAvatar(threadDisplayName),
|
||||
isSourceProject,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await context.params;
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const participants = project.isGroup
|
||||
? (project.groupMembers.length > 0
|
||||
? project.groupMembers.map((member) => {
|
||||
const device = state.devices.find((item) => item.id === member.deviceId);
|
||||
return buildParticipant(
|
||||
member.projectId,
|
||||
member.deviceId,
|
||||
member.threadId,
|
||||
member.threadDisplayName,
|
||||
member.folderName,
|
||||
device?.avatar,
|
||||
member.projectId === project.id,
|
||||
);
|
||||
})
|
||||
: [
|
||||
buildParticipant(
|
||||
project.id,
|
||||
project.deviceIds[0] ?? project.id,
|
||||
project.threadMeta.threadId,
|
||||
project.threadMeta.threadDisplayName,
|
||||
project.threadMeta.folderName,
|
||||
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
|
||||
true,
|
||||
),
|
||||
])
|
||||
: [
|
||||
buildParticipant(
|
||||
project.id,
|
||||
project.deviceIds[0] ?? project.id,
|
||||
project.threadMeta.threadId,
|
||||
project.threadMeta.threadDisplayName,
|
||||
project.threadMeta.folderName,
|
||||
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
projectId: project.id,
|
||||
isGroup: project.isGroup,
|
||||
threadMeta: project.threadMeta,
|
||||
participants,
|
||||
});
|
||||
}
|
||||
45
src/app/api/v1/projects/[projectId]/rename/route.ts
Normal file
45
src/app/api/v1/projects/[projectId]/rename/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { renameGroupChat, renameProjectThread } from "@/lib/boss-data";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await context.params;
|
||||
const body = (await request.json()) as {
|
||||
mode?: "thread" | "group";
|
||||
name?: string;
|
||||
};
|
||||
|
||||
const name = body.name?.trim();
|
||||
if (!name) {
|
||||
return NextResponse.json({ ok: false, message: "EMPTY_NAME" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const project =
|
||||
body.mode === "group"
|
||||
? await renameGroupChat({
|
||||
projectId,
|
||||
name,
|
||||
requestedBy: session.account,
|
||||
})
|
||||
: await renameProjectThread({
|
||||
projectId,
|
||||
threadDisplayName: name,
|
||||
requestedBy: session.account,
|
||||
});
|
||||
return NextResponse.json({ ok: true, project });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/app/api/v1/storage/config/route.ts
Normal file
53
src/app/api/v1/storage/config/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import {
|
||||
getAttachmentStorageConfig,
|
||||
upsertAttachmentStorageConfig,
|
||||
} from "@/lib/boss-data";
|
||||
import {
|
||||
type AttachmentStorageConfigPatch,
|
||||
applyAttachmentStorageConfigPatch,
|
||||
} from "@/lib/boss-storage-config";
|
||||
import {
|
||||
normalizeStorageError,
|
||||
sanitizeAttachmentStorageConfig,
|
||||
} from "@/lib/boss-storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getAttachmentStorageConfig(session.account);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
config: sanitizeAttachmentStorageConfig(config),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json()) as AttachmentStorageConfigPatch;
|
||||
|
||||
try {
|
||||
const existing = await getAttachmentStorageConfig(session.account);
|
||||
const nextConfig = await applyAttachmentStorageConfigPatch(existing, body);
|
||||
const saved = await upsertAttachmentStorageConfig(nextConfig);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
config: sanitizeAttachmentStorageConfig(saved),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: normalizeStorageError(error) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
49
src/app/api/v1/storage/config/validate/route.ts
Normal file
49
src/app/api/v1/storage/config/validate/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import {
|
||||
getAttachmentStorageConfig,
|
||||
upsertAttachmentStorageConfig,
|
||||
} from "@/lib/boss-data";
|
||||
import {
|
||||
type AttachmentStorageConfigPatch,
|
||||
applyAttachmentStorageConfigPatch,
|
||||
} from "@/lib/boss-storage-config";
|
||||
import {
|
||||
normalizeStorageError,
|
||||
sanitizeAttachmentStorageConfig,
|
||||
validateAttachmentStorageConfig,
|
||||
} from "@/lib/boss-storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as AttachmentStorageConfigPatch;
|
||||
|
||||
try {
|
||||
const existing = await getAttachmentStorageConfig(session.account);
|
||||
const draft = await applyAttachmentStorageConfigPatch(existing, body);
|
||||
const result = await validateAttachmentStorageConfig(draft);
|
||||
const saved = await upsertAttachmentStorageConfig({
|
||||
...draft,
|
||||
validatedAt: new Date().toISOString(),
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
provider: result.provider,
|
||||
bucket: result.bucket,
|
||||
endpoint: result.endpoint,
|
||||
region: result.region,
|
||||
config: sanitizeAttachmentStorageConfig(saved),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: normalizeStorageError(error) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ export default async function MePage() {
|
||||
<HeaderTitle title="我的" />
|
||||
<div className="flex flex-col gap-3 px-[18px] pb-5">
|
||||
<ProfileHero user={state.user} />
|
||||
<MenuRow
|
||||
href="/me/storage"
|
||||
title="附件与存储"
|
||||
description="当前附件存储模式、服务器文件存储与阿里 OSS"
|
||||
/>
|
||||
<MenuRow
|
||||
href="/me/security"
|
||||
title="账号与安全"
|
||||
|
||||
28
src/app/me/storage/page.tsx
Normal file
28
src/app/me/storage/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { AttachmentStorageClient } from "@/components/attachment-storage-client";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getAttachmentStorageConfig } from "@/lib/boss-data";
|
||||
import {
|
||||
sanitizeAttachmentStorageConfig,
|
||||
type SanitizedUserAttachmentStorageConfig,
|
||||
} from "@/lib/boss-storage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function getStorageConfigForSession(account: string): Promise<SanitizedUserAttachmentStorageConfig> {
|
||||
const config = await getAttachmentStorageConfig(account);
|
||||
return sanitizeAttachmentStorageConfig(config);
|
||||
}
|
||||
|
||||
export default async function StoragePage() {
|
||||
const session = await requirePageSession();
|
||||
const config = await getStorageConfigForSession(session.account);
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="附件与存储" backHref="/me" />
|
||||
<AttachmentStorageClient key={`${config.mode}:${config.updatedAt}`} config={config} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user