chore: checkpoint Boss app v2.5.11
This commit is contained in:
@@ -172,11 +172,23 @@ public class BossApiClient {
|
||||
}
|
||||
|
||||
public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
|
||||
return requestWithRestoreRaw(
|
||||
"GET",
|
||||
"/api/v1/projects/" + encode(projectId),
|
||||
null,
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
CONVERSATIONS_READ_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse getProjectMessages(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/messages", null);
|
||||
return requestWithRestoreRaw(
|
||||
"GET",
|
||||
"/api/v1/projects/" + encode(projectId) + "/messages",
|
||||
null,
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
CONVERSATIONS_READ_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException {
|
||||
@@ -560,6 +572,14 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/skills", null);
|
||||
}
|
||||
|
||||
public ApiResponse getSkillLifecycleRequests() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/admin/skills/requests", null);
|
||||
}
|
||||
|
||||
public ApiResponse createSkillLifecycleRequest(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/admin/skills/requests", payload == null ? new JSONObject() : payload);
|
||||
}
|
||||
|
||||
public ApiResponse getDeviceEnrollments() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/devices/enrollments", null);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public final class BossMarkdown {
|
||||
private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\s+(.+)$");
|
||||
private static final Pattern ORDERED_PATTERN = Pattern.compile("^(\\d+)\\.\\s+(.+)$");
|
||||
private static final Pattern LABEL_SECTION_PATTERN = Pattern.compile("^([^::\\n]{1,24})[::]\\s*(.+)$");
|
||||
private static final Pattern MARKDOWN_LINK_PATTERN = Pattern.compile("\\[([^\\]\\n]{1,90})\\]\\((https?://[^\\s)]+)\\)");
|
||||
private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");;
|
||||
private static final LruCache<String, CharSequence> RENDER_CACHE = new LruCache<>(180);
|
||||
|
||||
@@ -45,7 +46,7 @@ public final class BossMarkdown {
|
||||
}
|
||||
Palette palette = Palette.resolve(context, outgoing);
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder();
|
||||
String normalized = markdown.replace("\r\n", "\n").replace('\r', '\n');
|
||||
String normalized = normalizeMarkdownLinks(markdown).replace("\r\n", "\n").replace('\r', '\n');
|
||||
String[] lines = normalized.split("\n", -1);
|
||||
boolean inCodeFence = false;
|
||||
List<String> codeLines = new ArrayList<>();
|
||||
@@ -117,6 +118,21 @@ public final class BossMarkdown {
|
||||
return (outgoing ? "out" : "in") + "|" + uiMode + "|" + markdown;
|
||||
}
|
||||
|
||||
private static String normalizeMarkdownLinks(String markdown) {
|
||||
Matcher matcher = MARKDOWN_LINK_PATTERN.matcher(markdown);
|
||||
StringBuffer buffer = new StringBuffer();
|
||||
while (matcher.find()) {
|
||||
String label = matcher.group(1) == null ? "链接" : matcher.group(1).trim();
|
||||
label = label.replace("`", "").trim();
|
||||
if (TextUtils.isEmpty(label)) {
|
||||
label = "链接";
|
||||
}
|
||||
matcher.appendReplacement(buffer, Matcher.quoteReplacement(label));
|
||||
}
|
||||
matcher.appendTail(buffer);
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
private static void appendHeading(SpannableStringBuilder builder, String text, int level, Palette palette) {
|
||||
ensureBlockSeparation(builder, true);
|
||||
int start = builder.length();
|
||||
|
||||
@@ -136,6 +136,7 @@ final class BossRealtimeClient {
|
||||
connection.setRequestProperty("Accept", "text/event-stream");
|
||||
connection.setRequestProperty("Cache-Control", "no-cache");
|
||||
connection.setRequestProperty("x-boss-native-app", "1");
|
||||
connection.setRequestProperty("x-boss-realtime-capabilities", "message-patch-v1");
|
||||
String cookie = apiClient.getSessionCookie();
|
||||
if (!cookie.isEmpty()) {
|
||||
connection.setRequestProperty("Cookie", cookie);
|
||||
|
||||
@@ -1340,6 +1340,7 @@ public final class BossUi {
|
||||
card.addView(titleRow);
|
||||
|
||||
String phase = progress == null ? "" : progress.optString("phase", "").trim();
|
||||
String progressStatus = progress == null ? "" : progress.optString("status", "").trim();
|
||||
if (!TextUtils.isEmpty(phase)) {
|
||||
TextView phaseView = secondaryText(context, "当前状态:" + executionPhaseLabel(phase));
|
||||
phaseView.setPadding(0, dp(context, 8), 0, 0);
|
||||
@@ -1872,9 +1873,14 @@ public final class BossUi {
|
||||
}
|
||||
}
|
||||
|
||||
card.addView(divider(context));
|
||||
card.addView(sectionTitle(context, "分支详情"));
|
||||
JSONObject branch = progress == null ? null : progress.optJSONObject("branch");
|
||||
boolean shouldShowBranchFallback = TextUtils.isEmpty(progressStatus) ||
|
||||
"queued".equals(progressStatus) ||
|
||||
"running".equals(progressStatus);
|
||||
if (branch != null || shouldShowBranchFallback) {
|
||||
card.addView(divider(context));
|
||||
card.addView(sectionTitle(context, "分支详情"));
|
||||
}
|
||||
if (branch != null) {
|
||||
String changeText = formatChangeText(branch);
|
||||
if (!TextUtils.isEmpty(changeText)) {
|
||||
@@ -1888,7 +1894,7 @@ public final class BossUi {
|
||||
} else if ("available".equals(ghStatus)) {
|
||||
card.addView(detailRow(context, "○", "GitHub CLI 可用", "", false));
|
||||
}
|
||||
} else {
|
||||
} else if (shouldShowBranchFallback) {
|
||||
card.addView(detailRow(context, "⌘", "Git 操作", "等待执行器回写", false));
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private boolean pendingReload;
|
||||
private boolean pendingReloadMessagesOnly;
|
||||
private boolean pendingReloadForcedScrollToBottom;
|
||||
private boolean pendingReloadShowRefreshIndicator;
|
||||
private volatile boolean activityDestroyed;
|
||||
private volatile boolean markConversationReadInFlight;
|
||||
private @Nullable AlertDialog activeDialogGuardDialog;
|
||||
@@ -141,7 +142,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
return;
|
||||
}
|
||||
if (!reloadInFlight && !isComposerBusy() && shouldAutoRefreshConversation()) {
|
||||
reload(false);
|
||||
reloadInBackground(false);
|
||||
}
|
||||
armConversationAutoRefresh();
|
||||
}
|
||||
@@ -543,6 +544,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
if (event == null || !"project.messages.updated".equals(event.eventName)) {
|
||||
return false;
|
||||
}
|
||||
JSONObject projectMessagesPatch = event.payload.optJSONObject("projectMessagesPatch");
|
||||
if (projectMessagesPatch != null) {
|
||||
return tryApplyRealtimeMessageWindowPatch(projectMessagesPatch);
|
||||
}
|
||||
JSONObject projectMessagesPayload = event.payload.optJSONObject("projectMessagesPayload");
|
||||
if (projectMessagesPayload == null) {
|
||||
return false;
|
||||
@@ -565,6 +570,167 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean tryApplyRealtimeMessageWindowPatch(JSONObject projectMessagesPatch) {
|
||||
if (projectMessagesPatch == null || !"message_window".equals(projectMessagesPatch.optString("patchKind", ""))) {
|
||||
return false;
|
||||
}
|
||||
if (currentRenderedProjectPayload == null || contentLayout == null) {
|
||||
scheduleRealtimeReload(false);
|
||||
return true;
|
||||
}
|
||||
if (selectionState != null && selectionState.multiSelecting) {
|
||||
scheduleRealtimeReload(false);
|
||||
return true;
|
||||
}
|
||||
JSONObject patchProject = projectMessagesPatch.optJSONObject("project");
|
||||
JSONArray patchMessages = projectMessagesPatch.optJSONArray("messages");
|
||||
if (patchProject == null || patchMessages == null) {
|
||||
scheduleRealtimeReload(false);
|
||||
return true;
|
||||
}
|
||||
JSONObject currentPayload = copyJson(currentRenderedProjectPayload);
|
||||
JSONObject currentProject = currentPayload.optJSONObject("project");
|
||||
if (currentProject == null || !TextUtils.equals(
|
||||
currentProject.optString("id", "").trim(),
|
||||
projectMessagesPatch.optString("projectId", "").trim()
|
||||
)) {
|
||||
scheduleRealtimeReload(false);
|
||||
return true;
|
||||
}
|
||||
JSONArray currentMessages = currentProject.optJSONArray("messages");
|
||||
if (currentMessages == null) {
|
||||
scheduleRealtimeReload(false);
|
||||
return true;
|
||||
}
|
||||
int expectedMessageCount = projectMessagesPatch.optInt("messageCount", currentMessages.length());
|
||||
if (expectedMessageCount < currentMessages.length()) {
|
||||
scheduleRealtimeReload(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
PatchMergeResult mergeResult = mergeMessageWindowPatch(currentMessages, patchMessages, expectedMessageCount);
|
||||
if (!mergeResult.ok) {
|
||||
scheduleRealtimeReload(false);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
copyProjectPatchMetadata(currentProject, patchProject);
|
||||
currentProject.put("messages", mergeResult.messages);
|
||||
JSONArray devices = projectMessagesPatch.optJSONArray("devices");
|
||||
if (devices != null) {
|
||||
currentPayload.put("devices", devices);
|
||||
}
|
||||
} catch (org.json.JSONException ignored) {
|
||||
scheduleRealtimeReload(false);
|
||||
return true;
|
||||
}
|
||||
JSONObject nextPayload = currentPayload;
|
||||
runOnUiThread(() -> {
|
||||
renderNearBottom = isChatNearBottom();
|
||||
renderForcedScrollToBottom = false;
|
||||
renderLoadedProjectSnapshot(new ProjectSnapshot(nextPayload, null, null));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private PatchMergeResult mergeMessageWindowPatch(
|
||||
JSONArray currentMessages,
|
||||
JSONArray patchMessages,
|
||||
int expectedMessageCount
|
||||
) {
|
||||
JSONArray nextMessages = new JSONArray();
|
||||
for (int i = 0; i < currentMessages.length(); i++) {
|
||||
JSONObject message = currentMessages.optJSONObject(i);
|
||||
if (message != null) {
|
||||
nextMessages.put(copyJson(message));
|
||||
}
|
||||
}
|
||||
boolean changed = false;
|
||||
Map<String, Integer> currentIndexById = new HashMap<>();
|
||||
for (int i = 0; i < nextMessages.length(); i++) {
|
||||
JSONObject message = nextMessages.optJSONObject(i);
|
||||
String messageId = message == null ? "" : message.optString("id", "");
|
||||
if (!TextUtils.isEmpty(messageId)) {
|
||||
currentIndexById.put(messageId, i);
|
||||
}
|
||||
}
|
||||
|
||||
String currentLastId = latestMessageId(nextMessages);
|
||||
int currentLastIndexInPatch = -1;
|
||||
for (int i = 0; i < patchMessages.length(); i++) {
|
||||
JSONObject patchMessage = patchMessages.optJSONObject(i);
|
||||
String patchMessageId = patchMessage == null ? "" : patchMessage.optString("id", "");
|
||||
if (TextUtils.isEmpty(patchMessageId)) {
|
||||
continue;
|
||||
}
|
||||
Integer existingIndex = currentIndexById.get(patchMessageId);
|
||||
if (existingIndex != null) {
|
||||
JSONObject existing = nextMessages.optJSONObject(existingIndex);
|
||||
if (existing == null || !TextUtils.equals(existing.toString(), patchMessage.toString())) {
|
||||
try {
|
||||
nextMessages.put(existingIndex, copyJson(patchMessage));
|
||||
changed = true;
|
||||
} catch (org.json.JSONException ignored) {
|
||||
return PatchMergeResult.failed();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (TextUtils.equals(currentLastId, patchMessageId)) {
|
||||
currentLastIndexInPatch = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedMessageCount > nextMessages.length()) {
|
||||
if (currentLastIndexInPatch < 0) {
|
||||
return PatchMergeResult.failed();
|
||||
}
|
||||
for (int i = currentLastIndexInPatch + 1; i < patchMessages.length(); i++) {
|
||||
JSONObject patchMessage = patchMessages.optJSONObject(i);
|
||||
String patchMessageId = patchMessage == null ? "" : patchMessage.optString("id", "");
|
||||
if (TextUtils.isEmpty(patchMessageId) || currentIndexById.containsKey(patchMessageId)) {
|
||||
continue;
|
||||
}
|
||||
nextMessages.put(copyJson(patchMessage));
|
||||
currentIndexById.put(patchMessageId, nextMessages.length() - 1);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (nextMessages.length() != expectedMessageCount) {
|
||||
return PatchMergeResult.failed();
|
||||
}
|
||||
return new PatchMergeResult(true, changed, nextMessages);
|
||||
}
|
||||
|
||||
private String latestMessageId(@Nullable JSONArray messages) {
|
||||
if (messages == null || messages.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
JSONObject message = messages.optJSONObject(messages.length() - 1);
|
||||
return message == null ? "" : message.optString("id", "");
|
||||
}
|
||||
|
||||
private void copyProjectPatchMetadata(JSONObject currentProject, JSONObject patchProject) {
|
||||
putIfPresent(currentProject, patchProject, "name");
|
||||
putIfPresent(currentProject, patchProject, "threadMeta");
|
||||
putIfPresent(currentProject, patchProject, "unreadCount");
|
||||
putIfPresent(currentProject, patchProject, "isGroup");
|
||||
putIfPresent(currentProject, patchProject, "collaborationMode");
|
||||
putIfPresent(currentProject, patchProject, "approvalState");
|
||||
putIfPresent(currentProject, patchProject, "lightDispatchReminderEnabled");
|
||||
putIfPresent(currentProject, patchProject, "lastMessageAt");
|
||||
putIfPresent(currentProject, patchProject, "updatedAt");
|
||||
}
|
||||
|
||||
private void putIfPresent(JSONObject target, JSONObject source, String key) {
|
||||
if (target == null || source == null || !source.has(key)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
target.put(key, source.opt(key));
|
||||
} catch (org.json.JSONException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean trySkipUnchangedRealtimeMessagesPatch(JSONObject projectMessagesPayload) {
|
||||
if (currentRenderedProjectPayload == null || projectMessagesPayload == null) {
|
||||
return false;
|
||||
@@ -939,7 +1105,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
|
||||
void triggerRealtimeReload(boolean requireFullSnapshot) {
|
||||
if (requireFullSnapshot) {
|
||||
reload();
|
||||
reloadInBackground(false);
|
||||
return;
|
||||
}
|
||||
reloadMessagesOnly();
|
||||
@@ -960,14 +1126,22 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private void reload(boolean forcedScrollToBottom) {
|
||||
reloadSnapshot(forcedScrollToBottom, false);
|
||||
reloadSnapshot(forcedScrollToBottom, false, true);
|
||||
}
|
||||
|
||||
private void reloadInBackground(boolean forcedScrollToBottom) {
|
||||
reloadSnapshot(forcedScrollToBottom, false, false);
|
||||
}
|
||||
|
||||
private void reloadMessagesOnly() {
|
||||
reloadSnapshot(false, true);
|
||||
reloadSnapshot(false, true, false);
|
||||
}
|
||||
|
||||
private void reloadSnapshot(boolean forcedScrollToBottom, boolean messagesOnly) {
|
||||
reloadSnapshot(forcedScrollToBottom, messagesOnly, true);
|
||||
}
|
||||
|
||||
private void reloadSnapshot(boolean forcedScrollToBottom, boolean messagesOnly, boolean showRefreshIndicator) {
|
||||
if (shouldSkipAsyncUiWork()) {
|
||||
return;
|
||||
}
|
||||
@@ -983,12 +1157,15 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
pendingReload = true;
|
||||
pendingReloadForcedScrollToBottom = pendingReloadForcedScrollToBottom || forcedScrollToBottom;
|
||||
pendingReloadShowRefreshIndicator = pendingReloadShowRefreshIndicator || showRefreshIndicator;
|
||||
return;
|
||||
}
|
||||
renderNearBottom = isChatNearBottom();
|
||||
renderForcedScrollToBottom = forcedScrollToBottom;
|
||||
reloadInFlight = true;
|
||||
setRefreshing(true);
|
||||
if (showRefreshIndicator) {
|
||||
setRefreshing(true);
|
||||
}
|
||||
try {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
@@ -1004,7 +1181,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
if (messagesOnly) {
|
||||
reloadInFlight = false;
|
||||
setRefreshing(false);
|
||||
reload(forcedScrollToBottom);
|
||||
reloadSnapshot(forcedScrollToBottom, false, showRefreshIndicator);
|
||||
return;
|
||||
}
|
||||
handleProjectReloadFailure(error);
|
||||
@@ -1014,7 +1191,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
});
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
reloadInFlight = false;
|
||||
setRefreshing(false);
|
||||
if (showRefreshIndicator) {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1062,10 +1241,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
boolean forcedScrollToBottom = pendingReloadForcedScrollToBottom;
|
||||
boolean messagesOnly = pendingReloadMessagesOnly;
|
||||
boolean showRefreshIndicator = pendingReloadShowRefreshIndicator;
|
||||
pendingReload = false;
|
||||
pendingReloadMessagesOnly = false;
|
||||
pendingReloadForcedScrollToBottom = false;
|
||||
reloadSnapshot(forcedScrollToBottom, messagesOnly);
|
||||
pendingReloadShowRefreshIndicator = false;
|
||||
reloadSnapshot(forcedScrollToBottom, messagesOnly, showRefreshIndicator);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1271,7 +1452,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
&& !reloadInFlight
|
||||
&& refreshLayout != null
|
||||
&& !isComposerBusy()) {
|
||||
reload();
|
||||
reloadInBackground(false);
|
||||
}
|
||||
updateConversationAutoRefresh();
|
||||
}
|
||||
@@ -4110,4 +4291,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PatchMergeResult {
|
||||
final boolean ok;
|
||||
final boolean changed;
|
||||
final JSONArray messages;
|
||||
|
||||
PatchMergeResult(boolean ok, boolean changed, JSONArray messages) {
|
||||
this.ok = ok;
|
||||
this.changed = changed;
|
||||
this.messages = messages == null ? new JSONArray() : messages;
|
||||
}
|
||||
|
||||
static PatchMergeResult failed() {
|
||||
return new PatchMergeResult(false, false, new JSONArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -58,8 +64,18 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
String targetDeviceId = resolveTargetDeviceId();
|
||||
BossApiClient.ApiResponse response = apiClient.getDeviceSkills(targetDeviceId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
JSONObject lifecyclePayload = null;
|
||||
try {
|
||||
BossApiClient.ApiResponse lifecycleResponse = apiClient.getSkillLifecycleRequests();
|
||||
if (lifecycleResponse.ok()) {
|
||||
lifecyclePayload = lifecycleResponse.json;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
lifecyclePayload = null;
|
||||
}
|
||||
deviceId = targetDeviceId;
|
||||
runOnUiThread(() -> renderSkills(response.json));
|
||||
JSONObject finalLifecyclePayload = lifecyclePayload;
|
||||
runOnUiThread(() -> renderSkills(response.json, finalLifecyclePayload));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
@@ -203,9 +219,14 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private void renderSkills(JSONObject payload) {
|
||||
renderSkills(payload, null);
|
||||
}
|
||||
|
||||
private void renderSkills(JSONObject payload, @Nullable JSONObject lifecyclePayload) {
|
||||
replaceContent();
|
||||
JSONObject device = payload.optJSONObject("device");
|
||||
JSONArray skills = payload.optJSONArray("skills");
|
||||
boolean canManageLifecycle = lifecyclePayload != null;
|
||||
|
||||
if (device != null) {
|
||||
deviceName = device.optString("name", deviceId);
|
||||
@@ -220,6 +241,10 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
));
|
||||
}
|
||||
|
||||
if (canManageLifecycle) {
|
||||
appendSkillManagementWorkspace(lifecyclePayload);
|
||||
}
|
||||
|
||||
if (skills == null || skills.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前设备还没有同步 Skill。"));
|
||||
setRefreshing(false);
|
||||
@@ -244,8 +269,217 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
Button copyPath = BossUi.buildMiniActionButton(this, "复制路径", false);
|
||||
copyPath.setOnClickListener(v -> BossUi.copyText(this, "Skill 路径", skill.optString("path", "")));
|
||||
card.addView(BossUi.buildInlineActionRow(this, copyInvocation, copyPath));
|
||||
if (canManageLifecycle) {
|
||||
Button update = BossUi.buildMiniActionButton(this, "更新下发", true);
|
||||
update.setOnClickListener(v -> queueSkillLifecycleRequest("update", skill, null, null, null, null, null));
|
||||
Button rollback = BossUi.buildMiniActionButton(this, "回滚", false);
|
||||
rollback.setOnClickListener(v -> showVersionedSkillRequestDialog("rollback", skill, "回滚", "rollbackToVersion"));
|
||||
Button versionLock = BossUi.buildMiniActionButton(this, "版本锁定", false);
|
||||
versionLock.setOnClickListener(v -> showVersionedSkillRequestDialog("version_lock", skill, "版本锁定", "lockedVersion"));
|
||||
card.addView(BossUi.buildInlineActionRow(this, update, rollback, versionLock));
|
||||
}
|
||||
appendContent(card);
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void appendSkillManagementWorkspace(JSONObject lifecyclePayload) {
|
||||
JSONArray requests = lifecyclePayload.optJSONArray("requests");
|
||||
int requestCount = requests == null ? 0 : requests.length();
|
||||
int queuedCount = countRequestsByStatus(requests, "queued");
|
||||
int runningCount = countRunningRequests(requests);
|
||||
|
||||
LinearLayout card = new LinearLayout(this);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"Skill 管理分发",
|
||||
"安装、更新、回滚、版本锁定和账号权限分配统一在这里处理。",
|
||||
"Skill 请求状态:待执行 " + queuedCount + " · 执行中 " + runningCount + " · 最近请求 " + requestCount,
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
Button installRemote = BossUi.buildMiniActionButton(this, "安装远端 Skill", true);
|
||||
installRemote.setOnClickListener(v -> showInstallSkillDialog());
|
||||
Button grantPermission = BossUi.buildMiniActionButton(this, "分配权限", false);
|
||||
grantPermission.setOnClickListener(v -> startActivity(new Intent(this, AccessManagementActivity.class)));
|
||||
card.addView(BossUi.buildInlineActionRow(this, installRemote, grantPermission));
|
||||
|
||||
if (requests != null && requests.length() > 0) {
|
||||
int maxRows = Math.min(3, requests.length());
|
||||
for (int index = 0; index < maxRows; index += 1) {
|
||||
JSONObject request = requests.optJSONObject(index);
|
||||
if (request == null) continue;
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"Skill 请求状态",
|
||||
request.optString("action", "-") + " · " + request.optString("status", "-"),
|
||||
request.optString("skillId", request.optString("sourceUrl", "-"))
|
||||
+ " · " + request.optString("requestedAt", "-"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
appendContent(card);
|
||||
}
|
||||
|
||||
private void showInstallSkillDialog() {
|
||||
LinearLayout form = new LinearLayout(this);
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
int padding = BossUi.dp(this, 12);
|
||||
form.setPadding(padding, padding, padding, 0);
|
||||
|
||||
EditText sourceUrl = buildSingleLineInput("Git URL 或可信来源 URL");
|
||||
EditText targetVersion = buildSingleLineInput("目标版本,可选");
|
||||
EditText checksum = buildSingleLineInput("SHA256 校验和,可选");
|
||||
form.addView(sourceUrl);
|
||||
form.addView(targetVersion);
|
||||
form.addView(checksum);
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("安装远端 Skill")
|
||||
.setView(form)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("下发", (dialog, which) -> {
|
||||
String source = sourceUrl.getText().toString().trim();
|
||||
if (TextUtils.isEmpty(source)) {
|
||||
showMessage("请输入 Skill 来源 URL");
|
||||
return;
|
||||
}
|
||||
queueSkillLifecycleRequest(
|
||||
"install",
|
||||
null,
|
||||
source,
|
||||
targetVersion.getText().toString().trim(),
|
||||
checksum.getText().toString().trim(),
|
||||
null,
|
||||
null
|
||||
);
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showVersionedSkillRequestDialog(
|
||||
String action,
|
||||
JSONObject skill,
|
||||
String title,
|
||||
String versionField
|
||||
) {
|
||||
EditText input = buildSingleLineInput("请输入版本号");
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("下发", (dialog, which) -> {
|
||||
String version = input.getText().toString().trim();
|
||||
if (TextUtils.isEmpty(version)) {
|
||||
showMessage("请输入版本号");
|
||||
return;
|
||||
}
|
||||
if ("rollbackToVersion".equals(versionField)) {
|
||||
queueSkillLifecycleRequest(action, skill, null, null, null, version, null);
|
||||
} else {
|
||||
queueSkillLifecycleRequest(action, skill, null, null, null, null, version);
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private EditText buildSingleLineInput(String hint) {
|
||||
EditText input = new EditText(this);
|
||||
input.setHint(hint);
|
||||
input.setSingleLine(true);
|
||||
input.setInputType(InputType.TYPE_CLASS_TEXT);
|
||||
int verticalPadding = BossUi.dp(this, 8);
|
||||
input.setPadding(0, verticalPadding, 0, verticalPadding);
|
||||
return input;
|
||||
}
|
||||
|
||||
private void queueSkillLifecycleRequest(
|
||||
String action,
|
||||
@Nullable JSONObject skill,
|
||||
@Nullable String sourceUrl,
|
||||
@Nullable String targetVersion,
|
||||
@Nullable String checksum,
|
||||
@Nullable String rollbackToVersion,
|
||||
@Nullable String lockedVersion
|
||||
) {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", action);
|
||||
payload.put("deviceId", deviceId == null ? "" : deviceId);
|
||||
if (skill != null) {
|
||||
putIfNotBlank(payload, "skillId", skill.optString("skillId", ""));
|
||||
}
|
||||
putIfNotBlank(payload, "sourceUrl", sourceUrl);
|
||||
putIfNotBlank(payload, "targetVersion", targetVersion);
|
||||
putIfNotBlank(payload, "checksum", checksum);
|
||||
putIfNotBlank(payload, "rollbackToVersion", rollbackToVersion);
|
||||
putIfNotBlank(payload, "lockedVersion", lockedVersion);
|
||||
putIfNotBlank(payload, "note", "boss-app-skill-management");
|
||||
submitSkillLifecycleRequest(payload);
|
||||
} catch (JSONException error) {
|
||||
showMessage("Skill 请求构建失败:" + error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void putIfNotBlank(JSONObject payload, String key, @Nullable String value) throws JSONException {
|
||||
if (!TextUtils.isEmpty(value)) {
|
||||
payload.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private void submitSkillLifecycleRequest(JSONObject payload) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.createSkillLifecycleRequest(payload);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("Skill 请求已下发");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("Skill 请求失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static int countRequestsByStatus(@Nullable JSONArray requests, String status) {
|
||||
if (requests == null) {
|
||||
return 0;
|
||||
}
|
||||
int count = 0;
|
||||
for (int index = 0; index < requests.length(); index += 1) {
|
||||
JSONObject request = requests.optJSONObject(index);
|
||||
if (request != null && status.equalsIgnoreCase(request.optString("status", ""))) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int countRunningRequests(@Nullable JSONArray requests) {
|
||||
if (requests == null) {
|
||||
return 0;
|
||||
}
|
||||
int count = 0;
|
||||
for (int index = 0; index < requests.length(); index += 1) {
|
||||
JSONObject request = requests.optJSONObject(index);
|
||||
if (request == null) continue;
|
||||
String status = request.optString("status", "");
|
||||
if ("claimed".equalsIgnoreCase(status)
|
||||
|| "running".equalsIgnoreCase(status)
|
||||
|| "processing".equalsIgnoreCase(status)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,34 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getProjectDetailUsesExtendedReadTimeoutForChatPages() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectDetail("thread-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/thread-1", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(30000, connection.readTimeoutValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getProjectMessagesUsesExtendedReadTimeoutForRealtimeRefreshes() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectMessages("thread-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/thread-1/messages", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(30000, connection.readTimeoutValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createMasterAgentMemoryWritesStructuredPayload() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/memories"));
|
||||
|
||||
@@ -57,6 +57,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
waitFor(() -> activity.messageReloadCount == 1);
|
||||
assertEquals(0, activity.reloadCount);
|
||||
assertEquals(1, activity.messageReloadCount);
|
||||
}
|
||||
@@ -144,7 +145,8 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
waitFor(() -> activity.loadCallCount == 1);
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
assertEquals(0, activity.messageReloadCount);
|
||||
}
|
||||
|
||||
@@ -172,7 +174,8 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
waitFor(() -> activity.loadCallCount == 1);
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
assertEquals(0, activity.messageReloadCount);
|
||||
}
|
||||
|
||||
@@ -211,6 +214,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
waitFor(() -> activity.messageReloadCount == 1);
|
||||
assertEquals(0, activity.reloadCount);
|
||||
assertEquals(1, activity.messageReloadCount);
|
||||
}
|
||||
@@ -441,7 +445,8 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
waitFor(() -> activity.loadCallCount == 1);
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1532,6 +1532,42 @@ public class ProjectDetailActivityUiTest {
|
||||
assertFalse(viewTreeContainsText(messageView, "Codex"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedExecutionProgressWithoutBranchDoesNotRenderExecutorWaitingFallback() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "juyuwan")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "juyuwan");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "failed-progress-no-branch")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent")
|
||||
.put("body", "执行进度:失败")
|
||||
.put("kind", "execution_progress")
|
||||
.put("sentAt", "2026-06-07T14:24:00+08:00")
|
||||
.put("executionProgress", new JSONObject()
|
||||
.put("title", "进度")
|
||||
.put("controlMode", "codex_thread")
|
||||
.put("status", "failed")
|
||||
.put("phase", "terminal_failed")
|
||||
.put("steps", new JSONArray()
|
||||
.put(new JSONObject().put("text", "等待目标线程回复").put("status", "failed"))));
|
||||
|
||||
View messageView = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"buildMessageView",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
|
||||
);
|
||||
|
||||
assertTrue(viewTreeContainsText(messageView, "当前状态:执行失败"));
|
||||
assertFalse(viewTreeContainsText(messageView, "等待执行器回写"));
|
||||
assertFalse(viewTreeContainsText(messageView, "分支详情"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void completedReplyResponseRendersImmediatelyWithoutReloadingProjectDetail() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
@@ -1603,6 +1639,24 @@ public class ProjectDetailActivityUiTest {
|
||||
assertEquals(0, fakeApiClient.projectDetailCallCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void realtimeMessageReloadDoesNotShowSwipeRefreshSpinner() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "juyuwan")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "juyuwan");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
ReflectionHelpers.setField(activity, "apiClient", new SlowProjectMessagesApiClient());
|
||||
|
||||
activity.triggerRealtimeReload(false);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
SwipeRefreshLayout refreshLayout = activity.findViewById(R.id.screen_refresh_layout);
|
||||
assertFalse(refreshLayout.isRefreshing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentHeaderUsesWechatMoreMenuLabel() {
|
||||
Intent intent = new Intent()
|
||||
@@ -2458,6 +2512,30 @@ public class ProjectDetailActivityUiTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SlowProjectMessagesApiClient extends BossApiClient {
|
||||
SlowProjectMessagesApiClient() {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getProjectMessages(String projectId) throws java.io.IOException, org.json.JSONException {
|
||||
try {
|
||||
Thread.sleep(250L);
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return new ApiResponse(
|
||||
200,
|
||||
new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("project", new JSONObject()
|
||||
.put("id", projectId)
|
||||
.put("name", "juyuwan")
|
||||
.put("messages", new JSONArray()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConversationActionApiClient extends BossApiClient {
|
||||
int markConversationReadCount;
|
||||
String lastMarkedProjectId;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONArray;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
@@ -68,6 +73,61 @@ public class SkillInventoryActivityTest {
|
||||
assertEquals(0, activity.reloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderSkillsShowsManagementDispatchWorkspaceWhenLifecycleRequestsAreAvailable() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, "device-1")
|
||||
.putExtra(SkillInventoryActivity.EXTRA_DEVICE_NAME, "Mac Studio");
|
||||
TestSkillInventoryActivity activity = Robolectric
|
||||
.buildActivity(TestSkillInventoryActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderSkills",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildSkillPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildLifecyclePayload())
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "Skill 管理分发"));
|
||||
assertTrue(viewTreeContainsText(content, "安装远端 Skill"));
|
||||
assertTrue(viewTreeContainsText(content, "分配权限"));
|
||||
assertTrue(viewTreeContainsText(content, "Skill 请求状态"));
|
||||
assertTrue(viewTreeContainsText(content, "更新下发"));
|
||||
assertTrue(viewTreeContainsText(content, "版本锁定"));
|
||||
}
|
||||
|
||||
private static JSONObject buildSkillPayload() throws Exception {
|
||||
return new JSONObject()
|
||||
.put("device", new JSONObject()
|
||||
.put("id", "device-1")
|
||||
.put("name", "Mac Studio"))
|
||||
.put("skills", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("skillId", "device-1:boss-server-debug")
|
||||
.put("name", "boss-server-debug")
|
||||
.put("description", "服务器排障")
|
||||
.put("category", "ops")
|
||||
.put("path", "/Users/kris/.codex/skills/boss-server-debug/SKILL.md")
|
||||
.put("invocation", "使用 boss-server-debug")
|
||||
.put("updatedAt", "2026-06-08T10:00:00+08:00")));
|
||||
}
|
||||
|
||||
private static JSONObject buildLifecyclePayload() throws Exception {
|
||||
return new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("requests", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("requestId", "skill-request-1")
|
||||
.put("action", "update")
|
||||
.put("status", "queued")
|
||||
.put("deviceId", "device-1")
|
||||
.put("skillId", "device-1:boss-server-debug")
|
||||
.put("requestedAt", "2026-06-08T10:01:00+08:00")));
|
||||
}
|
||||
|
||||
public static class TestSkillInventoryActivity extends SkillInventoryActivity {
|
||||
private boolean reloadEnabled;
|
||||
private int reloadCount;
|
||||
@@ -81,4 +141,23 @@ public class SkillInventoryActivityTest {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (text != null && text.toString().contains(expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user