feat: complete chat routing and openai onboarding

This commit is contained in:
kris
2026-03-31 03:31:22 +08:00
parent 5b590f7cc1
commit 9c02ebb574
25 changed files with 2241 additions and 133 deletions

View File

@@ -1,8 +1,10 @@
package com.hyzq.boss;
import android.os.Bundle;
import android.text.InputType;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Spinner;
@@ -22,7 +24,7 @@ public class AiAccountsActivity extends BossScreenActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("AI 账号", "主 GPT / 备用 GPT / API 容灾");
configureScreen("AI 账号", "OpenAI API / Master Codex Node");
setHeaderAction("新增", v -> openAccountEditor(null, null));
replaceContent();
reload();
@@ -53,11 +55,12 @@ public class AiAccountsActivity extends BossScreenActivity {
this,
"AI 账号",
"这里统一管理主 GPT、备用 GPT 与 API 容灾账号。",
"主 GPT 的登录发生在绑定设备上的 Codex / ChatGPT Plus会在这里给登录指引",
"OpenAI API 可以在手机直接登录Master Codex Node 仍然在绑定设备上完成登录",
null,
null
));
appendContent(buildActiveIdentityCard(activeIdentity));
appendContent(buildOnboardingEntrySection());
appendContent(buildAccountsSection(accounts));
setRefreshing(false);
}
@@ -114,6 +117,31 @@ public class AiAccountsActivity extends BossScreenActivity {
return section;
}
private LinearLayout buildOnboardingEntrySection() {
LinearLayout section = new LinearLayout(this);
section.setOrientation(LinearLayout.VERTICAL);
section.addView(BossUi.buildWechatMenuRow(
this,
"登录 OpenAI 平台账号",
"填写 API Key 后直接设为当前主控。",
"适合手机端直连主 Agent。",
"推荐",
v -> openOpenAiOnboardingDialog()
));
section.addView(BossUi.buildWechatMenuRow(
this,
"绑定电脑上的 Codex 节点",
"把这台 Mac 上的 Codex / ChatGPT Plus 节点接回主 Agent。",
"登录发生在绑定设备上。",
null,
v -> openMasterNodeOnboardingDialog()
));
return section;
}
private LinearLayout buildAccountCard(JSONObject account) {
String statusLabel = account.optString("statusLabel", account.optString("status", "-"));
String meta = account.optString("roleLabel", "-")
@@ -181,6 +209,199 @@ public class AiAccountsActivity extends BossScreenActivity {
.show();
}
private void openOpenAiOnboardingDialog() {
final EditText labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false);
labelInput.setText("主 GPT");
final EditText displayNameInput = BossUi.buildInput(this, "显示名称", false);
displayNameInput.setText("OpenAI 平台账号");
final EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false);
final EditText modelInput = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
modelInput.setText("gpt-5.4");
final EditText apiKeyInput = BossUi.buildInput(this, "OpenAI API Key", false);
apiKeyInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
form.addView(BossUi.buildFormCell(this, "标签", "建议使用 主 GPT", labelInput));
form.addView(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表中", displayNameInput));
form.addView(BossUi.buildFormCell(this, "账号标识", "可填邮箱、账号名或自定义备注", accountIdentifierInput));
form.addView(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput));
form.addView(BossUi.buildFormCell(this, "API Key", "填写后会直接登录并设为当前主控", apiKeyInput));
new AlertDialog.Builder(this)
.setTitle("登录 OpenAI 平台账号")
.setMessage("手机端直接输入 OpenAI API Key登录成功后立即设为当前主控。")
.setView(form)
.setNegativeButton("取消", null)
.setPositiveButton("登录", (dialog, which) -> submitOpenAiOnboarding(
labelInput.getText().toString().trim(),
displayNameInput.getText().toString().trim(),
accountIdentifierInput.getText().toString().trim(),
modelInput.getText().toString().trim(),
apiKeyInput.getText().toString().trim()
))
.show();
}
private void openMasterNodeOnboardingDialog() {
final EditText labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false);
labelInput.setText("主 GPT");
final EditText displayNameInput = BossUi.buildInput(this, "显示名称", false);
displayNameInput.setText("绑定电脑上的 Codex 节点");
final EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false);
final EditText nodeIdInput = BossUi.buildInput(this, "节点 ID", false);
final EditText nodeLabelInput = BossUi.buildInput(this, "节点名称", false);
final EditText modelInput = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
modelInput.setText("gpt-5.4");
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
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", "本机 Codex 节点的唯一标识", nodeIdInput));
form.addView(BossUi.buildFormCell(this, "节点名称", "例如 Mac Studio", nodeLabelInput));
form.addView(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput));
new AlertDialog.Builder(this)
.setTitle("绑定电脑上的 Codex 节点")
.setMessage("主 GPT 不在手机里直接登录,请在绑定设备上的 Codex / ChatGPT Plus 会话里登录后再回来校验。")
.setView(form)
.setNegativeButton("取消", null)
.setPositiveButton("绑定", (dialog, which) -> submitMasterNodeOnboarding(
labelInput.getText().toString().trim(),
displayNameInput.getText().toString().trim(),
accountIdentifierInput.getText().toString().trim(),
nodeIdInput.getText().toString().trim(),
nodeLabelInput.getText().toString().trim(),
modelInput.getText().toString().trim()
))
.show();
}
private void submitOpenAiOnboarding(
String label,
String displayName,
String accountIdentifier,
String model,
String apiKey
) {
if (label.isEmpty() || displayName.isEmpty() || apiKey.isEmpty()) {
showMessage("标签、显示名称和 API Key 不能为空");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
JSONObject payload = new JSONObject();
payload.put("label", label);
payload.put("displayName", displayName);
payload.put("accountIdentifier", accountIdentifier);
payload.put("model", model);
payload.put("apiKey", apiKey);
payload.put("enabled", true);
payload.put("setActive", true);
payload.put("provider", "openai_api");
payload.put("role", "primary");
BossApiClient.ApiResponse response = apiClient.onboardOpenAiApiAccount(payload);
if (!response.ok()) throw new IllegalStateException(response.message());
String accountId = extractAccountId(response.json);
if (accountId.isEmpty()) {
runOnUiThread(() -> {
showMessage("OpenAI 平台账号已登录,并设为当前主控。");
reload();
});
return;
}
BossApiClient.ApiResponse validation = apiClient.validateAccount(accountId);
runOnUiThread(() -> {
showMessage(validation.ok()
? validation.message()
: "登录完成,但校验失败:" + validation.message());
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("登录失败:" + error.getMessage());
});
}
});
}
private void submitMasterNodeOnboarding(
String label,
String displayName,
String accountIdentifier,
String nodeId,
String nodeLabel,
String model
) {
if (label.isEmpty() || displayName.isEmpty() || nodeId.isEmpty()) {
showMessage("标签、显示名称和节点 ID 不能为空");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
JSONObject payload = new JSONObject();
payload.put("label", label);
payload.put("displayName", displayName);
payload.put("accountIdentifier", accountIdentifier);
payload.put("nodeId", nodeId);
payload.put("nodeLabel", nodeLabel);
payload.put("model", model);
payload.put("enabled", true);
payload.put("setActive", true);
payload.put("provider", "master_codex_node");
payload.put("role", "primary");
BossApiClient.ApiResponse response = apiClient.onboardMasterNodeAccount(payload);
if (!response.ok()) throw new IllegalStateException(response.message());
String accountId = extractAccountId(response.json);
if (accountId.isEmpty()) {
runOnUiThread(() -> {
showMessage("Master Codex Node 已绑定,并设为当前主控。");
reload();
});
return;
}
BossApiClient.ApiResponse validation = apiClient.validateAccount(accountId);
runOnUiThread(() -> {
showMessage(validation.ok()
? validation.message()
: "绑定完成,但校验失败:" + validation.message());
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("绑定失败:" + error.getMessage());
});
}
});
}
private String extractAccountId(JSONObject json) {
if (json == null) {
return "";
}
String accountId = json.optString("accountId", "");
if (!accountId.isEmpty()) {
return accountId;
}
JSONObject account = json.optJSONObject("account");
if (account != null) {
return account.optString("accountId", "");
}
return "";
}
private void openAccountEditor(@Nullable JSONObject existing, @Nullable String apiKeyHint) {
final android.widget.EditText labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false);
final android.widget.EditText displayNameInput = BossUi.buildInput(this, "显示名称", false);

