fix: restore safe top actions and home group chat entry
This commit is contained in:
@@ -94,8 +94,10 @@ Android APK:
|
||||
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
|
||||
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
|
||||
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
|
||||
- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角仍是 `+添加`
|
||||
- 当前聊天列表已切到“线程 = 会话窗口”的结构:主标题显示线程名,副标题显示所属文件夹名,右下角显示后台活跃数量动态图标;同一文件夹下多个线程会显示成多个独立聊天窗口
|
||||
- 当前会话信息页已经支持按微信最新逻辑改线程名;群聊会作为独立新会话创建,默认自动命名,创建后可在群资料页改名
|
||||
- 原生顶部安全区当前已补齐状态栏 inset 处理,并把首页 / 会话信息 / 群资料 / 发起群聊 / 转发目标等页面的顶部操作区域收回到可点击安全区内
|
||||
- 当前消息转发已经切到微信式链路:长按消息可直接 `转发 / 多选 / 复制 / 删除`,多选后底部只保留 `转发`,统一进入原生会话选择页
|
||||
- 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页
|
||||
- 当前 `设备` 和 `我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的`,`审计对话` 作为置顶会话保留在会话首页
|
||||
|
||||
@@ -92,6 +92,10 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/group-chat", payload == null ? new JSONObject() : payload);
|
||||
}
|
||||
|
||||
public ApiResponse createStandaloneGroupChat(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/group-chats", payload == null ? new JSONObject() : payload);
|
||||
}
|
||||
|
||||
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
@@ -20,6 +21,7 @@ public abstract class BossScreenActivity extends AppCompatActivity {
|
||||
protected Button backButton;
|
||||
protected Button refreshButton;
|
||||
protected Button headerActionButton;
|
||||
protected View topBarView;
|
||||
protected TextView titleView;
|
||||
protected TextView subtitleView;
|
||||
protected SwipeRefreshLayout refreshLayout;
|
||||
@@ -34,11 +36,14 @@ public abstract class BossScreenActivity extends AppCompatActivity {
|
||||
backButton = findViewById(R.id.screen_back_button);
|
||||
refreshButton = findViewById(R.id.screen_refresh_button);
|
||||
headerActionButton = findViewById(R.id.screen_header_action);
|
||||
topBarView = findViewById(R.id.screen_top_bar);
|
||||
titleView = findViewById(R.id.screen_title);
|
||||
subtitleView = findViewById(R.id.screen_subtitle);
|
||||
refreshLayout = findViewById(R.id.screen_refresh_layout);
|
||||
contentLayout = findViewById(R.id.screen_content);
|
||||
|
||||
BossWindowInsets.applyStatusBarInset(topBarView);
|
||||
|
||||
backButton.setOnClickListener(v -> finish());
|
||||
refreshButton.setOnClickListener(v -> reload());
|
||||
refreshLayout.setOnRefreshListener(this::reload);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
public final class BossWindowInsets {
|
||||
private BossWindowInsets() {}
|
||||
|
||||
public static void applyStatusBarInset(View view) {
|
||||
if (view == null) {
|
||||
return;
|
||||
}
|
||||
final int initialLeft = view.getPaddingLeft();
|
||||
final int initialTop = view.getPaddingTop();
|
||||
final int initialRight = view.getPaddingRight();
|
||||
final int initialBottom = view.getPaddingBottom();
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view, (target, insets) -> {
|
||||
Insets statusInsets = insets.getInsets(
|
||||
WindowInsetsCompat.Type.statusBars() | WindowInsetsCompat.Type.displayCutout()
|
||||
);
|
||||
target.setPadding(
|
||||
initialLeft,
|
||||
initialTop + statusInsets.top,
|
||||
initialRight,
|
||||
initialBottom
|
||||
);
|
||||
return insets;
|
||||
});
|
||||
|
||||
if (ViewCompat.isAttachedToWindow(view)) {
|
||||
ViewCompat.requestApplyInsets(view);
|
||||
return;
|
||||
}
|
||||
|
||||
view.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
|
||||
@Override
|
||||
public void onViewAttachedToWindow(View v) {
|
||||
v.removeOnAttachStateChangeListener(this);
|
||||
ViewCompat.requestApplyInsets(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewDetachedFromWindow(View v) {
|
||||
// no-op
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -42,24 +42,26 @@ public class GroupCreateActivity extends BossScreenActivity {
|
||||
super.onCreate(savedInstanceState);
|
||||
sourceProjectId = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_ID);
|
||||
sourceProjectName = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_NAME);
|
||||
configureScreen("发起群聊", sourceProjectName == null ? "从当前会话出发" : sourceProjectName);
|
||||
configureScreen(
|
||||
"发起群聊",
|
||||
hasSourceProject() ? (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());
|
||||
if (!hasSourceProject()) {
|
||||
runOnUiThread(() -> renderCreatePage(null, conversationsResponse.json, true));
|
||||
return;
|
||||
}
|
||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(sourceProjectId);
|
||||
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
|
||||
runOnUiThread(() -> renderCreatePage(participantsResponse.json, conversationsResponse.json, true));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
@@ -78,23 +80,32 @@ public class GroupCreateActivity extends BossScreenActivity {
|
||||
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);
|
||||
if (hasSourceProject()) {
|
||||
sourceProjectName = threadMeta == null
|
||||
? sourceProjectName
|
||||
: threadMeta.optString("threadDisplayName", sourceProjectName == null ? "当前会话" : sourceProjectName);
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"新建独立群聊",
|
||||
"群聊不是升级原会话,而是以当前会话为源,新建一个独立线程。",
|
||||
buildSourceMeta(threadMeta, participants)
|
||||
));
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"新建独立群聊",
|
||||
"群聊不是升级原会话,而是以当前会话为源,新建一个独立线程。",
|
||||
buildSourceMeta(threadMeta, participants)
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
sourceProjectName,
|
||||
buildSourceBody(threadMeta, participants),
|
||||
sourceProjectId + (sourceFolderName.isEmpty() ? "" : " · " + sourceFolderName)
|
||||
));
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
sourceProjectName,
|
||||
buildSourceBody(threadMeta, participants),
|
||||
sourceProjectId + (sourceFolderName.isEmpty() ? "" : " · " + sourceFolderName)
|
||||
));
|
||||
} else {
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"从会话首页发起群聊",
|
||||
"你可以直接把任意线程拉进一个新的独立群聊,原来的单线程会话会保留不变。",
|
||||
"至少选择 2 个线程"
|
||||
));
|
||||
}
|
||||
|
||||
if (rebuildCandidates) {
|
||||
List<JSONObject> selectableConversations = collectSelectableConversationItems(conversationsPayload, sourceProjectId);
|
||||
@@ -119,7 +130,8 @@ public class GroupCreateActivity extends BossScreenActivity {
|
||||
selectedProjectIds.addAll(reconcileSelectedProjectIds(
|
||||
currentSelectedProjectIds,
|
||||
lastCandidateProjectIds,
|
||||
nextCandidateProjectIds
|
||||
nextCandidateProjectIds,
|
||||
hasSourceProject()
|
||||
));
|
||||
lastCandidateProjectIds.clear();
|
||||
lastCandidateProjectIds.addAll(nextCandidateProjectIds);
|
||||
@@ -164,11 +176,14 @@ public class GroupCreateActivity extends BossScreenActivity {
|
||||
if (conversations == null) {
|
||||
return result;
|
||||
}
|
||||
boolean hasSourceProject = sourceProjectId != null && !sourceProjectId.isEmpty();
|
||||
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)) {
|
||||
if (projectId.isEmpty()
|
||||
|| (hasSourceProject && sourceProjectId.equals(projectId))
|
||||
|| item.optBoolean("isGroup", false)) {
|
||||
continue;
|
||||
}
|
||||
result.add(item);
|
||||
@@ -214,7 +229,7 @@ public class GroupCreateActivity extends BossScreenActivity {
|
||||
private void updateCreateButtonState() {
|
||||
if (createButton != null) {
|
||||
boolean refreshing = refreshLayout != null && refreshLayout.isRefreshing();
|
||||
createButton.setEnabled(canCreateGroupChat(refreshing, creatingGroupChat, selectedProjectIds));
|
||||
createButton.setEnabled(canCreateGroupChat(refreshing, creatingGroupChat, selectedProjectIds, hasSourceProject()));
|
||||
createButton.setText(creatingGroupChat ? "创建中..." : "创建群聊");
|
||||
}
|
||||
}
|
||||
@@ -240,7 +255,9 @@ public class GroupCreateActivity extends BossScreenActivity {
|
||||
memberProjectIds.put(projectId);
|
||||
}
|
||||
payload.put("memberProjectIds", memberProjectIds);
|
||||
BossApiClient.ApiResponse response = apiClient.createGroupChat(sourceProjectId, payload);
|
||||
BossApiClient.ApiResponse response = hasSourceProject()
|
||||
? apiClient.createGroupChat(sourceProjectId, payload)
|
||||
: apiClient.createStandaloneGroupChat(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");
|
||||
@@ -274,18 +291,33 @@ public class GroupCreateActivity extends BossScreenActivity {
|
||||
static boolean canCreateGroupChat(
|
||||
boolean refreshing,
|
||||
boolean creatingGroupChat,
|
||||
@Nullable Set<String> selectedProjectIds
|
||||
@Nullable Set<String> selectedProjectIds,
|
||||
boolean hasSourceProject
|
||||
) {
|
||||
return !refreshing
|
||||
&& !creatingGroupChat
|
||||
&& selectedProjectIds != null
|
||||
&& !selectedProjectIds.isEmpty();
|
||||
&& selectedProjectIds.size() >= (hasSourceProject ? 1 : 2);
|
||||
}
|
||||
|
||||
static Set<String> reconcileSelectedProjectIds(
|
||||
@Nullable Set<String> currentSelectedProjectIds,
|
||||
@Nullable Set<String> previousCandidateProjectIds,
|
||||
@Nullable Set<String> nextCandidateProjectIds
|
||||
) {
|
||||
return reconcileSelectedProjectIds(
|
||||
currentSelectedProjectIds,
|
||||
previousCandidateProjectIds,
|
||||
nextCandidateProjectIds,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
static Set<String> reconcileSelectedProjectIds(
|
||||
@Nullable Set<String> currentSelectedProjectIds,
|
||||
@Nullable Set<String> previousCandidateProjectIds,
|
||||
@Nullable Set<String> nextCandidateProjectIds,
|
||||
boolean defaultSelectAll
|
||||
) {
|
||||
Set<String> reconciled = new LinkedHashSet<>();
|
||||
if (nextCandidateProjectIds == null || nextCandidateProjectIds.isEmpty()) {
|
||||
@@ -294,7 +326,9 @@ public class GroupCreateActivity extends BossScreenActivity {
|
||||
if (previousCandidateProjectIds == null
|
||||
|| previousCandidateProjectIds.isEmpty()
|
||||
|| !previousCandidateProjectIds.equals(nextCandidateProjectIds)) {
|
||||
reconciled.addAll(nextCandidateProjectIds);
|
||||
if (defaultSelectAll) {
|
||||
reconciled.addAll(nextCandidateProjectIds);
|
||||
}
|
||||
return reconciled;
|
||||
}
|
||||
if (currentSelectedProjectIds == null || currentSelectedProjectIds.isEmpty()) {
|
||||
@@ -308,6 +342,10 @@ public class GroupCreateActivity extends BossScreenActivity {
|
||||
return reconciled;
|
||||
}
|
||||
|
||||
private boolean hasSourceProject() {
|
||||
return sourceProjectId != null && !sourceProjectId.isEmpty();
|
||||
}
|
||||
|
||||
private String buildSourceMeta(@Nullable JSONObject threadMeta, @Nullable JSONArray participants) {
|
||||
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
int count = participants == null ? 0 : participants.length();
|
||||
|
||||
@@ -32,6 +32,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private View loginPanel;
|
||||
private View contentPanel;
|
||||
private View loginShell;
|
||||
private View mainTopBar;
|
||||
private TextView loginTitle;
|
||||
private TextView loginHint;
|
||||
private Button loginButton;
|
||||
@@ -110,6 +112,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
private void bindViews() {
|
||||
loginPanel = findViewById(R.id.login_panel);
|
||||
contentPanel = findViewById(R.id.content_panel);
|
||||
loginShell = findViewById(R.id.login_shell);
|
||||
mainTopBar = findViewById(R.id.main_top_bar);
|
||||
loginTitle = findViewById(R.id.login_title);
|
||||
loginHint = findViewById(R.id.login_hint);
|
||||
loginButton = findViewById(R.id.login_button);
|
||||
@@ -131,6 +135,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
loginTitle.setText(WechatSurfaceMapper.loginTitle());
|
||||
loginHint.setText(WechatSurfaceMapper.loginHintText());
|
||||
loginButton.setText(WechatSurfaceMapper.loginButtonLabel());
|
||||
BossWindowInsets.applyStatusBarInset(loginShell);
|
||||
BossWindowInsets.applyStatusBarInset(mainTopBar);
|
||||
}
|
||||
|
||||
private void bindActions() {
|
||||
@@ -368,18 +374,18 @@ public class MainActivity extends AppCompatActivity {
|
||||
switch (activeTab) {
|
||||
case "devices":
|
||||
updateHeader("设备", "这里管理已接入设备与账号状态。");
|
||||
configureTopAction("+添加", true);
|
||||
configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false));
|
||||
renderDevicesRoot();
|
||||
break;
|
||||
case "me":
|
||||
updateHeader("我的", "");
|
||||
configureTopAction("刷新", false);
|
||||
configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false));
|
||||
renderMeRoot();
|
||||
break;
|
||||
case "conversations":
|
||||
default:
|
||||
updateHeader("会话", WechatSurfaceMapper.conversationsHeaderSubtitle());
|
||||
configureTopAction("刷新", false);
|
||||
configureTopAction(WechatSurfaceMapper.rootTopAction(activeTab, false));
|
||||
renderConversationsRoot();
|
||||
break;
|
||||
}
|
||||
@@ -402,27 +408,28 @@ public class MainActivity extends AppCompatActivity {
|
||||
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 configureTopAction(WechatSurfaceMapper.RootTopAction action) {
|
||||
refreshButton.setText(action.label);
|
||||
refreshButton.setBackgroundResource(action.primaryStyle ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button);
|
||||
refreshButton.setTextColor(getColor(action.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);
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing);
|
||||
configureTopAction(action);
|
||||
refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing);
|
||||
}
|
||||
|
||||
private void handleTopAction() {
|
||||
if ("devices".equals(activeTab)) {
|
||||
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false).actionKey;
|
||||
if ("add_device".equals(actionKey)) {
|
||||
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
|
||||
return;
|
||||
}
|
||||
if ("create_group_chat".equals(actionKey)) {
|
||||
startActivity(new Intent(this, GroupCreateActivity.class));
|
||||
return;
|
||||
}
|
||||
refreshCurrentTab();
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,16 @@ public final class WechatSurfaceMapper {
|
||||
return "cancel_on_detach";
|
||||
}
|
||||
|
||||
public static RootTopAction rootTopAction(String activeTab, boolean refreshing) {
|
||||
if ("devices".equals(activeTab)) {
|
||||
return new RootTopAction("+添加", true, "add_device");
|
||||
}
|
||||
if ("conversations".equals(activeTab)) {
|
||||
return new RootTopAction("+", true, "create_group_chat");
|
||||
}
|
||||
return new RootTopAction(refreshing ? "同步中" : "刷新", false, "refresh");
|
||||
}
|
||||
|
||||
public static <T> T resolveRefreshValue(T cachedValue, T freshValue, boolean requestSucceeded) {
|
||||
if (requestSucceeded) {
|
||||
return freshValue;
|
||||
@@ -170,6 +180,18 @@ public final class WechatSurfaceMapper {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
public static final class RootTopAction {
|
||||
public final String label;
|
||||
public final boolean primaryStyle;
|
||||
public final String actionKey;
|
||||
|
||||
RootTopAction(String label, boolean primaryStyle, String actionKey) {
|
||||
this.label = label;
|
||||
this.primaryStyle = primaryStyle;
|
||||
this.actionKey = actionKey;
|
||||
}
|
||||
}
|
||||
|
||||
private static String buildSubtitle(JSONObject source) {
|
||||
String status = localizeDeviceStatus(resolveDeviceStatusKey(source));
|
||||
String account = source.optString("account", "");
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
@@ -13,7 +14,7 @@
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingRight="28dp"
|
||||
android:paddingBottom="14dp">
|
||||
|
||||
<Button
|
||||
@@ -75,6 +76,7 @@
|
||||
android:id="@+id/screen_refresh_button"
|
||||
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"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
@@ -13,7 +14,7 @@
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingRight="28dp"
|
||||
android:paddingBottom="14dp">
|
||||
|
||||
<Button
|
||||
@@ -75,6 +76,7 @@
|
||||
android:id="@+id/screen_refresh_button"
|
||||
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"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
@@ -13,7 +14,7 @@
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingRight="28dp"
|
||||
android:paddingBottom="14dp">
|
||||
|
||||
<Button
|
||||
@@ -75,6 +76,7 @@
|
||||
android:id="@+id/screen_refresh_button"
|
||||
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"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
@@ -13,7 +14,7 @@
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingRight="28dp"
|
||||
android:paddingBottom="14dp">
|
||||
|
||||
<Button
|
||||
@@ -75,6 +76,7 @@
|
||||
android:id="@+id/screen_refresh_button"
|
||||
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"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/login_shell"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
@@ -82,6 +83,7 @@
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/main_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
@@ -89,7 +91,7 @@
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingRight="32dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<Button
|
||||
@@ -138,6 +140,7 @@
|
||||
android:id="@+id/refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="12dp"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
@@ -13,7 +14,7 @@
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingRight="28dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<Button
|
||||
@@ -75,6 +76,7 @@
|
||||
android:id="@+id/screen_refresh_button"
|
||||
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"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
@@ -13,7 +14,7 @@
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingRight="28dp"
|
||||
android:paddingBottom="14dp">
|
||||
|
||||
<Button
|
||||
@@ -75,6 +76,7 @@
|
||||
android:id="@+id/screen_refresh_button"
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.RuntimeEnvironment;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossWindowInsetsTest {
|
||||
@Test
|
||||
public void applyStatusBarInset_addsInsetOnTopOfInitialPadding() {
|
||||
View view = new View(RuntimeEnvironment.getApplication());
|
||||
view.setPadding(12, 16, 18, 20);
|
||||
|
||||
BossWindowInsets.applyStatusBarInset(view);
|
||||
|
||||
WindowInsetsCompat insets = new WindowInsetsCompat.Builder()
|
||||
.setInsets(WindowInsetsCompat.Type.statusBars(), Insets.of(0, 30, 0, 0))
|
||||
.build();
|
||||
|
||||
WindowInsetsCompat applied = androidx.core.view.ViewCompat.dispatchApplyWindowInsets(view, insets);
|
||||
|
||||
assertEquals(12, view.getPaddingLeft());
|
||||
assertEquals(46, view.getPaddingTop());
|
||||
assertEquals(18, view.getPaddingRight());
|
||||
assertEquals(20, view.getPaddingBottom());
|
||||
assertEquals(insets, applied);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,30 @@ public class GroupCreateActivityTest {
|
||||
assertEquals("thread-1", filtered.get(0).optString("projectId", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void collectSelectableConversationItems_keepsAllThreadsWhenSourceConversationIsMissing() {
|
||||
JSONObject threadConversation = new StubJSONObject()
|
||||
.withString("projectId", "thread-1")
|
||||
.withString("projectTitle", "线程一")
|
||||
.withBoolean("isGroup", false);
|
||||
JSONObject secondThreadConversation = new StubJSONObject()
|
||||
.withString("projectId", "thread-2")
|
||||
.withString("projectTitle", "线程二")
|
||||
.withBoolean("isGroup", false);
|
||||
JSONObject groupConversation = new StubJSONObject()
|
||||
.withString("projectId", "group-1")
|
||||
.withString("projectTitle", "已有群聊")
|
||||
.withBoolean("isGroup", true);
|
||||
JSONObject conversationsPayload = new StubJSONObject()
|
||||
.withObjectArray("conversations", threadConversation, secondThreadConversation, groupConversation);
|
||||
|
||||
java.util.List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, null);
|
||||
|
||||
assertEquals(2, filtered.size());
|
||||
assertEquals("thread-1", filtered.get(0).optString("projectId", ""));
|
||||
assertEquals("thread-2", filtered.get(1).optString("projectId", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reconcileSelectedProjectIds_keepsManualDeselectionWhenCandidatesStayTheSame() {
|
||||
Set<String> previousCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
|
||||
@@ -57,10 +81,16 @@ public class GroupCreateActivityTest {
|
||||
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()));
|
||||
assertFalse(GroupCreateActivity.canCreateGroupChat(true, false, selectedProjectIds, true));
|
||||
assertFalse(GroupCreateActivity.canCreateGroupChat(false, true, selectedProjectIds, true));
|
||||
assertTrue(GroupCreateActivity.canCreateGroupChat(false, false, selectedProjectIds, true));
|
||||
assertFalse(GroupCreateActivity.canCreateGroupChat(false, false, linkedSet(), true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void canCreateGroupChat_requiresTwoSelectionsWhenCreatedFromConversationList() {
|
||||
assertFalse(GroupCreateActivity.canCreateGroupChat(false, false, linkedSet("thread-1"), false));
|
||||
assertTrue(GroupCreateActivity.canCreateGroupChat(false, false, linkedSet("thread-1", "thread-2"), false));
|
||||
}
|
||||
|
||||
private static Set<String> linkedSet(String... values) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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 WechatSurfaceMapperTopActionTest {
|
||||
@Test
|
||||
public void rootTopAction_usesPlusForConversations() {
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("conversations", false);
|
||||
|
||||
assertEquals("+", action.label);
|
||||
assertTrue(action.primaryStyle);
|
||||
assertEquals("create_group_chat", action.actionKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootTopAction_keepsAddDeviceOnDevicesTab() {
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("devices", false);
|
||||
|
||||
assertEquals("+添加", action.label);
|
||||
assertTrue(action.primaryStyle);
|
||||
assertEquals("add_device", action.actionKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootTopAction_keepsRefreshOnMeTab() {
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("me", true);
|
||||
|
||||
assertEquals("同步中", action.label);
|
||||
assertFalse(action.primaryStyle);
|
||||
assertEquals("refresh", action.actionKey);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@
|
||||
- `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/BossWindowInsets.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`:原生运维 / 审计中心
|
||||
@@ -84,6 +85,7 @@
|
||||
- `POST /api/v1/storage/config/validate` 正常,已验证可校验并保存阿里 OSS 私有桶配置
|
||||
- `POST /api/v1/projects/[projectId]/attachments` 正常,已支持图片 / 视频 / 文件上传与附件消息写入
|
||||
- `POST /api/v1/projects/[projectId]/attachments/[attachmentId]/analyze` 正常,已支持手动触发主 Agent 附件分析
|
||||
- `POST /api/v1/group-chats` 正常,已支持从会话首页直接发起独立群聊
|
||||
- `GET /api/v1/attachments/[attachmentId]/download` 正常,已支持会话鉴权下载和 task token 下载
|
||||
- `POST /api/auth/login` 正常,会写入 `boss_session`
|
||||
- `boss_session` 当前默认保持 30 天
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
- 第二行显示所属文件夹名
|
||||
- 右下角显示后台活跃数量动态图标
|
||||
- `主 Agent / 审计对话` 已作为普通置顶会话固定在顶部
|
||||
- 会话首页右上角当前为微信式 `+` 入口,可直接从会话列表发起独立群聊
|
||||
- 当前 `关于` 页:
|
||||
- 保留版本与 OTA 操作
|
||||
- 当前已补上 OTA 下载进度、失败重试、安装授权提示和返回关于页后的本地状态恢复
|
||||
@@ -337,6 +338,17 @@
|
||||
- 新群聊默认自动命名
|
||||
- 新群聊默认由主 Agent 发起,并以开发任务协作为默认模式
|
||||
|
||||
#### `POST /api/v1/group-chats`
|
||||
|
||||
- 用途:从会话首页直接发起独立群聊,不依赖某个来源单线程会话
|
||||
- 输入:
|
||||
- `memberProjectIds[]`
|
||||
- 当前行为:
|
||||
- 至少要求 2 个线程
|
||||
- 新群聊会作为独立会话插入列表
|
||||
- 群名会按成员线程自动生成
|
||||
- 创建完成后仍可在群资料页改名
|
||||
|
||||
#### `GET /api/v1/accounts`
|
||||
|
||||
- 用途:返回 AI 账号列表、当前主控身份和切换历史
|
||||
|
||||
@@ -90,8 +90,10 @@ cd /Users/kris/code/boss
|
||||
- 根布局当前还会挂载原生运行时桥:维护 APP 内导航历史、拦截 Android 返回键、防止根页直接退回桌面,并在 OTA / 同签名覆盖安装后自动尝试恢复登录态
|
||||
- UI 外壳已收口为真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部一级导航固定在视口底部,背景图按手机 viewport 全屏 cover,WebView 不再显示外层圆角矩形预览壳
|
||||
- 原生 Android 当前也和这套产品方向对齐:`会话 / 设备 / 我的` 为固定底部 tab,一级面维持微信式简单列表和聊天优先;`主 Agent / 审计对话` 以普通置顶会话样式固定在会话首页顶部
|
||||
- 会话首页右上角当前已改成微信式 `+` 入口:直接从会话列表发起独立群聊;设备页右上角仍保留 `+添加`
|
||||
- 会话列表当前已切到“线程 = 聊天窗口”:主标题显示线程名,第二行显示所属文件夹名,第三行显示最后一条消息预览,右下角显示后台活跃数量动态图标;同一文件夹下多个线程会渲染成多个独立聊天窗口
|
||||
- 项目详情页右上角当前会进入微信式会话信息页:单线程会话支持改名和发起群聊,群聊会进入群资料页并支持改群名
|
||||
- 原生顶部安全区当前已统一补上状态栏 inset:首页、项目详情、会话信息、群资料、发起群聊和转发目标页的顶部按钮都已退回真机可点击区域
|
||||
- 项目详情页当前已补齐微信式消息转发:长按消息会弹出 `转发 / 多选 / 复制 / 删除 / 取消`;单条消息直接进入统一会话选择页,多选消息会进入合并转发链路
|
||||
- 原生转发目标页当前统一由 `ForwardTargetActivity` 承接;一次只允许选择一个目标会话,目标可为单线程会话、群聊、`主 Agent` 或 `审计对话`
|
||||
- 当前单条消息转发会在目标会话中显示为普通转发消息,并保留 `forwardSource`;多条消息会落成 `forward_bundle` 聊天记录卡片,并保留来源会话、时间范围和摘要条目
|
||||
@@ -127,6 +129,7 @@ cd /Users/kris/code/boss
|
||||
- `2.5.0` 已补齐聊天附件主链:原生聊天框左侧 `+` 已改成底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本会自动排队给主 Agent 分析,视频 / Office / 大文件改成手动触发
|
||||
- `2.5.0` 已上线 `我的 > 附件与存储`:默认使用服务器文件存储,用户可切到阿里 OSS 私有桶并填写最小配置;下载链会使用附件上传时固化的 OSS 快照,避免后续改配置后旧附件失效
|
||||
- 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net`
|
||||
- `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理
|
||||
|
||||
## 2. 服务器状态
|
||||
|
||||
|
||||
29
src/app/api/v1/group-chats/route.ts
Normal file
29
src/app/api/v1/group-chats/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { createIndependentGroupChat } from "@/lib/boss-data";
|
||||
|
||||
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()) as {
|
||||
memberProjectIds?: string[];
|
||||
};
|
||||
|
||||
try {
|
||||
const project = await createIndependentGroupChat({
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4411,84 +4411,129 @@ export async function createProjectGroupChat(input: {
|
||||
const project = await mutateState((state) => {
|
||||
const source = state.projects.find((item) => item.id === input.sourceProjectId);
|
||||
if (!source) throw new Error("GROUP_CHAT_SOURCE_NOT_FOUND");
|
||||
|
||||
const requestedProjectIds = [input.sourceProjectId, ...input.memberProjectIds];
|
||||
const memberProjects: Project[] = [];
|
||||
const seenProjectIds = new Set<string>();
|
||||
for (const projectId of requestedProjectIds) {
|
||||
if (seenProjectIds.has(projectId)) {
|
||||
continue;
|
||||
}
|
||||
seenProjectIds.add(projectId);
|
||||
|
||||
const memberProject = state.projects.find((item) => item.id === projectId);
|
||||
if (!memberProject) {
|
||||
throw new Error("GROUP_CHAT_MEMBER_NOT_FOUND");
|
||||
}
|
||||
memberProjects.push(memberProject);
|
||||
}
|
||||
if (memberProjects.length < 2) {
|
||||
throw new Error("GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS");
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
const projectId = randomToken("project");
|
||||
const threadId = randomToken("thread");
|
||||
const threadDisplayName = source.threadMeta.threadDisplayName ?? source.name;
|
||||
const folderName = source.threadMeta.folderName ?? (source.isGroup ? "群聊" : source.name);
|
||||
const groupMembers = memberProjects.map((memberProject) => ({
|
||||
projectId: memberProject.id,
|
||||
deviceId: memberProject.deviceIds[0] ?? memberProject.id,
|
||||
threadId: memberProject.threadMeta.threadId,
|
||||
threadDisplayName: memberProject.threadMeta.threadDisplayName,
|
||||
folderName: memberProject.threadMeta.folderName,
|
||||
}));
|
||||
const nextProject = normalizeProject({
|
||||
id: projectId,
|
||||
name: threadDisplayName,
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: dedupeStrings(groupMembers.map((member) => member.deviceId)),
|
||||
preview: `已创建群聊《${threadDisplayName}》`,
|
||||
updatedAt: now,
|
||||
lastMessageAt: now,
|
||||
isGroup: true,
|
||||
unreadCount: 0,
|
||||
riskLevel: source.riskLevel,
|
||||
threadMeta: {
|
||||
projectId,
|
||||
threadId,
|
||||
threadDisplayName,
|
||||
folderName,
|
||||
activityIconCount: Math.max(1, memberProjects.length),
|
||||
updatedAt: now,
|
||||
},
|
||||
groupMembers,
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
messages: [
|
||||
{
|
||||
id: randomToken("msg"),
|
||||
sender: "master",
|
||||
senderLabel: input.createdBy || "群聊创建",
|
||||
body: `已由 ${input.createdBy || "系统"} 创建群聊《${threadDisplayName}》。`,
|
||||
sentAt: now,
|
||||
kind: "text",
|
||||
},
|
||||
],
|
||||
goals: [],
|
||||
versions: [],
|
||||
return createGroupChatFromProjectIds(state, {
|
||||
requestedProjectIds: [input.sourceProjectId, ...input.memberProjectIds],
|
||||
createdBy: input.createdBy,
|
||||
defaultRiskLevel: source.riskLevel,
|
||||
});
|
||||
|
||||
state.projects.unshift(nextProject);
|
||||
return nextProject;
|
||||
});
|
||||
publishBossEvent("project.messages.updated", { projectId: project.id });
|
||||
publishBossEvent("conversation.updated", { projectId: project.id });
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function createIndependentGroupChat(input: {
|
||||
memberProjectIds: string[];
|
||||
createdBy: string;
|
||||
}) {
|
||||
const project = await mutateState((state) =>
|
||||
createGroupChatFromProjectIds(state, {
|
||||
requestedProjectIds: input.memberProjectIds,
|
||||
createdBy: input.createdBy,
|
||||
}),
|
||||
);
|
||||
publishBossEvent("project.messages.updated", { projectId: project.id });
|
||||
publishBossEvent("conversation.updated", { projectId: project.id });
|
||||
return project;
|
||||
}
|
||||
|
||||
function createGroupChatFromProjectIds(
|
||||
state: BossState,
|
||||
input: {
|
||||
requestedProjectIds: string[];
|
||||
createdBy: string;
|
||||
defaultRiskLevel?: Project["riskLevel"];
|
||||
},
|
||||
) {
|
||||
const memberProjects: Project[] = [];
|
||||
const seenProjectIds = new Set<string>();
|
||||
for (const projectId of input.requestedProjectIds) {
|
||||
if (!projectId || seenProjectIds.has(projectId)) {
|
||||
continue;
|
||||
}
|
||||
seenProjectIds.add(projectId);
|
||||
|
||||
const memberProject = state.projects.find((item) => item.id === projectId);
|
||||
if (!memberProject) {
|
||||
throw new Error("GROUP_CHAT_MEMBER_NOT_FOUND");
|
||||
}
|
||||
memberProjects.push(memberProject);
|
||||
}
|
||||
if (memberProjects.length < 2) {
|
||||
throw new Error("GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS");
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
const projectId = randomToken("project");
|
||||
const threadId = randomToken("thread");
|
||||
const threadDisplayName = buildAutoGroupChatName(memberProjects);
|
||||
const folderName = "群聊";
|
||||
const groupMembers = memberProjects.map((memberProject) => ({
|
||||
projectId: memberProject.id,
|
||||
deviceId: memberProject.deviceIds[0] ?? memberProject.id,
|
||||
threadId: memberProject.threadMeta.threadId,
|
||||
threadDisplayName: memberProject.threadMeta.threadDisplayName,
|
||||
folderName: memberProject.threadMeta.folderName,
|
||||
}));
|
||||
const seedProject = memberProjects[0];
|
||||
const nextProject = normalizeProject({
|
||||
id: projectId,
|
||||
name: threadDisplayName,
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: dedupeStrings(groupMembers.map((member) => member.deviceId)),
|
||||
preview: `已创建群聊《${threadDisplayName}》`,
|
||||
updatedAt: now,
|
||||
lastMessageAt: now,
|
||||
isGroup: true,
|
||||
unreadCount: 0,
|
||||
riskLevel: input.defaultRiskLevel ?? seedProject?.riskLevel ?? "normal",
|
||||
threadMeta: {
|
||||
projectId,
|
||||
threadId,
|
||||
threadDisplayName,
|
||||
folderName,
|
||||
activityIconCount: Math.max(1, memberProjects.length),
|
||||
updatedAt: now,
|
||||
},
|
||||
groupMembers,
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
messages: [
|
||||
{
|
||||
id: randomToken("msg"),
|
||||
sender: "master",
|
||||
senderLabel: input.createdBy || "群聊创建",
|
||||
body: `已由 ${input.createdBy || "系统"} 创建群聊《${threadDisplayName}》。`,
|
||||
sentAt: now,
|
||||
kind: "text",
|
||||
},
|
||||
],
|
||||
goals: [],
|
||||
versions: [],
|
||||
});
|
||||
|
||||
state.projects.unshift(nextProject);
|
||||
return nextProject;
|
||||
}
|
||||
|
||||
function buildAutoGroupChatName(memberProjects: Project[]) {
|
||||
const titles = memberProjects
|
||||
.map((project) => project.threadMeta.threadDisplayName || project.name)
|
||||
.filter((title) => typeof title === "string" && title.trim().length > 0);
|
||||
if (titles.length === 0) {
|
||||
return "新群聊";
|
||||
}
|
||||
if (titles.length === 1) {
|
||||
return titles[0];
|
||||
}
|
||||
if (titles.length === 2) {
|
||||
return `${titles[0]}、${titles[1]}`;
|
||||
}
|
||||
return `${titles[0]}、${titles[1]}等${titles.length}个线程`;
|
||||
}
|
||||
|
||||
export async function appendProjectMessage(payload: {
|
||||
projectId: string;
|
||||
sender?: MessageSender;
|
||||
|
||||
Reference in New Issue
Block a user