feat: add browser-assisted openai onboarding flow

This commit is contained in:
kris
2026-03-31 04:31:58 +08:00
parent 0cb2171dd3
commit 9d7f38412a
10 changed files with 326 additions and 6 deletions

View File

@@ -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" />

View File

@@ -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", "-")

View File

@@ -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);
});
}
});
}
}

View File

@@ -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;
}
}