feat(android): establish Roubao build baseline
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+452
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user