feat(android): establish Roubao build baseline

This commit is contained in:
QiuSW
2026-07-25 18:20:37 +08:00
parent 285606639b
commit 04fa4a0994
96 changed files with 15249 additions and 125 deletions
@@ -0,0 +1,77 @@
package com.roubao.autopilot
import android.app.Application
import android.content.pm.PackageManager
import com.roubao.autopilot.controller.AppScanner
import com.roubao.autopilot.controller.DeviceController
import com.roubao.autopilot.skills.SkillManager
import com.roubao.autopilot.tools.ToolManager
import com.roubao.autopilot.utils.CrashHandler
import rikka.shizuku.Shizuku
class App : Application() {
lateinit var deviceController: DeviceController
private set
lateinit var appScanner: AppScanner
private set
override fun onCreate() {
super.onCreate()
instance = this
// 初始化崩溃捕获(本地日志)
CrashHandler.getInstance().init(this)
// 初始化 Shizuku
Shizuku.addRequestPermissionResultListener(REQUEST_PERMISSION_RESULT_LISTENER)
// 初始化核心组件
initializeComponents()
}
private fun initializeComponents() {
// 初始化设备控制器
deviceController = DeviceController(this)
deviceController.setCacheDir(cacheDir)
// 初始化应用扫描器
appScanner = AppScanner(this)
// 初始化 Tools 层
val toolManager = ToolManager.init(this, deviceController, appScanner)
// 异步预扫描应用列表(避免 ANR)
println("[App] 开始异步扫描已安装应用...")
Thread {
appScanner.refreshApps()
println("[App] 已扫描 ${appScanner.getApps().size} 个应用")
}.start()
// 初始化 Skills 层(传入 appScanner 用于检测已安装应用)
val skillManager = SkillManager.init(this, toolManager, appScanner)
println("[App] SkillManager 已加载 ${skillManager.getAllSkills().size} 个 Skills")
println("[App] 组件初始化完成")
}
override fun onTerminate() {
super.onTerminate()
Shizuku.removeRequestPermissionResultListener(REQUEST_PERMISSION_RESULT_LISTENER)
}
companion object {
@Volatile
private var instance: App? = null
fun getInstance(): App {
return instance ?: throw IllegalStateException("App 未初始化")
}
private val REQUEST_PERMISSION_RESULT_LISTENER =
Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
val granted = grantResult == PackageManager.PERMISSION_GRANTED
println("[Shizuku] Permission result: $granted")
}
}
}
@@ -0,0 +1,625 @@
package com.roubao.autopilot
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import android.net.Uri
import android.provider.Settings
import com.roubao.autopilot.agent.MobileAgent
import com.roubao.autopilot.controller.AppScanner
import com.roubao.autopilot.controller.DeviceController
import com.roubao.autopilot.data.*
import com.roubao.autopilot.ui.screens.*
import com.roubao.autopilot.ui.theme.*
import androidx.compose.ui.graphics.toArgb
import androidx.core.view.WindowCompat
import com.roubao.autopilot.vlm.GUIOwlClient
import com.roubao.autopilot.vlm.MAIUIClient
import com.roubao.autopilot.vlm.VLMClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import rikka.shizuku.Shizuku
import android.util.Log
private const val TAG = "MainActivity"
sealed class Screen(val route: String, val title: String, val icon: ImageVector, val selectedIcon: ImageVector) {
object Home : Screen("home", "肉包", Icons.Outlined.Home, Icons.Filled.Home)
object Capabilities : Screen("capabilities", "能力", Icons.Outlined.Star, Icons.Filled.Star)
object History : Screen("history", "记录", Icons.Outlined.List, Icons.Filled.List)
object Settings : Screen("settings", "设置", Icons.Outlined.Settings, Icons.Filled.Settings)
}
class MainActivity : ComponentActivity() {
private lateinit var deviceController: DeviceController
private lateinit var settingsManager: SettingsManager
private lateinit var executionRepository: ExecutionRepository
private val mobileAgent = mutableStateOf<MobileAgent?>(null)
private var shizukuAvailable = mutableStateOf(false)
// 当前执行的协程 Job(用于停止任务)
private var currentExecutionJob: kotlinx.coroutines.Job? = null
// 执行记录列表
private val executionRecords = mutableStateOf<List<ExecutionRecord>>(emptyList())
// 是否正在执行(点击发送后立即为 true)
private val isExecuting = mutableStateOf(false)
// 当前执行的记录 ID(用于停止后跳转)
private val currentRecordId = mutableStateOf<String?>(null)
// 是否需要跳转到记录详情(悬浮窗停止后触发)
private val shouldNavigateToRecord = mutableStateOf(false)
private val binderReceivedListener = Shizuku.OnBinderReceivedListener {
Log.d(TAG, "Shizuku binder received")
shizukuAvailable.value = true
if (checkShizukuPermission()) {
Log.d(TAG, "Shizuku permission granted, binding service")
deviceController.bindService()
} else {
Log.d(TAG, "Shizuku permission not granted")
}
}
private val binderDeadListener = Shizuku.OnBinderDeadListener {
Log.d(TAG, "Shizuku binder dead")
shizukuAvailable.value = false
}
private val permissionResultListener = Shizuku.OnRequestPermissionResultListener { _, grantResult ->
Log.d(TAG, "Shizuku permission result: $grantResult")
if (grantResult == PackageManager.PERMISSION_GRANTED) {
deviceController.bindService()
Toast.makeText(this, "Shizuku 权限已获取", Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
// 设置边到边显示,深色状态栏和导航栏
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
)
deviceController = DeviceController(this)
deviceController.setCacheDir(cacheDir)
settingsManager = SettingsManager(this)
executionRepository = ExecutionRepository(this)
// 加载执行记录
lifecycleScope.launch {
executionRecords.value = executionRepository.getAllRecords()
}
// 添加 Shizuku 监听器
Shizuku.addBinderReceivedListenerSticky(binderReceivedListener)
Shizuku.addBinderDeadListener(binderDeadListener)
Shizuku.addRequestPermissionResultListener(permissionResultListener)
// 检查 Shizuku 状态
checkAndUpdateShizukuStatus()
// 预加载已安装应用
lifecycleScope.launch(Dispatchers.IO) {
AppScanner(this@MainActivity).getApps()
}
setContent {
val settings by settingsManager.settings.collectAsState()
BaoziTheme(themeMode = settings.themeMode) {
val colors = BaoziTheme.colors
// 动态更新系统栏颜色
SideEffect {
val window = this@MainActivity.window
window.statusBarColor = colors.background.toArgb()
window.navigationBarColor = colors.backgroundCard.toArgb()
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = !colors.isDark
isAppearanceLightNavigationBars = !colors.isDark
}
}
// 首次启动显示引导画面
if (!settings.hasSeenOnboarding) {
OnboardingScreen(
onComplete = {
settingsManager.setOnboardingSeen()
}
)
} else {
MainApp()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainApp() {
var currentScreen by remember { mutableStateOf<Screen>(Screen.Home) }
var selectedRecord by remember { mutableStateOf<ExecutionRecord?>(null) }
var showShizukuHelpDialog by remember { mutableStateOf(false) }
var hasShownShizukuHelp by remember { mutableStateOf(false) }
val settings by settingsManager.settings.collectAsState()
val colors = BaoziTheme.colors
val agent = mobileAgent.value
val agentState by agent?.state?.collectAsState() ?: remember { mutableStateOf(null) }
val logs by agent?.logs?.collectAsState() ?: remember { mutableStateOf(emptyList<String>()) }
val records by remember { executionRecords }
val isShizukuAvailable = shizukuAvailable.value && checkShizukuPermission()
val executing by remember { isExecuting }
val navigateToRecord by remember { shouldNavigateToRecord }
val recordId by remember { currentRecordId }
// 监听跳转事件
LaunchedEffect(navigateToRecord, recordId) {
if (navigateToRecord && recordId != null) {
// 找到对应的记录并跳转
val record = records.find { it.id == recordId }
if (record != null) {
selectedRecord = record
currentScreen = Screen.History
}
shouldNavigateToRecord.value = false
}
}
// 首次进入且 Shizuku 未连接时,显示帮助引导(只显示一次)
LaunchedEffect(Unit) {
if (!isShizukuAvailable && settings.hasSeenOnboarding && !hasShownShizukuHelp) {
hasShownShizukuHelp = true
showShizukuHelpDialog = true
}
}
Scaffold(
modifier = Modifier.background(colors.background),
containerColor = colors.background,
bottomBar = {
if (selectedRecord == null) {
NavigationBar(
containerColor = colors.background,
contentColor = colors.textPrimary,
tonalElevation = 0.dp
) {
listOf(Screen.Home, Screen.Capabilities, Screen.History, Screen.Settings).forEach { screen ->
val selected = currentScreen == screen
NavigationBarItem(
icon = {
Icon(
imageVector = if (selected) screen.selectedIcon else screen.icon,
contentDescription = screen.title
)
},
label = { Text(screen.title) },
selected = selected,
onClick = { currentScreen = screen },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = if (colors.isDark) colors.textPrimary else Color.White,
selectedTextColor = colors.primary,
unselectedIconColor = colors.textSecondary,
unselectedTextColor = colors.textSecondary,
indicatorColor = colors.primary
)
)
}
}
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// 处理系统返回手势
BackHandler(enabled = selectedRecord != null) {
selectedRecord = null
}
// 详情页优先显示
if (selectedRecord != null) {
HistoryDetailScreen(
record = selectedRecord!!,
onBack = { selectedRecord = null }
)
} else {
// 主页面切换
AnimatedContent(
targetState = currentScreen,
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "screen"
) { screen ->
when (screen) {
Screen.Home -> {
// 每次进入首页都检测 Shizuku 状态
LaunchedEffect(Unit) {
checkAndUpdateShizukuStatus()
}
HomeScreen(
agentState = agentState,
logs = logs,
onExecute = { instruction ->
runAgent(
instruction = instruction,
apiKey = settings.apiKey,
baseUrl = settings.baseUrl,
model = settings.model,
maxSteps = settings.maxSteps,
isGUIAgent = settings.currentProvider.isGUIAgent,
providerId = settings.currentProviderId
)
},
onStop = {
mobileAgent.value?.stop()
},
shizukuAvailable = isShizukuAvailable,
currentModel = settings.model,
onRefreshShizuku = { refreshShizukuStatus() },
onShizukuRequired = { showShizukuHelpDialog = true },
isExecuting = executing
)
}
Screen.Capabilities -> CapabilitiesScreen()
Screen.History -> HistoryScreen(
records = records,
onRecordClick = { record -> selectedRecord = record },
onDeleteRecord = { id -> deleteRecord(id) }
)
Screen.Settings -> SettingsScreen(
settings = settings,
onUpdateApiKey = { settingsManager.updateApiKey(it) },
onUpdateBaseUrl = { settingsManager.updateBaseUrl(it) },
onUpdateModel = { settingsManager.updateModel(it) },
onUpdateCachedModels = { settingsManager.updateCachedModels(it) },
onUpdateThemeMode = { settingsManager.updateThemeMode(it) },
onUpdateMaxSteps = { settingsManager.updateMaxSteps(it) },
onUpdateRootModeEnabled = { settingsManager.updateRootModeEnabled(it) },
onUpdateSuCommandEnabled = { settingsManager.updateSuCommandEnabled(it) },
onSelectProvider = { settingsManager.selectProvider(it) },
shizukuAvailable = isShizukuAvailable,
shizukuPrivilegeLevel = if (isShizukuAvailable) {
when (deviceController.getShizukuPrivilegeLevel()) {
DeviceController.ShizukuPrivilegeLevel.ROOT -> "ROOT"
DeviceController.ShizukuPrivilegeLevel.ADB -> "ADB"
else -> "NONE"
}
} else "NONE",
onFetchModels = { onSuccess, onError ->
lifecycleScope.launch {
val result = VLMClient.fetchModels(settings.baseUrl, settings.apiKey)
result.onSuccess { models ->
onSuccess(models)
}.onFailure { error ->
onError(error.message ?: "未知错误")
}
}
}
)
}
}
}
}
}
// Shizuku 帮助对话框
if (showShizukuHelpDialog) {
ShizukuHelpDialog(onDismiss = { showShizukuHelpDialog = false })
}
}
private fun deleteRecord(id: String) {
lifecycleScope.launch {
executionRepository.deleteRecord(id)
executionRecords.value = executionRepository.getAllRecords()
}
}
override fun onDestroy() {
super.onDestroy()
Shizuku.removeBinderReceivedListener(binderReceivedListener)
Shizuku.removeBinderDeadListener(binderDeadListener)
Shizuku.removeRequestPermissionResultListener(permissionResultListener)
deviceController.unbindService()
}
private fun checkShizukuPermission(): Boolean {
return try {
val granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
Log.d(TAG, "checkShizukuPermission: $granted")
granted
} catch (e: Exception) {
Log.e(TAG, "checkShizukuPermission error", e)
false
}
}
private fun checkAndUpdateShizukuStatus() {
Log.d(TAG, "checkAndUpdateShizukuStatus called")
try {
val binderAlive = Shizuku.pingBinder()
Log.d(TAG, "Shizuku pingBinder: $binderAlive")
if (binderAlive) {
shizukuAvailable.value = true
val hasPermission = checkShizukuPermission()
Log.d(TAG, "Shizuku hasPermission: $hasPermission")
if (hasPermission) {
Log.d(TAG, "Binding Shizuku service")
deviceController.bindService()
} else {
Log.d(TAG, "Requesting Shizuku permission")
requestShizukuPermission()
}
} else {
Log.d(TAG, "Shizuku binder not alive")
shizukuAvailable.value = false
}
} catch (e: Exception) {
Log.e(TAG, "checkAndUpdateShizukuStatus error", e)
shizukuAvailable.value = false
}
}
private fun refreshShizukuStatus() {
Log.d(TAG, "refreshShizukuStatus called by user")
Toast.makeText(this, "正在检查 Shizuku 状态...", Toast.LENGTH_SHORT).show()
checkAndUpdateShizukuStatus()
if (shizukuAvailable.value && checkShizukuPermission()) {
Toast.makeText(this, "Shizuku 已连接", Toast.LENGTH_SHORT).show()
} else if (shizukuAvailable.value) {
Toast.makeText(this, "请在弹窗中授权 Shizuku", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "请先启动 Shizuku App", Toast.LENGTH_SHORT).show()
}
}
private fun requestShizukuPermission() {
try {
if (!Shizuku.pingBinder()) {
Toast.makeText(this, "请先启动 Shizuku App", Toast.LENGTH_SHORT).show()
return
}
if (Shizuku.isPreV11()) {
Toast.makeText(this, "Shizuku 版本过低", Toast.LENGTH_SHORT).show()
return
}
if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
Toast.makeText(this, "Shizuku 权限已获取", Toast.LENGTH_SHORT).show()
shizukuAvailable.value = true
deviceController.bindService()
return
}
Shizuku.requestPermission(0)
} catch (e: Exception) {
Toast.makeText(this, "请先启动 Shizuku App", Toast.LENGTH_SHORT).show()
}
}
private fun runAgent(
instruction: String,
apiKey: String,
baseUrl: String,
model: String,
maxSteps: Int,
isGUIAgent: Boolean = false,
providerId: String = ""
) {
if (instruction.isBlank()) {
Toast.makeText(this, "请输入指令", Toast.LENGTH_SHORT).show()
return
}
// MAI-UI 本地部署不需要 API Key
val requiresApiKey = providerId != "mai_ui"
if (requiresApiKey && apiKey.isBlank()) {
Toast.makeText(this, "请输入 API Key", Toast.LENGTH_SHORT).show()
return
}
// 检查悬浮窗权限
if (!Settings.canDrawOverlays(this)) {
Toast.makeText(this, "请授予悬浮窗权限", Toast.LENGTH_LONG).show()
val intent = android.content.Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$packageName")
)
startActivity(intent)
return
}
// 立即设置执行状态为 true,显示停止按钮
isExecuting.value = true
// 根据服务商类型创建相应的客户端
if (isGUIAgent) {
// GUI-Owl 模式
val guiOwlClient = GUIOwlClient(
apiKey = apiKey,
model = model.ifBlank { "pre-gui_owl_7b" }
)
mobileAgent.value = MobileAgent(
vlmClient = null,
controller = deviceController,
context = this,
guiOwlClient = guiOwlClient
)
} else if (providerId == "mai_ui") {
// MAI-UI 模式
val maiuiClient = MAIUIClient(
baseUrl = baseUrl.ifBlank { "http://localhost:8000/v1" },
model = model.ifBlank { "MAI-UI-2B" }
)
mobileAgent.value = MobileAgent(
vlmClient = null,
controller = deviceController,
context = this,
maiuiClient = maiuiClient
)
} else {
// OpenAI 兼容模式 (阿里云 Qwen-VL, OpenAI, OpenRouter 等)
val vlmClient = VLMClient(
apiKey = apiKey,
baseUrl = baseUrl.ifBlank { "https://dashscope.aliyuncs.com/compatible-mode/v1" },
model = model.ifBlank { "qwen3-vl-plus" }
)
mobileAgent.value = MobileAgent(vlmClient, deviceController, this)
}
// 设置停止回调,用于取消协程
mobileAgent.value?.onStopRequested = {
currentExecutionJob?.cancel()
currentExecutionJob = null
}
// 创建执行记录
val record = ExecutionRecord(
title = generateTitle(instruction),
instruction = instruction,
startTime = System.currentTimeMillis(),
status = ExecutionStatus.RUNNING
)
// 保存当前记录 ID,用于停止后跳转
currentRecordId.value = record.id
// 取消之前的任务(如果有)
currentExecutionJob?.cancel()
currentExecutionJob = lifecycleScope.launch {
// 保存初始记录
executionRepository.saveRecord(record)
executionRecords.value = executionRepository.getAllRecords()
try {
val result = mobileAgent.value!!.runInstruction(instruction, maxSteps)
// 更新记录状态
val agentState = mobileAgent.value?.state?.value
val steps = agentState?.executionSteps ?: emptyList()
val currentLogs = mobileAgent.value?.logs?.value ?: emptyList()
val updatedRecord = record.copy(
endTime = System.currentTimeMillis(),
status = if (result.success) ExecutionStatus.COMPLETED else ExecutionStatus.FAILED,
steps = steps,
logs = currentLogs,
resultMessage = result.message
)
executionRepository.saveRecord(updatedRecord)
executionRecords.value = executionRepository.getAllRecords()
Toast.makeText(this@MainActivity, result.message, Toast.LENGTH_LONG).show()
// 重置执行状态
isExecuting.value = false
// 延迟3秒后清空日志,恢复默认状态
kotlinx.coroutines.delay(3000)
mobileAgent.value?.clearLogs()
} catch (e: kotlinx.coroutines.CancellationException) {
// 用户取消任务 - 使用 NonCancellable 确保清理操作完成
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) {
val agentState = mobileAgent.value?.state?.value
val steps = agentState?.executionSteps ?: emptyList()
val currentLogs = mobileAgent.value?.logs?.value ?: emptyList()
println("[MainActivity] 取消任务 - steps: ${steps.size}, logs: ${currentLogs.size}")
val updatedRecord = record.copy(
endTime = System.currentTimeMillis(),
status = ExecutionStatus.STOPPED,
steps = steps,
logs = currentLogs,
resultMessage = "已取消"
)
executionRepository.saveRecord(updatedRecord)
executionRecords.value = executionRepository.getAllRecords()
// 重置执行状态
isExecuting.value = false
Toast.makeText(this@MainActivity, "任务已停止", Toast.LENGTH_SHORT).show()
mobileAgent.value?.clearLogs()
// 触发跳转到记录详情页
shouldNavigateToRecord.value = true
}
} catch (e: Exception) {
// 更新失败记录
val currentLogs = mobileAgent.value?.logs?.value ?: emptyList()
val updatedRecord = record.copy(
endTime = System.currentTimeMillis(),
status = ExecutionStatus.FAILED,
logs = currentLogs,
resultMessage = "错误: ${e.message}"
)
executionRepository.saveRecord(updatedRecord)
executionRecords.value = executionRepository.getAllRecords()
// 重置执行状态
isExecuting.value = false
Toast.makeText(this@MainActivity, "错误: ${e.message}", Toast.LENGTH_LONG).show()
// 延迟3秒后清空日志,恢复默认状态
kotlinx.coroutines.delay(3000)
mobileAgent.value?.clearLogs()
}
}
}
private fun generateTitle(instruction: String): String {
// 生成简短标题
val keywords = listOf(
"打开" to "打开应用",
"点" to "点餐",
"发" to "发送消息",
"看" to "浏览内容",
"搜" to "搜索",
"设置" to "调整设置",
"播放" to "播放媒体"
)
for ((key, title) in keywords) {
if (instruction.contains(key)) {
return title
}
}
return if (instruction.length > 10) instruction.take(10) + "..." else instruction
}
}
@@ -0,0 +1,78 @@
package com.roubao.autopilot.agent
/**
* ActionReflector Agent - 反思动作是否成功
*/
class ActionReflector {
/**
* 生成反思 Prompt
*/
fun getPrompt(infoPool: InfoPool): String = buildString {
append("You are an agent verifying whether the last action produced the expected behavior.\n\n")
append("### User Request ###\n")
append("${infoPool.instruction}\n\n")
append("### Progress Status ###\n")
if (infoPool.completedPlan.isNotEmpty()) {
append("${infoPool.completedPlan}\n\n")
} else {
append("No progress yet.\n\n")
}
append("---\n")
append("The two attached images are phone screenshots taken BEFORE and AFTER your last action.\n\n")
append("### Latest Action ###\n")
append("Action: ${infoPool.lastAction}\n")
append("Expectation: ${infoPool.lastSummary}\n\n")
append("---\n")
append("Carefully examine whether the last action produced the expected behavior.\n\n")
append("Note: For swiping to scroll, if the content before and after is exactly the same, ")
append("the swipe is considered Failed (C) - the page may have reached the bottom.\n\n")
append("Provide your output in the following format:\n\n")
append("### Outcome ###\n")
append("Choose from:\n")
append("A: Successful. The result meets the expectation.\n")
append("B: Failed. The action resulted in a wrong page. Need to return to previous state.\n")
append("C: Failed. The action produced no changes.\n\n")
append("### Error Description ###\n")
append("If failed, describe the error. If successful, put \"None\".\n")
}
/**
* 解析反思响应
*/
fun parseResponse(response: String): ReflectorResult {
val outcomeSection = response
.substringAfter("### Outcome", "")
.substringBefore("### Error Description")
.replace("###", "")
.trim()
val outcome = when {
outcomeSection.contains("A") -> "A"
outcomeSection.contains("B") -> "B"
outcomeSection.contains("C") -> "C"
else -> "C"
}
val errorDescription = response
.substringAfter("### Error Description", "")
.replace("###", "")
.trim()
return ReflectorResult(outcome, errorDescription)
}
}
data class ReflectorResult(
val outcome: String, // A, B, C
val errorDescription: String
)
@@ -0,0 +1,163 @@
package com.roubao.autopilot.agent
import android.graphics.Bitmap
import android.util.Base64
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
/**
* 对话记忆管理 - 保存完整对话历史,图片用完即删
*
* 参考 Open-AutoGLM 的设计:
* - 保持完整对话历史,让模型看到之前所有操作
* - 图片用完即删,节省 token
*/
class ConversationMemory {
private val messages = mutableListOf<Message>()
/**
* 消息类型
*/
data class Message(
val role: String, // "system", "user", "assistant"
val textContent: String,
var imageBase64: String? = null // 图片用完后置为 null
)
/**
* 添加系统消息(通常只在开始时添加一次)
*/
fun addSystemMessage(text: String) {
messages.add(Message(role = "system", textContent = text))
}
/**
* 添加用户消息(带截图)
*/
fun addUserMessage(text: String, image: Bitmap? = null) {
val imageBase64 = image?.let { bitmapToBase64(it) }
messages.add(Message(role = "user", textContent = text, imageBase64 = imageBase64))
}
/**
* 添加助手消息
*/
fun addAssistantMessage(text: String) {
messages.add(Message(role = "assistant", textContent = text))
}
/**
* 删除最后一条用户消息中的图片(节省 token)
* 在获取模型响应后调用
*/
fun stripLastUserImage() {
for (i in messages.indices.reversed()) {
if (messages[i].role == "user" && messages[i].imageBase64 != null) {
messages[i].imageBase64 = null
println("[ConversationMemory] 已删除第 $i 条消息的图片")
break
}
}
}
/**
* 获取最近 N 条消息(不包括系统消息)
*/
fun getRecentMessages(count: Int): List<Message> {
val nonSystemMessages = messages.filter { it.role != "system" }
return nonSystemMessages.takeLast(count)
}
/**
* 构建 OpenAI 兼容的 messages JSON
* @param includeImages 是否包含图片(最后一条用户消息的图片)
*/
fun toMessagesJson(includeImages: Boolean = true): JSONArray {
val jsonArray = JSONArray()
for ((index, msg) in messages.withIndex()) {
val msgJson = JSONObject()
msgJson.put("role", msg.role)
// 判断是否需要包含图片
val shouldIncludeImage = includeImages &&
msg.imageBase64 != null &&
index == messages.indexOfLast { it.role == "user" }
if (shouldIncludeImage && msg.imageBase64 != null) {
// 多模态消息格式
val contentArray = JSONArray()
contentArray.put(JSONObject().apply {
put("type", "text")
put("text", msg.textContent)
})
contentArray.put(JSONObject().apply {
put("type", "image_url")
put("image_url", JSONObject().apply {
put("url", "data:image/jpeg;base64,${msg.imageBase64}")
})
})
msgJson.put("content", contentArray)
} else {
// 纯文本消息
msgJson.put("content", msg.textContent)
}
jsonArray.put(msgJson)
}
return jsonArray
}
/**
* 获取消息数量
*/
fun size(): Int = messages.size
/**
* 清空所有消息
*/
fun clear() {
messages.clear()
}
/**
* 获取 token 估算(粗略)
* 中文约 2 字符/token,英文约 4 字符/token
*/
fun estimateTokens(): Int {
var total = 0
for (msg in messages) {
// 文本 token
total += msg.textContent.length / 3
// 图片 token(约 1000 token 每张)
if (msg.imageBase64 != null) {
total += 1000
}
}
return total
}
/**
* Bitmap 转 Base64
*/
private fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
val bytes = outputStream.toByteArray()
return Base64.encodeToString(bytes, Base64.NO_WRAP)
}
companion object {
/**
* 创建带系统提示的记忆
*/
fun withSystemPrompt(systemPrompt: String): ConversationMemory {
return ConversationMemory().apply {
addSystemMessage(systemPrompt)
}
}
}
}
@@ -0,0 +1,194 @@
package com.roubao.autopilot.agent
/**
* Executor Agent - 决定具体执行什么动作
*/
class Executor {
companion object {
val GUIDELINES = """
General:
- For any pop-up window, close it (e.g., by clicking 'Don't Allow' or 'Accept') before proceeding.
- For requests that are questions, remember to use the `answer` action to reply before finish!
- If the desired state is already achieved, you can just complete the task.
Action Related:
- Use `open_app` to open an app, do not use the app drawer.
- Consider using `swipe` to reveal additional content.
- If swiping doesn't change the page, it may have reached the bottom.
Text Related:
- To input text: first click the input box, make sure keyboard is visible, then use `type` action.
- To clear text: long press the backspace button in the keyboard.
""".trimIndent()
}
/**
* 生成执行 Prompt
*/
fun getPrompt(infoPool: InfoPool): String = buildString {
append("You are an agent who can operate an Android phone. ")
append("Decide the next action based on the current state.\n\n")
append("### User Request ###\n")
append("${infoPool.instruction}\n\n")
append("### Overall Plan ###\n")
append("${infoPool.plan}\n\n")
append("### Current Subgoal ###\n")
val subgoals = infoPool.plan.split(Regex("(?<=\\d)\\. ")).take(3)
append("${subgoals.joinToString(". ")}\n\n")
append("### Progress Status ###\n")
if (infoPool.progressStatus.isNotEmpty()) {
append("${infoPool.progressStatus}\n\n")
} else {
append("No progress yet.\n\n")
}
append("### Guidelines ###\n")
append("$GUIDELINES\n\n")
append("---\n")
append("Examine all information and decide on the next action.\n\n")
append("#### Atomic Actions ####\n")
append("- click(coordinate): Click at (x, y). Example: {\"action\": \"click\", \"coordinate\": [x, y]}\n")
append("- double_tap(coordinate): Double tap at (x, y) for zoom or like. Example: {\"action\": \"double_tap\", \"coordinate\": [x, y]}\n")
append("- long_press(coordinate): Long press at (x, y). Example: {\"action\": \"long_press\", \"coordinate\": [x, y]}\n")
append("- type(text): Type text into activated input box. Example: {\"action\": \"type\", \"text\": \"hello\"}\n")
append("- swipe(coordinate, coordinate2): Swipe from point1 to point2. Example: {\"action\": \"swipe\", \"coordinate\": [x1, y1], \"coordinate2\": [x2, y2]}\n")
append("- system_button(button): Press Back/Home/Enter. Example: {\"action\": \"system_button\", \"button\": \"Back\"}\n")
append("- open_app(text): Open an app by name. ALWAYS use this instead of looking for app icons on screen! Example: {\"action\": \"open_app\", \"text\": \"设置\"}\n")
if (infoPool.installedApps.isNotEmpty()) {
append(" Available apps: ${infoPool.installedApps}\n")
}
append("- wait(duration): Wait for page loading. Duration in seconds (1-10). Example: {\"action\": \"wait\", \"duration\": 3}\n")
append("- take_over(message): Request user to manually complete login/captcha/verification. Example: {\"action\": \"take_over\", \"message\": \"请完成登录验证\"}\n")
append("- answer(text): Answer user's question. Example: {\"action\": \"answer\", \"text\": \"The answer is...\"}\n")
append("\n")
append("#### Sensitive Operations ####\n")
append("For payment, password, or privacy-related actions, add 'message' field to request user confirmation:\n")
append("Example: {\"action\": \"click\", \"coordinate\": [500, 800], \"message\": \"确认支付 ¥100\"}\n")
append("The user will see a confirmation dialog and can choose to confirm or cancel.\n")
append("\n")
append("### Latest Action History ###\n")
if (infoPool.actionHistory.isNotEmpty()) {
val numActions = minOf(5, infoPool.actionHistory.size)
val latestActions = infoPool.actionHistory.takeLast(numActions)
val latestSummaries = infoPool.summaryHistory.takeLast(numActions)
val latestOutcomes = infoPool.actionOutcomes.takeLast(numActions)
val latestErrors = infoPool.errorDescriptions.takeLast(numActions)
latestActions.forEachIndexed { i, act ->
val outcome = latestOutcomes.getOrNull(i) ?: "?"
if (outcome == "A") {
append("- Action: $act | Description: ${latestSummaries.getOrNull(i)} | Outcome: Successful\n")
} else {
append("- Action: $act | Description: ${latestSummaries.getOrNull(i)} | Outcome: Failed | Error: ${latestErrors.getOrNull(i)}\n")
}
}
} else {
append("No actions have been taken yet.\n")
}
append("\n")
append("---\n")
append("IMPORTANT:\n")
append("1. Do NOT repeat previously failed actions. Try a different approach.\n")
append("2. Prioritize the current subgoal.\n")
append("3. Always analyze the screen BEFORE deciding on an action.\n\n")
append("Provide your output in the following format:\n\n")
append("### Thought ###\n")
append("1. **Screen**: What app/page is currently shown? Is it the expected page for the current subgoal?\n")
append("2. **Blocker**: Any popup, dialog, keyboard, or loading state that needs to be handled first?\n")
append("3. **Target**: Is the target element visible? If not, should I scroll or navigate?\n")
append("4. **Decision**: Based on the above analysis, what action should I take and why?\n\n")
append("### Action ###\n")
append("A valid JSON specifying the action. Example: {\"action\":\"click\", \"coordinate\": [500, 800]}\n\n")
append("### Description ###\n")
append("A brief description of the chosen action.\n")
}
/**
* 解析执行响应
* 支持两种格式:
* 1. 标准格式 (### Thought / ### Action / ### Description)
* 2. MAI-UI 格式 (<thinking>...</thinking><tool_call>...</tool_call>)
*/
fun parseResponse(response: String): ExecutorResult {
// 检测是否为 MAI-UI 格式
if (response.contains("<tool_call>") || response.contains("<thinking>")) {
return parseMAIUIResponse(response)
}
// 标准格式解析
val thought = response
.substringAfter("### Thought", "")
.substringBefore("### Action")
.replace("###", "")
.trim()
val actionStr = response
.substringAfter("### Action", "")
.substringBefore("### Description")
.replace("###", "")
.replace("```json", "")
.replace("```", "")
.trim()
val description = response
.substringAfter("### Description", "")
.replace("###", "")
.trim()
val action = Action.fromJson(actionStr)
return ExecutorResult(thought, action, actionStr, description)
}
/**
* 解析 MAI-UI 格式响应
* 格式: <thinking>...</thinking><tool_call>{"name": "mobile_use", "arguments": {...}}</tool_call>
*/
private fun parseMAIUIResponse(response: String): ExecutorResult {
val thought = Action.extractThinking(response)
val action = Action.fromMAIUIFormat(response)
// 从 action 生成描述
val description = when (action?.type) {
"click" -> "点击坐标 (${action.x}, ${action.y})"
"long_press" -> "长按坐标 (${action.x}, ${action.y})"
"double_tap" -> "双击坐标 (${action.x}, ${action.y})"
"swipe" -> {
if (action.direction != null) {
"向${action.direction}滑动"
} else {
"从 (${action.x}, ${action.y}) 滑动到 (${action.x2}, ${action.y2})"
}
}
"type" -> "输入文字: ${action.text}"
"open_app" -> "打开应用: ${action.text}"
"system_button" -> "按${action.button}键"
"wait" -> "等待"
"terminate" -> "任务${if (action.status == "success") "完成" else "失败"}"
"answer" -> "回答: ${action.text?.take(30)}"
"take_over" -> "请求用户: ${action.text}"
else -> action?.type ?: "未知动作"
}
val actionStr = action?.toJson() ?: ""
return ExecutorResult(thought, action, actionStr, description)
}
}
data class ExecutorResult(
val thought: String,
val action: Action?,
val actionStr: String,
val description: String
)
@@ -0,0 +1,226 @@
package com.roubao.autopilot.agent
import org.json.JSONObject
/**
* Agent 状态池 - 保存所有执行过程中的信息
*/
data class InfoPool(
// 用户指令
var instruction: String = "",
// 规划相关
var plan: String = "",
var completedPlan: String = "",
var progressStatus: String = "",
var currentSubgoal: String = "",
// 动作历史
val actionHistory: MutableList<Action> = mutableListOf(),
val summaryHistory: MutableList<String> = mutableListOf(),
val actionOutcomes: MutableList<String> = mutableListOf(), // A=成功, B=错误页面, C=无变化
val errorDescriptions: MutableList<String> = mutableListOf(),
// 最近一次动作
var lastAction: Action? = null,
var lastActionThought: String = "",
var lastSummary: String = "",
// 笔记
var importantNotes: String = "",
// 错误处理
var errorFlagPlan: Boolean = false,
val errToManagerThresh: Int = 2,
// 屏幕尺寸
var screenWidth: Int = 1080,
var screenHeight: Int = 2400,
// 额外知识
var additionalKnowledge: String = "",
// Skill 上下文(从 SkillManager 获取的相关技能信息)
var skillContext: String = "",
// 对话记忆(保存完整对话历史,用于 Executor)
var executorMemory: ConversationMemory? = null,
// 已安装应用列表(用于 open_app 动作)
var installedApps: String = ""
)
/**
* 动作定义
* 支持的动作类型:
* - click, double_tap, long_press: 点击类操作
* - swipe, drag: 滑动类操作
* - type: 输入文字
* - system_button: 系统按键 (Back, Home, Enter)
* - open_app, open: 打开应用
* - answer: 回答问题
* - wait: 等待
* - take_over, ask_user: 人机交互
* - terminate: 任务结束
*/
data class Action(
val type: String,
val x: Int? = null,
val y: Int? = null,
val x2: Int? = null,
val y2: Int? = null,
val text: String? = null,
val button: String? = null, // Back, Home, Enter, menu
val duration: Int? = null, // wait 动作的等待时长(秒)
val message: String? = null, // take_over/ask_user 动作的提示消息
val needConfirm: Boolean = false,
val direction: String? = null, // swipe 方向: up, down, left, right
val status: String? = null // terminate 状态: success, fail
) {
companion object {
private const val SCALE_FACTOR = 999 // MAI-UI 坐标缩放因子
/**
* 从 JSON 字符串解析 Action
* 支持两种格式:
* 1. 标准格式: {"action": "click", "coordinate": [x, y]}
* 2. MAI-UI 格式: <tool_call>{"name": "mobile_use", "arguments": {...}}</tool_call>
*/
fun fromJson(json: String): Action? {
val cleanJson = json.trim()
.replace("```json", "")
.replace("```", "")
.trim()
// 检查是否是 MAI-UI 的 <tool_call> 格式
if (cleanJson.contains("<tool_call>")) {
return fromMAIUIFormat(cleanJson)
}
return fromStandardJson(cleanJson)
}
/**
* 解析标准 JSON 格式
*/
private fun fromStandardJson(json: String): Action? {
return try {
val obj = JSONObject(json)
val type = obj.optString("action", "")
Action(
type = type,
x = obj.optJSONArray("coordinate")?.optInt(0),
y = obj.optJSONArray("coordinate")?.optInt(1),
x2 = obj.optJSONArray("coordinate2")?.optInt(0),
y2 = obj.optJSONArray("coordinate2")?.optInt(1),
text = obj.optString("text", null),
button = obj.optString("button", null),
duration = if (obj.has("duration")) obj.optInt("duration", 3) else null,
message = obj.optString("message", null),
needConfirm = obj.optBoolean("need_confirm", false),
direction = obj.optString("direction", null),
status = obj.optString("status", null)
)
} catch (e: Exception) {
null
}
}
/**
* 解析 MAI-UI 的 <tool_call> 格式
* 格式: <tool_call>{"name": "mobile_use", "arguments": {"action": "click", "coordinate": [x, y]}}</tool_call>
*/
fun fromMAIUIFormat(response: String): Action? {
return try {
// 提取 <tool_call> 内容
val toolCallRegex = Regex("<tool_call>\\s*(.+?)\\s*</tool_call>", RegexOption.DOT_MATCHES_ALL)
val match = toolCallRegex.find(response) ?: return null
val toolCallJson = match.groupValues[1].trim()
val obj = JSONObject(toolCallJson)
val arguments = obj.optJSONObject("arguments") ?: return null
val type = arguments.optString("action", "")
// MAI-UI 坐标是 0-999 归一化的,需要标记(在执行时处理)
val coordinate = arguments.optJSONArray("coordinate")
var x = coordinate?.optInt(0)
var y = coordinate?.optInt(1)
// MAI-UI drag 动作使用 start_coordinate 和 end_coordinate
val startCoord = arguments.optJSONArray("start_coordinate")
val endCoord = arguments.optJSONArray("end_coordinate")
var x2: Int? = null
var y2: Int? = null
if (startCoord != null && endCoord != null) {
x = startCoord.optInt(0)
y = startCoord.optInt(1)
x2 = endCoord.optInt(0)
y2 = endCoord.optInt(1)
}
// 映射 MAI-UI 的动作类型到我们的类型
val mappedType = when (type) {
"open" -> "open_app"
"double_click" -> "double_tap"
"drag" -> "swipe" // drag 和 swipe 在执行层面相同
"ask_user" -> "take_over"
else -> type
}
Action(
type = mappedType,
x = x,
y = y,
x2 = x2,
y2 = y2,
text = arguments.optString("text", null),
button = arguments.optString("button", null),
direction = arguments.optString("direction", null),
status = arguments.optString("status", null)
)
} catch (e: Exception) {
println("[Action] MAI-UI 格式解析失败: ${e.message}")
null
}
}
/**
* 从 MAI-UI 响应中提取思考过程
*/
fun extractThinking(response: String): String {
val thinkingRegex = Regex("<thinking>\\s*(.+?)\\s*</thinking>", RegexOption.DOT_MATCHES_ALL)
val match = thinkingRegex.find(response)
return match?.groupValues?.get(1)?.trim() ?: ""
}
}
fun toJson(): String {
val obj = JSONObject()
obj.put("action", type)
if (x != null && y != null) {
val coord = org.json.JSONArray()
coord.put(x)
coord.put(y)
obj.put("coordinate", coord)
}
if (x2 != null && y2 != null) {
val coord2 = org.json.JSONArray()
coord2.put(x2)
coord2.put(y2)
obj.put("coordinate2", coord2)
}
text?.let { obj.put("text", it) }
button?.let { obj.put("button", it) }
duration?.let { obj.put("duration", it) }
message?.let { obj.put("message", it) }
if (needConfirm) obj.put("need_confirm", true)
return obj.toString()
}
override fun toString(): String = toJson()
}
@@ -0,0 +1,178 @@
package com.roubao.autopilot.agent
/**
* Manager Agent - 负责规划和进度管理
*/
class Manager {
/**
* 生成规划 Prompt
*/
fun getPrompt(infoPool: InfoPool): String {
return if (infoPool.plan.isEmpty()) {
getInitialPlanPrompt(infoPool)
} else {
getUpdatePlanPrompt(infoPool)
}
}
private fun getInitialPlanPrompt(infoPool: InfoPool): String = buildString {
append("You are an agent who can operate an Android phone on behalf of a user. ")
append("Your goal is to track progress and devise high-level plans to achieve the user's requests.\n\n")
append("### User Request ###\n")
append("${infoPool.instruction}\n\n")
append("---\n")
append("Make a high-level plan to achieve the user's request. ")
append("If the request is complex, break it down into subgoals. ")
append("The screenshot displays the starting state of the phone.\n\n")
append("IMPORTANT: For requests that explicitly require an answer, ")
append("always add 'perform the `answer` action' as the last step to the plan!\n\n")
// Skill 上下文
if (infoPool.skillContext.isNotEmpty()) {
append("### Available Skills ###\n")
append("${infoPool.skillContext}\n\n")
}
append("### Guidelines ###\n")
append("1. IMPORTANT: If you see the \"肉包\" or \"Baozi\" app interface (this automation tool), press Home button first to go back to the home screen, then proceed with the task.\n")
append("2. ALWAYS use `open_app` action to open apps - NEVER go to home screen to look for app icons! The open_app action can launch any installed app directly.\n")
append("3. Use search to quickly find a file or entry with a specific name.\n")
append("4. If there are relevant skills listed above, follow their suggested steps for better efficiency.\n")
if (infoPool.additionalKnowledge.isNotEmpty()) {
append("5. ${infoPool.additionalKnowledge}\n")
}
append("\n")
append("Provide your output in the following format:\n\n")
append("### Thought ###\n")
append("A detailed explanation of your rationale for the plan.\n\n")
append("### Plan ###\n")
append("1. first subgoal\n")
append("2. second subgoal\n")
append("...\n")
}
private fun getUpdatePlanPrompt(infoPool: InfoPool): String = buildString {
append("You are an agent who can operate an Android phone on behalf of a user. ")
append("Your goal is to track progress and update plans.\n\n")
append("### User Request ###\n")
append("${infoPool.instruction}\n\n")
if (infoPool.completedPlan.isNotEmpty() && infoPool.completedPlan != "No completed subgoal.") {
append("### Historical Operations ###\n")
append("${infoPool.completedPlan}\n\n")
}
append("### Current Plan ###\n")
append("${infoPool.plan}\n\n")
append("### Last Action ###\n")
append("${infoPool.lastAction}\n\n")
append("### Last Action Description ###\n")
append("${infoPool.lastSummary}\n\n")
// 最近的动作结果
if (infoPool.actionOutcomes.isNotEmpty()) {
val recentOutcomes = infoPool.actionOutcomes.takeLast(3)
val failCount = recentOutcomes.count { it in listOf("B", "C") }
if (failCount > 0) {
append("### Recent Action Results ###\n")
append("Last ${recentOutcomes.size} actions: ${recentOutcomes.joinToString(", ")} ")
append("(A=success, B=partial, C=failed)\n")
append("Failed attempts: $failCount\n\n")
}
}
if (infoPool.importantNotes.isNotEmpty()) {
append("### Important Notes ###\n")
append("${infoPool.importantNotes}\n\n")
}
// 错误升级
if (infoPool.errorFlagPlan) {
append("### ⚠️ STUCK - Multiple Failed Attempts! ###\n")
append("You have encountered several consecutive failed attempts:\n")
val k = infoPool.errToManagerThresh
val recentActions = infoPool.actionHistory.takeLast(k)
val recentSummaries = infoPool.summaryHistory.takeLast(k)
val recentErrors = infoPool.errorDescriptions.takeLast(k)
recentActions.forEachIndexed { i, act ->
append("- Action: $act | Description: ${recentSummaries.getOrNull(i)} | Failed: ${recentErrors.getOrNull(i)}\n")
}
append("\nIMPORTANT: DO NOT mark as \"Finished\" when there are failed attempts! ")
append("Try a different approach:\n")
append("- Wait for page to load (the UI might be loading)\n")
append("- Try clicking at different coordinates\n")
append("- Scroll to find the correct button\n")
append("- Press Back and try again\n\n")
}
append("---\n")
append("Assess the current status.\n\n")
append("### ⛔ SECURITY: Sensitive Pages - MUST STOP ###\n")
append("ONLY output \"STOP_SENSITIVE\" when the screen is ACTIVELY REQUESTING one of these:\n")
append("- A payment confirmation button that will charge money (确认支付, 立即付款, 确认付款)\n")
append("- A password input field that is focused and waiting for input\n")
append("- Face ID or fingerprint verification dialog\n")
append("DO NOT stop for: price displays, payment method selection, cart pages, or general app navigation.\n\n")
append("### CRITICAL: When to mark \"Finished\" ###\n")
append("ONLY mark as \"Finished\" when ALL of these are true:\n")
append("1. The user's request has been FULLY completed (not partially)\n")
append("2. You can SEE the final success state in the screenshot (e.g., order confirmed, message sent, setting changed)\n")
append("3. The last action was SUCCESSFUL (outcome A), not failed (outcome C)\n")
append("4. There are NO recent consecutive failures\n\n")
append("If any action failed or the task is incomplete, DO NOT say \"Finished\". Instead, update the plan with a new approach.\n\n")
append("Provide your output in the following format:\n\n")
append("### Thought ###\n")
append("Your rationale for the updated plan.\n\n")
append("### Historical Operations ###\n")
append("Add newly completed subgoals on top of existing ones.\n\n")
append("### Plan ###\n")
append("Updated plan or \"Finished\" if truly done.\n")
}
/**
* 解析规划响应
*/
fun parseResponse(response: String): PlanResult {
val thought = response
.substringAfter("### Thought", "")
.substringBefore("### Historical Operations")
.substringBefore("### Plan")
.replace("###", "")
.trim()
val completedSubgoal = if (response.contains("### Historical Operations")) {
response
.substringAfter("### Historical Operations")
.substringBefore("### Plan")
.replace("###", "")
.trim()
} else {
"No completed subgoal."
}
val plan = response
.substringAfter("### Plan")
.replace("###", "")
.trim()
return PlanResult(thought, completedSubgoal, plan)
}
}
data class PlanResult(
val thought: String,
val completedSubgoal: String,
val plan: String
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
package com.roubao.autopilot.agent
/**
* Notetaker Agent - 记录重要信息
*/
class Notetaker {
/**
* 生成笔记 Prompt
*/
fun getPrompt(infoPool: InfoPool): String = buildString {
append("You are a helpful AI assistant for operating mobile phones. ")
append("Your goal is to take notes of important content relevant to the user's request.\n\n")
append("### User Request ###\n")
append("${infoPool.instruction}\n\n")
append("### Progress Status ###\n")
append("${infoPool.progressStatus}\n\n")
append("### Existing Important Notes ###\n")
if (infoPool.importantNotes.isNotEmpty()) {
append("${infoPool.importantNotes}\n\n")
} else {
append("No important notes recorded.\n\n")
}
append("---\n")
append("Examine the current screen to identify any important content that needs to be recorded.\n\n")
append("IMPORTANT:\n")
append("- Do not take notes on low-level actions\n")
append("- Only keep track of significant textual or visual information relevant to the request\n")
append("- Do not repeat user request or progress status\n")
append("- Do not make up content that you are not sure about\n\n")
append("Provide your output in the following format:\n\n")
append("### Important Notes ###\n")
append("The updated important notes, combining old and new ones. ")
append("If nothing new to record, copy the existing important notes.\n")
}
/**
* 解析笔记响应
*/
fun parseResponse(response: String): String {
return response
.substringAfter("### Important Notes", "")
.replace("###", "")
.trim()
}
}
@@ -0,0 +1,435 @@
package com.roubao.autopilot.controller
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* App 扫描器 - 获取所有已安装应用信息
* 支持:预扫描缓存、拼音匹配、分类、语义搜索
*/
class AppScanner(private val context: Context) {
companion object {
private const val CACHE_FILE = "installed_apps.json"
// 内存缓存 (应用生命周期内有效)
@Volatile
private var cachedApps: List<AppInfo>? = null
// 预编译的正则表达式(避免重复创建)
private val PINYIN_CLEAN_REGEX = Regex("[^a-z0-9\\u4e00-\\u9fa5]")
// 应用分类关键词映射
private val CATEGORY_KEYWORDS = mapOf(
"社交" to listOf("微信", "QQ", "钉钉", "飞书", "Telegram", "WhatsApp", "Line", "微博", "陌陌", "探探"),
"购物" to listOf("淘宝", "京东", "拼多多", "天猫", "苏宁", "唯品会", "得物", "闲鱼", "当当"),
"外卖" to listOf("美团", "饿了么", "肯德基", "麦当劳", "星巴克", "瑞幸", "小美"),
"出行" to listOf("滴滴", "高德", "百度地图", "腾讯地图", "嘀嗒", "哈啰", "曹操", "T3", "花小猪"),
"地图" to listOf("高德", "百度地图", "腾讯地图", "谷歌地图", "导航"),
"音乐" to listOf("网易云", "QQ音乐", "酷狗", "酷我", "Spotify", "Apple Music", "虾米"),
"视频" to listOf("抖音", "快手", "B站", "bilibili", "优酷", "爱奇艺", "腾讯视频", "芒果TV", "西瓜视频"),
"支付" to listOf("支付宝", "微信", "云闪付", "翼支付"),
"笔记" to listOf("印象笔记", "有道云", "Notion", "备忘录", "便签", "笔记", "记事", "OneNote", "滴答"),
"相机" to listOf("相机", "Camera", "拍照", "美颜", "美图", "轻颜"),
"图片" to listOf("相册", "图库", "Photos", "Gallery", "照片"),
"浏览器" to listOf("Chrome", "Safari", "Firefox", "Edge", "浏览器", "UC", "夸克", "Via"),
"办公" to listOf("WPS", "Office", "钉钉", "飞书", "企业微信", "Slack", "Teams"),
"AI" to listOf("ChatGPT", "Claude", "豆包", "文心", "通义", "讯飞", "Copilot", "即梦", "Midjourney"),
"工具" to listOf("计算器", "手电筒", "指南针", "时钟", "闹钟", "日历", "天气", "文件管理"),
"阅读" to listOf("微信读书", "Kindle", "掌阅", "番茄小说", "起点", "知乎", "今日头条"),
"游戏" to listOf("王者荣耀", "和平精英", "原神", "崩坏", "阴阳师", "游戏")
)
// 拼音映射表 (常用应用)
private val PINYIN_MAP = mapOf(
// 社交
"weixin" to "微信", "wechat" to "微信", "wx" to "微信",
"qq" to "QQ",
"dingding" to "钉钉", "dingtalk" to "钉钉",
"feishu" to "飞书", "lark" to "飞书",
"weibo" to "微博",
// 购物
"taobao" to "淘宝", "tb" to "淘宝",
"jingdong" to "京东", "jd" to "京东",
"pinduoduo" to "拼多多", "pdd" to "拼多多",
"xianyu" to "闲鱼",
// 外卖/餐饮
"meituan" to "美团", "mt" to "美团",
"eleme" to "饿了么", "elm" to "饿了么",
"xiaomei" to "小美",
"kfc" to "肯德基", "kendeji" to "肯德基",
"maidanglao" to "麦当劳", "mcdonald" to "麦当劳",
"starbucks" to "星巴克", "xingbake" to "星巴克",
"ruixing" to "瑞幸", "luckin" to "瑞幸",
// 出行/地图
"didi" to "滴滴", "dd" to "滴滴",
"gaode" to "高德", "amap" to "高德",
"baidu" to "百度", "baidumap" to "百度地图",
"ditu" to "地图",
"daohang" to "导航",
// 支付
"zhifubao" to "支付宝", "alipay" to "支付宝", "zfb" to "支付宝",
// 音乐
"wangyiyun" to "网易云", "netease" to "网易云",
"qqmusic" to "QQ音乐", "qqyinyue" to "QQ音乐",
"kugou" to "酷狗",
"yinyue" to "音乐", "music" to "音乐",
// 视频
"douyin" to "抖音", "tiktok" to "抖音", "dy" to "抖音",
"kuaishou" to "快手", "ks" to "快手",
"bilibili" to "哔哩哔哩", "bzhan" to "哔哩哔哩", "b站" to "哔哩哔哩",
"youku" to "优酷",
"iqiyi" to "爱奇艺", "aiqiyi" to "爱奇艺",
"shipin" to "视频", "video" to "视频",
// 笔记/办公
"biji" to "笔记", "note" to "笔记", "notes" to "笔记",
"beiwanglu" to "备忘录", "memo" to "备忘录",
"bianjian" to "便签",
"wps" to "WPS",
// 系统
"shezhi" to "设置", "settings" to "设置",
"xiangji" to "相机", "camera" to "相机", "paizhao" to "相机",
"xiangce" to "相册", "photos" to "相册", "gallery" to "相册", "tuku" to "图库",
"dianhua" to "电话", "phone" to "电话",
"duanxin" to "短信", "message" to "短信", "sms" to "短信",
"liulanqi" to "浏览器", "browser" to "浏览器",
"jisuanqi" to "计算器", "calculator" to "计算器",
"shizhong" to "时钟", "clock" to "时钟",
"naozhong" to "闹钟", "alarm" to "闹钟",
"rili" to "日历", "calendar" to "日历",
"tianqi" to "天气", "weather" to "天气",
"wenjian" to "文件", "file" to "文件"
)
// 语义查询映射 (用户可能说的自然语言)
private val SEMANTIC_MAP = mapOf(
// 功能描述 -> 分类
"拍照" to "相机", "照相" to "相机", "自拍" to "相机", "拍摄" to "相机",
"看照片" to "图片", "看图" to "图片", "图片" to "图片",
"聊天" to "社交", "发消息" to "社交", "通讯" to "社交",
"买东西" to "购物", "购物" to "购物", "网购" to "购物", "下单" to "购物",
"点餐" to "外卖", "叫外卖" to "外卖", "点外卖" to "外卖", "吃饭" to "外卖", "订餐" to "外卖",
"打车" to "出行", "叫车" to "出行", "出行" to "出行", "坐车" to "出行",
"导航" to "地图", "找路" to "地图", "去哪" to "地图", "怎么走" to "地图",
"听歌" to "音乐", "听音乐" to "音乐", "放歌" to "音乐", "播放音乐" to "音乐",
"看视频" to "视频", "刷视频" to "视频", "追剧" to "视频", "看电影" to "视频", "看剧" to "视频",
"付款" to "支付", "支付" to "支付", "扫码" to "支付", "收款" to "支付",
"记笔记" to "笔记", "记事" to "笔记", "记录" to "笔记", "写笔记" to "笔记",
"上网" to "浏览器", "搜索" to "浏览器", "查资料" to "浏览器",
"办公" to "办公", "工作" to "办公", "文档" to "办公",
"画图" to "AI", "生成图片" to "AI", "AI画图" to "AI", "AI" to "AI",
"看书" to "阅读", "阅读" to "阅读", "读书" to "阅读", "看小说" to "阅读",
"玩游戏" to "游戏", "游戏" to "游戏"
)
}
/**
* 应用信息
*/
data class AppInfo(
val packageName: String,
val appName: String,
val pinyin: String, // 拼音(自动生成)
val category: String?, // 分类
val isSystem: Boolean,
val keywords: List<String> // 关键词(用于搜索)
)
/**
* 搜索结果
*/
data class SearchResult(
val app: AppInfo,
val score: Float, // 匹配分数 0-1
val matchType: String // 匹配类型:exact/contains/pinyin/category/semantic
)
/**
* 获取应用列表 (优先内存 -> 文件 -> 扫描)
*/
fun getApps(): List<AppInfo> {
cachedApps?.let { return it }
val cacheFile = File(context.filesDir, CACHE_FILE)
if (cacheFile.exists()) {
val loaded = loadFromFile(cacheFile)
if (loaded.isNotEmpty()) {
cachedApps = loaded
println("[AppScanner] 从文件加载 ${loaded.size} 个应用")
return loaded
}
}
return refreshApps()
}
/**
* 强制刷新应用列表
*/
fun refreshApps(): List<AppInfo> {
println("[AppScanner] 扫描已安装应用...")
val apps = scanAllApps()
cachedApps = apps
val cacheFile = File(context.filesDir, CACHE_FILE)
saveToFile(apps, cacheFile)
println("[AppScanner] 已缓存 ${apps.size} 个应用")
return apps
}
/**
* 扫描所有已安装应用
*/
private fun scanAllApps(): List<AppInfo> {
val pm = context.packageManager
val apps = mutableListOf<AppInfo>()
try {
// 使用 0 作为 flag,获取所有应用(不过滤)
val packages = pm.getInstalledApplications(0)
for (appInfo in packages) {
val appName = pm.getApplicationLabel(appInfo).toString()
val isSystem = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0
val pinyin = toPinyin(appName)
val category = detectCategory(appName, appInfo.packageName)
val keywords = generateKeywords(appName, appInfo.packageName, category)
apps.add(AppInfo(
packageName = appInfo.packageName,
appName = appName,
pinyin = pinyin,
category = category,
isSystem = isSystem,
keywords = keywords
))
}
} catch (e: Exception) {
e.printStackTrace()
}
return apps.sortedBy { it.appName }
}
/**
* 智能搜索应用
* @param query 搜索词(支持:应用名、拼音、分类、语义描述)
* @param topK 返回前 K 个结果
* @param includeSystem 是否包含系统应用
*/
fun searchApps(query: String, topK: Int = 5, includeSystem: Boolean = true): List<SearchResult> {
val apps = getApps()
val lowerQuery = query.lowercase().trim()
val results = mutableListOf<SearchResult>()
// 先检查是否是语义查询,转换为分类
val semanticCategory = SEMANTIC_MAP[lowerQuery]
val pinyinMapped = PINYIN_MAP[lowerQuery]
for (app in apps) {
if (!includeSystem && app.isSystem) continue
var score = 0f
var matchType = ""
// 1. 精确匹配应用名 (最高优先级)
if (app.appName.equals(query, ignoreCase = true)) {
score = 1.0f
matchType = "exact"
}
// 2. 拼音映射精确匹配
else if (pinyinMapped != null && app.appName.contains(pinyinMapped)) {
score = 0.95f
matchType = "pinyin_exact"
}
// 3. 应用名包含查询词
else if (app.appName.lowercase().contains(lowerQuery)) {
score = 0.9f
matchType = "contains"
}
// 4. 拼音包含
else if (app.pinyin.contains(lowerQuery)) {
score = 0.8f
matchType = "pinyin"
}
// 5. 关键词匹配
else if (app.keywords.any { it.contains(lowerQuery) || lowerQuery.contains(it) }) {
score = 0.7f
matchType = "keyword"
}
// 6. 分类匹配(语义查询)
else if (semanticCategory != null && app.category == semanticCategory) {
score = 0.6f
matchType = "semantic"
}
// 7. 包名包含
else if (app.packageName.lowercase().contains(lowerQuery)) {
score = 0.5f
matchType = "package"
}
if (score > 0) {
// 非系统应用加分
if (!app.isSystem) score += 0.05f
results.add(SearchResult(app, score.coerceAtMost(1f), matchType))
}
}
return results
.sortedByDescending { it.score }
.take(topK)
}
/**
* 根据名称模糊搜索包名 (兼容旧接口)
*/
fun findPackage(query: String): String? {
val results = searchApps(query, topK = 1)
return results.firstOrNull()?.app?.packageName
}
/**
* 按分类获取应用
*/
fun getAppsByCategory(category: String): List<AppInfo> {
return getApps().filter { it.category == category }
}
/**
* 获取所有分类
*/
fun getAllCategories(): List<String> {
return getApps().mapNotNull { it.category }.distinct().sorted()
}
/**
* 格式化搜索结果给 LLM
*/
fun formatSearchResultsForLLM(results: List<SearchResult>): String {
if (results.isEmpty()) return "未找到匹配的应用"
return buildString {
append("找到以下应用,请选择最合适的:\n")
results.forEachIndexed { index, result ->
val app = result.app
val categoryStr = app.category?.let { " [$it]" } ?: ""
append("${index + 1}. ${app.appName}$categoryStr (${app.packageName})\n")
}
}
}
// ========== 辅助方法 ==========
/**
* 简单拼音转换(仅处理常见中文字符)
*/
private fun toPinyin(text: String): String {
// 简单实现:保留英文数字,中文用首字母拼音
// 完整拼音需要引入 pinyin4j 库,这里用简化版本
return text.lowercase()
.replace(PINYIN_CLEAN_REGEX, "")
}
/**
* 检测应用分类
*/
private fun detectCategory(appName: String, packageName: String): String? {
val lowerName = appName.lowercase()
val lowerPackage = packageName.lowercase()
for ((category, keywords) in CATEGORY_KEYWORDS) {
for (keyword in keywords) {
if (lowerName.contains(keyword.lowercase()) ||
lowerPackage.contains(keyword.lowercase())) {
return category
}
}
}
return null
}
/**
* 生成搜索关键词
*/
private fun generateKeywords(appName: String, packageName: String, category: String?): List<String> {
val keywords = mutableListOf<String>()
// 从包名提取关键词
val packageParts = packageName.split(".")
keywords.addAll(packageParts.filter { it.length > 2 })
// 添加分类
category?.let { keywords.add(it) }
// 从拼音映射反向添加
for ((pinyin, name) in PINYIN_MAP) {
if (appName.contains(name)) {
keywords.add(pinyin)
}
}
return keywords.distinct()
}
// ========== 缓存相关 ==========
private fun saveToFile(apps: List<AppInfo>, file: File) {
try {
val jsonArray = JSONArray()
for (app in apps) {
val obj = JSONObject()
obj.put("package", app.packageName)
obj.put("name", app.appName)
obj.put("pinyin", app.pinyin)
obj.put("category", app.category ?: "")
obj.put("system", app.isSystem)
obj.put("keywords", JSONArray(app.keywords))
jsonArray.put(obj)
}
file.writeText(jsonArray.toString())
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun loadFromFile(file: File): List<AppInfo> {
val apps = mutableListOf<AppInfo>()
try {
val jsonArray = JSONArray(file.readText())
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val keywordsArray = obj.optJSONArray("keywords")
val keywords = mutableListOf<String>()
if (keywordsArray != null) {
for (j in 0 until keywordsArray.length()) {
keywords.add(keywordsArray.getString(j))
}
}
apps.add(AppInfo(
packageName = obj.getString("package"),
appName = obj.getString("name"),
pinyin = obj.optString("pinyin", ""),
category = obj.optString("category", null)?.takeIf { it.isNotEmpty() },
isSystem = obj.optBoolean("system", false),
keywords = keywords
))
}
} catch (e: Exception) {
e.printStackTrace()
}
return apps
}
}
@@ -0,0 +1,552 @@
package com.roubao.autopilot.controller
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ComponentName
import android.content.Context
import android.content.ServiceConnection
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import com.roubao.autopilot.App
import com.roubao.autopilot.IShellService
import com.roubao.autopilot.service.ShellService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import rikka.shizuku.Shizuku
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* 设备控制器 - 通过 Shizuku UserService 执行 shell 命令
*/
class DeviceController(private val context: Context? = null) {
companion object {
// 使用 /data/local/tmp,shell 用户有权限访问
private const val SCREENSHOT_PATH = "/data/local/tmp/autopilot_screen.png"
}
private var shellService: IShellService? = null
private var serviceBound = false
private val mainHandler = Handler(Looper.getMainLooper())
private val clipboardManager: ClipboardManager? by lazy {
context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
}
private val userServiceArgs = Shizuku.UserServiceArgs(
ComponentName(
"com.roubao.autopilot",
ShellService::class.java.name
)
)
.daemon(false)
.processNameSuffix("shell")
.debuggable(true)
.version(1)
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
shellService = IShellService.Stub.asInterface(service)
serviceBound = true
println("[DeviceController] ShellService connected")
}
override fun onServiceDisconnected(name: ComponentName?) {
shellService = null
serviceBound = false
println("[DeviceController] ShellService disconnected")
}
}
/**
* 绑定 Shizuku UserService
*/
fun bindService() {
if (!isShizukuAvailable()) {
println("[DeviceController] Shizuku not available")
return
}
try {
Shizuku.bindUserService(userServiceArgs, serviceConnection)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 解绑服务
*/
fun unbindService() {
try {
Shizuku.unbindUserService(userServiceArgs, serviceConnection, true)
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 检查 Shizuku 是否可用
*/
fun isShizukuAvailable(): Boolean {
return try {
Shizuku.checkSelfPermission() == android.content.pm.PackageManager.PERMISSION_GRANTED
} catch (e: Exception) {
false
}
}
/**
* 检查服务是否可用
*/
fun isAvailable(): Boolean {
return serviceBound && shellService != null
}
/**
* Shizuku 权限级别
*/
enum class ShizukuPrivilegeLevel {
NONE, // 未连接
ADB, // ADB 模式 (UID 2000)
ROOT // Root 模式 (UID 0)
}
/**
* 获取当前 Shizuku 权限级别
* UID 0 = root, UID 2000 = shell (ADB)
*/
fun getShizukuPrivilegeLevel(): ShizukuPrivilegeLevel {
if (!isAvailable()) {
return ShizukuPrivilegeLevel.NONE
}
return try {
val uid = Shizuku.getUid()
println("[DeviceController] Shizuku UID: $uid")
when (uid) {
0 -> ShizukuPrivilegeLevel.ROOT
else -> ShizukuPrivilegeLevel.ADB
}
} catch (e: Exception) {
e.printStackTrace()
ShizukuPrivilegeLevel.NONE
}
}
/**
* 执行 shell 命令 (本地,无权限)
*/
private fun execLocal(command: String): String {
return try {
val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val output = reader.readText()
process.waitFor()
reader.close()
output
} catch (e: Exception) {
e.printStackTrace()
""
}
}
/**
* 执行 shell 命令 (通过 Shizuku)
*/
private fun exec(command: String): String {
return try {
shellService?.exec(command) ?: execLocal(command)
} catch (e: Exception) {
e.printStackTrace()
execLocal(command)
}
}
/**
* 点击屏幕
*/
fun tap(x: Int, y: Int) {
exec("input tap $x $y")
}
/**
* 长按
*/
fun longPress(x: Int, y: Int, durationMs: Int = 1000) {
exec("input swipe $x $y $x $y $durationMs")
}
/**
* 双击
*/
fun doubleTap(x: Int, y: Int) {
exec("input tap $x $y && input tap $x $y")
}
/**
* 滑动
*/
fun swipe(x1: Int, y1: Int, x2: Int, y2: Int, durationMs: Int = 500) {
exec("input swipe $x1 $y1 $x2 $y2 $durationMs")
}
/**
* 输入文本 (使用剪贴板方式,支持中文)
*/
fun type(text: String) {
// 检查是否包含非 ASCII 字符
val hasNonAscii = text.any { it.code > 127 }
if (hasNonAscii) {
// 中文等使用剪贴板方式
typeViaClipboard(text)
} else {
// 纯英文数字使用 input text
val escaped = text.replace("'", "'\\''")
exec("input text '$escaped'")
}
}
/**
* 通过剪贴板方式输入中文
* 使用 Android ClipboardManager API 设置剪贴板,然后发送粘贴按键
*/
private fun typeViaClipboard(text: String) {
println("[DeviceController] 尝试输入中文: $text")
// 方法1: 使用 Android 剪贴板 API + 粘贴 (最可靠,不需要额外 App)
if (clipboardManager != null) {
try {
// 使用 CountDownLatch 等待剪贴板设置完成
val latch = CountDownLatch(1)
var clipboardSet = false
// 必须在主线程操作剪贴板
mainHandler.post {
try {
val clip = ClipData.newPlainText("baozi_input", text)
clipboardManager?.setPrimaryClip(clip)
clipboardSet = true
println("[DeviceController] ✅ 已设置剪贴板: $text")
} catch (e: Exception) {
println("[DeviceController] ❌ 设置剪贴板异常: ${e.message}")
} finally {
latch.countDown()
}
}
// 等待剪贴板设置完成 (最多等 1 秒)
val success = latch.await(1, TimeUnit.SECONDS)
if (!success) {
println("[DeviceController] ❌ 等待剪贴板超时")
return
}
if (!clipboardSet) {
println("[DeviceController] ❌ 剪贴板设置失败")
return
}
// 稍等一下确保剪贴板生效
Thread.sleep(200)
// 发送粘贴按键 (KEYCODE_PASTE = 279)
exec("input keyevent 279")
println("[DeviceController] ✅ 已发送粘贴按键")
return
} catch (e: Exception) {
println("[DeviceController] ❌ 剪贴板方式失败: ${e.message}")
e.printStackTrace()
}
} else {
println("[DeviceController] ❌ ClipboardManager 为 null,Context 未设置")
}
// 方法2: 使用 ADB Keyboard 广播 (备选,需要安装 ADBKeyboard)
val escaped = text.replace("\"", "\\\"")
val adbKeyboardResult = exec("am broadcast -a ADB_INPUT_TEXT --es msg \"$escaped\"")
println("[DeviceController] ADBKeyboard 广播结果: $adbKeyboardResult")
if (adbKeyboardResult.contains("result=0")) {
println("[DeviceController] ✅ ADBKeyboard 输入成功")
return
}
// 方法3: 使用 cmd input text (Android 12+ 可能支持 UTF-8)
println("[DeviceController] 尝试 cmd input text...")
exec("cmd input text '$text'")
}
/**
* 输入文本 (逐字符,兼容性更好)
*/
fun typeCharByChar(text: String) {
text.forEach { char ->
when {
char == ' ' -> exec("input text %s")
char == '\n' -> exec("input keyevent 66")
char.isLetterOrDigit() && char.code <= 127 -> exec("input text $char")
char in "-.,!?@'/:;()" -> exec("input text \"$char\"")
else -> {
// 非 ASCII 字符使用广播
exec("am broadcast -a ADB_INPUT_TEXT --es msg \"$char\"")
}
}
}
}
/**
* 返回键
*/
fun back() {
exec("input keyevent 4")
}
/**
* Home 键
*/
fun home() {
exec("input keyevent 3")
}
/**
* 回车键
*/
fun enter() {
exec("input keyevent 66")
}
private var cacheDir: File? = null
fun setCacheDir(dir: File) {
cacheDir = dir
}
/**
* 截图结果
*/
data class ScreenshotResult(
val bitmap: Bitmap,
val isSensitive: Boolean = false, // 是否是敏感页面(截图失败)
val isFallback: Boolean = false // 是否是降级的黑屏占位图
)
/**
* 截图 - 使用 /data/local/tmp 并设置全局可读权限
* 失败时返回黑屏占位图(降级处理)
*/
suspend fun screenshotWithFallback(): ScreenshotResult = withContext(Dispatchers.IO) {
try {
// 截图到 /data/local/tmp 并设置权限让 App 可读
val output = exec("screencap -p $SCREENSHOT_PATH && chmod 666 $SCREENSHOT_PATH")
delay(500)
// 检查是否截图失败(敏感页面保护)
if (output.contains("Status: -1") || output.contains("Failed") || output.contains("error")) {
println("[DeviceController] Screenshot blocked (sensitive screen), returning fallback")
return@withContext createFallbackScreenshot(isSensitive = true)
}
// 尝试直接读取
val file = File(SCREENSHOT_PATH)
if (file.exists() && file.canRead() && file.length() > 0) {
println("[DeviceController] Reading screenshot from: $SCREENSHOT_PATH, size: ${file.length()}")
val bitmap = BitmapFactory.decodeFile(SCREENSHOT_PATH)
if (bitmap != null) {
return@withContext ScreenshotResult(bitmap)
}
}
// 如果无法直接读取,通过 shell cat 读取二进制数据
println("[DeviceController] Cannot read directly, trying shell cat...")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat $SCREENSHOT_PATH"))
val bytes = process.inputStream.readBytes()
process.waitFor()
if (bytes.isNotEmpty()) {
println("[DeviceController] Read ${bytes.size} bytes via shell")
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
if (bitmap != null) {
return@withContext ScreenshotResult(bitmap)
}
}
println("[DeviceController] Screenshot file empty or not accessible, returning fallback")
createFallbackScreenshot(isSensitive = false)
} catch (e: Exception) {
e.printStackTrace()
println("[DeviceController] Screenshot exception, returning fallback")
createFallbackScreenshot(isSensitive = false)
}
}
/**
* 创建黑屏占位图(降级处理)
*/
private fun createFallbackScreenshot(isSensitive: Boolean): ScreenshotResult {
val (width, height) = getScreenSize()
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// 默认是黑色,无需填充
return ScreenshotResult(
bitmap = bitmap,
isSensitive = isSensitive,
isFallback = true
)
}
/**
* 截图 - 使用 /data/local/tmp 并设置全局可读权限
* @deprecated 使用 screenshotWithFallback() 代替
*/
suspend fun screenshot(): Bitmap? = withContext(Dispatchers.IO) {
try {
// 截图到 /data/local/tmp 并设置权限让 App 可读
exec("screencap -p $SCREENSHOT_PATH && chmod 666 $SCREENSHOT_PATH")
delay(500)
// 尝试直接读取
val file = File(SCREENSHOT_PATH)
if (file.exists() && file.canRead() && file.length() > 0) {
println("[DeviceController] Reading screenshot from: $SCREENSHOT_PATH, size: ${file.length()}")
return@withContext BitmapFactory.decodeFile(SCREENSHOT_PATH)
}
// 如果无法直接读取,通过 shell cat 读取二进制数据
println("[DeviceController] Cannot read directly, trying shell cat...")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat $SCREENSHOT_PATH"))
val bytes = process.inputStream.readBytes()
process.waitFor()
if (bytes.isNotEmpty()) {
println("[DeviceController] Read ${bytes.size} bytes via shell")
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} else {
println("[DeviceController] Screenshot file empty or not accessible")
null
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/**
* 获取屏幕尺寸(考虑屏幕方向)
*/
fun getScreenSize(): Pair<Int, Int> {
val output = exec("wm size")
// 输出格式: Physical size: 1080x2400
val match = Regex("(\\d+)x(\\d+)").find(output)
val (physicalWidth, physicalHeight) = if (match != null) {
val (w, h) = match.destructured
Pair(w.toInt(), h.toInt())
} else {
Pair(1080, 2400)
}
// 检测屏幕方向
val orientation = getScreenOrientation()
return if (orientation == 1 || orientation == 3) {
// 横屏:交换宽高
Pair(physicalHeight, physicalWidth)
} else {
// 竖屏
Pair(physicalWidth, physicalHeight)
}
}
/**
* 获取屏幕方向
* @return 0=竖屏, 1=横屏(90°), 2=倒置竖屏, 3=横屏(270°)
*/
private fun getScreenOrientation(): Int {
val output = exec("dumpsys window displays | grep mCurrentOrientation")
// 输出格式: mCurrentOrientation=0 或 mCurrentOrientation=1
val match = Regex("mCurrentOrientation=(\\d)").find(output)
return match?.groupValues?.get(1)?.toIntOrNull() ?: 0
}
/**
* 打开 App - 支持包名或应用名
*/
fun openApp(appNameOrPackage: String) {
// 常见应用名到包名的映射 (作为备选)
val packageMap = mapOf(
"settings" to "com.android.settings",
"设置" to "com.android.settings",
"chrome" to "com.android.chrome",
"浏览器" to "com.android.browser",
"camera" to "com.android.camera",
"相机" to "com.android.camera",
"phone" to "com.android.dialer",
"电话" to "com.android.dialer",
"contacts" to "com.android.contacts",
"联系人" to "com.android.contacts",
"messages" to "com.android.mms",
"短信" to "com.android.mms",
"gallery" to "com.android.gallery3d",
"相册" to "com.android.gallery3d",
"clock" to "com.android.deskclock",
"时钟" to "com.android.deskclock",
"calculator" to "com.android.calculator2",
"计算器" to "com.android.calculator2",
"calendar" to "com.android.calendar",
"日历" to "com.android.calendar",
"files" to "com.android.documentsui",
"文件" to "com.android.documentsui"
)
val lowerName = appNameOrPackage.lowercase().trim()
val finalPackage: String
if (appNameOrPackage.contains(".")) {
// 已经是包名格式
finalPackage = appNameOrPackage
} else if (packageMap.containsKey(lowerName)) {
// 从内置映射中查找
finalPackage = packageMap[lowerName]!!
} else {
// 使用 AppScanner 搜索应用
val appScanner = App.getInstance().appScanner
val searchResults = appScanner.searchApps(appNameOrPackage, topK = 1)
if (searchResults.isNotEmpty()) {
finalPackage = searchResults[0].app.packageName
println("[DeviceController] AppScanner found: ${searchResults[0].app.appName} -> $finalPackage")
} else {
// 找不到,直接用原始输入尝试
finalPackage = appNameOrPackage
println("[DeviceController] App not found in AppScanner: $appNameOrPackage")
}
}
// 使用 monkey 命令启动应用 (最可靠)
val result = exec("monkey -p $finalPackage -c android.intent.category.LAUNCHER 1 2>/dev/null")
println("[DeviceController] openApp: $appNameOrPackage -> $finalPackage, result: $result")
}
/**
* 通过 Intent 打开
*/
fun openIntent(action: String, data: String? = null) {
val cmd = buildString {
append("am start -a $action")
if (data != null) {
append(" -d \"$data\"")
}
}
exec(cmd)
}
/**
* 打开 DeepLink
*/
fun openDeepLink(uri: String) {
exec("am start -a android.intent.action.VIEW -d \"$uri\"")
}
}
@@ -0,0 +1,209 @@
package com.roubao.autopilot.data
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* 执行步骤记录
*/
data class ExecutionStep(
val stepNumber: Int,
val timestamp: Long,
val action: String,
val description: String,
val thought: String,
val outcome: String, // A=成功, B=部分成功, C=失败
val screenshotPath: String? = null
) {
fun toJson(): JSONObject = JSONObject().apply {
put("stepNumber", stepNumber)
put("timestamp", timestamp)
put("action", action)
put("description", description)
put("thought", thought)
put("outcome", outcome)
put("screenshotPath", screenshotPath ?: "")
}
companion object {
fun fromJson(json: JSONObject): ExecutionStep = ExecutionStep(
stepNumber = json.optInt("stepNumber", 0),
timestamp = json.optLong("timestamp", 0),
action = json.optString("action", ""),
description = json.optString("description", ""),
thought = json.optString("thought", ""),
outcome = json.optString("outcome", ""),
screenshotPath = json.optString("screenshotPath", "").ifEmpty { null }
)
}
}
/**
* 执行记录
*/
data class ExecutionRecord(
val id: String = UUID.randomUUID().toString(),
val title: String,
val instruction: String,
val startTime: Long,
val endTime: Long = 0,
val status: ExecutionStatus = ExecutionStatus.RUNNING,
val steps: List<ExecutionStep> = emptyList(),
val logs: List<String> = emptyList(),
val resultMessage: String = ""
) {
val duration: Long get() = if (endTime > 0) endTime - startTime else System.currentTimeMillis() - startTime
val formattedStartTime: String get() {
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
return sdf.format(Date(startTime))
}
val formattedDuration: String get() {
val seconds = duration / 1000
return if (seconds < 60) "${seconds}秒" else "${seconds / 60}分${seconds % 60}秒"
}
fun toJson(): JSONObject = JSONObject().apply {
put("id", id)
put("title", title)
put("instruction", instruction)
put("startTime", startTime)
put("endTime", endTime)
put("status", status.name)
put("resultMessage", resultMessage)
put("steps", JSONArray().apply {
steps.forEach { put(it.toJson()) }
})
put("logs", JSONArray().apply {
logs.forEach { put(it) }
})
}
companion object {
fun fromJson(json: JSONObject): ExecutionRecord {
val stepsArray = json.optJSONArray("steps") ?: JSONArray()
val steps = mutableListOf<ExecutionStep>()
for (i in 0 until stepsArray.length()) {
steps.add(ExecutionStep.fromJson(stepsArray.getJSONObject(i)))
}
val logsArray = json.optJSONArray("logs") ?: JSONArray()
val logs = mutableListOf<String>()
for (i in 0 until logsArray.length()) {
logs.add(logsArray.optString(i, ""))
}
return ExecutionRecord(
id = json.optString("id", UUID.randomUUID().toString()),
title = json.optString("title", ""),
instruction = json.optString("instruction", ""),
startTime = json.optLong("startTime", 0),
endTime = json.optLong("endTime", 0),
status = try {
ExecutionStatus.valueOf(json.optString("status", "COMPLETED"))
} catch (e: Exception) {
ExecutionStatus.COMPLETED
},
steps = steps,
logs = logs,
resultMessage = json.optString("resultMessage", "")
)
}
}
}
enum class ExecutionStatus {
RUNNING,
COMPLETED,
FAILED,
STOPPED
}
/**
* 执行记录仓库
*/
class ExecutionRepository(private val context: Context) {
private val historyFile: File
get() = File(context.filesDir, "execution_history.json")
/**
* 获取所有记录
*/
suspend fun getAllRecords(): List<ExecutionRecord> = withContext(Dispatchers.IO) {
try {
if (!historyFile.exists()) return@withContext emptyList()
val json = historyFile.readText()
val array = JSONArray(json)
val records = mutableListOf<ExecutionRecord>()
for (i in 0 until array.length()) {
records.add(ExecutionRecord.fromJson(array.getJSONObject(i)))
}
records.sortedByDescending { it.startTime }
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
/**
* 获取单条记录
*/
suspend fun getRecord(id: String): ExecutionRecord? = withContext(Dispatchers.IO) {
getAllRecords().find { it.id == id }
}
/**
* 保存记录
*/
suspend fun saveRecord(record: ExecutionRecord) = withContext(Dispatchers.IO) {
try {
val records = getAllRecords().toMutableList()
val existingIndex = records.indexOfFirst { it.id == record.id }
if (existingIndex >= 0) {
records[existingIndex] = record
} else {
records.add(0, record)
}
// 只保留最近100条记录
val trimmedRecords = records.take(100)
val array = JSONArray().apply {
trimmedRecords.forEach { put(it.toJson()) }
}
historyFile.writeText(array.toString())
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 删除记录
*/
suspend fun deleteRecord(id: String) = withContext(Dispatchers.IO) {
try {
val records = getAllRecords().filter { it.id != id }
val array = JSONArray().apply {
records.forEach { put(it.toJson()) }
}
historyFile.writeText(array.toString())
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 清空所有记录
*/
suspend fun clearAll() = withContext(Dispatchers.IO) {
try {
historyFile.delete()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -0,0 +1,352 @@
package com.roubao.autopilot.data
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.roubao.autopilot.ui.theme.ThemeMode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* API 提供商配置
*/
data class ApiProvider(
val id: String,
val name: String,
val baseUrl: String,
val defaultModel: String,
val isGUIAgent: Boolean = false // 是否为 GUI Agent 专用协议(非 OpenAI 兼容)
) {
companion object {
val GUI_OWL = ApiProvider(
id = "gui_owl",
name = "GUI-Owl (阿里云)",
baseUrl = "https://dashscope.aliyuncs.com/api/v2/apps/gui-owl/gui_agent_server",
defaultModel = "pre-gui_owl_7b",
isGUIAgent = true
)
val MAI_UI = ApiProvider(
id = "mai_ui",
name = "MAI-UI (本地部署)",
baseUrl = "http://localhost:8000/v1", // vLLM 默认地址
defaultModel = "MAI-UI-2B" // 支持 MAI-UI-2B 或 MAI-UI-8B
)
val ALIYUN = ApiProvider(
id = "aliyun",
name = "阿里云 (Qwen-VL)",
baseUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1",
defaultModel = "qwen3-vl-plus"
)
val OPENAI = ApiProvider(
id = "openai",
name = "OpenAI",
baseUrl = "https://api.openai.com/v1",
defaultModel = "gpt-4o"
)
val OPENROUTER = ApiProvider(
id = "openrouter",
name = "OpenRouter",
baseUrl = "https://openrouter.ai/api/v1",
defaultModel = "anthropic/claude-3.5-sonnet"
)
val CUSTOM = ApiProvider(
id = "custom",
name = "自定义",
baseUrl = "",
defaultModel = ""
)
val ALL = listOf(GUI_OWL, MAI_UI, ALIYUN, OPENAI, OPENROUTER, CUSTOM)
}
}
/**
* 服务商配置(每个服务商独立保存)
*/
data class ProviderConfig(
val apiKey: String = "",
val model: String = "",
val cachedModels: List<String> = emptyList(),
val customBaseUrl: String = "" // 仅 custom 服务商使用
)
/**
* 默认推荐模型
*/
const val DEFAULT_MODEL = "qwen3-vl-plus"
/**
* 应用设置
*/
data class AppSettings(
val currentProviderId: String = ApiProvider.ALIYUN.id, // 当前选中的服务商
val providerConfigs: Map<String, ProviderConfig> = emptyMap(), // 每个服务商的配置
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val hasSeenOnboarding: Boolean = false,
val maxSteps: Int = 25,
val rootModeEnabled: Boolean = false,
val suCommandEnabled: Boolean = false
) {
// 便捷属性:获取当前服务商的配置
val currentConfig: ProviderConfig
get() = providerConfigs[currentProviderId] ?: ProviderConfig()
val currentProvider: ApiProvider
get() = ApiProvider.ALL.find { it.id == currentProviderId } ?: ApiProvider.ALIYUN
val apiKey: String get() = currentConfig.apiKey
val model: String get() = currentConfig.model.ifEmpty { currentProvider.defaultModel }
val cachedModels: List<String> get() = currentConfig.cachedModels
val baseUrl: String
get() = when {
currentProviderId == "custom" -> currentConfig.customBaseUrl
// MAI-UI 支持自定义 URL(用于远程部署)
currentProviderId == "mai_ui" && currentConfig.customBaseUrl.isNotEmpty() -> currentConfig.customBaseUrl
else -> currentProvider.baseUrl
}
}
/**
* 设置管理器
*/
class SettingsManager(context: Context) {
// 普通设置存储
private val prefs: SharedPreferences =
context.getSharedPreferences("baozi_settings", Context.MODE_PRIVATE)
// 加密存储(用于敏感数据如 API Key)
private val securePrefs: SharedPreferences by lazy {
try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"baozi_secure_settings",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
// 加密失败时回退到普通存储(不应该发生)
android.util.Log.e("SettingsManager", "Failed to create encrypted prefs", e)
prefs
}
}
private val _settings = MutableStateFlow(loadSettings())
val settings: StateFlow<AppSettings> = _settings
init {
// 迁移旧的明文 API Key 到加密存储
migrateApiKeyToSecureStorage()
}
/**
* 迁移旧的明文 API Key 到加密存储
*/
private fun migrateApiKeyToSecureStorage() {
val oldApiKey = prefs.getString("api_key", null)
if (!oldApiKey.isNullOrEmpty()) {
// 保存到加密存储
securePrefs.edit().putString("api_key", oldApiKey).apply()
// 删除旧的明文存储
prefs.edit().remove("api_key").apply()
android.util.Log.d("SettingsManager", "API Key migrated to secure storage")
}
}
private fun loadSettings(): AppSettings {
val themeModeStr = prefs.getString("theme_mode", ThemeMode.SYSTEM.name) ?: ThemeMode.SYSTEM.name
val themeMode = try {
ThemeMode.valueOf(themeModeStr)
} catch (e: Exception) {
ThemeMode.SYSTEM
}
// 加载当前选中的服务商
val currentProviderId = prefs.getString("current_provider_id", ApiProvider.ALIYUN.id) ?: ApiProvider.ALIYUN.id
// 加载每个服务商的配置
val providerConfigs = mutableMapOf<String, ProviderConfig>()
for (provider in ApiProvider.ALL) {
val config = loadProviderConfig(provider.id)
providerConfigs[provider.id] = config
}
// 迁移旧数据(如果有)
val oldApiKey = securePrefs.getString("api_key", null)
val oldModel = prefs.getString("model", null)
val oldBaseUrl = prefs.getString("base_url", null)
val oldCachedModels = prefs.getStringSet("cached_models", null)
if (oldApiKey != null || oldModel != null) {
// 找到旧数据对应的服务商
val oldProviderId = when (oldBaseUrl) {
ApiProvider.ALIYUN.baseUrl -> ApiProvider.ALIYUN.id
ApiProvider.OPENAI.baseUrl -> ApiProvider.OPENAI.id
ApiProvider.OPENROUTER.baseUrl -> ApiProvider.OPENROUTER.id
else -> "custom"
}
// 迁移到新格式
val migratedConfig = ProviderConfig(
apiKey = oldApiKey ?: "",
model = oldModel ?: "",
cachedModels = oldCachedModels?.toList() ?: emptyList(),
customBaseUrl = if (oldProviderId == "custom") oldBaseUrl ?: "" else ""
)
providerConfigs[oldProviderId] = migratedConfig
saveProviderConfig(oldProviderId, migratedConfig)
// 清除旧数据
securePrefs.edit().remove("api_key").apply()
prefs.edit()
.remove("model")
.remove("base_url")
.remove("cached_models")
.putString("current_provider_id", oldProviderId)
.apply()
android.util.Log.d("SettingsManager", "Migrated old settings to provider: $oldProviderId")
}
return AppSettings(
currentProviderId = currentProviderId,
providerConfigs = providerConfigs,
themeMode = themeMode,
hasSeenOnboarding = prefs.getBoolean("has_seen_onboarding", false),
maxSteps = prefs.getInt("max_steps", 25),
rootModeEnabled = prefs.getBoolean("root_mode_enabled", false),
suCommandEnabled = prefs.getBoolean("su_command_enabled", false)
)
}
/**
* 加载指定服务商的配置
*/
private fun loadProviderConfig(providerId: String): ProviderConfig {
val prefix = "provider_${providerId}_"
return ProviderConfig(
apiKey = securePrefs.getString("${prefix}api_key", "") ?: "",
model = prefs.getString("${prefix}model", "") ?: "",
cachedModels = prefs.getStringSet("${prefix}cached_models", emptySet())?.toList() ?: emptyList(),
customBaseUrl = prefs.getString("${prefix}custom_base_url", "") ?: ""
)
}
/**
* 保存指定服务商的配置
*/
private fun saveProviderConfig(providerId: String, config: ProviderConfig) {
val prefix = "provider_${providerId}_"
securePrefs.edit().putString("${prefix}api_key", config.apiKey).apply()
prefs.edit()
.putString("${prefix}model", config.model)
.putStringSet("${prefix}cached_models", config.cachedModels.toSet())
.putString("${prefix}custom_base_url", config.customBaseUrl)
.apply()
}
/**
* 更新当前服务商的配置
*/
private fun updateCurrentConfig(update: (ProviderConfig) -> ProviderConfig) {
val currentId = _settings.value.currentProviderId
val currentConfig = _settings.value.currentConfig
val newConfig = update(currentConfig)
saveProviderConfig(currentId, newConfig)
val newConfigs = _settings.value.providerConfigs.toMutableMap()
newConfigs[currentId] = newConfig
_settings.value = _settings.value.copy(providerConfigs = newConfigs)
}
fun updateApiKey(apiKey: String) {
updateCurrentConfig { it.copy(apiKey = apiKey) }
}
fun updateBaseUrl(baseUrl: String) {
// 自定义服务商和 MAI-UI 可以修改 URL
val providerId = _settings.value.currentProviderId
if (providerId == "custom" || providerId == "mai_ui") {
updateCurrentConfig { it.copy(customBaseUrl = baseUrl) }
}
}
fun updateModel(model: String) {
updateCurrentConfig { it.copy(model = model) }
}
/**
* 更新缓存的模型列表(从 API 获取后调用)
*/
fun updateCachedModels(models: List<String>) {
val distinctModels = models.distinct()
updateCurrentConfig { it.copy(cachedModels = distinctModels) }
}
/**
* 清空缓存的模型列表
*/
fun clearCachedModels() {
updateCurrentConfig { it.copy(cachedModels = emptyList()) }
}
/**
* 选择服务商(切换时自动加载该服务商的配置)
*/
fun selectProvider(provider: ApiProvider) {
prefs.edit().putString("current_provider_id", provider.id).apply()
_settings.value = _settings.value.copy(currentProviderId = provider.id)
}
/**
* 获取当前服务商
*/
fun getCurrentProvider(): ApiProvider {
return _settings.value.currentProvider
}
/**
* 判断是否使用自定义 URL
*/
fun isCustomUrl(): Boolean {
return _settings.value.currentProviderId == "custom"
}
fun updateThemeMode(themeMode: ThemeMode) {
prefs.edit().putString("theme_mode", themeMode.name).apply()
_settings.value = _settings.value.copy(themeMode = themeMode)
}
fun setOnboardingSeen() {
prefs.edit().putBoolean("has_seen_onboarding", true).apply()
_settings.value = _settings.value.copy(hasSeenOnboarding = true)
}
fun updateMaxSteps(maxSteps: Int) {
val validSteps = maxSteps.coerceIn(5, 100) // 限制范围 5-100
prefs.edit().putInt("max_steps", validSteps).apply()
_settings.value = _settings.value.copy(maxSteps = validSteps)
}
fun updateRootModeEnabled(enabled: Boolean) {
prefs.edit().putBoolean("root_mode_enabled", enabled).apply()
_settings.value = _settings.value.copy(rootModeEnabled = enabled)
// 关闭 Root 模式时,同时关闭 su -c
if (!enabled) {
updateSuCommandEnabled(false)
}
}
fun updateSuCommandEnabled(enabled: Boolean) {
prefs.edit().putBoolean("su_command_enabled", enabled).apply()
_settings.value = _settings.value.copy(suCommandEnabled = enabled)
}
}
@@ -0,0 +1,35 @@
package com.roubao.autopilot.service
import com.roubao.autopilot.IShellService
import java.io.BufferedReader
import java.io.InputStreamReader
import kotlin.system.exitProcess
/**
* Shizuku UserService - 在 shell/root 权限下执行命令
*/
class ShellService : IShellService.Stub() {
override fun destroy() {
exitProcess(0)
}
override fun exec(command: String): String {
return try {
val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
val output = reader.readText()
val error = errorReader.readText()
process.waitFor()
reader.close()
errorReader.close()
if (output.isNotEmpty()) output else error
} catch (e: Exception) {
"Error: ${e.message}"
}
}
}
@@ -0,0 +1,299 @@
package com.roubao.autopilot.skills
import com.roubao.autopilot.tools.ToolManager
/**
* 执行类型
*/
enum class ExecutionType {
/** 委托:通过 DeepLink 打开 App */
DELEGATION,
/** GUI 自动化:通过截图-操作循环 */
GUI_AUTOMATION
}
/**
* 关联应用配置
*/
data class RelatedApp(
val packageName: String,
val name: String,
val type: ExecutionType,
val deepLink: String? = null,
val steps: List<String>? = null,
val priority: Int = 0,
val description: String? = null
)
/**
* Skill 参数定义
*/
data class SkillParam(
val name: String,
val type: String, // string, int, boolean
val description: String,
val required: Boolean = false,
val defaultValue: Any? = null,
val examples: List<String> = emptyList()
)
/**
* Skill 配置(意图定义)
*/
data class SkillConfig(
val id: String,
val name: String,
val description: String,
val category: String,
val keywords: List<String>,
val params: List<SkillParam>,
val relatedApps: List<RelatedApp>,
val promptHint: String? = null // 提示词约束,如"内容不超过100字"
)
/**
* Skill 执行计划
*
* 根据用户意图和本地已安装 App 生成的执行方案
*/
data class ExecutionPlan(
val skillId: String,
val skillName: String,
val app: RelatedApp,
val params: Map<String, Any?>,
val isInstalled: Boolean,
val promptHint: String? = null // 提示词约束
) {
/**
* 生成给 Agent 的上下文信息
*/
fun toAgentContext(): String {
return buildString {
append("【任务】${skillName}\n")
append("【目标应用】${app.name} (${app.packageName})\n")
append("【执行方式】${if (app.type == ExecutionType.DELEGATION) "快捷跳转" else "GUI 自动化"}\n")
if (!promptHint.isNullOrBlank()) {
append("【重要提示】⚠️ $promptHint\n")
}
if (!app.steps.isNullOrEmpty()) {
append("【操作步骤】\n")
app.steps.forEachIndexed { index, step ->
append(" ${index + 1}. $step\n")
}
}
if (params.isNotEmpty()) {
append("【参数】\n")
params.forEach { (key, value) ->
if (key != "_raw_query" && value != null) {
append(" $key: $value\n")
}
}
}
}
}
}
/**
* Skill 执行结果
*/
sealed class SkillResult {
/**
* 委托成功:已通过 DeepLink 跳转
*/
data class Delegated(
val app: RelatedApp,
val deepLink: String,
val message: String
) : SkillResult()
/**
* GUI 自动化:返回执行计划给 Agent
*/
data class NeedAutomation(
val plan: ExecutionPlan,
val message: String
) : SkillResult()
/**
* 失败
*/
data class Failed(
val error: String,
val suggestion: String? = null
) : SkillResult()
/**
* 无可用应用
*/
data class NoAvailableApp(
val skillName: String,
val requiredApps: List<String>
) : SkillResult()
}
/**
* Skill 意图匹配器
*/
class Skill(val config: SkillConfig) {
/**
* 计算与用户查询的匹配分数
* @return 0-1 之间的分数
*/
fun matchScore(query: String): Float {
val lowerQuery = query.lowercase()
// 精确匹配关键词(最高分)
for (keyword in config.keywords) {
if (lowerQuery.contains(keyword.lowercase())) {
return 0.9f
}
}
// 匹配 Skill 名称
if (lowerQuery.contains(config.name.lowercase())) {
return 0.8f
}
// 模糊匹配描述
val descWords = config.description.split(" ", ",", "、", "/")
val matchedWords = descWords.count { lowerQuery.contains(it.lowercase()) }
if (matchedWords > 0) {
return (0.3f + 0.3f * matchedWords / descWords.size).coerceAtMost(0.7f)
}
return 0f
}
/**
* 从查询中提取参数
*/
fun extractParams(query: String): Map<String, Any?> {
val params = mutableMapOf<String, Any?>()
for (param in config.params) {
when (param.name) {
"food", "item", "song", "book", "keyword" -> {
// 提取关键内容(去掉意图关键词后的部分)
var content = query
for (kw in config.keywords) {
content = content.replace(kw, "", ignoreCase = true)
}
content = content.trim()
if (content.isNotEmpty()) {
params[param.name] = content
}
}
"destination", "address", "location" -> {
// 提取目的地
val patterns = listOf(
"去(.+?)$",
"到(.+?)$",
"导航(.+?)$",
"(.+?)怎么走"
)
for (pattern in patterns) {
val match = Regex(pattern).find(query)
if (match != null) {
params[param.name] = match.groupValues[1].trim()
break
}
}
}
"contact" -> {
// 提取联系人
val patterns = listOf(
"给(.+?)发",
"跟(.+?)说",
"告诉(.+?)"
)
for (pattern in patterns) {
val match = Regex(pattern).find(query)
if (match != null) {
params[param.name] = match.groupValues[1].trim()
break
}
}
}
"message", "content", "prompt" -> {
// 保存原始查询作为内容
params[param.name] = query
}
"time" -> {
// 提取时间
val patterns = listOf(
"(\\d{1,2}[点::]\\d{0,2})",
"(\\d{1,2}点)",
"(早上|上午|中午|下午|晚上|明天).{0,5}(\\d{1,2}[点::]?\\d{0,2}?)"
)
for (pattern in patterns) {
val match = Regex(pattern).find(query)
if (match != null) {
params[param.name] = match.value
break
}
}
}
}
// 设置默认值
if (!params.containsKey(param.name) && param.defaultValue != null) {
params[param.name] = param.defaultValue
}
}
// 保存原始查询
params["_raw_query"] = query
return params
}
/**
* 生成 DeepLink(替换参数)
*/
fun generateDeepLink(app: RelatedApp, params: Map<String, Any?>): String {
var deepLink = app.deepLink ?: return ""
for ((key, value) in params) {
if (value != null && key != "_raw_query") {
deepLink = deepLink.replace("{$key}", value.toString())
}
}
// 清理未替换的占位符
deepLink = deepLink.replace(Regex("\\{[^}]+\\}"), "")
return deepLink
}
}
/**
* Skill 匹配结果
*/
data class SkillMatch(
val skill: Skill,
val score: Float,
val params: Map<String, Any?>
)
/**
* 可用应用匹配结果
*/
data class AvailableAppMatch(
val skill: Skill,
val app: RelatedApp,
val params: Map<String, Any?>,
val score: Float
)
/**
* LLM 意图匹配结果
*/
data class LLMIntentMatch(
val skillId: String,
val confidence: Float,
val reasoning: String
)
@@ -0,0 +1,503 @@
package com.roubao.autopilot.skills
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.roubao.autopilot.controller.AppScanner
import com.roubao.autopilot.tools.ToolManager
import com.roubao.autopilot.vlm.VLMClient
import org.json.JSONObject
/**
* Skill 管理器
*
* 作为 Skill 层的统一入口,负责:
* - 初始化和加载 Skills
* - 意图识别和 Skill 匹配(使用 LLM 语义理解)
* - 基于已安装 App 选择最佳执行方案
* - Skill 执行调度
*/
class SkillManager private constructor(
private val context: Context,
private val toolManager: ToolManager,
private val appScanner: AppScanner
) {
private val registry: SkillRegistry = SkillRegistry.init(context, appScanner)
// VLM 客户端(用于意图匹配)
private var vlmClient: VLMClient? = null
/**
* 设置 VLM 客户端(用于 LLM 意图匹配)
*/
fun setVLMClient(client: VLMClient) {
this.vlmClient = client
}
/**
* 初始化:加载 Skills 配置
*/
fun initialize() {
val loadedCount = registry.loadFromAssets("skills.json")
println("[SkillManager] 已加载 $loadedCount 个 Skills")
}
/**
* 刷新已安装应用列表
*/
fun refreshInstalledApps() {
registry.refreshInstalledApps()
}
/**
* 处理用户意图(新方法:返回最佳可用应用)
*
* @param query 用户输入
* @return 可用应用匹配结果,如果没有则返回 null
*/
fun matchAvailableApp(query: String): AvailableAppMatch? {
return registry.getBestAvailableApp(query, minScore = 0.3f)
}
/**
* 获取所有匹配的可用应用
*/
fun matchAllAvailableApps(query: String): List<AvailableAppMatch> {
return registry.matchAvailableApps(query, minScore = 0.2f)
}
/**
* 使用 LLM 进行意图匹配(异步方法)
*
* @param query 用户输入
* @return 匹配的 Skill ID,如果没有匹配返回 null
*/
suspend fun matchIntentWithLLM(query: String): LLMIntentMatch? {
val client = vlmClient ?: return null
// 构建 Skills 列表描述
val skillsInfo = buildString {
append("可用技能列表:\n")
for (skill in registry.getAll()) {
val config = skill.config
// 只展示有已安装应用的 Skill
val installedApps = config.relatedApps.filter { registry.isAppInstalled(it.packageName) }
if (installedApps.isNotEmpty()) {
append("- ID: ${config.id}\n")
append(" 名称: ${config.name}\n")
append(" 描述: ${config.description}\n")
append(" 关键词: ${config.keywords.joinToString(", ")}\n")
append(" 可用应用: ${installedApps.joinToString(", ") { it.name }}\n\n")
}
}
}
val prompt = """你是一个意图识别助手。根据用户输入,判断最匹配的技能。
$skillsInfo
用户输入: "$query"
请分析用户意图,返回 JSON 格式:
{
"skill_id": "匹配的技能ID,如果没有匹配返回 null",
"confidence": 0.0-1.0 的置信度,
"reasoning": "简短的匹配理由"
}
注意:
1. 只返回 JSON,不要有其他文字
2. 如果用户意图明确匹配某个技能,即使措辞不同也要识别
3. 如果确实没有匹配的技能,skill_id 返回 null
4. 例如"点个汉堡"、"帮我点外卖"、"想吃炸鸡" 都应该匹配 order_food
5. "附近好吃的"、"推荐美食" 应该匹配 find_food"""
return try {
val result = client.predict(prompt)
result.getOrNull()?.let { response ->
parseIntentResponse(response)
}
} catch (e: Exception) {
println("[SkillManager] LLM 意图匹配失败: ${e.message}")
null
}
}
/**
* 解析 LLM 返回的意图匹配结果
*/
private fun parseIntentResponse(response: String): LLMIntentMatch? {
return try {
// 提取 JSON(可能被 markdown 包裹)
val jsonStr = response
.replace("```json", "")
.replace("```", "")
.trim()
val json = JSONObject(jsonStr)
val skillId = json.optString("skill_id", null)?.takeIf { it != "null" && it.isNotEmpty() }
val confidence = json.optDouble("confidence", 0.0).toFloat()
val reasoning = json.optString("reasoning", "")
if (skillId != null) {
LLMIntentMatch(
skillId = skillId,
confidence = confidence,
reasoning = reasoning
)
} else {
null
}
} catch (e: Exception) {
println("[SkillManager] 解析意图响应失败: ${e.message}")
null
}
}
/**
* 使用 LLM 匹配意图并返回可用应用(组合方法)
*/
suspend fun matchAvailableAppWithLLM(query: String): AvailableAppMatch? {
// 先尝试 LLM 匹配
val llmMatch = matchIntentWithLLM(query)
if (llmMatch != null && llmMatch.confidence >= 0.5f) {
println("[SkillManager] LLM 匹配: ${llmMatch.skillId} (置信度: ${llmMatch.confidence})")
println("[SkillManager] 理由: ${llmMatch.reasoning}")
// 获取对应的 Skill 和已安装应用
val skill = registry.get(llmMatch.skillId)
if (skill != null) {
println("[SkillManager] 找到 Skill: ${skill.config.name}")
println("[SkillManager] 关联应用: ${skill.config.relatedApps.map { "${it.name}(${it.packageName})" }}")
// 检查每个应用的安装状态
for (app in skill.config.relatedApps) {
val installed = registry.isAppInstalled(app.packageName)
println("[SkillManager] ${app.name}(${app.packageName}): ${if (installed) "已安装" else "未安装"}")
}
val availableApp = skill.config.relatedApps
.filter { registry.isAppInstalled(it.packageName) }
.maxByOrNull { it.priority }
if (availableApp != null) {
println("[SkillManager] 选中应用: ${availableApp.name}")
val params = skill.extractParams(query)
return AvailableAppMatch(
skill = skill,
app = availableApp,
params = params,
score = llmMatch.confidence
)
} else {
println("[SkillManager] 没有可用应用(都未安装)")
}
} else {
println("[SkillManager] 未找到 Skill: ${llmMatch.skillId}")
}
}
// 如果 LLM 匹配失败,回退到关键词匹配
println("[SkillManager] LLM 未匹配或无可用应用,回退到关键词匹配")
return matchAvailableApp(query)
}
/**
* 生成给 Agent 的上下文提示(使用 LLM 匹配)
*/
suspend fun generateAgentContextWithLLM(query: String): String {
// 使用 LLM 匹配
val match = matchAvailableAppWithLLM(query)
if (match == null) {
return "未找到相关技能或可用应用,请使用通用 GUI 自动化完成任务。"
}
return buildString {
val config = match.skill.config
val app = match.app
append("根据用户意图,已匹配到技能:\n\n")
append("【${config.name}】(置信度: ${(match.score * 100).toInt()}%)\n")
append("描述: ${config.description}\n\n")
// 显示提示词约束(如小红书100字限制)
if (!config.promptHint.isNullOrBlank()) {
append("⚠️ 重要提示: ${config.promptHint}\n\n")
}
val typeLabel = when (app.type) {
ExecutionType.DELEGATION -> "🚀委托(快速)"
ExecutionType.GUI_AUTOMATION -> "🤖GUI自动化"
}
append("推荐应用: ${app.name} $typeLabel\n")
if (app.type == ExecutionType.DELEGATION && app.deepLink != null) {
append("DeepLink: ${app.deepLink}\n")
}
if (!app.steps.isNullOrEmpty()) {
append("操作步骤: ${app.steps.joinToString(" → ")}\n")
}
app.description?.let {
append("说明: $it\n")
}
append("\n建议:")
if (app.type == ExecutionType.DELEGATION) {
append("使用 DeepLink 直接打开 ${app.name},可快速完成任务。")
} else {
append("通过 GUI 自动化操作 ${app.name} 完成任务。")
}
}
}
/**
* 执行 Skill(核心执行方法)
*
* @param match 可用应用匹配结果
* @return 执行结果
*/
suspend fun execute(match: AvailableAppMatch): SkillResult {
val skill = match.skill
val app = match.app
val params = match.params
println("[SkillManager] 执行: ${skill.config.name} -> ${app.name} (${app.type})")
return when (app.type) {
ExecutionType.DELEGATION -> {
// 委托模式:通过 DeepLink 打开
executeDelegation(skill, app, params)
}
ExecutionType.GUI_AUTOMATION -> {
// GUI 自动化模式:返回执行计划
executeAutomation(skill, app, params)
}
}
}
/**
* 执行委托(DeepLink)
*/
private fun executeDelegation(
skill: Skill,
app: RelatedApp,
params: Map<String, Any?>
): SkillResult {
val deepLink = skill.generateDeepLink(app, params)
if (deepLink.isEmpty()) {
return SkillResult.Failed(
error = "无法生成 DeepLink",
suggestion = "尝试使用 GUI 自动化方式"
)
}
return try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deepLink)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// 明确指定目标包名,避免系统选择其他能响应此 scheme 的应用
setPackage(app.packageName)
}
context.startActivity(intent)
SkillResult.Delegated(
app = app,
deepLink = deepLink,
message = "已打开 ${app.name}"
)
} catch (e: Exception) {
// 如果指定包名失败,尝试不指定包名的方式
println("[SkillManager] 指定包名打开失败,尝试通用方式: ${e.message}")
try {
val fallbackIntent = Intent(Intent.ACTION_VIEW, Uri.parse(deepLink)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(fallbackIntent)
SkillResult.Delegated(
app = app,
deepLink = deepLink,
message = "已打开 ${app.name}(通用方式)"
)
} catch (e2: Exception) {
SkillResult.Failed(
error = "打开 ${app.name} 失败: ${e2.message}",
suggestion = "请确认应用已安装并支持 DeepLink"
)
}
}
}
/**
* 执行 GUI 自动化(返回执行计划给 Agent)
*/
private fun executeAutomation(
skill: Skill,
app: RelatedApp,
params: Map<String, Any?>
): SkillResult {
val plan = ExecutionPlan(
skillId = skill.config.id,
skillName = skill.config.name,
app = app,
params = params,
isInstalled = true,
promptHint = skill.config.promptHint
)
return SkillResult.NeedAutomation(
plan = plan,
message = "需要通过 GUI 自动化操作 ${app.name}"
)
}
/**
* 判断是否应该使用快速路径
*
* 条件:
* 1. 高置信度匹配 (score >= 0.8)
* 2. 最佳应用是委托类型 (delegation)
* 3. 应用已安装
*/
fun shouldUseFastPath(query: String): AvailableAppMatch? {
val match = matchAvailableApp(query) ?: return null
// 只有委托类型且高置信度才走快速路径
if (match.app.type == ExecutionType.DELEGATION && match.score >= 0.8f) {
return match
}
return null
}
/**
* 生成给 Agent 的上下文提示
*
* 包含:匹配的意图、可用应用列表、推荐操作步骤
*/
fun generateAgentContext(query: String): String {
val matches = matchAllAvailableApps(query)
if (matches.isEmpty()) {
return "未找到相关技能或可用应用,请使用通用 GUI 自动化完成任务。"
}
return buildString {
append("根据用户意图,匹配到以下可用方案:\n\n")
// 按 Skill 分组
val groupedBySkill = matches.groupBy { it.skill.config.id }
for ((_, skillMatches) in groupedBySkill) {
val firstMatch = skillMatches.first()
val config = firstMatch.skill.config
append("【${config.name}】(置信度: ${(firstMatch.score * 100).toInt()}%)\n")
for ((index, match) in skillMatches.withIndex()) {
val app = match.app
val typeLabel = when (app.type) {
ExecutionType.DELEGATION -> "🚀委托(快速)"
ExecutionType.GUI_AUTOMATION -> "🤖GUI自动化"
}
append(" ${index + 1}. ${app.name} $typeLabel (优先级: ${app.priority})\n")
if (app.type == ExecutionType.DELEGATION && app.deepLink != null) {
append(" DeepLink: ${app.deepLink}\n")
}
if (!app.steps.isNullOrEmpty()) {
append(" 步骤: ${app.steps.joinToString(" → ")}\n")
}
app.description?.let {
append(" 说明: $it\n")
}
}
append("\n")
}
append("建议:优先使用委托模式(🚀),速度更快。如果委托失败再使用 GUI 自动化(🤖)。")
}
}
/**
* 获取 Skill 信息
*/
fun getSkillInfo(skillId: String): SkillConfig? {
return registry.get(skillId)?.config
}
/**
* 获取所有 Skills 描述(给 LLM)
*/
fun getSkillsDescription(): String {
return registry.getSkillsDescription()
}
/**
* 获取所有 Skills
*/
fun getAllSkills(): List<Skill> {
return registry.getAll()
}
/**
* 按分类获取 Skills
*/
fun getSkillsByCategory(category: String): List<Skill> {
return registry.getByCategory(category)
}
/**
* 检查意图是否有可用应用
*/
fun hasAvailableApp(query: String): Boolean {
return matchAvailableApp(query) != null
}
/**
* 获取意图的所有关联应用(不管是否安装)
*/
fun getAllRelatedApps(query: String): List<RelatedApp> {
val skillMatch = registry.matchBest(query) ?: return emptyList()
return skillMatch.skill.config.relatedApps
}
/**
* 获取缺失的应用推荐(用户没装但可以装的)
*/
fun getMissingAppSuggestions(query: String): List<RelatedApp> {
val skillMatch = registry.matchBest(query) ?: return emptyList()
return skillMatch.skill.config.relatedApps
.filter { !registry.isAppInstalled(it.packageName) }
.sortedByDescending { it.priority }
}
companion object {
@Volatile
private var instance: SkillManager? = null
fun init(context: Context, toolManager: ToolManager, appScanner: AppScanner): SkillManager {
return instance ?: synchronized(this) {
instance ?: SkillManager(context.applicationContext, toolManager, appScanner).also {
it.initialize()
instance = it
}
}
}
fun getInstance(): SkillManager {
return instance ?: throw IllegalStateException("SkillManager 未初始化,请先调用 init()")
}
fun isInitialized(): Boolean = instance != null
}
}
@@ -0,0 +1,341 @@
package com.roubao.autopilot.skills
import android.content.Context
import com.roubao.autopilot.controller.AppScanner
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
/**
* Skill 注册表
*
* 管理所有 Skills 的注册、查找和匹配
* 核心功能:
* - 从 skills.json 加载意图定义
* - 查询本地已安装 App,筛选可用应用
* - 根据优先级选择最佳执行方案
*/
class SkillRegistry private constructor(
private val context: Context,
private val appScanner: AppScanner
) {
private val skills = mutableMapOf<String, Skill>()
private val categoryIndex = mutableMapOf<String, MutableList<Skill>>()
// 缓存已安装 App 的包名集合(启动时刷新)
private var installedPackages: Set<String> = emptySet()
/**
* 初始化:刷新已安装应用列表
*/
fun refreshInstalledApps() {
val apps = appScanner.getApps()
installedPackages = apps.map { it.packageName }.toSet()
println("[SkillRegistry] 已缓存 ${installedPackages.size} 个已安装应用")
// 调试:检查美团相关的应用
val meituanApps = installedPackages.filter { it.contains("meituan") || it.contains("dianping") }
println("[SkillRegistry] 美团相关应用: $meituanApps")
// 检查小美的 DeepLink 是否可用(间接检测安装状态)
try {
val pm = context.packageManager
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
data = android.net.Uri.parse("beam://www.meituan.com/home")
}
val resolveInfo = pm.resolveActivity(intent, 0)
if (resolveInfo != null) {
val pkgName = resolveInfo.activityInfo.packageName
println("[SkillRegistry] 小美 DeepLink 可用,包名: $pkgName")
if (!installedPackages.contains(pkgName)) {
installedPackages = installedPackages + pkgName
println("[SkillRegistry] 添加 $pkgName 到已安装列表")
}
} else {
println("[SkillRegistry] 小美 DeepLink 不可用")
}
} catch (e: Exception) {
println("[SkillRegistry] 检查小美失败: ${e.message}")
}
}
/**
* 检查包名是否已安装
*/
fun isAppInstalled(packageName: String): Boolean {
return installedPackages.contains(packageName)
}
/**
* 从 assets/skills.json 加载 Skills
*/
fun loadFromAssets(filename: String = "skills.json"): Int {
try {
val jsonString = context.assets.open(filename).bufferedReader().use { it.readText() }
return loadFromJson(jsonString)
} catch (e: IOException) {
println("[SkillRegistry] 无法加载 $filename: ${e.message}")
return 0
}
}
/**
* 从 JSON 字符串加载 Skills
*/
fun loadFromJson(jsonString: String): Int {
var loadedCount = 0
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val config = parseSkillConfig(obj)
register(Skill(config))
loadedCount++
}
println("[SkillRegistry] 已加载 $loadedCount 个 Skills")
} catch (e: Exception) {
println("[SkillRegistry] JSON 解析错误: ${e.message}")
e.printStackTrace()
}
return loadedCount
}
/**
* 解析单个 Skill 配置(新结构)
*/
private fun parseSkillConfig(obj: JSONObject): SkillConfig {
// 解析参数
val params = mutableListOf<SkillParam>()
val paramsArray = obj.optJSONArray("params")
if (paramsArray != null) {
for (i in 0 until paramsArray.length()) {
val paramObj = paramsArray.getJSONObject(i)
val examples = mutableListOf<String>()
val examplesArray = paramObj.optJSONArray("examples")
if (examplesArray != null) {
for (j in 0 until examplesArray.length()) {
examples.add(examplesArray.getString(j))
}
}
params.add(SkillParam(
name = paramObj.getString("name"),
type = paramObj.optString("type", "string"),
description = paramObj.optString("description", ""),
required = paramObj.optBoolean("required", false),
defaultValue = paramObj.opt("default"),
examples = examples
))
}
}
// 解析关键词
val keywords = mutableListOf<String>()
val keywordsArray = obj.optJSONArray("keywords")
if (keywordsArray != null) {
for (i in 0 until keywordsArray.length()) {
keywords.add(keywordsArray.getString(i))
}
}
// 解析关联应用列表(新结构)
val relatedApps = mutableListOf<RelatedApp>()
val appsArray = obj.optJSONArray("related_apps")
if (appsArray != null) {
for (i in 0 until appsArray.length()) {
val appObj = appsArray.getJSONObject(i)
// 解析执行类型
val typeStr = appObj.optString("type", "gui_automation")
val type = when (typeStr.lowercase()) {
"delegation" -> ExecutionType.DELEGATION
else -> ExecutionType.GUI_AUTOMATION
}
// 解析操作步骤
val steps = mutableListOf<String>()
val stepsArray = appObj.optJSONArray("steps")
if (stepsArray != null) {
for (j in 0 until stepsArray.length()) {
steps.add(stepsArray.getString(j))
}
}
relatedApps.add(RelatedApp(
packageName = appObj.getString("package"),
name = appObj.getString("name"),
type = type,
deepLink = appObj.optString("deep_link", null)?.takeIf { it.isNotEmpty() },
steps = if (steps.isEmpty()) null else steps,
priority = appObj.optInt("priority", 0),
description = appObj.optString("description", null)?.takeIf { it.isNotEmpty() }
))
}
}
return SkillConfig(
id = obj.getString("id"),
name = obj.getString("name"),
description = obj.optString("description", ""),
category = obj.optString("category", "通用"),
keywords = keywords,
params = params,
relatedApps = relatedApps,
promptHint = obj.optString("prompt_hint", null)?.takeIf { it.isNotEmpty() }
)
}
/**
* 注册 Skill
*/
fun register(skill: Skill) {
skills[skill.config.id] = skill
// 更新分类索引
val category = skill.config.category
categoryIndex.getOrPut(category) { mutableListOf() }.add(skill)
println("[SkillRegistry] 注册 Skill: ${skill.config.id} (${skill.config.relatedApps.size} 关联应用)")
}
/**
* 获取 Skill
*/
fun get(id: String): Skill? = skills[id]
/**
* 获取所有 Skills
*/
fun getAll(): List<Skill> = skills.values.toList()
/**
* 按分类获取 Skills
*/
fun getByCategory(category: String): List<Skill> {
return categoryIndex[category] ?: emptyList()
}
/**
* 获取所有分类
*/
fun getAllCategories(): List<String> = categoryIndex.keys.toList()
/**
* 匹配用户意图(基于关键词)
*/
fun match(query: String, topK: Int = 3, minScore: Float = 0.3f): List<SkillMatch> {
val matches = mutableListOf<SkillMatch>()
for (skill in skills.values) {
val score = skill.matchScore(query)
if (score >= minScore) {
val params = skill.extractParams(query)
matches.add(SkillMatch(skill, score, params))
}
}
return matches
.sortedByDescending { it.score }
.take(topK)
}
/**
* 获取最佳匹配
*/
fun matchBest(query: String, minScore: Float = 0.3f): SkillMatch? {
return match(query, topK = 1, minScore = minScore).firstOrNull()
}
/**
* 匹配意图并返回可用应用(核心方法)
*
* 1. 匹配用户意图到 Skill
* 2. 筛选出已安装的关联应用
* 3. 按优先级排序
*/
fun matchAvailableApps(
query: String,
minScore: Float = 0.3f
): List<AvailableAppMatch> {
val skillMatches = match(query, topK = 5, minScore = minScore)
val results = mutableListOf<AvailableAppMatch>()
for (skillMatch in skillMatches) {
val skill = skillMatch.skill
val params = skillMatch.params
// 筛选已安装的应用,按优先级排序
val availableApps = skill.config.relatedApps
.filter { isAppInstalled(it.packageName) }
.sortedByDescending { it.priority }
for (app in availableApps) {
results.add(AvailableAppMatch(
skill = skill,
app = app,
params = params,
score = skillMatch.score
))
}
}
// 按 (匹配分数 * 0.5 + 应用优先级 * 0.01) 综合排序
return results.sortedByDescending { it.score * 0.5f + it.app.priority * 0.01f }
}
/**
* 获取意图的最佳可用应用
*/
fun getBestAvailableApp(query: String, minScore: Float = 0.3f): AvailableAppMatch? {
return matchAvailableApps(query, minScore).firstOrNull()
}
/**
* 生成 Skills 描述(给 LLM)
*/
fun getSkillsDescription(): String {
return buildString {
append("可用技能列表:\n\n")
for ((category, categorySkills) in categoryIndex) {
append("【$category】\n")
for (skill in categorySkills) {
val config = skill.config
append("- ${config.name}: ${config.description}\n")
if (config.keywords.isNotEmpty()) {
append(" 关键词: ${config.keywords.joinToString(", ")}\n")
}
// 显示已安装的应用
val installedApps = config.relatedApps.filter { isAppInstalled(it.packageName) }
if (installedApps.isNotEmpty()) {
val appNames = installedApps.map {
val typeIcon = if (it.type == ExecutionType.DELEGATION) "🚀" else "🤖"
"$typeIcon${it.name}"
}
append(" 可用应用: ${appNames.joinToString(", ")}\n")
}
}
append("\n")
}
}
}
companion object {
@Volatile
private var instance: SkillRegistry? = null
fun init(context: Context, appScanner: AppScanner): SkillRegistry {
return instance ?: synchronized(this) {
instance ?: SkillRegistry(context.applicationContext, appScanner).also {
it.refreshInstalledApps()
instance = it
}
}
}
fun getInstance(): SkillRegistry {
return instance ?: throw IllegalStateException("SkillRegistry 未初始化,请先调用 init()")
}
fun isInitialized(): Boolean = instance != null
}
}
@@ -0,0 +1,152 @@
package com.roubao.autopilot.tools
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Handler
import android.os.Looper
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/**
* 剪贴板工具
*
* 提供剪贴板的读写功能
*/
class ClipboardTool(private val context: Context) : Tool {
override val name = "clipboard"
override val displayName = "剪贴板"
override val description = "读取或写入系统剪贴板内容"
override val params = listOf(
ToolParam(
name = "action",
type = "string",
description = "操作类型:read(读取)或 write(写入)",
required = true
),
ToolParam(
name = "text",
type = "string",
description = "要写入的文本(action=write 时必填)",
required = false
),
ToolParam(
name = "label",
type = "string",
description = "剪贴板标签(可选)",
required = false,
defaultValue = "roubao"
)
)
private val clipboardManager: ClipboardManager by lazy {
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
}
private val mainHandler = Handler(Looper.getMainLooper())
override suspend fun execute(params: Map<String, Any?>): ToolResult {
val action = params["action"] as? String
?: return ToolResult.Error("缺少 action 参数")
return when (action.lowercase()) {
"read" -> readClipboard()
"write" -> {
val text = params["text"] as? String
?: return ToolResult.Error("write 操作需要 text 参数")
val label = params["label"] as? String ?: "roubao"
writeClipboard(text, label)
}
else -> ToolResult.Error("不支持的操作: $action(只支持 read/write)")
}
}
/**
* 读取剪贴板内容
*/
private suspend fun readClipboard(): ToolResult = suspendCancellableCoroutine { cont ->
mainHandler.post {
try {
val clip = clipboardManager.primaryClip
if (clip == null || clip.itemCount == 0) {
cont.resume(ToolResult.Success(data = "", message = "剪贴板为空"))
} else {
val text = clip.getItemAt(0).coerceToText(context).toString()
cont.resume(ToolResult.Success(
data = text,
message = "已读取剪贴板内容(${text.length} 字符)"
))
}
} catch (e: Exception) {
cont.resume(ToolResult.Error("读取剪贴板失败: ${e.message}"))
}
}
}
/**
* 写入剪贴板内容
*/
private suspend fun writeClipboard(text: String, label: String): ToolResult = suspendCancellableCoroutine { cont ->
mainHandler.post {
try {
val clip = ClipData.newPlainText(label, text)
clipboardManager.setPrimaryClip(clip)
cont.resume(ToolResult.Success(
data = text,
message = "已写入剪贴板(${text.length} 字符)"
))
} catch (e: Exception) {
cont.resume(ToolResult.Error("写入剪贴板失败: ${e.message}"))
}
}
}
/**
* 同步读取(用于非协程环境)
*/
fun readSync(): String? {
var result: String? = null
val latch = java.util.concurrent.CountDownLatch(1)
mainHandler.post {
try {
val clip = clipboardManager.primaryClip
if (clip != null && clip.itemCount > 0) {
result = clip.getItemAt(0).coerceToText(context).toString()
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
latch.countDown()
}
}
latch.await(1, java.util.concurrent.TimeUnit.SECONDS)
return result
}
/**
* 同步写入(用于非协程环境)
*/
fun writeSync(text: String, label: String = "roubao"): Boolean {
var success = false
val latch = java.util.concurrent.CountDownLatch(1)
mainHandler.post {
try {
val clip = ClipData.newPlainText(label, text)
clipboardManager.setPrimaryClip(clip)
success = true
} catch (e: Exception) {
e.printStackTrace()
} finally {
latch.countDown()
}
}
latch.await(1, java.util.concurrent.TimeUnit.SECONDS)
return success
}
}
@@ -0,0 +1,108 @@
package com.roubao.autopilot.tools
import com.roubao.autopilot.controller.DeviceController
/**
* DeepLink 工具
*
* 通过 Intent 打开应用的特定页面或功能
* 这是实现 delegation 类型 Skill 的核心工具
*/
class DeepLinkTool(private val deviceController: DeviceController) : Tool {
override val name = "deep_link"
override val displayName = "深度链接"
override val description = "通过 DeepLink/Intent 打开应用的特定页面或功能"
override val params = listOf(
ToolParam(
name = "uri",
type = "string",
description = "DeepLink URI(如:weixin://、alipays://、amap://)",
required = true
),
ToolParam(
name = "action",
type = "string",
description = "Intent Action(默认 VIEW)",
required = false,
defaultValue = "android.intent.action.VIEW"
)
)
override suspend fun execute(params: Map<String, Any?>): ToolResult {
val uri = params["uri"] as? String
?: return ToolResult.Error("缺少 uri 参数")
return try {
deviceController.openDeepLink(uri)
ToolResult.Success(
data = mapOf("uri" to uri),
message = "已打开: $uri"
)
} catch (e: Exception) {
ToolResult.Error("打开 DeepLink 失败: ${e.message}")
}
}
companion object {
/**
* 常用 DeepLink 模板
*/
val TEMPLATES = mapOf(
// ========== 外卖类 ==========
"meituan_food" to "imeituan://www.meituan.com/waimai/home",
"meituan_search" to "imeituan://www.meituan.com/search?q={query}",
"eleme_home" to "eleme://search",
"xiaomei_chat" to "xiaomei://chat?message={query}", // 小美 AI 对话
// ========== 出行类 ==========
"amap_route" to "amap://route/plan?sourceApplication=roubao&dlat={lat}&dlon={lon}&dname={destination}&dev=0&t=0",
"amap_navi" to "amap://navi?sourceApplication=roubao&lat={lat}&lon={lon}&dev=0",
"amap_search" to "amap://search?keyword={query}&sourceApplication=roubao",
"didi_call" to "diditaxi://",
"baidu_map_route" to "baidumap://map/direction?destination={destination}&mode=driving&src=roubao",
// ========== 社交类 ==========
"weixin_scan" to "weixin://scanqrcode",
"weixin_pay" to "weixin://pay",
"alipay_scan" to "alipays://platformapi/startapp?appId=10000007",
"alipay_pay" to "alipays://platformapi/startapp?appId=20000056",
// ========== 支付类 ==========
"alipay_transfer" to "alipays://platformapi/startapp?appId=20000200&actionType=toAccount&account={account}",
// ========== 音乐类 ==========
"netease_play" to "orpheus://song/{id}",
"netease_search" to "orpheus://search?keyword={query}",
"qqmusic_search" to "qqmusic://qq.com/ui/search?key={query}",
// ========== 视频类 ==========
"douyin_search" to "snssdk1128://search?keyword={query}",
"bilibili_search" to "bilibili://search?keyword={query}",
"bilibili_video" to "bilibili://video/{bvid}",
// ========== AI 类(delegation 目标)==========
"doubao_chat" to "doubao://chat?message={query}",
"tongyi_chat" to "tongyi://chat?message={query}",
// ========== 通用 ==========
"web" to "{url}",
"tel" to "tel:{phone}",
"sms" to "sms:{phone}?body={message}",
"email" to "mailto:{email}?subject={subject}&body={body}"
)
/**
* 根据模板生成 DeepLink
*/
fun fromTemplate(templateName: String, params: Map<String, String>): String? {
val template = TEMPLATES[templateName] ?: return null
var result = template
for ((key, value) in params) {
result = result.replace("{$key}", value)
}
return result
}
}
}
@@ -0,0 +1,164 @@
package com.roubao.autopilot.tools
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
/**
* HTTP 请求工具
*
* 用于调用外部 API,如:
* - 调用 AI 服务
* - 获取天气信息
* - 查询数据
*/
class HttpTool : Tool {
override val name = "http_request"
override val displayName = "HTTP 请求"
override val description = "发送 HTTP 请求调用外部 API"
override val params = listOf(
ToolParam(
name = "url",
type = "string",
description = "请求 URL",
required = true
),
ToolParam(
name = "method",
type = "string",
description = "HTTP 方法(GET/POST/PUT/DELETE)",
required = false,
defaultValue = "GET"
),
ToolParam(
name = "headers",
type = "object",
description = "请求头(JSON 格式)",
required = false
),
ToolParam(
name = "body",
type = "string",
description = "请求体(POST/PUT 时使用)",
required = false
),
ToolParam(
name = "timeout",
type = "int",
description = "超时时间(毫秒)",
required = false,
defaultValue = 30000
)
)
override suspend fun execute(params: Map<String, Any?>): ToolResult = withContext(Dispatchers.IO) {
val urlStr = params["url"] as? String
?: return@withContext ToolResult.Error("缺少 url 参数")
val method = (params["method"] as? String)?.uppercase() ?: "GET"
val timeout = (params["timeout"] as? Number)?.toInt() ?: 30000
val body = params["body"] as? String
@Suppress("UNCHECKED_CAST")
val headers = params["headers"] as? Map<String, String> ?: emptyMap()
try {
val url = URL(urlStr)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = method
connection.connectTimeout = timeout
connection.readTimeout = timeout
connection.doInput = true
// 设置请求头
headers.forEach { (key, value) ->
connection.setRequestProperty(key, value)
}
// 默认 Content-Type
if (body != null && !headers.containsKey("Content-Type")) {
connection.setRequestProperty("Content-Type", "application/json")
}
// 发送请求体
if (body != null && (method == "POST" || method == "PUT")) {
connection.doOutput = true
OutputStreamWriter(connection.outputStream).use { writer ->
writer.write(body)
writer.flush()
}
}
// 读取响应
val responseCode = connection.responseCode
val inputStream = if (responseCode >= 400) {
connection.errorStream
} else {
connection.inputStream
}
val response = BufferedReader(InputStreamReader(inputStream)).use { reader ->
reader.readText()
}
connection.disconnect()
if (responseCode >= 400) {
return@withContext ToolResult.Error(
"HTTP $responseCode: $response",
code = responseCode
)
}
ToolResult.Success(
data = mapOf(
"status_code" to responseCode,
"body" to response
),
message = "请求成功 (HTTP $responseCode)"
)
} catch (e: Exception) {
ToolResult.Error("请求失败: ${e.message}")
}
}
/**
* 简化的 GET 请求
*/
suspend fun get(url: String, headers: Map<String, String> = emptyMap()): ToolResult {
return execute(mapOf(
"url" to url,
"method" to "GET",
"headers" to headers
))
}
/**
* 简化的 POST 请求
*/
suspend fun post(url: String, body: String, headers: Map<String, String> = emptyMap()): ToolResult {
return execute(mapOf(
"url" to url,
"method" to "POST",
"body" to body,
"headers" to headers
))
}
/**
* POST JSON 请求
*/
suspend fun postJson(url: String, json: JSONObject, headers: Map<String, String> = emptyMap()): ToolResult {
val allHeaders = headers.toMutableMap()
allHeaders["Content-Type"] = "application/json"
return post(url, json.toString(), allHeaders)
}
}
@@ -0,0 +1,56 @@
package com.roubao.autopilot.tools
import com.roubao.autopilot.controller.AppScanner
import com.roubao.autopilot.controller.DeviceController
/**
* 打开应用工具
*
* 支持:
* - 通过包名打开
* - 通过应用名打开(自动搜索包名)
*/
class OpenAppTool(
private val deviceController: DeviceController,
private val appScanner: AppScanner
) : Tool {
override val name = "open_app"
override val displayName = "打开应用"
override val description = "打开指定的应用程序"
override val params = listOf(
ToolParam(
name = "app",
type = "string",
description = "应用名称或包名(如:微信、com.tencent.mm)",
required = true
)
)
override suspend fun execute(params: Map<String, Any?>): ToolResult {
val app = params["app"] as? String
?: return ToolResult.Error("缺少 app 参数")
// 判断是包名还是应用名
val packageName = if (app.contains(".")) {
// 已经是包名格式
app
} else {
// 需要搜索包名
val results = appScanner.searchApps(app, topK = 1)
results.firstOrNull()?.app?.packageName
?: return ToolResult.Error("未找到应用: $app")
}
return try {
deviceController.openApp(packageName)
ToolResult.Success(
data = mapOf("package_name" to packageName),
message = "已打开应用: $app"
)
} catch (e: Exception) {
ToolResult.Error("打开应用失败: ${e.message}")
}
}
}
@@ -0,0 +1,83 @@
package com.roubao.autopilot.tools
import com.roubao.autopilot.controller.AppScanner
/**
* 搜索应用工具
*
* 支持:
* - 应用名搜索
* - 拼音搜索
* - 语义搜索(如"点外卖"会匹配外卖类应用)
* - 分类搜索
*/
class SearchAppsTool(private val appScanner: AppScanner) : Tool {
override val name = "search_apps"
override val displayName = "搜索应用"
override val description = "在已安装应用中搜索,支持应用名、拼音、语义描述等多种方式"
override val params = listOf(
ToolParam(
name = "query",
type = "string",
description = "搜索词(应用名/拼音/描述,如:微信、weixin、聊天)",
required = true
),
ToolParam(
name = "top_k",
type = "int",
description = "返回结果数量",
required = false,
defaultValue = 5
),
ToolParam(
name = "include_system",
type = "boolean",
description = "是否包含系统应用",
required = false,
defaultValue = true
)
)
override suspend fun execute(params: Map<String, Any?>): ToolResult {
val query = params["query"] as? String
?: return ToolResult.Error("缺少 query 参数")
val topK = (params["top_k"] as? Number)?.toInt() ?: 5
val includeSystem = params["include_system"] as? Boolean ?: true
val results = appScanner.searchApps(query, topK, includeSystem)
if (results.isEmpty()) {
return ToolResult.Success(
data = emptyList<Map<String, Any>>(),
message = "未找到匹配\"$query\"的应用"
)
}
val data = results.map { result ->
mapOf(
"package_name" to result.app.packageName,
"app_name" to result.app.appName,
"category" to (result.app.category ?: ""),
"score" to result.score,
"match_type" to result.matchType,
"is_system" to result.app.isSystem
)
}
return ToolResult.Success(
data = data,
message = "找到 ${results.size} 个匹配的应用"
)
}
/**
* 快捷方法:直接获取最佳匹配的包名
*/
fun findBestMatch(query: String): String? {
val results = appScanner.searchApps(query, topK = 1)
return results.firstOrNull()?.app?.packageName
}
}
@@ -0,0 +1,150 @@
package com.roubao.autopilot.tools
import com.roubao.autopilot.controller.DeviceController
import com.roubao.autopilot.data.SettingsManager
/**
* Shell 命令工具
*
* 通过 Shizuku 执行 shell 命令
* 注意:这是一个底层工具,主要供其他工具或高级场景使用
*/
class ShellTool(
private val deviceController: DeviceController,
private val settingsManager: SettingsManager? = null
) : Tool {
override val name = "shell"
override val displayName = "Shell 命令"
override val description = "执行 shell 命令(需要 Shizuku 权限)"
override val params = listOf(
ToolParam(
name = "command",
type = "string",
description = "要执行的 shell 命令",
required = true
)
)
// 安全白名单:允许执行的命令前缀
private val ALLOWED_PREFIXES = listOf(
"input ", // 输入操作
"am ", // Activity Manager
"pm ", // Package Manager
"wm ", // Window Manager
"screencap ", // 截图
"monkey ", // 启动应用
"dumpsys ", // 系统信息
"getprop ", // 系统属性
"settings ", // 系统设置
"content ", // Content Provider
"cmd ", // 通用命令
"ls ", // 文件列表
"cat ", // 读文件
"echo " // 输出
)
// 基础黑名单:始终禁止的危险命令
private val BASE_BLOCKED_COMMANDS = listOf(
"rm -rf",
"rm -r /",
"format",
"mkfs",
"dd if=",
"reboot",
"shutdown",
"> /dev",
"chmod 777 /"
)
// su -c 命令(需要特殊权限才能使用)
private val SU_COMMAND = "su -c"
/**
* 获取当前生效的黑名单
* 根据设置决定是否允许 su -c
*/
private fun getBlockedCommands(): List<String> {
val settings = settingsManager?.settings?.value
val suEnabled = settings?.rootModeEnabled == true && settings.suCommandEnabled == true
return if (suEnabled) {
BASE_BLOCKED_COMMANDS // su -c 已启用,不在黑名单中
} else {
BASE_BLOCKED_COMMANDS + SU_COMMAND // su -c 禁用
}
}
override suspend fun execute(params: Map<String, Any?>): ToolResult {
val command = params["command"] as? String
?: return ToolResult.Error("缺少 command 参数")
// 安全检查
val securityCheck = checkSecurity(command)
if (securityCheck != null) {
return ToolResult.Error(securityCheck)
}
// 由于 DeviceController.exec 是 private,这里需要通过已有的公开方法
// 或者扩展 DeviceController
// 暂时只支持特定的安全命令
return try {
val result = executeCommand(command)
ToolResult.Success(
data = result,
message = "命令执行完成"
)
} catch (e: Exception) {
ToolResult.Error("执行失败: ${e.message}")
}
}
/**
* 安全检查
*/
private fun checkSecurity(command: String): String? {
val lowerCmd = command.lowercase().trim()
val blockedCommands = getBlockedCommands()
// 检查黑名单
for (blocked in blockedCommands) {
if (lowerCmd.contains(blocked.lowercase())) {
// 特殊提示 su -c 命令
if (blocked == SU_COMMAND) {
return "安全限制:su -c 命令需要在设置中开启「Root 模式」和「允许 su -c」"
}
return "安全限制:禁止执行此类命令"
}
}
// 检查白名单(可选,如果需要更严格的控制可以启用)
// val isAllowed = ALLOWED_PREFIXES.any { lowerCmd.startsWith(it.lowercase()) }
// if (!isAllowed) {
// return "安全限制:命令不在允许列表中"
// }
return null
}
/**
* 执行命令
* 注意:这里需要扩展 DeviceController 或使用反射
* 暂时使用简化实现
*/
private fun executeCommand(command: String): String {
return try {
val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command))
val output = process.inputStream.bufferedReader().readText()
val error = process.errorStream.bufferedReader().readText()
process.waitFor()
if (error.isNotBlank()) {
"Output: $output\nError: $error"
} else {
output
}
} catch (e: Exception) {
"Error: ${e.message}"
}
}
}
@@ -0,0 +1,144 @@
package com.roubao.autopilot.tools
import org.json.JSONObject
/**
* Tool 执行结果
*/
sealed class ToolResult {
data class Success(val data: Any?, val message: String = "") : ToolResult()
data class Error(val error: String, val code: Int = -1) : ToolResult()
val isSuccess: Boolean get() = this is Success
fun getDataOrNull(): Any? = (this as? Success)?.data
fun toJson(): JSONObject = JSONObject().apply {
when (this@ToolResult) {
is Success -> {
put("success", true)
put("data", data?.toString() ?: "")
put("message", message)
}
is Error -> {
put("success", false)
put("error", error)
put("code", code)
}
}
}
}
/**
* Tool 参数定义
*/
data class ToolParam(
val name: String,
val type: String, // string, int, boolean, list
val description: String,
val required: Boolean = true,
val defaultValue: Any? = null
)
/**
* Tool 接口 - 所有工具的基类
*
* Tool 是原子能力层,提供单一、可复用的功能
* - 每个 Tool 完成一个独立的操作
* - Tool 之间可以组合使用
* - Tool 的输入输出是结构化的
*/
interface Tool {
/** 工具名称(唯一标识) */
val name: String
/** 工具显示名称 */
val displayName: String
/** 工具描述 */
val description: String
/** 参数定义 */
val params: List<ToolParam>
/**
* 执行工具
* @param params 参数 Map
* @return 执行结果
*/
suspend fun execute(params: Map<String, Any?>): ToolResult
/**
* 验证参数
*/
fun validateParams(params: Map<String, Any?>): List<String> {
val errors = mutableListOf<String>()
for (param in this.params) {
if (param.required && !params.containsKey(param.name)) {
errors.add("缺少必填参数: ${param.name}")
}
}
return errors
}
/**
* 生成给 LLM 的工具描述
*/
fun toLLMDescription(): String {
val paramsDesc = params.joinToString("\n") { p ->
val required = if (p.required) "(必填)" else "(可选)"
" - ${p.name}: ${p.type} $required - ${p.description}"
}
return """
|工具: $name
|说明: $description
|参数:
|$paramsDesc
""".trimMargin()
}
}
/**
* Tool 注册表 - 管理所有可用工具
*/
object ToolRegistry {
private val tools = mutableMapOf<String, Tool>()
fun register(tool: Tool) {
tools[tool.name] = tool
println("[ToolRegistry] 注册工具: ${tool.name}")
}
fun get(name: String): Tool? = tools[name]
fun getAll(): List<Tool> = tools.values.toList()
fun getNames(): List<String> = tools.keys.toList()
fun contains(name: String): Boolean = tools.containsKey(name)
/**
* 生成所有工具的描述(给 LLM)
*/
fun getAllDescriptions(): String {
return tools.values.joinToString("\n\n") { it.toLLMDescription() }
}
/**
* 根据名称执行工具
*/
suspend fun execute(toolName: String, params: Map<String, Any?>): ToolResult {
val tool = tools[toolName] ?: return ToolResult.Error("未找到工具: $toolName")
val errors = tool.validateParams(params)
if (errors.isNotEmpty()) {
return ToolResult.Error("参数错误: ${errors.joinToString(", ")}")
}
return try {
tool.execute(params)
} catch (e: Exception) {
ToolResult.Error("执行失败: ${e.message}")
}
}
}
@@ -0,0 +1,109 @@
package com.roubao.autopilot.tools
import android.content.Context
import com.roubao.autopilot.controller.AppScanner
import com.roubao.autopilot.controller.DeviceController
/**
* 工具管理器
*
* 负责初始化、注册和管理所有 Tools
* 作为 Tool 层的统一入口
*/
class ToolManager private constructor(
private val context: Context,
private val deviceController: DeviceController,
private val appScanner: AppScanner
) {
// 持有各个工具的引用(方便直接调用)
lateinit var searchAppsTool: SearchAppsTool
private set
lateinit var openAppTool: OpenAppTool
private set
lateinit var clipboardTool: ClipboardTool
private set
lateinit var deepLinkTool: DeepLinkTool
private set
lateinit var shellTool: ShellTool
private set
lateinit var httpTool: HttpTool
private set
/**
* 初始化所有工具
*/
private fun initialize() {
// 创建工具实例
searchAppsTool = SearchAppsTool(appScanner)
openAppTool = OpenAppTool(deviceController, appScanner)
clipboardTool = ClipboardTool(context)
deepLinkTool = DeepLinkTool(deviceController)
shellTool = ShellTool(deviceController)
httpTool = HttpTool()
// 注册到全局 Registry
ToolRegistry.register(searchAppsTool)
ToolRegistry.register(openAppTool)
ToolRegistry.register(clipboardTool)
ToolRegistry.register(deepLinkTool)
ToolRegistry.register(shellTool)
ToolRegistry.register(httpTool)
println("[ToolManager] 已初始化 ${ToolRegistry.getAll().size} 个工具")
}
/**
* 执行工具
*/
suspend fun execute(toolName: String, params: Map<String, Any?>): ToolResult {
return ToolRegistry.execute(toolName, params)
}
/**
* 获取所有工具描述(给 LLM)
*/
fun getToolDescriptions(): String {
return ToolRegistry.getAllDescriptions()
}
/**
* 获取可用工具列表
*/
fun getAvailableTools(): List<Tool> {
return ToolRegistry.getAll()
}
companion object {
@Volatile
private var instance: ToolManager? = null
/**
* 初始化单例
*/
fun init(
context: Context,
deviceController: DeviceController,
appScanner: AppScanner
): ToolManager {
return instance ?: synchronized(this) {
instance ?: ToolManager(context, deviceController, appScanner).also {
it.initialize()
instance = it
}
}
}
/**
* 获取单例
*/
fun getInstance(): ToolManager {
return instance ?: throw IllegalStateException("ToolManager 未初始化,请先调用 init()")
}
/**
* 检查是否已初始化
*/
fun isInitialized(): Boolean = instance != null
}
}
@@ -0,0 +1,501 @@
package com.roubao.autopilot.ui
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.*
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.IBinder
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.roubao.autopilot.MainActivity
import com.roubao.autopilot.R
/**
* 七彩悬浮窗服务 - 显示当前执行步骤
* 放在屏幕顶部状态栏下方,不影响截图识别
*/
class OverlayService : Service() {
private var windowManager: WindowManager? = null
private var overlayView: View? = null
private var textView: TextView? = null
private var actionButton: TextView? = null
private var cancelButton: TextView? = null // 确认模式下的取消按钮
private var divider: View? = null
private var divider2: View? = null // 确认模式下第二个分隔线
private var animator: ValueAnimator? = null
companion object {
private var instance: OverlayService? = null
private var stopCallback: (() -> Unit)? = null
private var continueCallback: (() -> Unit)? = null
private var confirmCallback: ((Boolean) -> Unit)? = null // 敏感操作确认回调
private var isTakeOverMode = false
private var isConfirmMode = false // 敏感操作确认模式
// 等待 instance 回调队列
private val pendingCallbacks = mutableListOf<() -> Unit>()
fun show(context: Context, text: String, onStop: (() -> Unit)? = null) {
stopCallback = onStop
isTakeOverMode = false
isConfirmMode = false
instance?.updateText(text) ?: run {
val intent = Intent(context, OverlayService::class.java).apply {
putExtra("text", text)
}
ContextCompat.startForegroundService(context, intent)
}
instance?.setNormalMode()
}
fun hide(context: Context) {
stopCallback = null
continueCallback = null
confirmCallback = null
isTakeOverMode = false
isConfirmMode = false
pendingCallbacks.clear()
// 只有当 service 已经启动完成时才停止它
// 否则会导致 ForegroundServiceDidNotStartInTimeException
if (instance != null) {
context.stopService(Intent(context, OverlayService::class.java))
}
}
fun update(text: String) {
instance?.updateText(text)
}
/** 截图时临时隐藏悬浮窗 */
fun setVisible(visible: Boolean) {
instance?.overlayView?.post {
instance?.overlayView?.visibility = if (visible) View.VISIBLE else View.INVISIBLE
}
}
/** 显示人机协作模式 - 等待用户手动完成操作 */
fun showTakeOver(message: String, onContinue: () -> Unit) {
val action: () -> Unit = {
println("[OverlayService] showTakeOver: $message")
continueCallback = onContinue
isTakeOverMode = true
isConfirmMode = false
instance?.setTakeOverMode(message)
Unit
}
if (instance != null) {
action()
} else {
// 悬浮窗尚未启动,加入等待队列
println("[OverlayService] showTakeOver: instance is null, queuing...")
pendingCallbacks.add(action)
}
}
/** 显示敏感操作确认模式 - 用户确认或取消 */
fun showConfirm(message: String, onConfirm: (Boolean) -> Unit) {
val action: () -> Unit = {
println("[OverlayService] showConfirm: $message")
confirmCallback = onConfirm
isConfirmMode = true
isTakeOverMode = false
instance?.setConfirmMode(message)
Unit
}
if (instance != null) {
action()
} else {
// 悬浮窗尚未启动,加入等待队列
println("[OverlayService] showConfirm: instance is null, queuing...")
pendingCallbacks.add(action)
}
}
/** 当 instance 可用时执行等待中的回调 */
private fun processPendingCallbacks() {
println("[OverlayService] processPendingCallbacks: ${pendingCallbacks.size} pending")
pendingCallbacks.forEach { it.invoke() }
pendingCallbacks.clear()
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
instance = this
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
// 必须第一时间调用 startForeground,否则会崩溃
startForegroundNotification()
// 创建悬浮窗(可能因权限问题失败)
try {
createOverlayView()
} catch (e: Exception) {
println("[OverlayService] createOverlayView failed: ${e.message}")
}
// 处理在 service 启动前排队的回调
processPendingCallbacks()
}
private fun startForegroundNotification() {
val channelId = "baozi_overlay"
val channelName = "肉包状态"
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
channelName,
NotificationManager.IMPORTANCE_LOW
).apply {
description = "显示肉包执行状态"
setShowBadge(false)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, channelId)
.setContentTitle("肉包运行中")
.setContentText("正在执行自动化任务...")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
startForeground(1001, notification)
} catch (e: Exception) {
println("[OverlayService] startForegroundNotification error: ${e.message}")
// 降级:使用最简单的通知确保 startForeground 被调用
try {
val fallbackNotification = NotificationCompat.Builder(this, channelId)
.setContentTitle("肉包")
.setSmallIcon(R.mipmap.ic_launcher)
.build()
startForeground(1001, fallbackNotification)
} catch (e2: Exception) {
println("[OverlayService] fallback startForeground also failed: ${e2.message}")
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val text = intent?.getStringExtra("text") ?: "AutoPilot"
updateText(text)
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
instance = null
animator?.cancel()
overlayView?.let { windowManager?.removeView(it) }
}
@SuppressLint("ClickableViewAccessibility")
private fun createOverlayView() {
// 容器
val container = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(16, 12, 16, 12)
}
// 七彩渐变背景
val gradientDrawable = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 30f
setStroke(2, Color.WHITE)
}
container.background = gradientDrawable
// 状态文字
textView = TextView(this).apply {
text = "肉包"
textSize = 13f
setTextColor(Color.WHITE)
gravity = Gravity.CENTER
setPadding(16, 4, 16, 4)
setShadowLayer(4f, 0f, 0f, Color.BLACK)
typeface = Typeface.DEFAULT_BOLD
}
container.addView(textView)
// 分隔线
divider = View(this).apply {
setBackgroundColor(Color.WHITE)
alpha = 0.5f
}
val dividerParams = LinearLayout.LayoutParams(2, 36).apply {
setMargins(12, 0, 12, 0)
}
container.addView(divider, dividerParams)
// 动作按钮(停止/继续/确认)
actionButton = TextView(this).apply {
text = "⏹ 停止"
textSize = 13f
setTextColor(Color.WHITE)
gravity = Gravity.CENTER
setPadding(16, 4, 16, 4)
setShadowLayer(4f, 0f, 0f, Color.BLACK)
typeface = Typeface.DEFAULT_BOLD
setOnClickListener {
when {
isConfirmMode -> {
// 确认模式:点击确认
confirmCallback?.invoke(true)
confirmCallback = null
isConfirmMode = false
setNormalMode()
}
isTakeOverMode -> {
// 人机协作模式:点击继续
continueCallback?.invoke()
continueCallback = null
isTakeOverMode = false
setNormalMode()
}
else -> {
// 正常模式:点击停止
stopCallback?.invoke()
hide(this@OverlayService)
}
}
}
}
container.addView(actionButton)
// 第二个分隔线(确认模式用)
divider2 = View(this).apply {
setBackgroundColor(Color.WHITE)
alpha = 0.5f
visibility = View.GONE
}
val divider2Params = LinearLayout.LayoutParams(2, 36).apply {
setMargins(12, 0, 12, 0)
}
container.addView(divider2, divider2Params)
// 取消按钮(确认模式用)
cancelButton = TextView(this).apply {
text = "❌ 取消"
textSize = 13f
setTextColor(Color.parseColor("#FF6B6B")) // 红色
gravity = Gravity.CENTER
setPadding(16, 4, 16, 4)
setShadowLayer(4f, 0f, 0f, Color.BLACK)
typeface = Typeface.DEFAULT_BOLD
visibility = View.GONE
setOnClickListener {
if (isConfirmMode) {
confirmCallback?.invoke(false)
confirmCallback = null
isConfirmMode = false
setNormalMode()
}
}
}
container.addView(cancelButton)
// 动画:七彩渐变流动效果
startRainbowAnimation(gradientDrawable)
val params = WindowManager.LayoutParams().apply {
type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
} else {
@Suppress("DEPRECATION")
WindowManager.LayoutParams.TYPE_PHONE
}
flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON // 保持屏幕常亮
format = PixelFormat.TRANSLUCENT
width = WindowManager.LayoutParams.WRAP_CONTENT
height = WindowManager.LayoutParams.WRAP_CONTENT
gravity = Gravity.TOP or Gravity.START
x = 100
y = 200
}
// 添加拖动功能(只拦截文字区域,不影响按钮点击)
var initialX = 0
var initialY = 0
var initialTouchX = 0f
var initialTouchY = 0f
var isDragging = false
val dragThreshold = 20f // 增大阈值,避免误触
// 只在文字区域启用拖动,按钮区域不拦截
textView?.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
initialX = params.x
initialY = params.y
initialTouchX = event.rawX
initialTouchY = event.rawY
isDragging = false
true
}
MotionEvent.ACTION_MOVE -> {
val deltaX = event.rawX - initialTouchX
val deltaY = event.rawY - initialTouchY
if (kotlin.math.abs(deltaX) > dragThreshold || kotlin.math.abs(deltaY) > dragThreshold) {
isDragging = true
}
if (isDragging) {
params.x = initialX + deltaX.toInt()
params.y = initialY + deltaY.toInt()
windowManager?.updateViewLayout(container, params)
}
true
}
MotionEvent.ACTION_UP -> {
isDragging
}
else -> false
}
}
overlayView = container
windowManager?.addView(overlayView, params)
}
private fun startRainbowAnimation(drawable: GradientDrawable) {
val colors = intArrayOf(
Color.parseColor("#FF6B6B"), // 红
Color.parseColor("#FFA94D"), // 橙
Color.parseColor("#FFE066"), // 黄
Color.parseColor("#69DB7C"), // 绿
Color.parseColor("#4DABF7"), // 蓝
Color.parseColor("#9775FA"), // 紫
Color.parseColor("#F783AC"), // 粉
Color.parseColor("#FF6B6B") // 回到红
)
animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 3000
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.RESTART
addUpdateListener { animation ->
val fraction = animation.animatedValue as Float
val index = (fraction * (colors.size - 1)).toInt()
val nextIndex = minOf(index + 1, colors.size - 1)
val localFraction = (fraction * (colors.size - 1)) - index
val color1 = interpolateColor(colors[index], colors[nextIndex], localFraction)
val color2 = interpolateColor(
colors[(index + 2) % colors.size],
colors[(nextIndex + 2) % colors.size],
localFraction
)
val color3 = interpolateColor(
colors[(index + 4) % colors.size],
colors[(nextIndex + 4) % colors.size],
localFraction
)
drawable.colors = intArrayOf(color1, color2, color3)
drawable.orientation = GradientDrawable.Orientation.LEFT_RIGHT
}
start()
}
}
private fun interpolateColor(startColor: Int, endColor: Int, fraction: Float): Int {
val startA = Color.alpha(startColor)
val startR = Color.red(startColor)
val startG = Color.green(startColor)
val startB = Color.blue(startColor)
val endA = Color.alpha(endColor)
val endR = Color.red(endColor)
val endG = Color.green(endColor)
val endB = Color.blue(endColor)
return Color.argb(
(startA + (endA - startA) * fraction).toInt(),
(startR + (endR - startR) * fraction).toInt(),
(startG + (endG - startG) * fraction).toInt(),
(startB + (endB - startB) * fraction).toInt()
)
}
private fun updateText(text: String) {
textView?.post {
textView?.text = text
}
}
/** 切换到人机协作模式 */
private fun setTakeOverMode(message: String) {
println("[OverlayService] setTakeOverMode: $message")
overlayView?.post {
// 确保悬浮窗可见
overlayView?.visibility = View.VISIBLE
textView?.text = "🖐 $message"
actionButton?.text = "✅ 继续"
actionButton?.setTextColor(Color.parseColor("#90EE90")) // 浅绿色
// 隐藏取消按钮(人机协作只有继续按钮)
divider2?.visibility = View.GONE
cancelButton?.visibility = View.GONE
}
}
/** 切换到正常模式 */
private fun setNormalMode() {
println("[OverlayService] setNormalMode")
overlayView?.post {
actionButton?.text = "⏹ 停止"
actionButton?.setTextColor(Color.WHITE)
// 隐藏取消按钮和第二分隔线
divider2?.visibility = View.GONE
cancelButton?.visibility = View.GONE
}
}
/** 切换到敏感操作确认模式 */
private fun setConfirmMode(message: String) {
println("[OverlayService] setConfirmMode: $message")
overlayView?.post {
// 确保悬浮窗可见
overlayView?.visibility = View.VISIBLE
textView?.text = "⚠️ $message"
actionButton?.text = "✅ 确认"
actionButton?.setTextColor(Color.parseColor("#90EE90")) // 浅绿色
// 显示取消按钮和第二分隔线
divider2?.visibility = View.VISIBLE
cancelButton?.visibility = View.VISIBLE
}
}
}
@@ -0,0 +1,452 @@
package com.roubao.autopilot.ui.screens
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.tools.ToolManager
import com.roubao.autopilot.ui.theme.BaoziTheme
/**
* 工具信息(用于展示)
*/
data class ToolInfo(
val name: String,
val description: String
)
/**
* Agent 角色信息
*/
data class AgentInfo(
val name: String,
val icon: String,
val role: String,
val description: String,
val responsibilities: List<String>
)
/**
* 预定义的 Agents 列表
*/
val agentsList = listOf(
AgentInfo(
name = "Manager",
icon = "🎯",
role = "规划者",
description = "负责理解用户意图,制定高层次的执行计划,并跟踪任务进度。",
responsibilities = listOf(
"分析用户请求,理解真实意图",
"将复杂任务分解为可执行的子目标",
"制定执行计划和步骤顺序",
"根据执行反馈动态调整计划"
)
),
AgentInfo(
name = "Executor",
icon = "⚡",
role = "执行者",
description = "负责分析当前屏幕状态,决定具体的操作动作。",
responsibilities = listOf(
"分析屏幕截图,理解界面元素",
"根据计划选择下一步操作",
"确定点击、滑动、输入等具体动作",
"输出精确的操作坐标和参数"
)
),
AgentInfo(
name = "Reflector",
icon = "🔍",
role = "反思者",
description = "负责评估操作结果,判断动作是否成功执行。",
responsibilities = listOf(
"对比操作前后的屏幕变化",
"判断操作是否达到预期效果",
"识别异常情况(如弹窗、错误)",
"提供反馈帮助调整后续策略"
)
),
AgentInfo(
name = "Notetaker",
icon = "📝",
role = "记录者",
description = "负责记录执行过程中的关键信息,供其他 Agent 参考。",
responsibilities = listOf(
"记录任务执行的重要节点",
"保存中间结果和状态信息",
"为后续步骤提供上下文参考",
"生成执行摘要和日志"
)
)
)
/**
* 能力展示页面
*
* 展示 Agents 和 Tools(只读)
*/
@Composable
fun CapabilitiesScreen() {
val colors = BaoziTheme.colors
// 获取 Tools
val tools = remember {
if (ToolManager.isInitialized()) {
ToolManager.getInstance().getAvailableTools().map { tool ->
ToolInfo(name = tool.name, description = tool.description)
}
} else {
emptyList()
}
}
// 额外的内置工具(不在 ToolManager 中但是系统能力)
val builtInTools = listOf(
ToolInfo("screenshot", "截取当前屏幕,获取界面图像供 AI 分析"),
ToolInfo("tap", "点击屏幕指定坐标位置"),
ToolInfo("swipe", "在屏幕上滑动,支持上下左右方向"),
ToolInfo("type", "输入文本内容到当前焦点位置"),
ToolInfo("press_key", "按下系统按键(Home、Back、Enter 等)")
)
val allTools = tools + builtInTools
// Tab 状态
var selectedTab by remember { mutableStateOf(0) }
val tabs = listOf("Agents (${agentsList.size})", "Tools (${allTools.size})")
Column(
modifier = Modifier
.fillMaxSize()
.background(colors.background)
) {
// 顶部标题
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp)
) {
Column {
Text(
text = "能力",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = colors.primary
)
Text(
text = "${agentsList.size} 个 Agent,${allTools.size} 个工具",
fontSize = 14.sp,
color = colors.textSecondary
)
}
}
// Tab 切换
TabRow(
selectedTabIndex = selectedTab,
containerColor = colors.background,
contentColor = colors.primary
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = {
Text(
text = title,
color = if (selectedTab == index) colors.primary else colors.textSecondary
)
}
)
}
}
// 内容区域
when (selectedTab) {
0 -> AgentsListView()
1 -> ToolsListView(tools = allTools)
}
}
}
@Composable
fun AgentsListView() {
val colors = BaoziTheme.colors
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 架构说明卡片
item(key = "arch_intro") {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.primary.copy(alpha = 0.1f))
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "🧠 多 Agent 协作架构",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = colors.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "肉包采用多 Agent 协作架构,每个 Agent 专注于特定职责,通过协作完成复杂的手机自动化任务。",
fontSize = 13.sp,
color = colors.textSecondary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Manager → Executor → Reflector → Notetaker",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = colors.textHint
)
}
}
}
// Agent 列表
items(agentsList, key = { it.name }) { agent ->
AgentCard(agent = agent)
}
}
}
@Composable
fun AgentCard(agent: AgentInfo) {
val colors = BaoziTheme.colors
var expanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundCard)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Agent 图标
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(colors.primary.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
) {
Text(
text = agent.icon,
fontSize = 28.sp
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = agent.name,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = colors.textPrimary
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(colors.secondary.copy(alpha = 0.2f))
.padding(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = agent.role,
fontSize = 11.sp,
color = colors.secondary
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = agent.description,
fontSize = 13.sp,
color = colors.textSecondary,
maxLines = if (expanded) Int.MAX_VALUE else 2,
overflow = TextOverflow.Ellipsis
)
}
Icon(
imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (expanded) "收起" else "展开",
tint = colors.textHint
)
}
// 展开显示职责列表
AnimatedVisibility(
visible = expanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(
modifier = Modifier.padding(top = 16.dp)
) {
Text(
text = "职责",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colors.textPrimary
)
Spacer(modifier = Modifier.height(8.dp))
agent.responsibilities.forEach { responsibility ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.Top
) {
Text(
text = "•",
fontSize = 14.sp,
color = colors.primary,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = responsibility,
fontSize = 13.sp,
color = colors.textSecondary
)
}
}
}
}
}
}
}
@Composable
fun ToolsListView(tools: List<ToolInfo>) {
if (tools.isEmpty()) {
EmptyState(message = "暂无工具")
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(tools, key = { it.name }) { tool ->
ToolCard(tool = tool)
}
}
}
}
@Composable
fun ToolCard(tool: ToolInfo) {
val colors = BaoziTheme.colors
// 根据工具名获取图标
val toolIcon = when (tool.name) {
"search_apps" -> "🔍"
"open_app" -> "📱"
"deep_link" -> "🔗"
"clipboard" -> "📋"
"shell" -> "💻"
"http" -> "🌐"
else -> "🔧"
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundCard)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 工具图标
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(colors.secondary.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Text(
text = toolIcon,
fontSize = 20.sp
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = tool.name,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = colors.textPrimary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = tool.description,
fontSize = 13.sp,
color = colors.textSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
fun EmptyState(message: String) {
val colors = BaoziTheme.colors
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "📦",
fontSize = 64.sp
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = message,
fontSize = 16.sp,
color = colors.textSecondary
)
}
}
}
@@ -0,0 +1,595 @@
package com.roubao.autopilot.ui.screens
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.data.ExecutionRecord
import com.roubao.autopilot.data.ExecutionStatus
import com.roubao.autopilot.data.ExecutionStep
import com.roubao.autopilot.ui.theme.BaoziTheme
import com.roubao.autopilot.ui.theme.Primary
import com.roubao.autopilot.ui.theme.Secondary
@Composable
fun HistoryScreen(
records: List<ExecutionRecord>,
onRecordClick: (ExecutionRecord) -> Unit,
onDeleteRecord: (String) -> Unit
) {
val colors = BaoziTheme.colors
Column(
modifier = Modifier
.fillMaxSize()
.background(colors.background)
) {
// 顶部标题
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp)
) {
Column {
Text(
text = "执行记录",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = colors.primary
)
Text(
text = "共 ${records.size} 条记录",
fontSize = 14.sp,
color = colors.textSecondary
)
}
}
if (records.isEmpty()) {
// 空状态
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "📝",
fontSize = 64.sp
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "暂无执行记录",
fontSize = 16.sp,
color = colors.textSecondary
)
Text(
text = "执行任务后记录会显示在这里",
fontSize = 14.sp,
color = colors.textHint
)
}
}
} else {
// 记录列表
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(
items = records,
key = { it.id }
) { record ->
HistoryRecordCard(
record = record,
onClick = { onRecordClick(record) },
onDelete = { onDeleteRecord(record.id) }
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryRecordCard(
record: ExecutionRecord,
onClick: () -> Unit,
onDelete: () -> Unit
) {
val colors = BaoziTheme.colors
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
containerColor = colors.backgroundCard,
title = { Text("删除记录", color = colors.textPrimary) },
text = { Text("确定要删除这条执行记录吗?", color = colors.textSecondary) },
confirmButton = {
TextButton(onClick = {
onDelete()
showDeleteDialog = false
}) {
Text("删除", color = colors.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("取消", color = colors.textSecondary)
}
}
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundCard)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 状态图标
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
when (record.status) {
ExecutionStatus.COMPLETED -> colors.success.copy(alpha = 0.2f)
ExecutionStatus.FAILED -> colors.error.copy(alpha = 0.2f)
ExecutionStatus.STOPPED -> colors.warning.copy(alpha = 0.2f)
ExecutionStatus.RUNNING -> colors.primary.copy(alpha = 0.2f)
}
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = when (record.status) {
ExecutionStatus.COMPLETED -> Icons.Default.CheckCircle
ExecutionStatus.FAILED -> Icons.Default.Warning
ExecutionStatus.STOPPED -> Icons.Default.PlayArrow
ExecutionStatus.RUNNING -> Icons.Default.PlayArrow
},
contentDescription = null,
tint = when (record.status) {
ExecutionStatus.COMPLETED -> colors.success
ExecutionStatus.FAILED -> colors.error
ExecutionStatus.STOPPED -> colors.warning
ExecutionStatus.RUNNING -> colors.primary
},
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
// 内容
Column(modifier = Modifier.weight(1f)) {
Text(
text = record.title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = record.instruction,
fontSize = 13.sp,
color = colors.textSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 状态标签
val (statusText, statusColor) = when (record.status) {
ExecutionStatus.COMPLETED -> "已完成" to colors.success
ExecutionStatus.FAILED -> "失败" to colors.error
ExecutionStatus.STOPPED -> "已取消" to colors.warning
ExecutionStatus.RUNNING -> "执行中" to colors.primary
}
Text(
text = statusText,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
color = statusColor,
modifier = Modifier
.background(
statusColor.copy(alpha = 0.15f),
RoundedCornerShape(4.dp)
)
.padding(horizontal = 6.dp, vertical = 2.dp)
)
Text(
text = "·",
fontSize = 12.sp,
color = colors.textHint
)
Text(
text = record.formattedStartTime,
fontSize = 12.sp,
color = colors.textHint,
maxLines = 1
)
Text(
text = "·",
fontSize = 12.sp,
color = colors.textHint
)
Text(
text = "${record.steps.size}步",
fontSize = 12.sp,
color = colors.textHint,
maxLines = 1
)
Text(
text = "·",
fontSize = 12.sp,
color = colors.textHint
)
Text(
text = record.formattedDuration,
fontSize = 12.sp,
color = colors.textHint,
maxLines = 1
)
}
}
// 删除按钮
IconButton(
onClick = { showDeleteDialog = true }
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "删除",
tint = colors.textHint
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryDetailScreen(
record: ExecutionRecord,
onBack: () -> Unit
) {
val colors = BaoziTheme.colors
// Tab 状态:0 = 时间线,1 = 日志
var selectedTab by remember { mutableStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.background(colors.background)
) {
// 顶部栏
TopAppBar(
title = {
Column {
Text(
text = record.title,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = colors.textPrimary
)
Text(
text = record.formattedStartTime,
fontSize = 12.sp,
color = colors.textSecondary
)
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "返回",
tint = colors.textPrimary
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = colors.background
)
)
// 任务信息卡片
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundCard)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "任务指令",
fontSize = 12.sp,
color = colors.textHint
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = record.instruction,
fontSize = 15.sp,
color = colors.textPrimary
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text("状态", fontSize = 12.sp, color = colors.textHint)
Text(
text = when (record.status) {
ExecutionStatus.COMPLETED -> "已完成"
ExecutionStatus.FAILED -> "失败"
ExecutionStatus.STOPPED -> "已停止"
ExecutionStatus.RUNNING -> "执行中"
},
fontSize = 14.sp,
color = when (record.status) {
ExecutionStatus.COMPLETED -> colors.success
ExecutionStatus.FAILED -> colors.error
ExecutionStatus.STOPPED -> colors.warning
ExecutionStatus.RUNNING -> colors.primary
}
)
}
Column {
Text("步骤数", fontSize = 12.sp, color = colors.textHint)
Text("${record.steps.size}", fontSize = 14.sp, color = colors.textPrimary)
}
Column {
Text("耗时", fontSize = 12.sp, color = colors.textHint)
Text(record.formattedDuration, fontSize = 14.sp, color = colors.textPrimary)
}
}
}
}
// Tab 切换
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// 时间线 Tab
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(
if (selectedTab == 0) colors.primary
else colors.backgroundCard
)
.clickable { selectedTab = 0 }
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "执行时间线",
fontSize = 14.sp,
fontWeight = if (selectedTab == 0) FontWeight.Medium else FontWeight.Normal,
color = if (selectedTab == 0) Color.White else colors.textSecondary
)
}
// 日志 Tab
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(
if (selectedTab == 1) colors.primary
else colors.backgroundCard
)
.clickable { selectedTab = 1 }
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "执行日志",
fontSize = 14.sp,
fontWeight = if (selectedTab == 1) FontWeight.Medium else FontWeight.Normal,
color = if (selectedTab == 1) Color.White else colors.textSecondary
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// 内容区域
when (selectedTab) {
0 -> {
// 时间线列表
if (record.steps.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "暂无执行步骤",
fontSize = 14.sp,
color = colors.textHint
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
items(record.steps) { step ->
TimelineItem(step = step, isLast = step == record.steps.lastOrNull())
}
}
}
}
1 -> {
// 日志列表
if (record.logs.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "暂无执行日志",
fontSize = 14.sp,
color = colors.textHint
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
items(record.logs) { log ->
val logColor = when {
log.contains("❌") -> colors.error
log.contains("✅") -> colors.success
log.contains("📋") || log.contains("🎬") -> colors.secondary
log.contains("Step") || log.contains("=====") -> colors.primary
log.contains("⛔") -> colors.error
else -> colors.textSecondary
}
Text(
text = log,
fontSize = 12.sp,
color = logColor,
modifier = Modifier.padding(vertical = 2.dp)
)
}
}
}
}
}
}
}
@Composable
fun TimelineItem(
step: ExecutionStep,
isLast: Boolean
) {
val colors = BaoziTheme.colors
Row(modifier = Modifier.fillMaxWidth()) {
// 时间线指示器
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
// 圆点
Box(
modifier = Modifier
.size(16.dp)
.clip(CircleShape)
.background(
when (step.outcome) {
"A" -> colors.success
"B" -> colors.warning
"?" -> colors.textHint // 进行中被取消
else -> colors.error
}
)
)
// 连接线
if (!isLast) {
Box(
modifier = Modifier
.width(2.dp)
.height(80.dp)
.background(colors.backgroundInput)
)
}
}
Spacer(modifier = Modifier.width(16.dp))
// 步骤内容
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = if (isLast) 0.dp else 8.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundCard)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Step ${step.stepNumber}",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colors.primary
)
Text(
text = step.action,
fontSize = 12.sp,
color = colors.secondary
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = step.description,
fontSize = 13.sp,
color = colors.textPrimary
)
if (step.thought.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = step.thought,
fontSize = 12.sp,
color = colors.textSecondary,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
@@ -0,0 +1,557 @@
package com.roubao.autopilot.ui.screens
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.agent.AgentState
import com.roubao.autopilot.ui.theme.BaoziTheme
import com.roubao.autopilot.ui.theme.Primary
import com.roubao.autopilot.ui.theme.Secondary
/**
* 预设命令
*/
data class PresetCommand(
val icon: String,
val title: String,
val command: String
)
val presetCommands = listOf(
PresetCommand("🍔", "点汉堡", "帮我点个附近好吃的汉堡"),
PresetCommand("📕", "发小红书", "帮我发一条小红书,内容是今日份好心情"),
PresetCommand("📺", "刷B站", "打开B站搜索肉包,找到第一个视频点个赞"),
PresetCommand("✈️", "旅游攻略", "用小美帮我查一下三亚旅游攻略"),
PresetCommand("🎵", "听音乐", "打开网易云音乐播放每日推荐"),
PresetCommand("🛒", "点外卖", "帮我在美团点一份猪脚饭")
)
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
fun HomeScreen(
agentState: AgentState?,
logs: List<String>,
onExecute: (String) -> Unit,
onStop: () -> Unit,
shizukuAvailable: Boolean,
currentModel: String = "",
onRefreshShizuku: () -> Unit = {},
onShizukuRequired: () -> Unit = {},
isExecuting: Boolean = false
) {
val colors = BaoziTheme.colors
var inputText by remember { mutableStateOf("") }
// 使用 isExecuting 或 agentState?.isRunning 来判断是否运行中
val isRunning = isExecuting || agentState?.isRunning == true
val listState = rememberLazyListState()
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
// 记录上一次的运行状态,用于检测任务结束
var wasRunning by remember { mutableStateOf(false) }
// 任务结束时清空输入框
LaunchedEffect(isRunning) {
if (wasRunning && !isRunning) {
// 从运行中变为未运行,说明任务结束
inputText = ""
}
wasRunning = isRunning
}
// 自动滚动到底部
LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(colors.background)
.imePadding()
) {
// 顶部标题
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "肉包",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = colors.primary
)
Text(
text = if (shizukuAvailable) "准备就绪,告诉我你想做什么" else "请先连接 Shizuku",
fontSize = 14.sp,
color = if (shizukuAvailable) colors.textSecondary else colors.error
)
}
// 未连接时显示刷新按钮
if (!shizukuAvailable) {
IconButton(
onClick = onRefreshShizuku,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(colors.backgroundCard)
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "刷新 Shizuku 状态",
tint = colors.primary
)
}
}
}
}
// 内容区域
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
if (isRunning || logs.isNotEmpty()) {
// 执行中或有日志时显示日志
ExecutionLogView(
logs = logs,
isRunning = isRunning,
currentStep = agentState?.currentStep ?: 0,
currentModel = currentModel,
listState = listState,
modifier = Modifier.fillMaxSize()
)
} else {
// 空闲时显示预设命令
PresetCommandsView(
onCommandClick = { command ->
if (shizukuAvailable) {
inputText = command
} else {
onShizukuRequired()
}
},
modifier = Modifier.fillMaxSize()
)
}
}
// 底部输入区域
InputArea(
inputText = inputText,
onInputChange = { inputText = it },
onExecute = {
if (inputText.isNotBlank()) {
// 收起键盘并清除焦点
keyboardController?.hide()
focusManager.clearFocus()
onExecute(inputText)
}
},
onStop = {
// 停止任务并清空输入框
inputText = ""
onStop()
},
isRunning = isRunning,
enabled = shizukuAvailable,
onInputClick = {
if (!shizukuAvailable) {
onShizukuRequired()
}
}
)
}
}
@Composable
fun PresetCommandsView(
onCommandClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
val colors = BaoziTheme.colors
Column(
modifier = modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = "试试这些指令",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = colors.textSecondary,
modifier = Modifier.padding(bottom = 16.dp)
)
presetCommands.chunked(2).forEach { rowCommands ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
rowCommands.forEach { preset ->
PresetCommandCard(
preset = preset,
onClick = { onCommandClick(preset.command) },
modifier = Modifier.weight(1f)
)
}
// 如果是奇数,补一个空白
if (rowCommands.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
fun PresetCommandCard(
preset: PresetCommand,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colors = BaoziTheme.colors
Card(
modifier = modifier
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = colors.backgroundCard
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = preset.icon,
fontSize = 24.sp
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = preset.title,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colors.textPrimary
)
Text(
text = preset.command,
fontSize = 11.sp,
color = colors.textSecondary,
maxLines = 1
)
}
}
}
}
@Composable
fun ExecutionLogView(
logs: List<String>,
isRunning: Boolean,
currentStep: Int,
currentModel: String,
listState: androidx.compose.foundation.lazy.LazyListState,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
// 执行状态指示器
if (isRunning) {
ExecutingIndicator(currentStep = currentStep, currentModel = currentModel)
}
// 日志列表
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(logs) { log ->
LogItem(log = log)
}
}
}
}
@Composable
fun ExecutingIndicator(currentStep: Int, currentModel: String = "") {
val colors = BaoziTheme.colors
val infiniteTransition = rememberInfiniteTransition(label = "executing")
val animatedProgress by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1500, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "progress"
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundCard)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
// 动画圆点
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(
Brush.sweepGradient(
listOf(Primary, Secondary, Primary)
)
)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "正在执行 Step $currentStep",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colors.primary
)
}
// 显示当前模型
if (currentModel.isNotEmpty()) {
Text(
text = currentModel,
fontSize = 11.sp,
color = colors.textHint,
maxLines = 1
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// 进度条
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(colors.backgroundInput)
) {
Box(
modifier = Modifier
.fillMaxWidth(animatedProgress)
.fillMaxHeight()
.background(
Brush.horizontalGradient(
listOf(Primary, Secondary)
)
)
)
}
}
}
}
}
@Composable
fun LogItem(log: String) {
val colors = BaoziTheme.colors
val logColor = when {
log.contains("❌") -> colors.error
log.contains("✅") -> colors.success
log.contains("📋") || log.contains("🎬") -> colors.secondary
log.contains("Step") || log.contains("=====") -> colors.primary
log.contains("⛔") -> colors.error
else -> colors.textSecondary
}
Text(
text = log,
fontSize = 12.sp,
color = logColor,
modifier = Modifier.padding(vertical = 2.dp)
)
}
@Composable
fun InputArea(
inputText: String,
onInputChange: (String) -> Unit,
onExecute: () -> Unit,
onStop: () -> Unit,
isRunning: Boolean,
enabled: Boolean,
onInputClick: () -> Unit = {}
) {
val colors = BaoziTheme.colors
Surface(
modifier = Modifier.fillMaxWidth(),
color = colors.backgroundCard,
shadowElevation = 8.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (isRunning) Arrangement.Center else Arrangement.Start
) {
if (isRunning) {
// 运行中只显示停止按钮
Button(
onClick = onStop,
colors = ButtonDefaults.buttonColors(
containerColor = colors.error
),
shape = RoundedCornerShape(24.dp),
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "停止",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "停止执行",
color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
}
} else {
// 非运行状态显示输入框和发送按钮
// 输入框
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(24.dp))
.background(colors.backgroundInput)
.then(
if (!enabled) {
Modifier.clickable { onInputClick() }
} else {
Modifier
}
)
.padding(horizontal = 20.dp, vertical = 14.dp)
) {
if (enabled) {
// Shizuku 已连接,显示可编辑的输入框
BasicTextField(
value = inputText,
onValueChange = onInputChange,
textStyle = TextStyle(
color = colors.textPrimary,
fontSize = 15.sp
),
cursorBrush = SolidColor(colors.primary),
modifier = Modifier.fillMaxWidth(),
decorationBox = { innerTextField ->
Box {
if (inputText.isEmpty()) {
Text(
text = "告诉肉包你想做什么...",
color = colors.textHint,
fontSize = 15.sp
)
}
innerTextField()
}
}
)
} else {
// Shizuku 未连接,显示提示文字
Text(
text = "请先连接 Shizuku",
color = colors.textHint,
fontSize = 15.sp
)
}
}
Spacer(modifier = Modifier.width(12.dp))
// 发送按钮
IconButton(
onClick = onExecute,
enabled = enabled && inputText.isNotBlank(),
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
if (inputText.isNotBlank() && enabled) colors.primary
else colors.backgroundInput
)
) {
Icon(
imageVector = Icons.Default.Send,
contentDescription = "发送",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
}
}
}
@@ -0,0 +1,247 @@
package com.roubao.autopilot.ui.screens
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roubao.autopilot.ui.theme.BaoziTheme
import com.roubao.autopilot.ui.theme.Primary
import kotlinx.coroutines.launch
data class OnboardingPage(
val icon: ImageVector,
val title: String,
val description: String,
val iconColor: Color = Primary
)
val onboardingPages = listOf(
OnboardingPage(
icon = Icons.Outlined.Star,
title = "欢迎使用肉包",
description = "肉包是一个智能自动化助手,\n可以帮你操作手机完成各种任务",
iconColor = Color(0xFF6366F1) // Indigo
),
OnboardingPage(
icon = Icons.Outlined.Settings,
title = "AI 驱动",
description = "基于先进的视觉语言模型,\n肉包能够理解屏幕内容并做出智能决策",
iconColor = Color(0xFF8B5CF6) // Violet
),
OnboardingPage(
icon = Icons.Outlined.Home,
title = "简单易用",
description = "只需用自然语言描述你想做的事,\n肉包会自动帮你完成",
iconColor = Color(0xFF06B6D4) // Cyan
),
OnboardingPage(
icon = Icons.Filled.Lock,
title = "安全可靠",
description = "遇到敏感页面(如支付、密码)会自动停止,\n保护你的账户安全",
iconColor = Color(0xFF10B981) // Emerald
)
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun OnboardingScreen(
onComplete: () -> Unit
) {
val colors = BaoziTheme.colors
val pagerState = rememberPagerState(pageCount = { onboardingPages.size })
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
colors.background,
colors.backgroundCard
)
)
)
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 页面内容
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) { page ->
OnboardingPageContent(
page = onboardingPages[page],
modifier = Modifier.fillMaxSize()
)
}
// 指示器
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
repeat(onboardingPages.size) { index ->
val isSelected = pagerState.currentPage == index
Box(
modifier = Modifier
.size(if (isSelected) 24.dp else 8.dp, 8.dp)
.clip(CircleShape)
.background(
if (isSelected) Primary else colors.textHint.copy(alpha = 0.3f)
)
.animateContentSize()
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// 底部按钮
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
// 跳过按钮
TextButton(
onClick = onComplete,
modifier = Modifier.weight(1f)
) {
Text(
text = "跳过",
color = colors.textSecondary,
fontSize = 16.sp
)
}
Spacer(modifier = Modifier.width(16.dp))
// 下一步/开始按钮
Button(
onClick = {
if (pagerState.currentPage < onboardingPages.size - 1) {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
} else {
onComplete()
}
},
modifier = Modifier
.weight(2f)
.height(52.dp),
shape = RoundedCornerShape(26.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Primary
)
) {
Text(
text = if (pagerState.currentPage < onboardingPages.size - 1) "下一步" else "开始使用",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White
)
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@Composable
fun OnboardingPageContent(
page: OnboardingPage,
modifier: Modifier = Modifier
) {
val colors = BaoziTheme.colors
Column(
modifier = modifier.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// 动画图标
val infiniteTransition = rememberInfiniteTransition(label = "icon")
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.08f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = EaseInOutSine),
repeatMode = RepeatMode.Reverse
),
label = "scale"
)
Box(
modifier = Modifier
.size(160.dp)
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(
page.iconColor.copy(alpha = 0.15f),
page.iconColor.copy(alpha = 0.05f),
colors.background.copy(alpha = 0f)
)
)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = page.icon,
contentDescription = page.title,
modifier = Modifier.size((72 * scale).dp),
tint = page.iconColor
)
}
Spacer(modifier = Modifier.height(48.dp))
// 标题
Text(
text = page.title,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = colors.textPrimary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
// 描述
Text(
text = page.description,
fontSize = 16.sp,
color = colors.textSecondary,
textAlign = TextAlign.Center,
lineHeight = 26.sp
)
}
}
@@ -0,0 +1,180 @@
package com.roubao.autopilot.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
// 主色调
val Primary = Color(0xFFD67744)
val PrimaryDark = Color(0xFFB85E2E)
val PrimaryLight = Color(0xFFE89060)
val Secondary = Color(0xFFEFB773)
val SecondaryDark = Color(0xFFD69B52)
val SecondaryLight = Color(0xFFF5CB94)
// 深色主题背景色
val BackgroundDark = Color(0xFF1A1A1A)
val BackgroundCard = Color(0xFF252525)
val BackgroundInput = Color(0xFF2A2A2A)
val SurfaceVariant = Color(0xFF303030)
// 深色主题文字颜色
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFFB0B0B0)
val TextHint = Color(0xFF666666)
// 浅色主题背景色 - 纯白简洁风格
val BackgroundLight = Color(0xFFFFFFFF)
val BackgroundCardLight = Color(0xFFF5F5F5)
val BackgroundInputLight = Color(0xFFEEEEEE)
val SurfaceVariantLight = Color(0xFFE0E0E0)
// 浅色主题文字颜色
val TextPrimaryLight = Color(0xFF212121)
val TextSecondaryLight = Color(0xFF757575)
val TextHintLight = Color(0xFFBDBDBD)
// 状态颜色
val Success = Color(0xFF4CAF50)
val Error = Color(0xFFF44336)
val Warning = Color(0xFFFF9800)
// 主题颜色数据类
data class BaoziColors(
val primary: Color,
val primaryDark: Color,
val primaryLight: Color,
val secondary: Color,
val background: Color,
val backgroundCard: Color,
val backgroundInput: Color,
val surfaceVariant: Color,
val textPrimary: Color,
val textSecondary: Color,
val textHint: Color,
val success: Color,
val error: Color,
val warning: Color,
val isDark: Boolean
)
// 深色主题颜色
val DarkBaoziColors = BaoziColors(
primary = Primary,
primaryDark = PrimaryDark,
primaryLight = PrimaryLight,
secondary = Secondary,
background = BackgroundDark,
backgroundCard = BackgroundCard,
backgroundInput = BackgroundInput,
surfaceVariant = SurfaceVariant,
textPrimary = TextPrimary,
textSecondary = TextSecondary,
textHint = TextHint,
success = Success,
error = Error,
warning = Warning,
isDark = true
)
// 浅色主题颜色
val LightBaoziColors = BaoziColors(
primary = Primary,
primaryDark = PrimaryDark,
primaryLight = PrimaryLight,
secondary = Secondary,
background = BackgroundLight,
backgroundCard = BackgroundCardLight,
backgroundInput = BackgroundInputLight,
surfaceVariant = SurfaceVariantLight,
textPrimary = TextPrimaryLight,
textSecondary = TextSecondaryLight,
textHint = TextHintLight,
success = Success,
error = Error,
warning = Warning,
isDark = false
)
// CompositionLocal 用于访问当前主题颜色
val LocalBaoziColors = staticCompositionLocalOf { DarkBaoziColors }
// Material 3 深色配色方案
private val DarkColorScheme = darkColorScheme(
primary = Primary,
onPrimary = Color.White,
primaryContainer = PrimaryDark,
onPrimaryContainer = Color.White,
secondary = Secondary,
onSecondary = Color.Black,
secondaryContainer = SecondaryDark,
onSecondaryContainer = Color.White,
background = BackgroundDark,
onBackground = TextPrimary,
surface = BackgroundCard,
onSurface = TextPrimary,
surfaceVariant = SurfaceVariant,
onSurfaceVariant = TextSecondary,
error = Error,
onError = Color.White
)
// Material 3 浅色配色方案
private val LightColorScheme = lightColorScheme(
primary = Primary,
onPrimary = Color.White,
primaryContainer = PrimaryLight,
onPrimaryContainer = Color.Black,
secondary = Secondary,
onSecondary = Color.Black,
secondaryContainer = SecondaryLight,
onSecondaryContainer = Color.Black,
background = BackgroundLight,
onBackground = TextPrimaryLight,
surface = BackgroundCardLight,
onSurface = TextPrimaryLight,
surfaceVariant = SurfaceVariantLight,
onSurfaceVariant = TextSecondaryLight,
error = Error,
onError = Color.White
)
// 主题模式枚举
enum class ThemeMode {
LIGHT,
DARK,
SYSTEM
}
@Composable
fun BaoziTheme(
themeMode: ThemeMode = ThemeMode.DARK,
content: @Composable () -> Unit
) {
val isDarkTheme = when (themeMode) {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
ThemeMode.SYSTEM -> isSystemInDarkTheme()
}
val colorScheme = if (isDarkTheme) DarkColorScheme else LightColorScheme
val baoziColors = if (isDarkTheme) DarkBaoziColors else LightBaoziColors
CompositionLocalProvider(LocalBaoziColors provides baoziColors) {
MaterialTheme(
colorScheme = colorScheme,
typography = Typography(),
content = content
)
}
}
// 便捷访问当前主题颜色
object BaoziTheme {
val colors: BaoziColors
@Composable
get() = LocalBaoziColors.current
}
@@ -0,0 +1,245 @@
package com.roubao.autopilot.utils
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.FileProvider
import java.io.File
import java.io.FileWriter
import java.io.PrintWriter
import java.text.SimpleDateFormat
import java.util.*
/**
* 全局崩溃捕获器
* 捕获未处理的异常并保存到本地文件
*/
class CrashHandler private constructor() : Thread.UncaughtExceptionHandler {
private var context: Context? = null
private var defaultHandler: Thread.UncaughtExceptionHandler? = null
companion object {
private const val LOG_DIR = "crash_logs"
private const val MAX_LOG_FILES = 10 // 最多保留10个日志文件
@Volatile
private var instance: CrashHandler? = null
fun getInstance(): CrashHandler {
return instance ?: synchronized(this) {
instance ?: CrashHandler().also { instance = it }
}
}
/**
* 获取日志目录
*/
fun getLogDir(context: Context): File {
val dir = File(context.filesDir, LOG_DIR)
if (!dir.exists()) {
dir.mkdirs()
}
return dir
}
/**
* 获取所有日志文件
*/
fun getLogFiles(context: Context): List<File> {
val dir = getLogDir(context)
return dir.listFiles()
?.filter { it.isFile && it.name.endsWith(".log") }
?.sortedByDescending { it.lastModified() }
?: emptyList()
}
/**
* 导出日志(合并所有日志到一个文件)
*/
fun exportLogs(context: Context): File? {
val logFiles = getLogFiles(context)
if (logFiles.isEmpty()) {
return null
}
val exportFile = File(context.cacheDir, "roubao_logs_${System.currentTimeMillis()}.txt")
try {
FileWriter(exportFile).use { writer ->
// 写入设备信息
writer.write("========== 设备信息 ==========\n")
writer.write("设备: ${Build.MANUFACTURER} ${Build.MODEL}\n")
writer.write("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})\n")
writer.write("应用版本: ${getAppVersion(context)}\n")
writer.write("导出时间: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())}\n")
writer.write("\n")
// 合并所有日志
logFiles.forEach { file ->
writer.write("========== ${file.name} ==========\n")
writer.write(file.readText())
writer.write("\n\n")
}
}
return exportFile
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
/**
* 分享日志文件
*/
fun shareLogs(context: Context) {
val exportFile = exportLogs(context)
if (exportFile == null) {
android.widget.Toast.makeText(context, "没有日志可导出", android.widget.Toast.LENGTH_SHORT).show()
return
}
try {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
exportFile
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "肉包 App 日志")
putExtra(Intent.EXTRA_TEXT, "请查看附件中的日志文件")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "分享日志"))
} catch (e: Exception) {
e.printStackTrace()
android.widget.Toast.makeText(context, "分享失败: ${e.message}", android.widget.Toast.LENGTH_SHORT).show()
}
}
/**
* 清除所有日志
*/
fun clearLogs(context: Context) {
val dir = getLogDir(context)
dir.listFiles()?.forEach { it.delete() }
}
/**
* 获取日志统计信息
*/
fun getLogStats(context: Context): String {
val files = getLogFiles(context)
if (files.isEmpty()) {
return "暂无日志"
}
val totalSize = files.sumOf { it.length() }
val sizeStr = if (totalSize > 1024) "${totalSize / 1024} KB" else "$totalSize B"
return "${files.size} 个文件, $sizeStr"
}
private fun getAppVersion(context: Context): String {
return try {
val pInfo = context.packageManager.getPackageInfo(context.packageName, 0)
"${pInfo.versionName} (${pInfo.longVersionCode})"
} catch (e: Exception) {
"Unknown"
}
}
}
/**
* 初始化
*/
fun init(context: Context) {
this.context = context.applicationContext
defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler(this)
// 清理旧日志
cleanOldLogs()
}
override fun uncaughtException(thread: Thread, throwable: Throwable) {
// 保存崩溃日志
saveCrashLog(throwable)
// 调用默认处理器(让系统显示崩溃对话框或直接退出)
defaultHandler?.uncaughtException(thread, throwable)
}
/**
* 保存崩溃日志
*/
private fun saveCrashLog(throwable: Throwable) {
val ctx = context ?: return
try {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val fileName = "crash_$timestamp.log"
val file = File(getLogDir(ctx), fileName)
PrintWriter(FileWriter(file)).use { writer ->
// 时间
writer.println("========== 崩溃时间 ==========")
writer.println(SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()))
writer.println()
// 设备信息
writer.println("========== 设备信息 ==========")
writer.println("设备: ${Build.MANUFACTURER} ${Build.MODEL}")
writer.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
writer.println("CPU ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
writer.println("应用版本: ${getAppVersion(ctx)}")
writer.println()
// 异常信息
writer.println("========== 异常信息 ==========")
throwable.printStackTrace(writer)
}
println("[CrashHandler] 崩溃日志已保存: $fileName")
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 记录普通日志(非崩溃)
*/
fun log(tag: String, message: String, throwable: Throwable? = null) {
val ctx = context ?: return
try {
val today = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())
val fileName = "log_$today.log"
val file = File(getLogDir(ctx), fileName)
FileWriter(file, true).use { writer ->
val time = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
writer.appendLine("[$time] [$tag] $message")
throwable?.let {
val sw = java.io.StringWriter()
it.printStackTrace(PrintWriter(sw))
writer.appendLine(sw.toString())
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 清理旧日志,只保留最近的文件
*/
private fun cleanOldLogs() {
val ctx = context ?: return
val files = getLogFiles(ctx)
if (files.size > MAX_LOG_FILES) {
files.drop(MAX_LOG_FILES).forEach { it.delete() }
}
}
}
@@ -0,0 +1,295 @@
package com.roubao.autopilot.vlm
import android.graphics.Bitmap
import android.util.Base64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.ConnectionPool
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.util.concurrent.TimeUnit
/**
* GUI-Owl API 客户端
* 专用于阿里云 GUI Agent 服务(非 OpenAI 兼容格式)
*
* API 特点:
* - 专用端点: https://dashscope.aliyuncs.com/api/v2/apps/gui-owl/gui_agent_server
* - 返回直接的操作指令: Click(x,y), Swipe(x1,y1,x2,y2) 等
* - 支持 session_id 管理多轮操作
*/
class GUIOwlClient(
private val apiKey: String,
private val model: String = "pre-gui_owl_7b",
private val deviceType: String = "mobile",
private val thoughtLanguage: String = "chinese"
) {
companion object {
private const val TAG = "GUIOwlClient"
private const val ENDPOINT = "https://dashscope.aliyuncs.com/api/v2/apps/gui-owl/gui_agent_server"
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
}
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.connectionPool(ConnectionPool(5, 1, TimeUnit.MINUTES))
.build()
// 会话 ID(用于关联多轮操作)
private var sessionId: String = ""
/**
* GUI-Owl 响应结果
*/
data class GUIOwlResponse(
val thought: String, // 思考过程
val operation: String, // 操作指令: Click (x, y, x, y) / Swipe (x1, y1, x2, y2) 等
val explanation: String, // 操作说明
val sessionId: String, // 会话 ID
val rawResponse: String // 原始响应
)
/**
* 解析操作指令为 Action
*/
data class ParsedAction(
val type: String, // click, swipe, type, etc.
val x: Int? = null,
val y: Int? = null,
val x2: Int? = null,
val y2: Int? = null,
val text: String? = null
)
/**
* 调用 GUI-Owl 进行界面理解和操作推理
*
* @param instruction 用户指令
* @param imageUrl 截图 URL(支持 http/https 或 data:image/... base64)
* @param addInfo 额外的操作提示信息
* @return GUIOwlResponse
*/
suspend fun predict(
instruction: String,
imageUrl: String,
addInfo: String = ""
): Result<GUIOwlResponse> = withContext(Dispatchers.IO) {
var lastException: Exception? = null
for (attempt in 1..MAX_RETRIES) {
try {
val messagesArray = JSONArray().apply {
put(JSONObject().put("image", imageUrl))
put(JSONObject().put("instruction", instruction))
put(JSONObject().put("session_id", sessionId))
put(JSONObject().put("device_type", deviceType))
put(JSONObject().put("pipeline_type", "agent"))
put(JSONObject().put("model_name", model))
put(JSONObject().put("thought_language", thoughtLanguage))
put(JSONObject().put("param_list", JSONArray().apply {
put(JSONObject().put("add_info", addInfo))
}))
}
val dataObj = JSONObject().apply {
put("messages", messagesArray)
}
val contentArray = JSONArray().apply {
put(JSONObject().apply {
put("type", "data")
put("data", dataObj)
})
}
val inputArray = JSONArray().apply {
put(JSONObject().apply {
put("role", "user")
put("content", contentArray)
})
}
val requestBody = JSONObject().apply {
put("app_id", "gui-owl")
put("input", inputArray)
}
val request = Request.Builder()
.url(ENDPOINT)
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", "application/json")
.post(requestBody.toString().toRequestBody("application/json".toMediaType()))
.build()
println("[$TAG] 请求: instruction=$instruction")
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
if (response.isSuccessful) {
val json = JSONObject(responseBody)
// 更新 session_id
val newSessionId = json.optString("session_id", "")
if (newSessionId.isNotEmpty()) {
sessionId = newSessionId
}
// 解析 output
val outputArray = json.optJSONArray("output")
if (outputArray != null && outputArray.length() > 0) {
val output = outputArray.getJSONObject(0)
val contentArr = output.optJSONArray("content")
if (contentArr != null && contentArr.length() > 0) {
val content = contentArr.getJSONObject(0)
val data = content.optJSONObject("data")
if (data != null) {
val result = GUIOwlResponse(
thought = data.optString("Thought", ""),
operation = data.optString("Operation", ""),
explanation = data.optString("Explanation", ""),
sessionId = sessionId,
rawResponse = responseBody
)
println("[$TAG] 响应: operation=${result.operation}")
return@withContext Result.success(result)
}
}
}
lastException = Exception("Invalid response format: $responseBody")
} else {
lastException = Exception("API error: ${response.code} - $responseBody")
}
} catch (e: Exception) {
println("[$TAG] 请求失败 (attempt $attempt): ${e.message}")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
}
}
Result.failure(lastException ?: Exception("Unknown error"))
}
/**
* 使用 Bitmap 调用 GUI-Owl
* 自动将 Bitmap 转换为 base64 data URL
*/
suspend fun predict(
instruction: String,
image: Bitmap,
addInfo: String = ""
): Result<GUIOwlResponse> {
val imageUrl = bitmapToDataUrl(image)
return predict(instruction, imageUrl, addInfo)
}
/**
* 解析操作指令字符串为 ParsedAction
*
* 支持的格式:
* - Click (x, y, x, y) 或 Click (x, y)
* - Swipe (x1, y1, x2, y2)
* - Type (text)
* - Long_press (x, y)
* - Scroll (direction)
*/
fun parseOperation(operation: String): ParsedAction? {
val trimmed = operation.trim()
// Click (x, y, x, y) 或 Click (x, y)
val clickPattern = Regex("""Click\s*\(\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*\d+\s*,\s*\d+)?\s*\)""", RegexOption.IGNORE_CASE)
clickPattern.find(trimmed)?.let { match ->
val x = match.groupValues[1].toIntOrNull() ?: return null
val y = match.groupValues[2].toIntOrNull() ?: return null
return ParsedAction(type = "click", x = x, y = y)
}
// Swipe (x1, y1, x2, y2)
val swipePattern = Regex("""Swipe\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)""", RegexOption.IGNORE_CASE)
swipePattern.find(trimmed)?.let { match ->
val x1 = match.groupValues[1].toIntOrNull() ?: return null
val y1 = match.groupValues[2].toIntOrNull() ?: return null
val x2 = match.groupValues[3].toIntOrNull() ?: return null
val y2 = match.groupValues[4].toIntOrNull() ?: return null
return ParsedAction(type = "swipe", x = x1, y = y1, x2 = x2, y2 = y2)
}
// Long_press (x, y) 或 LongPress (x, y)
val longPressPattern = Regex("""Long[_\s]?[Pp]ress\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)""", RegexOption.IGNORE_CASE)
longPressPattern.find(trimmed)?.let { match ->
val x = match.groupValues[1].toIntOrNull() ?: return null
val y = match.groupValues[2].toIntOrNull() ?: return null
return ParsedAction(type = "long_press", x = x, y = y)
}
// Type (text) 或 Input (text)
val typePattern = Regex("""(?:Type|Input)\s*\(\s*["\']?(.+?)["\']?\s*\)""", RegexOption.IGNORE_CASE)
typePattern.find(trimmed)?.let { match ->
val text = match.groupValues[1]
return ParsedAction(type = "type", text = text)
}
// Scroll (direction) 或 Scroll_down / Scroll_up
val scrollPattern = Regex("""Scroll[_\s]?(up|down|left|right)?""", RegexOption.IGNORE_CASE)
scrollPattern.find(trimmed)?.let { match ->
val direction = match.groupValues.getOrNull(1)?.lowercase() ?: "down"
return ParsedAction(type = "scroll", text = direction)
}
// Back
if (trimmed.contains("Back", ignoreCase = true)) {
return ParsedAction(type = "system_button", text = "Back")
}
// Home
if (trimmed.contains("Home", ignoreCase = true)) {
return ParsedAction(type = "system_button", text = "Home")
}
// FINISH / DONE / COMPLETE
if (trimmed.contains(Regex("FINISH|DONE|COMPLETE|Finished", RegexOption.IGNORE_CASE))) {
return ParsedAction(type = "finish")
}
println("[$TAG] 无法解析操作: $operation")
return null
}
/**
* 重置会话(开始新任务时调用)
*/
fun resetSession() {
sessionId = ""
}
/**
* 获取当前会话 ID
*/
fun getSessionId(): String = sessionId
/**
* Bitmap 转换为 data URL
*/
private fun bitmapToDataUrl(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
val bytes = outputStream.toByteArray()
println("[$TAG] 图片压缩: ${bitmap.width}x${bitmap.height}, ${bytes.size / 1024}KB")
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
return "data:image/jpeg;base64,$base64"
}
}
@@ -0,0 +1,390 @@
package com.roubao.autopilot.vlm
import android.graphics.Bitmap
import android.util.Base64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.ConnectionPool
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.util.concurrent.TimeUnit
/**
* MAI-UI 专用客户端
* 实现 MAI-UI 的特定 prompt 格式和对话历史管理
*/
class MAIUIClient(
private val baseUrl: String = "http://localhost:8000/v1",
private val model: String = "MAI-UI-2B",
private val historyN: Int = 3 // 保留的历史图片数量
) {
companion object {
private const val SCALE_FACTOR = 999
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
}
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.connectionPool(ConnectionPool(5, 1, TimeUnit.MINUTES))
.build()
// 对话历史
private val historyImages = mutableListOf<String>() // Base64 encoded images
private val historyResponses = mutableListOf<String>() // Assistant responses
// 可用应用列表 (会在运行时更新)
private var availableApps: List<String> = emptyList()
/**
* 系统提示词 (参考 MAI-UI 官方实现)
*/
private fun getSystemPrompt(): String = """
You are a GUI agent. You are given a task and your action history, with screenshots. You need to perform the next action to complete the task.
## Output Format
For each function call, return the thinking process in <thinking> </thinking> tags, and a json object with function name and arguments within <tool_call></tool_call> XML tags:
```
<thinking>
...
</thinking>
<tool_call>
{"name": "mobile_use", "arguments": <args-json-object>}
</tool_call>
```
## Action Space
{"action": "click", "coordinate": [x, y]}
{"action": "long_press", "coordinate": [x, y]}
{"action": "type", "text": ""}
{"action": "swipe", "direction": "up or down or left or right", "coordinate": [x, y]}
{"action": "open", "text": "app_name"}
{"action": "drag", "start_coordinate": [x1, y1], "end_coordinate": [x2, y2]}
{"action": "system_button", "button": "button_name"}
{"action": "wait"}
{"action": "terminate", "status": "success or fail"}
{"action": "answer", "text": "xxx"}
{"action": "ask_user", "text": "xxx"}
## Note
- Write a small plan and finally summarize your next action (with its target element) in one sentence in <thinking></thinking> part.
- Available Apps: `${if (availableApps.isNotEmpty()) availableApps.toString() else "[请通过open动作打开应用]"}`.
- You should use the `open` action to open the app as possible as you can, because it is the fast way to open the app.
- You must follow the Action Space strictly, and return the correct json object within <thinking> </thinking> and <tool_call></tool_call> XML tags.
""".trimIndent()
/**
* 设置可用应用列表
*/
fun setAvailableApps(apps: List<String>) {
availableApps = apps
}
/**
* 重置对话历史
*/
fun reset() {
historyImages.clear()
historyResponses.clear()
}
/**
* 预测下一步动作
* @param instruction 用户指令
* @param screenshot 当前截图
* @return Result<MAIUIResponse>
*/
suspend fun predict(
instruction: String,
screenshot: Bitmap
): Result<MAIUIResponse> = withContext(Dispatchers.IO) {
var lastException: Exception? = null
// 编码当前截图
val currentImageBase64 = bitmapToBase64(screenshot)
for (attempt in 1..MAX_RETRIES) {
try {
// 构建消息
val messages = buildMessages(instruction, currentImageBase64)
val requestBody = JSONObject().apply {
put("model", model)
put("messages", messages)
put("max_tokens", 2048)
put("temperature", 0.0)
put("top_p", 1.0)
}
val request = Request.Builder()
.url("${normalizeUrl(baseUrl)}/chat/completions")
.addHeader("Content-Type", "application/json")
.post(requestBody.toString().toRequestBody("application/json".toMediaType()))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
if (response.isSuccessful) {
val json = JSONObject(responseBody)
val choices = json.getJSONArray("choices")
if (choices.length() > 0) {
val message = choices.getJSONObject(0).getJSONObject("message")
val content = message.getString("content")
println("[MAIUIClient] Raw response: $content")
// 解析响应
val parsed = parseResponse(content)
// 保存到历史
historyImages.add(currentImageBase64)
historyResponses.add(content)
// 限制历史数量
while (historyImages.size > historyN) {
historyImages.removeAt(0)
historyResponses.removeAt(0)
}
return@withContext Result.success(parsed)
} else {
lastException = Exception("No response from model")
}
} else {
lastException = Exception("API error: ${response.code} - $responseBody")
}
} catch (e: Exception) {
println("[MAIUIClient] Error on attempt $attempt: ${e.message}")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
}
}
Result.failure(lastException ?: Exception("Unknown error"))
}
/**
* 构建消息列表 (参考 MAI-UI 官方实现)
*/
private fun buildMessages(instruction: String, currentImageBase64: String): JSONArray {
val messages = JSONArray()
// 1. System message
messages.put(JSONObject().apply {
put("role", "system")
put("content", JSONArray().apply {
put(JSONObject().apply {
put("type", "text")
put("text", getSystemPrompt())
})
})
})
// 2. User instruction
messages.put(JSONObject().apply {
put("role", "user")
put("content", JSONArray().apply {
put(JSONObject().apply {
put("type", "text")
put("text", instruction)
})
})
})
// 3. History (image + assistant response pairs)
val startIdx = maxOf(0, historyImages.size - (historyN - 1))
for (i in startIdx until historyImages.size) {
// User message with image
messages.put(JSONObject().apply {
put("role", "user")
put("content", JSONArray().apply {
put(JSONObject().apply {
put("type", "image_url")
put("image_url", JSONObject().apply {
put("url", "data:image/jpeg;base64,${historyImages[i]}")
})
})
})
})
// Assistant response
messages.put(JSONObject().apply {
put("role", "assistant")
put("content", JSONArray().apply {
put(JSONObject().apply {
put("type", "text")
put("text", historyResponses[i])
})
})
})
}
// 4. Current image
messages.put(JSONObject().apply {
put("role", "user")
put("content", JSONArray().apply {
put(JSONObject().apply {
put("type", "image_url")
put("image_url", JSONObject().apply {
put("url", "data:image/jpeg;base64,$currentImageBase64")
})
})
})
})
return messages
}
/**
* 解析 MAI-UI 响应
*/
private fun parseResponse(text: String): MAIUIResponse {
var processedText = text.trim()
// 处理 thinking model 输出格式 (</think> instead of </thinking>)
if (processedText.contains("</think>") && !processedText.contains("</thinking>")) {
processedText = processedText.replace("</think>", "</thinking>")
processedText = "<thinking>$processedText"
}
// 提取 thinking
val thinkingRegex = Regex("<thinking>(.*?)</thinking>", RegexOption.DOT_MATCHES_ALL)
val thinking = thinkingRegex.find(processedText)?.groupValues?.get(1)?.trim() ?: ""
// 提取 tool_call
val toolCallRegex = Regex("<tool_call>\\s*(.+?)\\s*</tool_call>", RegexOption.DOT_MATCHES_ALL)
val toolCallMatch = toolCallRegex.find(processedText)
var action: MAIUIAction? = null
if (toolCallMatch != null) {
try {
val toolCallJson = JSONObject(toolCallMatch.groupValues[1].trim())
val arguments = toolCallJson.optJSONObject("arguments")
if (arguments != null) {
action = parseAction(arguments)
}
} catch (e: Exception) {
println("[MAIUIClient] Failed to parse tool_call: ${e.message}")
}
}
return MAIUIResponse(
thinking = thinking,
action = action,
rawResponse = text
)
}
/**
* 解析动作
*/
private fun parseAction(arguments: JSONObject): MAIUIAction {
val actionType = arguments.optString("action", "")
// 解析坐标 (0-999 归一化)
val coordinate = arguments.optJSONArray("coordinate")
var x: Float? = null
var y: Float? = null
if (coordinate != null && coordinate.length() >= 2) {
x = coordinate.getDouble(0).toFloat() / SCALE_FACTOR
y = coordinate.getDouble(1).toFloat() / SCALE_FACTOR
}
// 解析 drag 坐标
val startCoord = arguments.optJSONArray("start_coordinate")
val endCoord = arguments.optJSONArray("end_coordinate")
var startX: Float? = null
var startY: Float? = null
var endX: Float? = null
var endY: Float? = null
if (startCoord != null && endCoord != null) {
startX = startCoord.getDouble(0).toFloat() / SCALE_FACTOR
startY = startCoord.getDouble(1).toFloat() / SCALE_FACTOR
endX = endCoord.getDouble(0).toFloat() / SCALE_FACTOR
endY = endCoord.getDouble(1).toFloat() / SCALE_FACTOR
}
return MAIUIAction(
type = actionType,
x = x,
y = y,
startX = startX,
startY = startY,
endX = endX,
endY = endY,
text = arguments.optString("text", null),
button = arguments.optString("button", null),
direction = arguments.optString("direction", null),
status = arguments.optString("status", null)
)
}
private fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
val bytes = outputStream.toByteArray()
println("[MAIUIClient] Image compressed: ${bitmap.width}x${bitmap.height}, ${bytes.size / 1024}KB")
return Base64.encodeToString(bytes, Base64.NO_WRAP)
}
private fun normalizeUrl(url: String): String {
var normalized = url.trim().removeSuffix("/")
if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
normalized = "http://$normalized"
}
return normalized
}
}
/**
* MAI-UI 响应
*/
data class MAIUIResponse(
val thinking: String,
val action: MAIUIAction?,
val rawResponse: String
)
/**
* MAI-UI 动作 (坐标已归一化为 0-1)
*/
data class MAIUIAction(
val type: String,
val x: Float? = null, // 归一化坐标 0-1
val y: Float? = null,
val startX: Float? = null, // drag 起点
val startY: Float? = null,
val endX: Float? = null, // drag 终点
val endY: Float? = null,
val text: String? = null,
val button: String? = null,
val direction: String? = null, // swipe 方向: up, down, left, right
val status: String? = null // terminate 状态: success, fail
) {
/**
* 转换为屏幕像素坐标
*/
fun toScreenCoordinates(screenWidth: Int, screenHeight: Int): MAIUIAction {
return copy(
x = x?.let { it * screenWidth },
y = y?.let { it * screenHeight },
startX = startX?.let { it * screenWidth },
startY = startY?.let { it * screenHeight },
endX = endX?.let { it * screenWidth },
endY = endY?.let { it * screenHeight }
)
}
}
@@ -0,0 +1,351 @@
package com.roubao.autopilot.vlm
import android.graphics.Bitmap
import android.util.Base64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.ConnectionPool
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.net.UnknownHostException
import java.util.concurrent.TimeUnit
/**
* VLM (Vision Language Model) API 客户端
* 支持 OpenAI 兼容接口 (GPT-4V, Qwen-VL, Claude, etc.)
*/
class VLMClient(
private val apiKey: String,
baseUrl: String = "https://api.openai.com/v1",
private val model: String = "gpt-4-vision-preview"
) {
// 规范化 URL:自动添加 https:// 前缀,移除末尾斜杠
private val baseUrl: String = normalizeUrl(baseUrl)
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.connectionPool(ConnectionPool(5, 1, TimeUnit.MINUTES))
.build()
companion object {
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
/** 规范化 URL:自动添加 https:// 前缀,移除末尾斜杠 */
private fun normalizeUrl(url: String): String {
var normalized = url.trim().removeSuffix("/")
if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
normalized = "https://$normalized"
}
return normalized
}
/**
* 从 API 获取可用模型列表
* @param baseUrl API 基础地址
* @param apiKey API 密钥
* @return 模型 ID 列表
*/
suspend fun fetchModels(baseUrl: String, apiKey: String): Result<List<String>> = withContext(Dispatchers.IO) {
// 验证 baseUrl 是否为空
if (baseUrl.isBlank()) {
return@withContext Result.failure(Exception("Base URL 不能为空"))
}
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
// 清理 URL,确保正确拼接
val cleanBaseUrl = normalizeUrl(baseUrl.removeSuffix("/chat/completions"))
val request = try {
Request.Builder()
.url("$cleanBaseUrl/models")
.apply {
if (apiKey.isNotBlank()) {
addHeader("Authorization", "Bearer $apiKey")
}
}
.get()
.build()
} catch (e: IllegalArgumentException) {
return@withContext Result.failure(Exception("Base URL 格式无效: ${e.message}"))
}
try {
client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: ""
if (response.isSuccessful) {
val json = JSONObject(responseBody)
val data = json.optJSONArray("data") ?: JSONArray()
val models = mutableListOf<String>()
for (i in 0 until data.length()) {
val item = data.optJSONObject(i)
if (item != null) {
val id = item.optString("id", "").trim()
if (id.isNotEmpty()) {
models.add(id)
}
}
}
Result.success(models)
} else {
Result.failure(Exception("HTTP ${response.code}: $responseBody"))
}
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* 调用 VLM 进行多模态推理 (带重试)
*/
suspend fun predict(
prompt: String,
images: List<Bitmap> = emptyList()
): Result<String> = withContext(Dispatchers.IO) {
var lastException: Exception? = null
// 预先编码图片 (避免重试时重复编码)
val encodedImages = images.map { bitmapToBase64Url(it) }
for (attempt in 1..MAX_RETRIES) {
try {
val content = JSONArray().apply {
put(JSONObject().apply {
put("type", "text")
put("text", prompt)
})
encodedImages.forEach { imageUrl ->
put(JSONObject().apply {
put("type", "image_url")
put("image_url", JSONObject().apply {
put("url", imageUrl)
})
})
}
}
val messages = JSONArray().apply {
put(JSONObject().apply {
put("role", "user")
put("content", content)
})
}
val requestBody = JSONObject().apply {
put("model", model)
put("messages", messages)
put("max_tokens", 4096)
put("temperature", 0.0)
put("top_p", 0.85)
put("frequency_penalty", 0.2) // 减少重复输出
}
val request = Request.Builder()
.url("$baseUrl/chat/completions")
.apply {
if (apiKey.isNotBlank()) {
addHeader("Authorization", "Bearer $apiKey")
}
}
.addHeader("Content-Type", "application/json")
.post(requestBody.toString().toRequestBody("application/json".toMediaType()))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
if (response.isSuccessful) {
val json = JSONObject(responseBody)
val choices = json.getJSONArray("choices")
if (choices.length() > 0) {
val message = choices.getJSONObject(0).getJSONObject("message")
val responseContent = message.getString("content")
return@withContext Result.success(responseContent)
} else {
lastException = Exception("No response from model")
}
} else {
lastException = Exception("API error: ${response.code} - $responseBody")
}
} catch (e: UnknownHostException) {
// DNS 解析失败,重试
println("[VLMClient] DNS 解析失败,重试 $attempt/$MAX_RETRIES...")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
} catch (e: java.net.SocketTimeoutException) {
// 超时,重试
println("[VLMClient] 请求超时,重试 $attempt/$MAX_RETRIES...")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
} catch (e: java.io.IOException) {
// IO 错误,重试
println("[VLMClient] IO 错误: ${e.message},重试 $attempt/$MAX_RETRIES...")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
} catch (e: Exception) {
// 其他错误,不重试
return@withContext Result.failure(e)
}
}
Result.failure(lastException ?: Exception("Unknown error"))
}
/**
* 调用 VLM 进行多模态推理 (使用完整对话历史)
* @param messagesJson OpenAI 兼容的 messages JSON 数组
*/
suspend fun predictWithContext(
messagesJson: JSONArray
): Result<String> = withContext(Dispatchers.IO) {
var lastException: Exception? = null
for (attempt in 1..MAX_RETRIES) {
try {
val requestBody = JSONObject().apply {
put("model", model)
put("messages", messagesJson)
put("max_tokens", 4096)
put("temperature", 0.0)
}
val request = Request.Builder()
.url("$baseUrl/chat/completions")
.apply {
if (apiKey.isNotBlank()) {
addHeader("Authorization", "Bearer $apiKey")
}
}
.addHeader("Content-Type", "application/json")
.post(requestBody.toString().toRequestBody("application/json".toMediaType()))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: ""
if (response.isSuccessful) {
val json = JSONObject(responseBody)
val choices = json.getJSONArray("choices")
if (choices.length() > 0) {
val message = choices.getJSONObject(0).getJSONObject("message")
val responseContent = message.getString("content")
return@withContext Result.success(responseContent)
} else {
lastException = Exception("No response from model")
}
} else {
lastException = Exception("API error: ${response.code} - $responseBody")
}
} catch (e: UnknownHostException) {
println("[VLMClient] DNS 解析失败,重试 $attempt/$MAX_RETRIES...")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
} catch (e: java.net.SocketTimeoutException) {
println("[VLMClient] 请求超时,重试 $attempt/$MAX_RETRIES...")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
} catch (e: java.io.IOException) {
println("[VLMClient] IO 错误: ${e.message},重试 $attempt/$MAX_RETRIES...")
lastException = e
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
} catch (e: Exception) {
return@withContext Result.failure(e)
}
}
Result.failure(lastException ?: Exception("Unknown error"))
}
/**
* Bitmap 转 Base64 URL (只压缩质量,不压缩分辨率)
* 保持原始分辨率以确保坐标准确
*/
private fun bitmapToBase64Url(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
// 使用 JPEG 格式,质量 70%,保持原始分辨率
bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
val bytes = outputStream.toByteArray()
println("[VLMClient] 图片压缩: ${bitmap.width}x${bitmap.height}, ${bytes.size / 1024}KB")
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
return "data:image/jpeg;base64,$base64"
}
/**
* 调整图片大小
*/
private fun resizeBitmap(bitmap: Bitmap, maxWidth: Int, maxHeight: Int): Bitmap {
val width = bitmap.width
val height = bitmap.height
if (width <= maxWidth && height <= maxHeight) {
return bitmap
}
val ratio = minOf(maxWidth.toFloat() / width, maxHeight.toFloat() / height)
val newWidth = (width * ratio).toInt()
val newHeight = (height * ratio).toInt()
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
}
/**
* 常用 VLM 配置
*/
object VLMConfigs {
// OpenAI GPT-4V
fun gpt4v(apiKey: String) = VLMClient(
apiKey = apiKey,
baseUrl = "https://api.openai.com/v1",
model = "gpt-4-vision-preview"
)
// Qwen-VL (阿里云)
fun qwenVL(apiKey: String) = VLMClient(
apiKey = apiKey,
baseUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1",
model = "qwen-vl-max"
)
// Claude (Anthropic)
fun claude(apiKey: String) = VLMClient(
apiKey = apiKey,
baseUrl = "https://api.anthropic.com/v1",
model = "claude-3-5-sonnet-20241022"
)
// 自定义 (vLLM / Ollama / LocalAI)
fun custom(apiKey: String, baseUrl: String, model: String) = VLMClient(
apiKey = apiKey,
baseUrl = baseUrl,
model = model
)
}