Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Agent = 服务商 + Key + 模型 + 温度 / 上限 / 深度思考 + 系统提示

**内容与网络**

`files`(浏览 / 搜索 / 读取,含 `grep` 全文搜索)· `file_write` · `download_file` · `web_search`(多引擎并发,支持 `freshness` / `engines`)· `web_read`(正文提取 + 分页)· `app_research`(只读 App 内检索,按资源 id 识别搜索入口并记住已学到的结构,支持 `open` / `open_top` 直接读详情,并可用 `session` 复用已打开的搜索页(换 query 时优先在原结果页重设搜索框,必要时在同一虚拟屏上重启目标 App);输出 `Search state: results / empty / not-found / not-submitted`,用户安装的 App 优先 OCR、系统 App 优先无障碍树;无 Shizuku/root 时走前台无障碍,有则后台虚拟屏)· `web_research`(通用浏览器代理(默认只读):open / snapshot / screenshot / read / click / type / press / select / check / scroll / wait / back / forward / reload / network;`screenshot` 返回视口 PNG + OCR,多模态模型能真正看见 CSS 图表、Canvas、图片和布局,文本模型自动只拿 OCR;支持开放 shadow DOM 与同源 iframe,操作后自动等待 DOM 稳定,输出登录/验证码/同意/拦截状态;登录/验证码可交给前台 show_to_user(使用用户自己的账号,不保存密码);默认只读,是否点击发送/发布/关注等状态变更控件由模型根据用户意图判断,端侧不做正则硬拦;密码 handoff、文件上传/下载不支持;空闲 5 分钟自动释放,设置 → 存储可清除网页数据;平台支持因站点而异,不依赖无障碍、Shizuku 或 ADB)· `fetch_rss` · `get_weather` · `generate_qr` / `decode_qr` · `ocr_file`
`files`(浏览 / 搜索 / 读取,含 `grep` 全文搜索)· `file_write` · `download_file` · `web_search`(多引擎并发,支持 `freshness` / `engines`)· `web_read`(正文提取 + 分页)· `app_research`(只读 App 内检索,按资源 id 识别搜索入口并记住已学到的结构,支持 `open` / `open_top` 直接读详情,并可用 `session` 复用已打开的搜索页(换 query 时优先在原结果页重设搜索框,必要时在同一虚拟屏上重启目标 App);输出 `Search state: results / empty / not-found / not-submitted`,用户安装的 App 优先 OCR、系统 App 优先无障碍树;无 Shizuku/root 时走前台无障碍,有则后台虚拟屏)· `web_research`(通用浏览器代理(默认只读):open / snapshot / screenshot / read / click / type / press / select / check / scroll / wait / back / forward / reload / network;`screenshot` 返回视口 PNG + OCR,`full_page=true` 最多 6 段全页图片,多模态模型能真正看见 CSS 图表、Canvas、图片和布局,文本模型自动只拿 OCR;用户可在 assistant 消息里看到 AI 实际看到的截图;密码框/银行卡/支付/验证码页面默认拒绝截图;支持开放 shadow DOM 与同源 iframe,操作后自动等待 DOM 稳定,输出登录/验证码/同意/拦截状态;登录/验证码可交给前台 show_to_user(使用用户自己的账号,不保存密码);默认只读,是否点击发送/发布/关注等状态变更控件由模型根据用户意图判断,端侧不做正则硬拦;密码 handoff、文件上传/下载不支持;空闲 5 分钟自动释放,设置 → 存储可清除网页数据;平台支持因站点而异,不依赖无障碍、Shizuku 或 ADB)· `fetch_rss` · `get_weather` · `generate_qr` / `decode_qr` · `ocr_file`

**个人助理**

