feat: add browser-assisted openai onboarding flow
This commit is contained in:
@@ -110,6 +110,7 @@ Android APK:
|
||||
- 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝`,刷新后也能恢复最近一条待确认推荐
|
||||
- 当前三条聊天主链都已接入真实等待链路:`主 Agent 单聊 / 普通线程单聊 / 群聊确认下发` 当前都会返回任务信息,原生 Android 会保持等待直到收到真实回写或明确超时提示
|
||||
- 当前 `我的 > AI 账号` 已补 `登录 OpenAI 平台账号` 与 `绑定 Master Codex Node` 两条显式入口;OpenAI API 登录成功后会立即设为当前主控
|
||||
- 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:会先进入原生引导页,再自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入
|
||||
- 当前主控若还是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,避免聊天直接掉成失败日志
|
||||
- 当前群资料页已经支持“修复群成员”:如果历史脏群里混入了 `master-agent` 或失效线程引用,前台会明确提示并允许重新选择真实线程成员,修复后会正式写回群成员账本
|
||||
- 当前原生聊天页也会直接提示“修复群成员”:当群里存在失效线程或不可下发成员时,`ProjectDetailActivity` 会在消息流上方直接给出 `去修复` 入口,并跳到群资料页完成修复
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<activity android:name=".SecurityActivity" android:exported="false" />
|
||||
<activity android:name=".SettingsActivity" android:exported="false" />
|
||||
<activity android:name=".AiAccountsActivity" android:exported="false" />
|
||||
<activity android:name=".OpenAiOnboardingActivity" android:exported="false" />
|
||||
<activity android:name=".OpsCenterActivity" android:exported="false" />
|
||||
<activity android:name=".AboutActivity" android:exported="false" />
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.content.Intent;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
@@ -20,6 +21,7 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
private static final String[] ROLE_LABELS = {"主 GPT", "备用 GPT", "API 容灾"};
|
||||
private static final String[] PROVIDER_VALUES = {"master_codex_node", "openai_api"};
|
||||
private static final String[] PROVIDER_LABELS = {"Master Codex Node", "OpenAI API"};
|
||||
private boolean refreshOnResume;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
@@ -30,6 +32,15 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
if (refreshOnResume) {
|
||||
refreshOnResume = false;
|
||||
reload();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
@@ -124,10 +135,10 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
section.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"登录 OpenAI 平台账号",
|
||||
"填写 API Key 后直接设为当前主控。",
|
||||
"适合手机端直连主 Agent。",
|
||||
"先打开 OpenAI 登录页,再回 APP 完成接入。",
|
||||
"成功后会立即设为当前主控。",
|
||||
"推荐",
|
||||
v -> openOpenAiOnboardingDialog()
|
||||
v -> openOpenAiOnboardingScreen()
|
||||
));
|
||||
|
||||
section.addView(BossUi.buildWechatMenuRow(
|
||||
@@ -142,6 +153,13 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
return section;
|
||||
}
|
||||
|
||||
private void openOpenAiOnboardingScreen() {
|
||||
refreshOnResume = true;
|
||||
Intent intent = new Intent(this, OpenAiOnboardingActivity.class);
|
||||
intent.putExtra(OpenAiOnboardingActivity.EXTRA_AUTO_OPEN_LOGIN, true);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private LinearLayout buildAccountCard(JSONObject account) {
|
||||
String statusLabel = account.optString("statusLabel", account.optString("status", "-"));
|
||||
String meta = account.optString("roleLabel", "-")
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class OpenAiOnboardingActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_AUTO_OPEN_LOGIN = "extra_auto_open_login";
|
||||
private static final String STATE_AUTO_OPENED = "state_auto_opened";
|
||||
private static final String OPENAI_LOGIN_URL = "https://platform.openai.com/login";
|
||||
private static final String OPENAI_KEYS_URL = "https://platform.openai.com/api-keys";
|
||||
|
||||
private EditText labelInput;
|
||||
private EditText displayNameInput;
|
||||
private EditText accountIdentifierInput;
|
||||
private EditText modelInput;
|
||||
private EditText apiKeyInput;
|
||||
private boolean autoOpened;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("登录 OpenAI 平台账号", "先登录 OpenAI,再回这里接入 API Key");
|
||||
hideHeaderAction();
|
||||
refreshButton.setVisibility(View.GONE);
|
||||
refreshLayout.setEnabled(false);
|
||||
if (savedInstanceState != null) {
|
||||
autoOpened = savedInstanceState.getBoolean(STATE_AUTO_OPENED, false);
|
||||
}
|
||||
buildForm();
|
||||
reload();
|
||||
if (getIntent().getBooleanExtra(EXTRA_AUTO_OPEN_LOGIN, false) && !autoOpened) {
|
||||
autoOpened = true;
|
||||
openExternalUrl(OPENAI_LOGIN_URL, "已打开 OpenAI 登录页,登录后回到这里继续。");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putBoolean(STATE_AUTO_OPENED, autoOpened);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
"OpenAI 平台账号",
|
||||
"像 Codex 一样,先去浏览器登录,再回 APP 完成接入。",
|
||||
"OpenAI 目前不会把可直接调用 API 的凭据通过第三方 OAuth 直接交给 APP,所以最后一步仍然需要 API Key。"
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"第一步:打开 OpenAI 登录页",
|
||||
"先在浏览器完成 OpenAI Platform 登录。",
|
||||
"返回 APP 后继续下一步。",
|
||||
null,
|
||||
v -> openExternalUrl(OPENAI_LOGIN_URL, "已打开 OpenAI 登录页。")
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"第二步:打开 API Keys 页面",
|
||||
"登录后创建或复制新的 API Key。",
|
||||
"建议创建专门给 Boss 使用的 Key。",
|
||||
null,
|
||||
v -> openExternalUrl(OPENAI_KEYS_URL, "已打开 API Keys 页面。")
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildFormCell(this, "标签", "建议使用 主 GPT", labelInput));
|
||||
appendContent(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表和当前主控里", displayNameInput));
|
||||
appendContent(BossUi.buildFormCell(this, "账号标识", "可填邮箱、账号名或自定义备注", accountIdentifierInput));
|
||||
appendContent(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput));
|
||||
appendContent(BossUi.buildFormCell(this, "API Key", "从 OpenAI Platform 复制后粘贴到这里", apiKeyInput));
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"从剪贴板粘贴 API Key",
|
||||
"如果你刚从浏览器复制了 key,可以直接粘贴到输入框。",
|
||||
null,
|
||||
null,
|
||||
v -> pasteApiKeyFromClipboard()
|
||||
));
|
||||
|
||||
android.widget.Button guideButton = BossUi.buildSecondaryButton(this, "主 GPT 登录说明");
|
||||
guideButton.setOnClickListener(v ->
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("为什么还要 API Key?")
|
||||
.setMessage("浏览器登录解决的是账号身份校验,但主 Agent 真正调用 OpenAI 模型仍然需要 API Key。当前这条链会先帮你打开 OpenAI 登录和 API Keys 页面,再回 APP 完成接入。")
|
||||
.setPositiveButton("知道了", null)
|
||||
.show()
|
||||
);
|
||||
appendContent(guideButton);
|
||||
|
||||
android.widget.Button submitButton = BossUi.buildPrimaryButton(this, "验证并设为当前主控");
|
||||
submitButton.setOnClickListener(v -> submit());
|
||||
appendContent(submitButton);
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void buildForm() {
|
||||
labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false);
|
||||
labelInput.setText("主 GPT");
|
||||
displayNameInput = BossUi.buildInput(this, "显示名称", false);
|
||||
displayNameInput.setText("OpenAI 平台账号");
|
||||
accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false);
|
||||
modelInput = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
|
||||
modelInput.setText("gpt-5.4");
|
||||
apiKeyInput = BossUi.buildInput(this, "OpenAI API Key", false);
|
||||
apiKeyInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
}
|
||||
|
||||
private void pasteApiKeyFromClipboard() {
|
||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
if (clipboard == null || !clipboard.hasPrimaryClip()) {
|
||||
showMessage("剪贴板里还没有可用的 API Key。");
|
||||
return;
|
||||
}
|
||||
ClipData clip = clipboard.getPrimaryClip();
|
||||
if (clip == null || clip.getItemCount() == 0) {
|
||||
showMessage("剪贴板里还没有可用的 API Key。");
|
||||
return;
|
||||
}
|
||||
CharSequence text = clip.getItemAt(0).coerceToText(this);
|
||||
if (text == null || text.toString().trim().isEmpty()) {
|
||||
showMessage("剪贴板里还没有可用的 API Key。");
|
||||
return;
|
||||
}
|
||||
apiKeyInput.setText(text.toString().trim());
|
||||
showMessage("已从剪贴板粘贴 API Key。");
|
||||
}
|
||||
|
||||
private void openExternalUrl(String url, String successMessage) {
|
||||
try {
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
||||
startActivity(intent);
|
||||
showMessage(successMessage);
|
||||
} catch (Exception error) {
|
||||
showMessage("打开浏览器失败:" + error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void submit() {
|
||||
String label = labelInput.getText().toString().trim();
|
||||
String displayName = displayNameInput.getText().toString().trim();
|
||||
String accountIdentifier = accountIdentifierInput.getText().toString().trim();
|
||||
String model = modelInput.getText().toString().trim();
|
||||
String apiKey = apiKeyInput.getText().toString().trim();
|
||||
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());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("OpenAI 平台账号已登录,并设为当前主控。");
|
||||
setResult(RESULT_OK);
|
||||
finish();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
String detail = error.getMessage();
|
||||
showMessage(detail == null || detail.trim().isEmpty()
|
||||
? "OpenAI 平台账号登录失败,请稍后重试。"
|
||||
: "OpenAI 平台账号登录失败:" + detail);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowActivity;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class OpenAiOnboardingActivityTest {
|
||||
@Test
|
||||
public void autoOpenLoginLaunchesOpenAiPlatformLoginPage() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(OpenAiOnboardingActivity.EXTRA_AUTO_OPEN_LOGIN, true);
|
||||
OpenAiOnboardingActivity activity = Robolectric
|
||||
.buildActivity(OpenAiOnboardingActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ShadowActivity shadowActivity = org.robolectric.Shadows.shadowOf(activity);
|
||||
Intent nextIntent = shadowActivity.getNextStartedActivity();
|
||||
assertNotNull(nextIntent);
|
||||
assertEquals(Intent.ACTION_VIEW, nextIntent.getAction());
|
||||
assertEquals(Uri.parse("https://platform.openai.com/login"), nextIntent.getData());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingApiKeysRowLaunchesApiKeysPage() {
|
||||
OpenAiOnboardingActivity activity = Robolectric
|
||||
.buildActivity(OpenAiOnboardingActivity.class)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
View row = findClickableViewContainingText(
|
||||
activity.findViewById(R.id.screen_content),
|
||||
"第二步:打开 API Keys 页面"
|
||||
);
|
||||
assertNotNull(row);
|
||||
row.performClick();
|
||||
|
||||
ShadowActivity shadowActivity = org.robolectric.Shadows.shadowOf(activity);
|
||||
Intent nextIntent = shadowActivity.getNextStartedActivity();
|
||||
assertNotNull(nextIntent);
|
||||
assertEquals(Intent.ACTION_VIEW, nextIntent.getAction());
|
||||
assertEquals(Uri.parse("https://platform.openai.com/api-keys"), nextIntent.getData());
|
||||
}
|
||||
|
||||
private static View findClickableViewContainingText(View root, String expectedText) {
|
||||
if (root == null) {
|
||||
return null;
|
||||
}
|
||||
if (root.isClickable() && viewTreeContainsText(root, expectedText)) {
|
||||
return root;
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
View match = findClickableViewContainingText(group.getChildAt(index), expectedText);
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (text != null && text.toString().contains(expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@
|
||||
- `SecurityActivity`
|
||||
- `SettingsActivity`
|
||||
- `AiAccountsActivity`
|
||||
- `OpenAiOnboardingActivity`
|
||||
- `OpsCenterActivity`
|
||||
- `AboutActivity`
|
||||
- 当前项目聊天页:
|
||||
@@ -72,6 +73,10 @@
|
||||
- 当前 `我的` 根页:
|
||||
- 保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
|
||||
- `运维与修复` 直接进入 `OpsCenterActivity`
|
||||
- 当前 `OpenAiOnboardingActivity`:
|
||||
- 会先自动打开 `OpenAI Platform` 登录页
|
||||
- 支持继续打开 `API Keys` 页面
|
||||
- 回 APP 后可直接粘贴 key,并设为当前主控
|
||||
- 当前登录:临时免验证,点击登录直接创建最高管理员会话
|
||||
- 当前会话恢复:`SharedPreferences` 中保存 `boss_session / restore_token / account`
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ cd /Users/kris/code/boss
|
||||
- 当前普通单线程聊天也已补上真实执行链:`POST /api/v1/projects/[projectId]/messages` 不再只写用户消息,而是会追加 `conversation_reply` 任务;绑定设备上的 `local-agent` 认领后会继续恢复到真实 Codex 线程,再把线程原始回复回写到该聊天窗口
|
||||
- 当前 Web 群聊详情页也已补上待确认推荐的刷新恢复:服务端会在页面渲染时读取最近一条 `pending_user_confirmation` 的 dispatch plan,聊天输入区会继续显示“等待你确认主 Agent 推荐”,不再因刷新丢失确认入口
|
||||
- 当前 `AI 账号` 页面已分成两条显式接入链:`登录 OpenAI 平台账号(API Key)` 和 `绑定 Master Codex Node`;OpenAI API 登录成功后会立即切成当前主控
|
||||
- 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:原生 Android 会先进入 `OpenAiOnboardingActivity`,自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入
|
||||
- 当前如果主控身份还是 `Master Codex Node`,但该节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,不再把失败日志直接原样回给用户
|
||||
- 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口
|
||||
- Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链
|
||||
|
||||
Binary file not shown.
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"fileName": "boss-android-v2.5.5-release.apk",
|
||||
"urlPath": "/api/v1/user/ota/package",
|
||||
"sizeBytes": 3105013,
|
||||
"updatedAt": "2026-03-30T20:15:32Z",
|
||||
"sha256": "f2da722d8ea57e7bd6e16687ae161c45332cc0ba00304725f9e57ddb5b20293e",
|
||||
"sizeBytes": 3107985,
|
||||
"updatedAt": "2026-03-30T20:31:07Z",
|
||||
"sha256": "61127ae868995d367616192200667b48b980dd39623c37ab2f776a5edc2700e6",
|
||||
"versionName": "2.5.5",
|
||||
"versionCode": 18,
|
||||
"buildFlavor": "release"
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user