feat: complete chat routing and openai onboarding
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user