Expand Down Expand Up @@ -269,7 +269,7 @@ adb reverse tcp:8765 tcp:8765
- **已完成 · v0.4.0 稳定性**:虚拟屏与无线 ADB 独立实现、`app_research` 真机搜索/session 复用、附件生命周期与截图保留、自动保存技能参数化、开机/更新保活
- **已完成 · v0.4.1 补丁**:修复 release 包 ML Kit OCR 组件注册导致的第三方 App 裸崩溃;`app_research` 增加语义化 `Search state`、OCR-first 第三方 App 路径和搜索入口上半屏过滤;新增俄语与局域网 HTTP(仅私有地址)
- **已完成 · v0.5.0**:`web_research` 升级为通用浏览器代理(应用自有虚拟屏 + WebView,默认只读,状态变更由模型判断;shadow DOM / 同源 iframe / press / select / check / 前进后退 / network / 状态提示),登录 handoff、会话回收与清除、SPA `wait`、HN / 新版 Reddit 真机验证;新增用户可管理的对话模式与 MCP 端点策略
- **进行中 · 0.6.0**:网页视觉通道 — `web_research screenshot` 返回视口 PNG + OCR;已知多模态模型通过 synthetic user message 拿到一轮图片,文本模型自动只拿 OCR;CSS 图表、Canvas 页面和 `vision=false` 已在 API 35 模拟器验证
- **进行中 · 0.6.0**:网页视觉通道 Phase 2 — 视口 + 全页分段截图、用户可见回执、主屏 `take_screenshot` / `screen_ocr` 图片、密码/支付/验证码页面默认拦截;CSS 图表、Canvas、5/9 段全页、敏感页拦截和主屏截图已在 API 35 模拟器验证
- **进行中**:OEM 矩阵扩展与重启 / 断网恢复验证、Shizuku / 语音 / 录屏全量验收、UI 细节与评测场景扩充
- **计划中**:更丰富的设备能力与技能生态、更多模型协议跟进