View File

@@ -29,7 +29,7 @@ import java.util.Map;
public class BossApiClient {
private static final int DEFAULT_CONNECT_TIMEOUT_MS = 12000;
private static final int DEFAULT_READ_TIMEOUT_MS = 12000;
private static final int MASTER_AGENT_READ_TIMEOUT_MS = 65000;
private static final int CHAT_FLOW_READ_TIMEOUT_MS = 65000;
private static final String PREFS_NAME = "boss_native_client";
private static final String KEY_SESSION_COOKIE = "session_cookie";
private static final String KEY_RESTORE_TOKEN = "restore_token";
@@ -103,10 +103,12 @@ public class BossApiClient {
"approvedTargetProjectIds",
approvedTargetProjectIds == null ? new JSONArray() : approvedTargetProjectIds
);
return requestWithRestore(
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/confirm",
payload
payload.toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
CHAT_FLOW_READ_TIMEOUT_MS
);
}
@@ -133,13 +135,12 @@ public class BossApiClient {
JSONObject payload = new JSONObject();
payload.put("body", body);
payload.put("kind", kind);
int readTimeoutMs = "master-agent".equals(projectId) ? MASTER_AGENT_READ_TIMEOUT_MS : DEFAULT_READ_TIMEOUT_MS;
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/messages",
payload.toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
readTimeoutMs
CHAT_FLOW_READ_TIMEOUT_MS
);
}
@@ -312,6 +313,14 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject());
}
public ApiResponse onboardOpenAiApiAccount(JSONObject payload) throws IOException, JSONException {
return onboardAccount("/api/v1/accounts/onboard/openai-api", payload);
}
public ApiResponse onboardMasterNodeAccount(JSONObject payload) throws IOException, JSONException {
return onboardAccount("/api/v1/accounts/onboard/master-node", payload);
}
public ApiResponse getOpsSummary() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/ops/summary", null);
}
@@ -413,6 +422,23 @@ public class BossApiClient {
return response;
}
private ApiResponse onboardAccount(String onboardPath, JSONObject payload) throws IOException, JSONException {
JSONObject normalized = payload == null ? new JSONObject() : new JSONObject(payload.toString());
normalized.put("setActive", true);
ApiResponse response = requestWithRestore("POST", onboardPath, normalized);
if (response.statusCode != 404) {
return response;
}
JSONObject fallbackPayload = new JSONObject(normalized.toString());
String accountId = fallbackPayload.optString("accountId", "");
if (!accountId.isEmpty()) {
return updateAccount(accountId, fallbackPayload);
}
fallbackPayload.remove("accountId");
return createAccount(fallbackPayload);
}
private ApiResponse request(String method, String path, JSONObject body, boolean expectProtected) throws IOException, JSONException {
return requestRaw(
method,

View File

@@ -59,6 +59,16 @@ public final class ProjectChatUiState {
}
}
public static final class ReplyWaitSpec {
public final boolean shouldWait;
public final String baselineMessageId;
private ReplyWaitSpec(boolean shouldWait, @Nullable String baselineMessageId) {
this.shouldWait = shouldWait && !isBlank(baselineMessageId);
this.baselineMessageId = this.shouldWait ? baselineMessageId.trim() : "";
}
}
public static boolean canSend(String text, boolean sending) {
return !sending && text != null && !text.trim().isEmpty();
}
@@ -295,6 +305,55 @@ public final class ProjectChatUiState {
return Math.max(fileSizeBytes, 0L) + " B";
}
public static ReplyWaitSpec resolveReplyWaitAfterSend(@Nullable JSONObject response) {
if (response == null) {
return new ReplyWaitSpec(false, null);
}
JSONObject task = response.optJSONObject("task");
if (task == null) {
return new ReplyWaitSpec(false, null);
}
String taskStatus = task.optString("status", "");
if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) {
return new ReplyWaitSpec(false, null);
}
JSONObject message = response.optJSONObject("message");
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""));
}
public static ReplyWaitSpec resolveReplyWaitAfterDispatchConfirm(@Nullable JSONObject response) {
if (response == null) {
return new ReplyWaitSpec(false, null);
}
JSONArray executions = response.optJSONArray("executions");
if (executions == null || executions.length() == 0) {
return new ReplyWaitSpec(false, null);
}
JSONObject notice = response.optJSONObject("notice");
return new ReplyWaitSpec(true, notice == null ? null : notice.optString("id", ""));
}
public static boolean hasReplyBeyondBaseline(@Nullable JSONObject project, @Nullable String baselineMessageId) {
if (project == null || isBlank(baselineMessageId)) {
return false;
}
String latestMessageId = latestMessageId(project.optJSONArray("messages"));
return !isBlank(latestMessageId) && !baselineMessageId.trim().equals(latestMessageId);
}
@Nullable
public static String latestMessageId(@Nullable JSONArray messages) {
if (messages == null || messages.length() == 0) {
return null;
}
JSONObject latestMessage = messages.optJSONObject(messages.length() - 1);
if (latestMessage == null) {
return null;
}
String messageId = latestMessage.optString("id", "").trim();
return messageId.isEmpty() ? null : messageId;
}
private static boolean isBlank(@Nullable String value) {
return value == null || value.trim().isEmpty();
}

