fix: restore master agent relay guidance
This commit is contained in:
@@ -43,6 +43,7 @@
|
||||
- `GET http://127.0.0.1:3000/api/v1/conversations` 正常
|
||||
- `GET http://127.0.0.1:3000/api/v1/projects/master-agent` 正常,主 Agent 项目页已能显示最近 APP 日志
|
||||
- `GET http://127.0.0.1:3000/api/v1/accounts` 正常,已返回主 GPT / 备用 GPT / API 容灾账号摘要
|
||||
- `POST http://127.0.0.1:3000/api/v1/accounts/master-codex-primary/validate` 正常,已验证会明确提示“主 GPT 不在手机里直接登录”,并校验绑定设备在线状态
|
||||
- `GET http://127.0.0.1:3000/api/v1/devices/mac-studio/skills` 正常,已返回本机同步 Skill 列表
|
||||
- `POST http://127.0.0.1:3000/api/auth/login` 正常,会写入 `boss_session` Cookie
|
||||
- `GET http://127.0.0.1:3000/api/auth/session` 正常
|
||||
@@ -279,6 +280,8 @@ npm run aab:release
|
||||
- `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新
|
||||
- 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill,并支持一键复制调用语句
|
||||
- 我的页新增 `AI 账号` 入口,`/me/ai-accounts` 会展示主 GPT / 备用 GPT / API 容灾,并明确主链路优先走已登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
- `AI 账号` 页面当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth;主 GPT 的登录动作必须在绑定电脑上的 Codex / ChatGPT Plus 会话里完成,再回手机端点“测试连接 / 校验连接”
|
||||
- `POST /api/v1/accounts/[accountId]/validate` 当前不再只看 `nodeId`;对 `master_codex_node` 会同时校验绑定设备是否在线,并在设备离线时返回明确的降级说明
|
||||
- API 容灾当前不走服务器预置 Key,而是由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号
|
||||
- 设备页当前只展示已接入生产链路的设备,历史演示脏数据已经从正式设备视图、运维视图和审计视图中剔除
|
||||
- 本机 `local-agent` 现在会直接从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 动态发现真实 Codex 线程,并在 heartbeat 里上报 `projectCandidates`
|
||||
@@ -314,3 +317,4 @@ npm run aab:release
|
||||
- 图片 / PDF / 文本默认自动进入主 Agent 附件分析;视频 / Office / 大文件默认手动触发
|
||||
- 当前采用“极轻云 + 本地设备端”的路线,云端只承载 Web、轻 API 和状态文件
|
||||
- 服务器侧主 Agent 对话能否返回真实大模型回复,依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;服务器本身不直接持有主 GPT 会话
|
||||
- 原生 Android 当前对 `master-agent` 聊天消息已单独放宽读超时到 `65s`;不会再因为默认 `12s` 超时把“主 Agent 无响应”误判成对话失败
|
||||
|
||||
@@ -53,7 +53,7 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
this,
|
||||
"AI 账号",
|
||||
"这里统一管理主 GPT、备用 GPT 与 API 容灾账号。",
|
||||
"轻点条目可编辑,按钮可切换、校验或删除。",
|
||||
"主 GPT 的登录发生在绑定设备上的 Codex / ChatGPT Plus,会在这里给登录指引。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
@@ -143,16 +143,44 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
activate.setEnabled(!account.optBoolean("isActive"));
|
||||
activate.setOnClickListener(v -> activateAccount(account));
|
||||
|
||||
Button loginGuide = null;
|
||||
if ("master_codex_node".equals(account.optString("provider"))) {
|
||||
loginGuide = BossUi.buildMiniActionButton(this, "登录指引", false);
|
||||
loginGuide.setOnClickListener(v -> showMasterNodeLoginGuide(account));
|
||||
}
|
||||
|
||||
Button validate = BossUi.buildMiniActionButton(this, "校验连接", false);
|
||||
validate.setOnClickListener(v -> validateAccount(account));
|
||||
|
||||
Button delete = BossUi.buildMiniActionButton(this, "删除账号", false);
|
||||
delete.setOnClickListener(v -> confirmDeleteAccount(account));
|
||||
card.addView(BossUi.buildInlineActionRow(this, activate, validate, delete));
|
||||
card.addView(loginGuide == null
|
||||
? BossUi.buildInlineActionRow(this, activate, validate, delete)
|
||||
: BossUi.buildInlineActionRow(this, activate, loginGuide, validate, delete));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private void showMasterNodeLoginGuide(JSONObject account) {
|
||||
String nodeLabel = account.optString("nodeLabel");
|
||||
if (nodeLabel == null || nodeLabel.trim().isEmpty()) {
|
||||
nodeLabel = account.optString("nodeId");
|
||||
}
|
||||
if (nodeLabel == null || nodeLabel.trim().isEmpty()) {
|
||||
nodeLabel = "绑定设备";
|
||||
}
|
||||
|
||||
String message = "主 GPT 不在手机里直接登录。\n\n"
|
||||
+ "请到绑定设备 " + nodeLabel + " 上打开 Codex / ChatGPT Plus 会话完成登录。\n"
|
||||
+ "登录完成后,回到这里点“校验连接”,确认主 Agent relay 已经接通。";
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("主 GPT 登录指引")
|
||||
.setMessage(message)
|
||||
.setPositiveButton("知道了", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -313,7 +341,7 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
BossApiClient.ApiResponse response = apiClient.validateAccount(account.optString("accountId"));
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("账号校验成功");
|
||||
showMessage(response.message());
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
|
||||
@@ -27,6 +27,9 @@ import java.util.List;
|
||||
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 String PREFS_NAME = "boss_native_client";
|
||||
private static final String KEY_SESSION_COOKIE = "session_cookie";
|
||||
private static final String KEY_RESTORE_TOKEN = "restore_token";
|
||||
@@ -130,7 +133,14 @@ public class BossApiClient {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("body", body);
|
||||
payload.put("kind", kind);
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/messages", payload);
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse uploadAttachment(
|
||||
@@ -157,7 +167,7 @@ public class BossApiClient {
|
||||
String sourceType
|
||||
) throws IOException, JSONException {
|
||||
HttpURLConnection connection = openConnection("/api/v1/projects/" + encode(projectId) + "/attachments");
|
||||
prepareConnection(connection, "POST");
|
||||
prepareConnection(connection, "POST", DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
|
||||
connection.setDoOutput(true);
|
||||
|
||||
String boundary = "BossBoundary" + System.currentTimeMillis();
|
||||
@@ -373,27 +383,61 @@ public class BossApiClient {
|
||||
}
|
||||
|
||||
private ApiResponse requestWithRestore(String method, String path, JSONObject body) throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(method, path, body == null ? null : body.toString());
|
||||
return requestWithRestoreRaw(
|
||||
method,
|
||||
path,
|
||||
body == null ? null : body.toString(),
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
DEFAULT_READ_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
private ApiResponse requestWithRestoreRaw(String method, String path, @Nullable String body) throws IOException, JSONException {
|
||||
ApiResponse response = requestRaw(method, path, body, true);
|
||||
return requestWithRestoreRaw(method, path, body, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private ApiResponse requestWithRestoreRaw(
|
||||
String method,
|
||||
String path,
|
||||
@Nullable String body,
|
||||
int connectTimeoutMs,
|
||||
int readTimeoutMs
|
||||
) throws IOException, JSONException {
|
||||
ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
|
||||
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
|
||||
ApiResponse restored = restoreSession();
|
||||
if (restored.ok()) {
|
||||
return requestRaw(method, path, body, true);
|
||||
return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private ApiResponse request(String method, String path, JSONObject body, boolean expectProtected) throws IOException, JSONException {
|
||||
return requestRaw(method, path, body == null ? null : body.toString(), expectProtected);
|
||||
return requestRaw(
|
||||
method,
|
||||
path,
|
||||
body == null ? null : body.toString(),
|
||||
expectProtected,
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
DEFAULT_READ_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
private ApiResponse requestRaw(String method, String path, @Nullable String body, boolean expectProtected) throws IOException, JSONException {
|
||||
return requestRaw(method, path, body, expectProtected, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private ApiResponse requestRaw(
|
||||
String method,
|
||||
String path,
|
||||
@Nullable String body,
|
||||
boolean expectProtected,
|
||||
int connectTimeoutMs,
|
||||
int readTimeoutMs
|
||||
) throws IOException, JSONException {
|
||||
HttpURLConnection connection = openConnection(path);
|
||||
prepareConnection(connection, method);
|
||||
prepareConnection(connection, method, connectTimeoutMs, readTimeoutMs);
|
||||
|
||||
if (body != null) {
|
||||
connection.setDoOutput(true);
|
||||
@@ -411,10 +455,15 @@ public class BossApiClient {
|
||||
return (HttpURLConnection) new URL(baseUrl + path).openConnection();
|
||||
}
|
||||
|
||||
private void prepareConnection(HttpURLConnection connection, String method) throws IOException {
|
||||
private void prepareConnection(
|
||||
HttpURLConnection connection,
|
||||
String method,
|
||||
int connectTimeoutMs,
|
||||
int readTimeoutMs
|
||||
) throws IOException {
|
||||
connection.setRequestMethod(method);
|
||||
connection.setConnectTimeout(12000);
|
||||
connection.setReadTimeout(12000);
|
||||
connection.setConnectTimeout(connectTimeoutMs);
|
||||
connection.setReadTimeout(readTimeoutMs);
|
||||
connection.setUseCaches(false);
|
||||
connection.setDoInput(true);
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
@@ -500,7 +549,7 @@ public class BossApiClient {
|
||||
boolean expectProtected
|
||||
) throws IOException {
|
||||
HttpURLConnection connection = openConnection("/api/v1/attachments/" + encode(attachmentId) + "/download");
|
||||
prepareConnection(connection, "GET");
|
||||
prepareConnection(connection, "GET", DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
|
||||
int statusCode = connection.getResponseCode();
|
||||
captureSessionCookie(connection.getHeaderFields());
|
||||
|
||||
|
||||
@@ -52,6 +52,20 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals("{\"approvedTargetProjectIds\":[\"target-1\",\"target-2\"]}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sendProjectMessageUsesExtendedReadTimeoutForMasterAgent() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/messages"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.sendProjectMessage("master-agent", "你好", "text");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/messages", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(65000, connection.readTimeoutValue);
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
@@ -82,6 +96,8 @@ public class BossApiClientDispatchPlansTest {
|
||||
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;
|
||||
|
||||
RecordingConnection(URL url) {
|
||||
super(url);
|
||||
@@ -108,6 +124,16 @@ public class BossApiClientDispatchPlansTest {
|
||||
requestHeaders.put(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setConnectTimeout(int timeout) {
|
||||
connectTimeoutValue = timeout;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadTimeout(int timeout) {
|
||||
readTimeoutValue = timeout;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestProperty(String key) {
|
||||
return requestHeaders.get(key);
|
||||
|
||||
@@ -430,10 +430,11 @@
|
||||
|
||||
- 用途:校验指定 AI 账号是否可用
|
||||
- 当前行为:
|
||||
- `master_codex_node`:校验是否配置节点 ID,并提示由 local-agent relay 执行
|
||||
- `master_codex_node`:明确提示“主 GPT 不在手机里直接登录”,并同时校验绑定设备是否在线;在线时返回 `ready`,离线时返回 `degraded`
|
||||
- `openai_api`:实际调用模型返回探针结果
|
||||
- 当前约束:
|
||||
- `openai_api` 的容灾 Key 由用户在 APP 内配置,不走服务器默认预置
|
||||
- 手机端当前没有直接的 ChatGPT OAuth 登录流程;主 GPT 必须先在绑定电脑上的 Codex / ChatGPT Plus 会话里完成登录
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/forwards`
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- 会话聚合接口:`http://127.0.0.1:3000/api/v1/conversations`
|
||||
- 主 Agent 项目详情:`http://127.0.0.1:3000/api/v1/projects/master-agent`
|
||||
- AI 账号摘要接口:`http://127.0.0.1:3000/api/v1/accounts`
|
||||
- AI 账号校验接口:`POST http://127.0.0.1:3000/api/v1/accounts/[accountId]/validate`
|
||||
- 设备 Skill 同步接口:`http://127.0.0.1:3000/api/v1/devices/mac-studio/skills`
|
||||
- 登录接口:`POST http://127.0.0.1:3000/api/auth/login`
|
||||
- 登录态接口:`GET http://127.0.0.1:3000/api/auth/session`
|
||||
@@ -108,6 +109,8 @@ cd /Users/kris/code/boss
|
||||
- 原生会话页当前的刷新失败策略已改成按当前 tab 独立判错:`会话` 不会再因为 `设备 / OTA / 设置` 的旁路请求失败而整体提示“刷新失败”
|
||||
- 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新
|
||||
- 我的页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` 六个一级入口;`AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
- `AI 账号` 页当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth;`主 GPT` 需要先在绑定电脑上的 Codex / ChatGPT Plus 会话里登录,再回手机端点“测试连接 / 校验连接”
|
||||
- `POST /api/v1/accounts/[accountId]/validate` 当前对 `master_codex_node` 不再只看 `nodeId`,还会同时校验绑定设备是否在线;设备离线时返回 `degraded` 和清晰的人类可读提示
|
||||
- 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本`
|
||||
- 主 Agent 同步等待窗口当前为 55 秒;若本机 Codex 节点回复更慢,项目页仍会通过 SSE 在任务完成后自动刷新出真实回复
|
||||
- `GET /api/v1/app-logs` 当前已支持登录态分页查询
|
||||
@@ -147,6 +150,7 @@ cd /Users/kris/code/boss
|
||||
- `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理
|
||||
- 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列
|
||||
- 当前设备导入决议已经会先落 `device_import_resolution` master task 再写回结果,但决议内容仍是服务端 heuristic 版;下一阶段可再升级成真正通过 `local-agent -> codex exec` 参与理解的主 Agent 决议
|
||||
- 原生 Android 当前对 `master-agent` 聊天消息已单独放宽读超时到 `65s`;之前默认 `12s` 会把等待 `Master Codex Node / local-agent` 回写的长请求误判成“主 Agent 无响应”
|
||||
|
||||
## 2. 服务器状态
|
||||
|
||||
|
||||
@@ -57,6 +57,22 @@ function emptyDraft(): AccountDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMasterNodeLoginGuide(account: {
|
||||
nodeLabel?: string;
|
||||
nodeId?: string;
|
||||
statusLabel?: string;
|
||||
}) {
|
||||
const nodeLabel = account.nodeLabel?.trim() || account.nodeId?.trim() || "绑定设备";
|
||||
return [
|
||||
`主 GPT 不在手机里直接登录。`,
|
||||
`请到绑定设备 ${nodeLabel} 上打开 Codex / ChatGPT Plus 会话完成登录。`,
|
||||
`登录完成后,回到这里点“测试连接”确认 relay 和主 Agent 已接通。`,
|
||||
account.statusLabel ? `当前状态:${account.statusLabel}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function draftFromAccount(account: AiAccountSummary): AccountDraft {
|
||||
return {
|
||||
label: account.label,
|
||||
@@ -142,6 +158,7 @@ export function AiAccountsClient({
|
||||
const [newDraft, setNewDraft] = useState<AccountDraft>(emptyDraft());
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [guideAccountId, setGuideAccountId] = useState<string | null>(null);
|
||||
|
||||
const accountDrafts = useMemo(
|
||||
() =>
|
||||
@@ -437,6 +454,19 @@ export function AiAccountsClient({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{account.provider === "master_codex_node" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setGuideAccountId((current) =>
|
||||
current === account.accountId ? null : account.accountId,
|
||||
)
|
||||
}
|
||||
className="rounded-full border border-[#D9D9D9] px-3 py-2 text-[12px] font-semibold text-[#57606A]"
|
||||
>
|
||||
{guideAccountId === account.accountId ? "收起登录指引" : "登录指引"}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void validateAccount(account.accountId)}
|
||||
@@ -472,6 +502,12 @@ export function AiAccountsClient({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{account.provider === "master_codex_node" && guideAccountId === account.accountId ? (
|
||||
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A] whitespace-pre-line">
|
||||
{buildMasterNodeLoginGuide(account)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -753,13 +753,55 @@ export async function validateAiAccountConnection(accountId: string) {
|
||||
}
|
||||
|
||||
if (account.provider === "master_codex_node") {
|
||||
const state = await readState();
|
||||
const nodeId = account.nodeId?.trim() || state.user.boundDeviceId || "";
|
||||
const boundDevice = state.devices.find((device) => device.id === nodeId);
|
||||
const boundNodeLabel =
|
||||
account.nodeLabel?.trim() ||
|
||||
boundDevice?.name ||
|
||||
state.user.boundCodexNodeLabel ||
|
||||
state.user.boundDeviceId ||
|
||||
"绑定设备";
|
||||
|
||||
if (!nodeId) {
|
||||
await updateAiAccountHealth({
|
||||
accountId: account.accountId,
|
||||
status: "needs_login",
|
||||
lastError: "MASTER_CODEX_NODE_NOT_CONFIGURED",
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
});
|
||||
return {
|
||||
ok: false as const,
|
||||
status: "needs_login" as const,
|
||||
message: `主 GPT 不在手机里直接登录。请先在绑定设备(例如 ${boundNodeLabel})上的 Codex / ChatGPT Plus 会话里登录,并填写正确的节点 ID,再回来校验连接。`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!boundDevice || boundDevice.status !== "online") {
|
||||
await updateAiAccountHealth({
|
||||
accountId: account.accountId,
|
||||
status: "degraded",
|
||||
lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE",
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
});
|
||||
return {
|
||||
ok: false as const,
|
||||
status: "degraded" as const,
|
||||
message: `主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过该节点对话。请先在这台设备上登录 Codex / ChatGPT Plus,并确保 local-agent 在线。`,
|
||||
};
|
||||
}
|
||||
|
||||
await updateAiAccountHealth({
|
||||
accountId: account.accountId,
|
||||
status: "ready",
|
||||
lastError: undefined,
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
lastUsedAt: boundDevice.lastSeenAt || new Date().toISOString(),
|
||||
});
|
||||
return {
|
||||
ok: Boolean(account.nodeId?.trim()) as boolean,
|
||||
status: account.nodeId?.trim() ? "ready" : "needs_login",
|
||||
message:
|
||||
account.nodeId?.trim()
|
||||
? "Master Codex Node 已配置。主 Agent 会通过 local-agent relay 把任务转交给该节点上的 Codex。"
|
||||
: "请先填写 Master Codex Node 的节点 ID,再让 local-agent 认领主 Agent 任务。",
|
||||
ok: true as const,
|
||||
status: "ready" as const,
|
||||
message: `主 GPT 不在手机里直接登录。当前已通过绑定设备 ${boundNodeLabel} 接好 Master Codex Node,主 Agent 会把任务转交给这台设备上的 Codex / ChatGPT Plus 会话。`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -811,6 +853,27 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
if (runtime.account.provider === "master_codex_node") {
|
||||
const state = await readState();
|
||||
const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio";
|
||||
const boundDevice = state.devices.find((device) => device.id === deviceId);
|
||||
const boundNodeLabel =
|
||||
runtime.account.nodeLabel?.trim() ||
|
||||
boundDevice?.name ||
|
||||
state.user.boundCodexNodeLabel ||
|
||||
deviceId;
|
||||
|
||||
if (!boundDevice || boundDevice.status !== "online") {
|
||||
await updateAiAccountHealth({
|
||||
accountId: runtime.account.accountId,
|
||||
status: "degraded",
|
||||
lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE",
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
});
|
||||
await appendMasterAgentSystemReply(
|
||||
`主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus,并确保 local-agent 在线后再重试。`,
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
);
|
||||
return { ok: false as const, reason: "MASTER_NODE_OFFLINE" };
|
||||
}
|
||||
|
||||
const task = await queueMasterAgentTask({
|
||||
requestMessageId: params.requestMessageId ?? "master-agent-manual",
|
||||
requestText: params.requestText,
|
||||
@@ -845,7 +908,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
|
||||
await appendMasterAgentSystemReply(
|
||||
[
|
||||
`当前主控身份是 ${runtime.summary.roleLabel},任务已经转交到 ${runtime.account.nodeLabel ?? deviceId} 的 Master Codex Node。`,
|
||||
`当前主控身份是 ${runtime.summary.roleLabel},任务已经转交到 ${boundNodeLabel} 的 Master Codex Node。`,
|
||||
"如果本机 Codex 节点在线,回复会在稍后自动回写到当前会话。",
|
||||
].join(""),
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
|
||||
79
tests/ai-account-validation.test.ts
Normal file
79
tests/ai-account-validation.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let validateAiAccountConnection: (typeof import("../src/lib/boss-master-agent"))["validateAiAccountConnection"];
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let deviceHeartbeatRoute: (typeof import("../src/app/api/device-heartbeat/route"))["POST"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-ai-account-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [masterAgent, data, heartbeatModule] = await Promise.all([
|
||||
import("../src/lib/boss-master-agent.ts"),
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/app/api/device-heartbeat/route.ts"),
|
||||
]);
|
||||
|
||||
validateAiAccountConnection = masterAgent.validateAiAccountConnection;
|
||||
readState = data.readState;
|
||||
deviceHeartbeatRoute = heartbeatModule.POST;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("validateAiAccountConnection explains master node login happens on the bound device", async () => {
|
||||
await setup();
|
||||
|
||||
const result = await validateAiAccountConnection("master-codex-primary");
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.status, "ready");
|
||||
assert.match(result.message, /不在手机里直接登录/);
|
||||
assert.match(result.message, /Mac Studio|本机 Codex/);
|
||||
});
|
||||
|
||||
test("validateAiAccountConnection reports degraded when the bound master node device is offline", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
const device = state.devices.find((item) => item.id === "mac-studio");
|
||||
assert.ok(device, "expected default mac-studio seed device");
|
||||
|
||||
const heartbeatResponse = await deviceHeartbeatRoute(
|
||||
new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
deviceId: device!.id,
|
||||
token: device!.token,
|
||||
name: device!.name,
|
||||
avatar: device!.avatar,
|
||||
account: device!.account,
|
||||
status: "offline",
|
||||
quota5h: device!.quota5h,
|
||||
quota7d: device!.quota7d,
|
||||
projects: device!.projects,
|
||||
endpoint: device!.endpoint,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(heartbeatResponse.status, 200);
|
||||
|
||||
const result = await validateAiAccountConnection("master-codex-primary");
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, "degraded");
|
||||
assert.match(result.message, /当前不在线|不在线/);
|
||||
});
|
||||
Reference in New Issue
Block a user