Expand Down
3 changes: 3 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
<activity
android:name=".DebugUiActivity"
android:exported="true" />
<activity
android:name=".DebugToolActivity"
android:exported="true" />
<activity
android:name=".DebugSkillActivity"
android:exported="true" />
Expand Down
36 changes: 24 additions & 12 deletions app/src/debug/java/com/verlintas/baic2/DebugOcrActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.verlintas.baic2.device.impl.ocr.MlKitOcrProvider
import java.io.ByteArrayOutputStream
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
Expand All @@ -39,19 +40,30 @@ class DebugOcrActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
val bytes = withContext(Dispatchers.Default) {
val bitmap = Bitmap.createBitmap(800, 240, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
textSize = 72f
val path = intent.getStringExtra("path")?.trim().orEmpty()
val bytes = if (path.isNotEmpty()) {
withContext(Dispatchers.IO) {
File(path).takeIf { it.isFile }?.readBytes()
} ?: run {
Log.e(TAG, "file not found: $path")
finish()
return@launch
}
canvas.drawText("电池 WLAN 设置", 24f, 140f, paint)
ByteArrayOutputStream().use { output ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
bitmap.recycle()
output.toByteArray()
} else {
withContext(Dispatchers.Default) {
val bitmap = Bitmap.createBitmap(800, 240, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
textSize = 72f
}
canvas.drawText("电池 WLAN 设置", 24f, 140f, paint)
ByteArrayOutputStream().use { output ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
bitmap.recycle()
output.toByteArray()
}
}
}
val result = runCatching { MlKitOcrProvider().recognize(bytes) }
Expand Down
143 changes: 143 additions & 0 deletions app/src/debug/java/com/verlintas/baic2/DebugToolActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Copyright (C) 2026 Verlintas
* SPDX-License-Identifier: GPL-3.0-or-later
*
* This file is part of BetterAIChat2.
*
* BetterAIChat2 is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* BetterAIChat2 is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* BetterAIChat2. If not, see <https://www.gnu.org/licenses/>.
*/

package com.verlintas.baic2

import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.verlintas.baic2.core.model.ToolResult
import com.verlintas.baic2.device.api.AccessibilityBridge
import com.verlintas.baic2.device.api.OcrProvider
import com.verlintas.baic2.device.api.ScreenshotProvider
import com.verlintas.baic2.tools.ToolContext
import com.verlintas.baic2.tools.ToolRegistry
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject

/** Debug-only runner for any registered tool: --es tool take_screenshot --es json {...} */
@AndroidEntryPoint
class DebugToolActivity : ComponentActivity() {

@Inject
lateinit var registry: ToolRegistry

@Inject
lateinit var ocr: OcrProvider

@Inject
lateinit var screenshot: ScreenshotProvider

@Inject
lateinit var accessibility: AccessibilityBridge

private var pendingName: String? = null
private var pendingArgs: JsonObject? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val name = intent.getStringExtra("tool")?.trim().orEmpty().ifEmpty { "screen_ocr" }
val raw = intent.getStringExtra("json")?.trim().orEmpty().ifEmpty { "{}" }
val args = runCatching {
Json.parseToJsonElement(raw) as? JsonObject
}.getOrNull()
if (args == null) {
Log.e(TAG, "bad json: $raw")
finish()
return
}
if (registry.tool(name) == null) {
Log.e(TAG, "tool not found: $name")
finish()
return
}
pendingName = name
pendingArgs = args
if (intent.getBooleanExtra("request_capture", false)) {
startActivityForResult(screenshot.createPermissionIntent(), REQUEST_CAPTURE)
} else {
runTool()
}
}

@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode != REQUEST_CAPTURE) {
super.onActivityResult(requestCode, resultCode, data)
return
}
if (resultCode != Activity.RESULT_OK || data == null ||
!screenshot.onPermissionResult(resultCode, data)
) {
Log.e(TAG, "screen capture permission denied")
finish()
return
}
runTool()
}

private fun runTool() {
val name = pendingName ?: return
val args = pendingArgs ?: return
val tool = registry.tool(name) ?: return
lifecycleScope.launch {
val context = ToolContext(
appContext = applicationContext,
permissions = { true },
screenshot = screenshot,
ocr = ocr,
accessibility = accessibility,
)
val startedAt = System.currentTimeMillis()
val result = runCatching { tool.execute(args, context) }
val summary = when (val value = result.getOrNull()) {
is ToolResult.Success ->
"Success(images=${value.images.size}, output=${value.output.take(1_000)})"
is ToolResult.Failure ->
"Failure(reason=${value.reason.take(500)}, recoverable=${value.recoverable})"
is ToolResult.Denied -> "Denied(reason=${value.reason.take(500)})"
null -> "CRASH: ${result.exceptionOrNull()?.message}"
}
Log.i(
TAG,
"tool=$name elapsedMs=${System.currentTimeMillis() - startedAt} $summary",
)
val text = when (val value = result.getOrNull()) {
is ToolResult.Success -> value.output
is ToolResult.Failure -> "FAILURE: ${value.reason}"
is ToolResult.Denied -> "DENIED: ${value.reason}"
null -> "CRASH: ${result.exceptionOrNull()?.message}"
}
File(getExternalFilesDir(null), "debug-tool.txt").writeText(text)
finish()
}
}

private companion object {
const val TAG = "BAIC2TOOL"
const val REQUEST_CAPTURE = 7101
}
}
5 changes: 5 additions & 0 deletions app/src/main/java/com/verlintas/baic2/Baic2Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package com.verlintas.baic2

import android.app.Application
import android.content.Intent
import android.webkit.WebView
import androidx.core.content.ContextCompat
import com.verlintas.baic2.core.data.prefs.SettingsRepository
import com.verlintas.baic2.core.data.repository.ConversationRepository
Expand Down Expand Up @@ -62,6 +63,10 @@ class Baic2Application : Application() {

override fun onCreate() {
super.onCreate()
// Must run before the first WebView is loaded. Full-page web research
// captures rely on capturePicture() containing the whole document;
// without this, tiles below the first viewport are blank.
runCatching { WebView.enableSlowWholeDocumentDraw() }
runCatching { automationBootstrap.start() }
runCatching { wirelessAdb.start() }
CoroutineScope(Dispatchers.IO).launch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,10 @@ class AndroidWebBrowser @Inject constructor(
?: Result.failure(IllegalStateException("no_browser_session: open a URL first"))
}

override suspend fun screenshot(): Result<WebScreenshot> = mutex.withLock {
session()?.screenshot() ?: noSession()
}
override suspend fun screenshot(fullPage: Boolean): Result<List<WebScreenshot>> =
mutex.withLock {
session()?.screenshot(fullPage) ?: noSession()
}

override suspend fun click(ref: String): Result<WebSnapshot> = mutex.withLock {
session()?.click(ref)
Expand Down
Loading
Loading