View File

@@ -39,6 +39,8 @@ import java.util.List;
public class ProjectDetailActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private static final long REPLY_WAIT_TIMEOUT_MS = 55_000L;
private static final long REPLY_WAIT_POLL_INTERVAL_MS = 1_500L;
private String projectId;
private String initialProjectName;
@@ -106,6 +108,16 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
}
private static final class ProjectSnapshot {
final JSONObject payload;
final @Nullable JSONArray dispatchPlans;
ProjectSnapshot(JSONObject payload, @Nullable JSONArray dispatchPlans) {
this.payload = payload;
this.dispatchPlans = dispatchPlans;
}
}
@Override
protected int getLayoutResId() {
return R.layout.activity_project_chat;
@@ -215,24 +227,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getProjectDetail(projectId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
JSONObject project = response.json.optJSONObject("project");
JSONArray dispatchPlans = null;
if (project != null && project.optBoolean("isGroup", false)) {
try {
BossApiClient.ApiResponse dispatchPlansResponse = apiClient.getDispatchPlans(projectId);
if (dispatchPlansResponse.ok()) {
dispatchPlans = dispatchPlansResponse.json.optJSONArray("plans");
}
} catch (Exception ignored) {
dispatchPlans = null;
}
}
JSONArray finalDispatchPlans = dispatchPlans;
runOnUiThread(() -> renderProject(response.json, finalDispatchPlans));
ProjectSnapshot snapshot = fetchProjectSnapshot();
runOnUiThread(() -> renderProject(snapshot.payload, snapshot.dispatchPlans));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -461,8 +457,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan");
JSONObject collaborationGate = response.json.optJSONObject("collaborationGate");
ProjectChatUiState.ReplyWaitSpec waitSpec =
ProjectChatUiState.resolveReplyWaitAfterSend(response.json);
runOnUiThread(() -> {
composerSending = false;
composerInput.setText("");
if (collaborationGate != null) {
projectCollaborationMode = collaborationGate.optString("collaborationMode", projectCollaborationMode);
@@ -470,18 +467,25 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
currentPendingDispatchPlan = dispatchPlan;
if (dispatchPlan != null) {
composerSending = false;
updateComposerSendButtonState();
showMessage(
"approval_required".equals(projectCollaborationMode)
? "消息已发送,等待你批准主 Agent 下发。"
: "消息已发送,主 Agent 已给出推荐线程。"
);
} else {
showMessage("消息已发送");
}
reload(true);
if (dispatchPlan != null) {
reload(true);
showDispatchPlanConfirmation(dispatchPlan);
return;
}
if (waitSpec.shouldWait) {
startReplyWait(waitSpec, false, "消息已发送,正在等待回复…");
return;
}
composerSending = false;
updateComposerSendButtonState();
showMessage("消息已发送");
reload(true);
});
} catch (Exception error) {
runOnUiThread(() -> {
@@ -577,7 +581,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
this,
"approval_required".equals(projectCollaborationMode) ? "等待你批准主 Agent 下发" : "主 Agent 推荐下发",
ProjectChatUiState.summarizeDispatchPlan(dispatchPlan),
"当前确认状态:" + projectApprovalState
"当前确认状态:" + describeDispatchPlanApprovalState(projectApprovalState)
));
Button confirmButton = BossUi.buildMiniActionButton(this, "确认下发", true);
confirmButton.setOnClickListener(v -> showDispatchPlanConfirmation(dispatchPlan));
@@ -623,9 +627,18 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
JSONArray executions = response.json.optJSONArray("executions");
int executionCount = executions == null ? approvedTargetProjectIds.size() : executions.length();
ProjectChatUiState.ReplyWaitSpec waitSpec =
ProjectChatUiState.resolveReplyWaitAfterDispatchConfirm(response.json);
runOnUiThread(() -> {
currentPendingDispatchPlan = null;
projectApprovalState = "approval_required".equals(projectCollaborationMode) ? "approved" : "not_required";
if (waitSpec.shouldWait) {
startReplyWait(
waitSpec,
true,
"已确认下发到 " + executionCount + " 个线程,正在等待线程回复…"
);
return;
}
showMessage("已确认下发到 " + executionCount + " 个线程");
reload(true);
});
@@ -1463,6 +1476,111 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
}
private ProjectSnapshot fetchProjectSnapshot() throws Exception {
BossApiClient.ApiResponse response = apiClient.getProjectDetail(projectId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
JSONObject project = response.json.optJSONObject("project");
boolean includeDispatchPlans = project != null && project.optBoolean("isGroup", false);
return fetchProjectSnapshot(includeDispatchPlans, response);
}
private ProjectSnapshot fetchProjectSnapshot(boolean includeDispatchPlans) throws Exception {
return fetchProjectSnapshot(includeDispatchPlans, null);
}
private ProjectSnapshot fetchProjectSnapshot(
boolean includeDispatchPlans,
@Nullable BossApiClient.ApiResponse existingDetailResponse
) throws Exception {
BossApiClient.ApiResponse detailResponse = existingDetailResponse == null
? apiClient.getProjectDetail(projectId)
: existingDetailResponse;
if (!detailResponse.ok()) {
throw new IllegalStateException(detailResponse.message());
}
JSONArray dispatchPlans = null;
if (includeDispatchPlans) {
try {
BossApiClient.ApiResponse dispatchPlansResponse = apiClient.getDispatchPlans(projectId);
if (dispatchPlansResponse.ok()) {
dispatchPlans = dispatchPlansResponse.json.optJSONArray("plans");
}
} catch (Exception ignored) {
dispatchPlans = null;
}
}
return new ProjectSnapshot(detailResponse.json, dispatchPlans);
}
private void startReplyWait(
ProjectChatUiState.ReplyWaitSpec waitSpec,
boolean includeDispatchPlans,
String waitingMessage
) {
composerSending = true;
updateComposerSendButtonState();
setRefreshing(true);
showMessage(waitingMessage);
executor.execute(() -> pollUntilReply(waitSpec, includeDispatchPlans));
}
private void pollUntilReply(
ProjectChatUiState.ReplyWaitSpec waitSpec,
boolean includeDispatchPlans
) {
long deadlineAt = System.currentTimeMillis() + REPLY_WAIT_TIMEOUT_MS;
boolean renderedInitialSnapshot = false;
try {
while (!Thread.currentThread().isInterrupted() && System.currentTimeMillis() < deadlineAt) {
ProjectSnapshot snapshot = fetchProjectSnapshot(includeDispatchPlans);
JSONObject project = snapshot.payload.optJSONObject("project");
boolean hasReply = ProjectChatUiState.hasReplyBeyondBaseline(project, waitSpec.baselineMessageId);
if (!renderedInitialSnapshot || hasReply) {
runOnUiThread(() -> {
renderProject(snapshot.payload, snapshot.dispatchPlans);
if (!hasReply) {
composerSending = true;
updateComposerSendButtonState();
setRefreshing(true);
}
});
renderedInitialSnapshot = true;
}
if (hasReply) {
runOnUiThread(() -> {
composerSending = false;
updateComposerSendButtonState();
setRefreshing(false);
scrollChatToBottom();
});
return;
}
Thread.sleep(REPLY_WAIT_POLL_INTERVAL_MS);
}
runOnUiThread(() -> {
composerSending = false;
updateComposerSendButtonState();
setRefreshing(false);
showMessage("对方还在处理中,稍后下拉刷新查看最新回复。");
reload(false);
});
} catch (Exception error) {
runOnUiThread(() -> {
composerSending = false;
updateComposerSendButtonState();
setRefreshing(false);
showMessage("等待回复失败:" + error.getMessage());
reload(false);
});
}
}
static ChromeBindings buildChromeBindings(
ProjectChatUiState.ChromeState chromeState,
boolean composerBusy
@@ -1481,6 +1599,26 @@ public class ProjectDetailActivity extends BossScreenActivity {
);
}
static String describeDispatchPlanApprovalState(@Nullable String approvalState) {
if (approvalState == null || approvalState.trim().isEmpty()) {
return "状态未知";
}
switch (approvalState) {
case "pending_user":
return "待确认";
case "pending_agent":
return "等待主 Agent 处理";
case "approved":
return "已确认,等待线程回流";
case "rejected":
return "已拒绝";
case "not_required":
return "无需确认";
default:
return approvalState;
}
}
private List<String> collectMessageIds(@Nullable JSONArray messages) {
ArrayList<String> ids = new ArrayList<>();
if (messages == null) {

View File

@@ -49,6 +49,8 @@ public class BossApiClientDispatchPlansTest {
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/confirm", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(65000, connection.readTimeoutValue);
assertEquals("{\"approvedTargetProjectIds\":[\"target-1\",\"target-2\"]}", connection.requestBody());
}
@@ -66,6 +68,71 @@ public class BossApiClientDispatchPlansTest {
assertEquals(65000, connection.readTimeoutValue);
}
@Test
public void sendProjectMessageUsesExtendedReadTimeoutForNormalThread() 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.sendProjectMessage("thread-1", "你好", "text");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1/messages", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(65000, connection.readTimeoutValue);
}
@Test
public void onboardOpenAiApiAccountUsesDedicatedRouteAndSetsActive() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
JSONObject payload = new JSONObject()
.put("label", "主 GPT")
.put("displayName", "OpenAI 平台账号")
.put("accountIdentifier", "sk-test")
.put("model", "gpt-5.4")
.put("apiKey", "sk-test-key");
BossApiClient.ApiResponse response = apiClient.onboardOpenAiApiAccount(payload);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/accounts/onboard/openai-api", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"label\":\"主 GPT\",\"displayName\":\"OpenAI 平台账号\",\"accountIdentifier\":\"sk-test\",\"model\":\"gpt-5.4\",\"apiKey\":\"sk-test-key\",\"setActive\":true}",
connection.requestBody()
);
}
@Test
public void onboardMasterNodeFallsBackToGenericAccountCreationWhenDedicatedRouteMissing() throws Exception {
RecordingConnection dedicated = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/master-node"),
404,
"{\"ok\":false,\"message\":\"NOT_FOUND\"}",
"{\"ok\":false,\"message\":\"NOT_FOUND\"}"
);
RecordingConnection fallback = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts"));
ScriptedBossApiClient apiClient = new ScriptedBossApiClient(dedicated, fallback);
JSONObject payload = new JSONObject()
.put("label", "主 GPT")
.put("displayName", "Mac Studio")
.put("accountIdentifier", "mac-studio")
.put("nodeId", "mac-studio")
.put("nodeLabel", "Mac Studio")
.put("model", "gpt-5.4");
BossApiClient.ApiResponse response = apiClient.onboardMasterNodeAccount(payload);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/accounts", apiClient.lastPath);
assertEquals("POST", fallback.requestMethodValue);
assertEquals(
"{\"label\":\"主 GPT\",\"displayName\":\"Mac Studio\",\"accountIdentifier\":\"mac-studio\",\"nodeId\":\"mac-studio\",\"nodeLabel\":\"Mac Studio\",\"model\":\"gpt-5.4\",\"setActive\":true}",
fallback.requestBody()
);
}
private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
@@ -92,15 +159,58 @@ public class BossApiClientDispatchPlansTest {
}
}
private static final class ScriptedBossApiClient extends BossApiClient {
private final Map<String, RecordingConnection> connections;
private String lastPath = "";
ScriptedBossApiClient(RecordingConnection... connections) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connections = new HashMap<>();
for (RecordingConnection connection : connections) {
this.connections.put(connection.url().getPath(), connection);
}
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
RecordingConnection connection = connections.get(path);
if (connection == null) {
throw new IllegalStateException("Missing scripted connection for " + path);
}
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// no-op for JVM unit test
}
}
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 int connectTimeoutValue = 0;
private int readTimeoutValue = 0;
private final int responseCodeValue;
private final String responseBody;
private final String errorBody;
RecordingConnection(URL url) {
this(url, 200, "{\"ok\":true}", "{\"ok\":false}");
}
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
super(url);
this.responseCodeValue = responseCodeValue;
this.responseBody = responseBody;
this.errorBody = errorBody;
}
@Override
@@ -146,17 +256,29 @@ public class BossApiClientDispatchPlansTest {
@Override
public int getResponseCode() {
return 200;
return responseCodeValue;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
return new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8));
}
@Override
public InputStream getErrorStream() {
if (responseCodeValue < 400) {
return null;
}
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
URL url() {
return getURL();
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {

View File

@@ -194,4 +194,59 @@ public class ProjectChatUiStateTest {
assertEquals(List.of("p2", "p1"), approvedTargetIds);
}
@Test
public void queuedReplyTaskStartsReplyWaitFromRequestMessageId() throws Exception {
JSONObject response = new JSONObject()
.put("message", new JSONObject().put("id", "msg-user-1"))
.put("task", new JSONObject()
.put("taskId", "task-1")
.put("taskType", "conversation_reply")
.put("status", "queued"));
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response);
assertTrue(waitSpec.shouldWait);
assertEquals("msg-user-1", waitSpec.baselineMessageId);
}
@Test
public void completedReplyTaskDoesNotStartReplyWait() throws Exception {
JSONObject response = new JSONObject()
.put("message", new JSONObject().put("id", "msg-user-1"))
.put("task", new JSONObject()
.put("taskId", "task-1")
.put("taskType", "conversation_reply")
.put("status", "completed"));
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response);
assertFalse(waitSpec.shouldWait);
assertEquals("", waitSpec.baselineMessageId);
}
@Test
public void dispatchConfirmWaitsFromNoticeMessageId() throws Exception {
JSONObject response = new JSONObject()
.put("notice", new JSONObject().put("id", "msg-notice-1"))
.put("executions", new JSONArray()
.put(new JSONObject().put("executionId", "exec-1")));
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterDispatchConfirm(response);
assertTrue(waitSpec.shouldWait);
assertEquals("msg-notice-1", waitSpec.baselineMessageId);
}
@Test
public void replyWaitSatisfiedOnlyAfterLatestMessageMovesPastBaseline() throws Exception {
JSONObject project = new JSONObject()
.put("messages", new JSONArray()
.put(new JSONObject().put("id", "msg-user-1"))
.put(new JSONObject().put("id", "msg-thread-1")));
assertTrue(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-user-1"));
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-thread-1"));
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, ""));
}
}

View File

@@ -54,4 +54,13 @@ public class ProjectDetailActivityChromeBindingsTest {
assertEquals("北区试产线回归", bindings.title);
assertEquals("归档确认", bindings.subtitle);
}
@Test
public void describeDispatchPlanApprovalStateUsesUserFacingLabels() {
assertEquals("待确认", ProjectDetailActivity.describeDispatchPlanApprovalState("pending_user"));
assertEquals("等待主 Agent 处理", ProjectDetailActivity.describeDispatchPlanApprovalState("pending_agent"));
assertEquals("已确认,等待线程回流", ProjectDetailActivity.describeDispatchPlanApprovalState("approved"));
assertEquals("已拒绝", ProjectDetailActivity.describeDispatchPlanApprovalState("rejected"));
assertEquals("无需确认", ProjectDetailActivity.describeDispatchPlanApprovalState("not_required"));
}
}